feat: first commit

This commit is contained in:
eleanor.mao
2025-03-31 22:34:22 +08:00
commit d25187c9c8
390 changed files with 57031 additions and 0 deletions

3
src/app.config.ts Normal file
View File

@ -0,0 +1,3 @@
import { APP_CONFIG } from './hooks/use-config';
export default defineAppConfig(APP_CONFIG);

15
src/app.less Normal file
View File

@ -0,0 +1,15 @@
@import '@/styles/variables.less';
.base-bg {
background: @pageBg;
}
page {
.base-bg();
// 全部覆盖 taroify tabs 的背景色
--tabs-nav-background-color: @pageBg;
.taroify-tabs__wrap__scroll {
.base-bg();
}
}

29
src/app.tsx Normal file
View File

@ -0,0 +1,29 @@
import { useLaunch } from '@tarojs/taro';
import { PropsWithChildren } from 'react';
import { Provider } from 'react-redux';
import { REFRESH_UNREAD_COUNT_TIME } from '@/constants/message';
import http from '@/http';
import store from '@/store';
import { requestUnreadMessageCount } from '@/utils/message';
import qiniuUpload from '@/utils/qiniu-upload';
import { requestUserInfo, updateLastLoginTime } from '@/utils/user';
import './app.less';
function App({ children }: PropsWithChildren<BL.Anything>) {
useLaunch(async () => {
console.log('App launched.');
await http.init();
requestUserInfo();
updateLastLoginTime();
qiniuUpload.init();
requestUnreadMessageCount();
setInterval(() => requestUnreadMessageCount(), REFRESH_UNREAD_COUNT_TIME);
});
return <Provider store={store}>{children}</Provider>;
}
export default App;

View File

@ -0,0 +1,123 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.anchor-card {
width: 100%;
.flex-row();
align-items: flex-start;
padding: 24px;
box-sizing: border-box;
background: #FFFFFF;
.color(@defaultColor) {
color: var(--read-color, @defaultColor);
}
&__cover {
position: relative;
width: 188px;
min-width: 188px;
max-width: 188px;
height: 242px;
min-height: 242px;
max-height: 242px;
border-radius: 12px;
}
&__cover-skeleton {
position: absolute;
border-radius: 12px;
}
&__info-container {
height: 100%;
flex: 1;
.flex-row();
align-items: flex-start;
margin-left: 24px;
}
&__info {
height: 100%;
flex: 1;
.flex-column();
align-items: flex-start;
margin-right: 10px;
&__title {
font-size: 32px;
line-height: 32px;
font-weight: 500;
.color(@blColor);
}
@maxTextWidth: 49vw;
&__basic {
max-width: @maxTextWidth;
font-size: 24px;
line-height: 36px;
font-weight: 400;
.color(@blColorG2);
margin-top: 16px;
.noWrap();
}
&__categories {
max-width: @maxTextWidth;
font-size: 24px;
line-height: 36px;
font-weight: 400;
.color(@blColorG2);
margin-top: 12px;
.noWrap();
}
&__year {
max-width: @maxTextWidth;
font-size: 24px;
line-height: 36px;
font-weight: 400;
.color(@blColorG2);
margin-top: 12px;
.noWrap();
}
&__salary {
max-width: @maxTextWidth;
font-size: 28px;
line-height: 40px;
font-weight: 500;
.color(@blHighlightColor);
margin-top: 20px;
.noWrap();
}
}
&__right {
height: 242px;
.flex-column();
justify-content: space-between;
font-size: 24px;
line-height: 32px;
font-weight: 400;
.color(@blColorG1);
}
&__distance-wrapper {
.flex-row();
}
&__distance-icon {
width: 28px;
height: 28px;
margin-right: 6px;
}
&__distance {
font-size: 24px;
line-height: 40px;
font-weight: 400;
.color(@blColorG1);
}
}

View File

@ -0,0 +1,82 @@
import { Image as TaroImage } from '@tarojs/components';
import { Image } from '@taroify/core';
import { PhotoFail } from '@taroify/icons';
import { useCallback } from 'react';
import SkeletonLoading from '@/components/skeleton-loading';
import { PageUrl } from '@/constants/app';
import { MaterialViewSource, WORK_YEAR_LABELS } from '@/constants/material';
import { AnchorInfo } from '@/types/material';
import { calcDistance } from '@/utils/location';
import { getBasicInfo } from '@/utils/material';
import { navigateTo } from '@/utils/route';
import { activeDate } from '@/utils/time';
import './index.less';
interface IProps {
data: AnchorInfo;
jobId?: string;
}
const PREFIX = 'anchor-card';
const getSalary = (data: AnchorInfo) => {
const { fullTimeMinPrice, fullTimeMaxPrice, partyTimeMinPrice, partyTimeMaxPrice } = data;
const prices: string[] = [];
if (fullTimeMinPrice && fullTimeMaxPrice) {
prices.push(`${fullTimeMinPrice / 1000}-${fullTimeMaxPrice / 1000}K/月`);
}
if (partyTimeMinPrice && partyTimeMaxPrice) {
prices.push(`${partyTimeMinPrice}-${partyTimeMaxPrice}/小时`);
}
return prices.filter(Boolean).join(' ');
};
function AnchorCard(props: IProps) {
const { data, jobId } = props;
const style = data.isRead ? ({ '--read-color': '#999999' } as React.CSSProperties) : {};
const cover = (data.materialVideoInfoList.find(video => video.isDefault) || data.materialVideoInfoList[0])?.coverUrl;
const handleClick = useCallback(
() => navigateTo(PageUrl.MaterialView, { jobId, resumeId: data.id, source: MaterialViewSource.AnchorList }),
[data, jobId]
);
return (
<div className={PREFIX} style={style} onClick={handleClick}>
<Image
lazyLoad
src={cover}
width={188}
height={242}
mode="aspectFill"
fallback={<PhotoFail />}
className={`${PREFIX}__cover`}
placeholder={<SkeletonLoading customName={`${PREFIX}__cover-skeleton`} />}
/>
<div className={`${PREFIX}__info-container`}>
<div className={`${PREFIX}__info`}>
<div className={`${PREFIX}__info__title`}>{data.name}</div>
<div className={`${PREFIX}__info__basic`}>{getBasicInfo(data)}</div>
<div className={`${PREFIX}__info__year`}>{WORK_YEAR_LABELS[data.workedYear] || ''}</div>
{data.workedSecCategoryStr && (
<div className={`${PREFIX}__info__categories`}>{`播过 ${data.workedSecCategoryStr}`}</div>
)}
<div className={`${PREFIX}__info__salary`}>{getSalary(data)}</div>
</div>
<div className={`${PREFIX}__right`}>
<div className={`${PREFIX}__active-time`}>{activeDate(data.sortTime)}</div>
{typeof data.distance !== 'undefined' && (
<div className={`${PREFIX}__distance-wrapper`}>
<TaroImage className={`${PREFIX}__distance-icon`} src={require('@/statics/svg/location.svg')} />
<div className={`${PREFIX}__distance`}>{calcDistance(data.distance, 1)}</div>
</div>
)}
</div>
</div>
</div>
);
}
export default AnchorCard;

View File

View File

@ -0,0 +1,209 @@
import Taro from '@tarojs/taro';
import { List, PullRefresh } from '@taroify/core';
import classNames from 'classnames';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef, useState } from 'react';
import AnchorCard from '@/components/anchor-card';
import ListPlaceholder from '@/components/list-placeholder';
import { EventName } from '@/constants/app';
import { AnchorSortType } from '@/constants/material';
import { AnchorInfo, GetAnchorListRequest, IAnchorFilters } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { requestAnchorList as requestData } from '@/utils/material';
import './index.less';
interface IRequestProps extends Partial<GetAnchorListRequest> {
filters?: IAnchorFilters;
}
export interface IAnchorListProps extends IRequestProps {
ready?: boolean;
refreshDisabled?: boolean;
listHeight?: number;
className?: string;
onListEmpty?: () => void;
}
const FIRST_PAGE = 0;
const PAGE_SIZE = 10;
const PREFIX = 'anchor-list';
const log = logWithPrefix(PREFIX);
function AnchorList(props: IAnchorListProps) {
const {
className,
listHeight,
refreshDisabled,
jobId,
filters,
cityCode = 'ALL',
sortType = AnchorSortType.Recommend,
latitude,
longitude,
ready,
onListEmpty,
} = props;
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState(false);
const [dataList, setDataList] = useState<AnchorInfo[]>([]);
const currentPage = useRef<number>(FIRST_PAGE);
const requestProps = useRef<IRequestProps>({});
const prevRequestProps = useRef<IRequestProps>({});
const onListEmptyRef = useRef(onListEmpty);
const handleRefresh = useCallback(async () => {
log('start pull refresh');
try {
setRefreshing(true);
setLoadMoreError(false);
const { page, hasMore: more, data: anchorResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(anchorResults);
currentPage.current = page;
!anchorResults.length && onListEmptyRef.current?.();
log('pull refresh success');
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
log('pull refresh failed');
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
log('start load more', hasMore);
if (!hasMore) {
return;
}
setLoadMoreError(false);
setLoadingMore(true);
try {
const {
page,
hasMore: more,
data: anchorResults,
} = await requestData({ ...requestProps.current, page: currentPage.current + 1 });
setDataList([...dataList, ...anchorResults]);
setHasMore(more);
currentPage.current = page;
log('load more success');
} catch (e) {
setLoadMoreError(true);
log('load more failed');
} finally {
setLoadingMore(false);
}
}, [dataList, hasMore]);
const handleReadMaterial = useCallback(
(materialId: string) => {
const index = dataList.findIndex(d => String(d.id) === materialId);
if (index < 0) {
return;
}
const material = dataList[index];
if (!material || material.isRead) {
return;
}
log('auto mark read', materialId);
dataList.splice(index, 1, { ...material, isRead: true });
setDataList([...dataList]);
},
[dataList]
);
useEffect(() => {
onListEmptyRef.current = onListEmpty;
}, [onListEmpty]);
useEffect(() => {
log('request params changed');
requestProps.current = {
...filters,
jobId,
cityCode,
sortType,
latitude,
longitude,
pageSize: PAGE_SIZE,
};
}, [jobId, filters, cityCode, sortType, latitude, longitude]);
useEffect(() => {
Taro.eventCenter.on(EventName.VIEW_MATERIAL_SUCCESS, handleReadMaterial);
return () => {
Taro.eventCenter.off(EventName.VIEW_MATERIAL_SUCCESS, handleReadMaterial);
};
}, [handleReadMaterial]);
// 初始化数据&配置变更后刷新数据
useEffect(() => {
// 相比前一次可见时没有数据变更时,不再重新请求
if (isEqual(prevRequestProps.current, requestProps.current)) {
log('visible/city changed, but request params not change, ignore');
return;
}
// 列表不可见时,先不做处理
if (!ready) {
log('visible/city changed, but is not ready, only refresh list');
return;
}
prevRequestProps.current = requestProps.current;
const refresh = async () => {
log('visible/city changed, start refresh list data');
try {
setDataList([]);
setLoadingMore(true);
setLoadMoreError(false);
const { page, hasMore: more, data: anchorResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(anchorResults);
currentPage.current = page;
!anchorResults.length && onListEmptyRef.current?.();
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
} finally {
log('visible/city changed, refresh list data end');
setLoadingMore(false);
}
};
refresh();
}, [jobId, ready, filters, cityCode, sortType]);
return (
<PullRefresh
className={classNames(`${PREFIX}__pull-refresh`, className)}
loading={refreshing}
onRefresh={handleRefresh}
disabled={refreshDisabled || !ready}
>
<List
hasMore={hasMore}
onLoad={handleLoadMore}
loading={loadingMore || refreshing}
disabled={loadMoreError || !ready}
fixedHeight={typeof listHeight !== 'undefined'}
style={listHeight ? { height: `${listHeight}px` } : undefined}
>
{dataList.map(item => (
<AnchorCard data={item} jobId={jobId} key={item.id} />
))}
<ListPlaceholder hasMore={hasMore} loadingMore={loadingMore} loadMoreError={loadMoreError} />
</List>
</PullRefresh>
);
}
export default AnchorList;

View File

@ -0,0 +1,69 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.anchor-picker {
width: 100%;
background: #FFFFFF;
padding: 24px;
&__title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
margin-top: 32px;
&:first-child {
margin-top: 0;
}
}
&__container {
.flex-row();
flex-wrap: wrap;
margin-top: 16px;
}
&__item {
min-width: 164px;
height: 64px;
font-size: 28px;
line-height: 64px;
font-weight: 400;
text-align: center;
color: @blColor;
background: #F6F6F6;
margin-left: 16px;
border-radius: 32px;
&:first-child {
margin-left: 0;
}
&.selected {
color: @blHighlightColor;
background: @blHighlightBg;
}
}
&__input {
width: 344px;
height: 72px;
font-size: 28px;
line-height: 72px;
font-weight: 400;
color: @blColor;
background: #F6F6F6;
padding: 0 24px;
margin-top: 16px;
border-radius: 12px;
}
&__input-placeholder {
color: @blColorG1;
}
&__toolbar {
margin-top: 20px;
}
}

View File

@ -0,0 +1,186 @@
import { BaseEventOrig, Input, InputProps } from '@tarojs/components';
import classNames from 'classnames';
import { isEqual } from 'lodash-es';
import { useCallback, useState } from 'react';
import PickerToolbar from '@/components/picker-toolbar';
import {
EmployType,
ALL_EMPLOY_TYPES,
FULL_PRICE_OPTIONS,
PART_PRICE_OPTIONS,
EMPLOY_TYPE_TITLE_MAP,
} from '@/constants/job';
import {
ALL_ANCHOR_READ_TYPES,
ALL_GENDER_TYPES,
ANCHOR_READ_TITLE_MAP,
AnchorReadType,
GENDER_TYPE_TITLE_MAP,
GenderType,
} from '@/constants/material';
import { IAnchorFilters } from '@/types/material';
import { isUndefined } from '@/utils/common';
import './index.less';
interface IProps {
value: IAnchorFilters;
onConfirm: (newValue: IAnchorFilters) => void;
}
const PREFIX = 'anchor-picker';
const getDefaultGender = (value: IAnchorFilters) => value.gender;
const getDefaultEmploy = (value: IAnchorFilters) => value.employType;
const getDefaultReadType = (value: IAnchorFilters) => value.readType;
const getDefaultCategory = (value: IAnchorFilters) => value.category || '';
const getSalaryValue = (value: IAnchorFilters, full: boolean) => {
const min = full ? value.lowPriceForFullTime : value.lowPriceForPartyTime;
const max = full ? value.highPriceForFullTime : value.highPriceForPartyTime;
if (!min || !max) {
return null;
}
const options = full ? FULL_PRICE_OPTIONS : PART_PRICE_OPTIONS;
return options.find(v => v.value && v.value.minSalary <= min && v.value.maxSalary >= max)?.value;
};
function AnchorPicker(props: IProps) {
const { value, onConfirm } = props;
const [gender, setGender] = useState<GenderType | undefined>(getDefaultGender(value));
const [readType, setReadType] = useState<AnchorReadType | undefined>(getDefaultReadType(value));
const [employType, setEmployType] = useState<EmployType | undefined>(getDefaultEmploy(value));
const [fullSalary, setFullSalary] = useState(getSalaryValue(value, true));
const [partSalary, setPartSalary] = useState(getSalaryValue(value, false));
const [category, setCategory] = useState(getDefaultCategory(value));
const handleInputCategory = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
setCategory(e.detail.value || '');
}, []);
const handleClickReset = useCallback(() => {
setGender(undefined);
setReadType(undefined);
setEmployType(undefined);
setFullSalary(null);
setPartSalary(null);
setCategory('');
}, []);
const handleSelectFull = useCallback(
(newSalary?: { minSalary: number; maxSalary: number }) => {
setFullSalary(newSalary === fullSalary ? null : newSalary);
},
[fullSalary]
);
const handleSelectPart = useCallback(
(newSalary?: { minSalary: number; maxSalary: number }) => {
setPartSalary(newSalary === partSalary ? null : newSalary);
},
[partSalary]
);
const handleClickConfirm = useCallback(() => {
const filters: IAnchorFilters = {};
if (!isUndefined(gender)) {
filters.gender = gender === GenderType.All ? undefined : gender;
}
employType && (filters.employType = employType);
readType && (filters.readType = readType);
category && (filters.category = category);
if (fullSalary) {
filters.lowPriceForFullTime = fullSalary.minSalary;
filters.highPriceForFullTime = fullSalary.maxSalary;
}
if (partSalary) {
filters.lowPriceForPartyTime = partSalary.minSalary;
filters.highPriceForPartyTime = partSalary.maxSalary;
}
onConfirm(filters);
}, [gender, employType, readType, category, fullSalary, partSalary, onConfirm]);
return (
<div className={PREFIX}>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__container`}>
{ALL_GENDER_TYPES.map((type: GenderType) => (
<div
key={type}
onClick={() => setGender(type)}
className={classNames(`${PREFIX}__item`, { selected: type === gender })}
>
{GENDER_TYPE_TITLE_MAP[type]}
</div>
))}
</div>
<div className={`${PREFIX}__title`}>/</div>
<div className={`${PREFIX}__container`}>
{ALL_EMPLOY_TYPES.map(type => (
<div
key={type}
onClick={() => setEmployType(type)}
className={classNames(`${PREFIX}__item`, { selected: type === employType })}
>
{EMPLOY_TYPE_TITLE_MAP[type]}
</div>
))}
</div>
<div className={`${PREFIX}__title`}>/</div>
<div className={`${PREFIX}__container`}>
{ALL_ANCHOR_READ_TYPES.map(type => (
<div
key={type}
onClick={() => setReadType(type)}
className={classNames(`${PREFIX}__item`, { selected: type === readType })}
>
{ANCHOR_READ_TITLE_MAP[type]}
</div>
))}
</div>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__container`}>
{FULL_PRICE_OPTIONS.map(option => (
<div
key={option.label}
onClick={() => handleSelectFull(option.value)}
className={classNames(`${PREFIX}__item`, { selected: isEqual(option.value, fullSalary) })}
>
{option.label}
</div>
))}
</div>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__container`}>
{PART_PRICE_OPTIONS.map(option => (
<div
key={option.label}
onClick={() => handleSelectPart(option.value)}
className={classNames(`${PREFIX}__item`, { selected: isEqual(option.value, partSalary) })}
>
{option.label}
</div>
))}
</div>
<div className={`${PREFIX}__title`}></div>
<Input
maxlength={20}
value={category}
confirmType="done"
placeholder="如 服装"
onInput={handleInputCategory}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<PickerToolbar
cancelText="重置"
confirmText="确定"
className={`${PREFIX}__toolbar`}
onClickCancel={handleClickReset}
onClickConfirm={handleClickConfirm}
/>
</div>
);
}
export default AnchorPicker;

View File

@ -0,0 +1,13 @@
.badge {
position: absolute;
top: 0;
right: 0;
color: #FFFFFF;
background: #FF5051;
font-size: 24px;
line-height: 34px;
padding: 0 8px;
border-radius: 10px;
border-bottom-left-radius: 0;
transform: translate3d(30%, -50%, 0);
}

View File

@ -0,0 +1,16 @@
import classNames from 'classnames';
import './index.less';
interface IProps {
text: string;
className?: string;
}
const PREFIX = 'badge';
function Badge(props: IProps) {
const { className, text } = props;
return <div className={classNames(PREFIX, className)}>{text}</div>;
}
export default Badge;

View File

@ -0,0 +1,21 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.profile-checkbox {
&__group {
width: 100%;
height: 100%;
.flex-row();
}
&__item {
flex: 1;
height: 100%;
}
&__icon {
width: 36px;
height: 36px;
}
}

View File

@ -0,0 +1,43 @@
import { Image } from '@tarojs/components';
import { Checkbox } from '@taroify/core';
import { CheckboxProps, CheckboxGroupProps } from '@taroify/core/checkbox';
import './index.less';
interface IProps extends CheckboxProps {
text: string;
value: BL.Anything[];
}
interface IGroupProps extends CheckboxGroupProps {}
const PREFIX = 'profile-checkbox';
export function BlCheckboxGroup(props: IGroupProps) {
return <Checkbox.Group className={`${PREFIX}__group`} direction="horizontal" {...props} />;
}
export function BlCheckbox(props: IProps) {
const { name, text, value } = props;
return (
<Checkbox
className={`${PREFIX}__item`}
name={name}
icon={
<Image
className={`${PREFIX}__icon`}
mode="aspectFit"
src={
value.includes(name)
? require('@/statics/svg/radio-checked.svg')
: require('@/statics/svg/radio-uncheck.svg')
}
/>
}
>
{text}
</Checkbox>
);
}

View File

@ -0,0 +1,28 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-form-cell {
height: 100%;
width: 100%;
.flex-row();
&__text {
flex: 1;
font-size: 32px;
line-height: 32px;
color: #CCCCCC;
.noWrap();
&.hasText {
color: @blColor;
}
}
&__right-icon {
.flex-row();
height: 48px;
font-size: 32px;
line-height: 48px;
color: #969799;
}
}

View File

@ -0,0 +1,25 @@
import { ArrowRight } from '@taroify/icons';
import classNames from 'classnames';
import './index.less';
interface IProps {
text: string;
placeholder?: string;
onClick?: () => void;
}
const PREFIX = 'bl-form-cell';
function BlFormCell(props: IProps) {
const { text, placeholder, onClick } = props;
return (
<div className={PREFIX} onClick={onClick}>
<div className={classNames(`${PREFIX}__text`, { hasText: !!text })}>{text || placeholder}</div>
<ArrowRight className={`${PREFIX}__right-icon`} />
</div>
);
}
export default BlFormCell;

View File

@ -0,0 +1,36 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-form-input {
width: 100%;
.flex-row();
&__input {
height: 60px;
flex: 1;
font-size: 32px;
line-height: 60px;
color: @blColor;
}
&__input-placeholder {
font-size: 32px;
line-height: 32px;
color: #CCCCCC;
}
&__right-text {
font-size: 32px;
line-height: 32px;
color: @blColor;
margin-left: 10px;
}
&__max-length-tips {
font-size: 28px;
line-height: 32px;
color: @blColorG1;
margin-left: 10px;
}
}

View File

@ -0,0 +1,35 @@
import { Input, InputProps } from '@tarojs/components';
import './index.less';
interface IProps extends InputProps {
rightText?: string;
maxLengthTips?: boolean;
}
const PREFIX = 'bl-form-input';
function BlFormInput(props: IProps) {
const { value, maxlength = 140, maxLengthTips, rightText, onInput, ...otherProps } = props;
return (
<div className={PREFIX}>
<Input
value={value}
maxlength={maxlength}
confirmType="done"
placeholder="请输入"
onInput={onInput}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
{...otherProps}
/>
{rightText && <div className={`${PREFIX}__right-text`}>{rightText}</div>}
{maxLengthTips && maxlength && (
<div className={`${PREFIX}__max-length-tips`}>{`${(value || '').length}/${maxlength}`}</div>
)}
</div>
);
}
export default BlFormInput;

View File

@ -0,0 +1,45 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-form-item {
width: 100%;
margin-bottom: 40px;
&:last-child {
margin-bottom: 0;
}
&__header {
.flex-row();
&__title {
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blColor;
}
&__type {
font-size: 24px;
line-height: 32px;
font-weight: 400;
color: @blColorG1;
margin-left: 4px;
}
}
&__content {
width: 100%;
height: 100px;
background: #FFFFFF;
border-radius: 16px;
.flex-row();
margin-top: 24px;
padding: 0 32px;
box-sizing: border-box;
&.dynamicHeight {
height: fit-content;
}
}
}

View File

@ -0,0 +1,42 @@
import classNames from 'classnames';
import React from 'react';
import './index.less';
interface IProps extends React.PropsWithChildren {
title: string;
subTitle?: string | boolean;
optional?: boolean;
dynamicHeight?: boolean;
className?: string;
contentClassName?: string;
}
const PREFIX = 'bl-form-item';
function BlFormItem(props: IProps) {
const {
children,
className,
contentClassName,
title,
subTitle = true,
optional = false,
dynamicHeight = false,
} = props;
return (
<div className={classNames(PREFIX, className)}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header__title`}>{title}</div>
{subTitle !== false && (
<div
className={`${PREFIX}__header__type`}
>{`(${typeof subTitle === 'string' ? subTitle : optional ? '建议填写' : '必填'})`}</div>
)}
</div>
<div className={classNames(`${PREFIX}__content`, contentClassName, { dynamicHeight })}>{children}</div>
</div>
);
}
export default BlFormItem;

View File

@ -0,0 +1,19 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-form-radio {
&__group {
width: 100%;
height: 100%;
}
&__item {
flex: 1;
}
&__icon {
width: 36px;
height: 36px;
}
}

View File

@ -0,0 +1,39 @@
import { Image } from '@tarojs/components';
import { Radio } from '@taroify/core';
import { RadioGroupProps, RadioProps } from '@taroify/core/radio';
import './index.less';
interface IProps extends RadioProps {
text: string;
value: BL.Anything;
}
interface IGroupProps extends RadioGroupProps {}
const PREFIX = 'bl-form-radio';
export function BlFormRadioGroup(props: IGroupProps) {
return <Radio.Group className={`${PREFIX}__group`} {...props} />;
}
export function BlFormRadio(props: IProps) {
const { name, text, value } = props;
return (
<Radio
className={`${PREFIX}__item`}
name={name}
icon={
<Image
className={`${PREFIX}__icon`}
mode="aspectFit"
src={value === name ? require('@/statics/svg/radio-checked.svg') : require('@/statics/svg/radio-uncheck.svg')}
/>
}
>
{text}
</Radio>
);
}

View File

@ -0,0 +1,30 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-form-select {
height: 100%;
width: 100%;
.flex-row();
&__input {
flex: 1;
height: 100%;
font-size: 32px;
line-height: 32px;
color: @blColor;
}
&__input-placeholder {
font-size: 32px;
line-height: 32px;
color: #CCCCCC;
}
&__right-icon {
.flex-row();
height: 48px;
font-size: 32px;
line-height: 48px;
color: #969799;
}
}

View File

@ -0,0 +1,50 @@
import { Input } from '@tarojs/components';
import { ArrowRight } from '@taroify/icons';
import { useCallback, useState } from 'react';
import { ISelectProps, PopupSelect } from '@/components/select';
import './index.less';
interface IProps extends ISelectProps {}
const PREFIX = 'bl-form-select';
function BlFormSelect(props: IProps) {
const { value, options, onSelect, ...otherProps } = props;
const [showSelect, setShowSelect] = useState(false);
const handleSelect = useCallback(
newValue => {
setShowSelect(false);
onSelect(newValue);
},
[onSelect]
);
return (
<>
<div className={PREFIX} onClick={() => setShowSelect(true)}>
<Input
disabled
placeholder="请选择"
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
value={options.find(i => i.value === value)?.label || ''}
/>
<ArrowRight className={`${PREFIX}__right-icon`} />
</div>
<PopupSelect
value={value}
options={options}
open={showSelect}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}
{...otherProps}
/>
</>
);
}
export default BlFormSelect;

View File

@ -0,0 +1,71 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-salary-input {
&__item {
height: 100px;
.flex-row();
position: relative;
&::after {
content: "";
height: 2px;
background: #00000026;
position: absolute;
top: 0;
left: 0;
right: -32px;
}
&:first-child {
&::after {
height: 0;
}
}
}
.text(@fontSize: 28px) {
font-size: @fontSize;
line-height: 1;
font-weight: 400;
color: @blColor;
white-space: nowrap
}
&__title {
.text(@fontSize: 32px);
margin-right: 40px;
}
&__input-container {
height: 72px;
flex: 1;
.flex-row();
border-radius: 16px;
background: #F6F6F6;
padding: 0 16px;
}
&__input {
.text();
flex: 1;
height: 72px;
line-height: 72px;
}
&__input-placeholder {
color: @blColorG1;
}
&__unit {
.text();
margin-left: 16px;
}
&__center-divider {
.text();
margin: 0 12px;
}
}

View File

@ -0,0 +1,155 @@
import { BaseEventOrig, Input, InputProps } from '@tarojs/components';
import { useCallback, useEffect } from 'react';
import { logWithPrefix, string2Number } from '@/utils/common';
import { EmployType } from '@/constants/job';
import './index.less';
import { isFullTimePriceRequired, isPartTimePriceRequired } from '@/utils/job';
export type BlSalaryValue = [number, number, number, number];
interface IProps {
value: BlSalaryValue;
employType?: EmployType;
onChange: (result: BlSalaryValue) => void;
}
const PREFIX = 'bl-salary-input';
const log = logWithPrefix(PREFIX);
const MAX_FULL_PRICE = 1000;
const MAX_PART_PRICE = 2000;
function BlSalaryInput(props: IProps) {
const { value: initValue = [], onChange, employType } = props;
const [minFull = '', maxFull = '', minPart = '', maxPart = ''] = initValue;
const onInput = useCallback(
(value: number, index: number) => {
const newValue = [...initValue] as BlSalaryValue;
newValue.splice(index, 1, value);
log('onInput', newValue);
onChange(newValue);
},
[initValue, onChange]
);
const handleInputMinFull = useCallback(
(e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
if (Number.isNaN(Number(value))) {
return;
}
onInput(string2Number(value), 0);
},
[onInput]
);
const handleInputMaxFull = useCallback(
(e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
if (Number.isNaN(Number(value))) {
return;
}
onInput(Math.min(string2Number(value), MAX_FULL_PRICE), 1);
},
[onInput]
);
const handleInputMinPart = useCallback(
(e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
if (Number.isNaN(Number(value))) {
return;
}
onInput(string2Number(value), 2);
},
[onInput]
);
const handleInputMaxPart = useCallback(
(e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
if (Number.isNaN(Number(value))) {
return;
}
onInput(Math.min(string2Number(value), MAX_PART_PRICE), 3);
},
[onInput]
);
useEffect(() => {
onChange([minFull, maxFull, minPart, maxPart] as BlSalaryValue);
}, [minFull, maxFull, minPart, maxPart, onChange]);
return (
<div className={PREFIX}>
{isFullTimePriceRequired(employType) && (
<div className={`${PREFIX}__item`}>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__input-container`}>
<Input
type="number"
maxlength={5}
confirmType="done"
placeholder="输入最低值"
value={String(minFull)}
onInput={handleInputMinFull}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<div className={`${PREFIX}__unit`}>K/</div>
</div>
<div className={`${PREFIX}__center-divider`}>-</div>
<div className={`${PREFIX}__input-container`}>
<Input
type="number"
maxlength={5}
confirmType="done"
placeholder="输入最高值"
value={String(maxFull)}
onInput={handleInputMaxFull}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<div className={`${PREFIX}__unit`}>K/</div>
</div>
</div>
)}
{isPartTimePriceRequired(employType) && (
<div className={`${PREFIX}__item`}>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__input-container`}>
<Input
type="number"
maxlength={5}
confirmType="done"
placeholder="输入最低值"
value={String(minPart)}
onInput={handleInputMinPart}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<div className={`${PREFIX}__unit`}>/</div>
</div>
<div className={`${PREFIX}__center-divider`}>-</div>
<div className={`${PREFIX}__input-container`}>
<Input
type="number"
maxlength={5}
confirmType="done"
placeholder="输入最高值"
value={String(maxPart)}
onInput={handleInputMaxPart}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<div className={`${PREFIX}__unit`}>/</div>
</div>
</div>)}
</div>
);
}
export default BlSalaryInput;

View File

@ -0,0 +1,12 @@
.bl-cell {
height: 112px;
padding-left: 24px;
padding-right: 24px;
border-radius: 16px;
&__right-text {
font-size: 28px;
font-weight: 400;
color: @blColor;
}
}

View File

@ -0,0 +1,22 @@
import { Cell } from '@taroify/core';
import { CellProps } from '@taroify/core/cell';
import classNames from 'classnames';
import './index.less';
interface IProps extends CellProps {
rightText?: string;
}
const PREFIX = 'bl-cell';
function BlCell(props: IProps) {
const { className, rightText, ...otherProps } = props;
return (
<Cell className={classNames(PREFIX, className)} {...otherProps}>
{rightText && <div className={`${PREFIX}__right-text`}>{rightText}</div>}
</Cell>
);
}
export default BlCell;

View File

@ -0,0 +1,74 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.certification-status {
.taroify-cell__title {
flex: unset;
margin-right: 24px;
}
&__content {
.flex-row();
justify-content: space-between;
}
&__right-text {
font-size: 28px;
font-weight: 400;
color: @blColor;
}
}
.certification-status-icon {
height: 38px;
.flex-row();
padding: 0 8px;
border-radius: 4px;
&.none {
color: @blColorG1;
background: #F7F7F7;
}
&.success {
color: #117264;
background: #DCF7F0;
}
&.fail {
color: #FF5051;
background: #FF50511F;
}
&__icon {
width: 24px;
height: 24px;
}
&__describe {
font-size: 24px;
line-height: 24px;
font-weight: 400;
margin-left: 6px;
white-space: nowrap;
}
&.small {
height: 30px;
padding: 0 6px;
&__icon {
width: 22px;
height: 22px;
}
&__describe {
font-size: 20px;
line-height: 24px;
font-weight: 400;
margin-left: 4px;
}
}
}

View File

@ -0,0 +1,109 @@
import { Image } from '@tarojs/components';
import { Cell } from '@taroify/core';
import classNames from 'classnames';
import { useCallback } from 'react';
import { PageUrl } from '@/constants/app';
import { CertificationStatusType } from '@/constants/company';
import useUserInfo from '@/hooks/use-user-info';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps {
className?: string;
}
interface IIconProps {
status: CertificationStatusType;
className?: string;
small?: boolean;
}
const PREFIX = 'certification-status';
const ICON_PREFIX = 'certification-status-icon';
const getStatusClassName = (status: CertificationStatusType) => {
switch (status) {
case CertificationStatusType.None:
return 'none';
case CertificationStatusType.Success:
return 'success';
case CertificationStatusType.Fail:
return 'fail';
default:
return '';
}
};
const getStatusIconUrl = (status: CertificationStatusType) => {
switch (status) {
case CertificationStatusType.None:
return require('@/statics/svg/certification-status-none.svg');
case CertificationStatusType.Success:
return require('@/statics/svg/certification-status-success.svg');
case CertificationStatusType.Fail:
return require('@/statics/svg/certification-status-fail.svg');
default:
return;
}
};
const getStatusDescribe = (status: CertificationStatusType) => {
switch (status) {
case CertificationStatusType.None:
return '未认证';
case CertificationStatusType.Success:
return '实人认证';
case CertificationStatusType.Fail:
return '认证失败';
default:
return;
}
};
const getRightText = (status: CertificationStatusType) => {
switch (status) {
case CertificationStatusType.None:
return '去认证';
case CertificationStatusType.Fail:
return '重新认证';
default:
return;
}
};
export function CertificationStatusIcon(props: IIconProps) {
const { status, className, small = false } = props;
return (
<div className={classNames(ICON_PREFIX, className, { [getStatusClassName(status)]: true, small })}>
<Image mode="aspectFit" className={`${ICON_PREFIX}__icon`} src={getStatusIconUrl(status)} />
<div className={`${ICON_PREFIX}__describe`}>{getStatusDescribe(status)}</div>
</div>
);
}
function CertificationStatus(props: IProps) {
const { className } = props;
const { bossAuthStatus: status = CertificationStatusType.None } = useUserInfo();
const handleClick = useCallback(() => {
if (status !== CertificationStatusType.Success) {
navigateTo(PageUrl.CertificationStart);
}
}, [status]);
return (
<Cell
align="center"
title="认证状态"
className={classNames(PREFIX, className)}
isLink={status !== CertificationStatusType.Success}
onClick={handleClick}
>
<div className={`${PREFIX}__content`}>
<CertificationStatusIcon status={status} />
<div className={`${PREFIX}__right-text`}>{getRightText(status)}</div>
</div>
</Cell>
);
}
export default CertificationStatus;

View File

@ -0,0 +1,4 @@
@import '@/styles/variables.less';
.city-picker {
}

View File

@ -0,0 +1,70 @@
import { AreaPicker, Popup } from '@taroify/core';
import { AreaData } from '@taroify/core/area-picker/area-picker.shared';
import { useCallback, useRef } from 'react';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { CITY_LIST, COUNTY_LIST, PROVINCE_LIST } from '@/constants/city';
import './index.less';
interface IProps {
depth?: 1 | 2 | 3;
areaValues?: string[];
onCancel: () => void;
onConfirm: (areaValues: string[]) => void;
}
interface IPopupProps extends IProps {
open: boolean;
}
const PREFIX = 'city-picker';
const areaList: AreaData = {
province_list: PROVINCE_LIST,
city_list: CITY_LIST,
county_list: COUNTY_LIST,
};
const DEFAULT_AREA = ['110000', '110100', '110101'];
function CityPicker(props: IProps) {
const { areaValues = DEFAULT_AREA, depth = 3, onCancel, onConfirm } = props;
const provinceCodeRef = useRef(areaValues[0]);
const cityCodeRef = useRef(areaValues[1]);
const countyCodeRef = useRef(areaValues[2]);
const handleClickConfirm = useCallback(() => {
onConfirm([provinceCodeRef.current, cityCodeRef.current, countyCodeRef.current]);
}, [onConfirm]);
const handleChange = useCallback((values: string[]) => {
provinceCodeRef.current = values[0];
cityCodeRef.current = values[1];
countyCodeRef.current = values[2];
}, []);
return (
<div className={PREFIX}>
<AreaPicker
depth={depth}
className={`${PREFIX}__area-picker`}
areaList={areaList}
defaultValue={areaValues}
onCancel={onCancel}
onChange={handleChange}
onConfirm={handleClickConfirm}
></AreaPicker>
</div>
);
}
export function CityPickerPopup(props: IPopupProps) {
const { open, onCancel } = props;
return (
<Popup className={PREFIX} placement="bottom" open={open} onClose={onCancel}>
<CityPicker {...props} />
<SafeBottomPadding />
</Popup>
);
}
export default CityPicker;

View File

@ -0,0 +1,32 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.common-dialog {
&__container {
display: flex;
flex-direction: column;
align-items: center;
}
&__title {
font-size: 36px;
line-height: 58px;
font-weight: 500;
color: @blColor;
}
&__confirm-button,
&__cancel-button {
.button(@width: 360px, @height: 72px, @fontSize: 28px, @fontWeight: 400, @borderRadius: 44px);
}
&__confirm-button {
margin-top: 48px;
}
&__cancel-button {
color: @blHighlightColor;
background: transparent;
margin-top: 40px;
}
}

View File

@ -0,0 +1,47 @@
import { Button } from '@tarojs/components';
import { Dialog } from '@taroify/core';
import classNames from 'classnames';
import { PropsWithChildren } from 'react';
import './index.less';
interface IProps extends PropsWithChildren {
visible: boolean;
content: string;
onClose?: () => void;
className?: string;
confirm?: string;
cancel?: string;
onClick?: () => void;
onCancel?: () => void;
}
const PREFIX = 'common-dialog';
function CommonDialog(props: IProps) {
const { visible, content, confirm, cancel, className, children, onClose, onClick, onCancel } = props;
return (
<Dialog className={classNames(PREFIX, className)} open={visible} onClose={onClose}>
<Dialog.Content>
<div className={`${PREFIX}__container`}>
<div className={`${PREFIX}__title`}>{content}</div>
{children}
{confirm && (
<Button className={`${PREFIX}__confirm-button`} onClick={onClick}>
{confirm}
</Button>
)}
{cancel && (
<Button className={`${PREFIX}__cancel-button`} onClick={onCancel}>
{cancel}
</Button>
)}
</div>
</Dialog.Content>
</Dialog>
);
}
export default CommonDialog;

View File

@ -0,0 +1,8 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.bl-navigation-bar {
.flex-row();
padding: 0 24px;
}

View File

@ -0,0 +1,30 @@
import Taro from '@tarojs/taro';
import classNames from 'classnames';
import { PropsWithChildren } from 'react';
import useNavigation from '@/hooks/use-navigation';
import './index.less';
interface IProps extends PropsWithChildren {
className?: string;
}
const PREFIX = 'bl-navigation-bar';
function CustomNavigationBar(props: IProps) {
const { className, children } = props;
const { barHeight, statusBarHeight } = useNavigation();
return (
<div
className={classNames(PREFIX, className)}
style={{ height: Taro.pxTransform(barHeight.current), paddingTop: statusBarHeight.current }}
>
{children}
</div>
);
}
export default CustomNavigationBar;

View File

@ -0,0 +1,43 @@
import React, { useCallback, useRef } from 'react';
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
OnDev?: () => void;
}
const CLICK_COUNT = 5;
function DevDiv(props: IProps) {
const { OnDev, onClick, ...otherProps } = props;
const lastClickTime = useRef(0);
const clickCount = useRef(0);
const handleClick = useCallback(
e => {
onClick?.(e);
if (!OnDev) {
return;
}
const currentTime = Date.now();
const timeDiff = currentTime - lastClickTime.current;
if (timeDiff < 300) {
clickCount.current = clickCount.current + 1;
if (clickCount.current >= CLICK_COUNT) {
OnDev?.();
clickCount.current = 0;
}
} else {
clickCount.current = 1;
}
lastClickTime.current = currentTime;
},
[OnDev, onClick]
);
return <div onClick={handleClick} {...otherProps}></div>;
}
export default DevDiv;

View File

@ -0,0 +1,92 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.group-card {
width: 100%;
padding: 32px;
background: #FFF;
border-radius: 16px;
margin-bottom: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
&__group-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
}
&__group-avatar {
width: 88px;
height: 88px;
}
&__group-info {
width: 340px;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-left: 32px;
&.full {
flex: 1;
}
}
&__group-title-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
&__group-title {
flex: 1;
font-size: 32px;
line-height: 40px;
font-weight: 500;
color: @blColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__group-full-icon {
font-size: 20px;
line-height: 30px;
font-weight: 400;
color: @blHighlightColor;
border: 2px solid @blHighlightColor;
border-radius: 4px;
padding: 0 6px;
margin-left: 10px;
margin-bottom: 1px;
}
&__group-desc {
display: flex;
flex-direction: row;
align-items: center;
font-size: 28px;
line-height: 40px;
font-weight: 400;
margin-top: 16px;
}
&__group-job-count {
color: @blColor;
}
&__group-view {
color: @blHighlightColor;
margin-left: 10px;
}
&__group-button {
.button(@width: 176px; @height: 56px; @fontSize: 28px; @fontWeight: 500; @borderRadius: 48px;);
}
}

View File

@ -0,0 +1,61 @@
import { Image } from '@tarojs/components';
import classNames from 'classnames';
import { useCallback } from 'react';
import LoginButton from '@/components/login-button';
import { PageUrl } from '@/constants/app';
import { GroupType } from '@/constants/group';
import { GroupInfo } from '@/types/group';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps {
type: GroupType;
data: GroupInfo;
onClick: () => void;
}
const PREFIX = 'group-card';
function GroupCard(props: IProps) {
const { type, data, onClick } = props;
const showButton = type === GroupType.All;
const isFull = (data.groupMemberCount || 0) >= 500;
const handleClickView = useCallback(() => {
navigateTo(PageUrl.GroupJob, { groupId: data.blGroupId, title: data.imGroupNick });
}, [data]);
return (
<div className={PREFIX}>
<div className={`${PREFIX}__group-container`}>
<Image
mode="aspectFit"
className={`${PREFIX}__group-avatar`}
src={data.groupAvatar || require('@/statics/svg/wechat.svg')}
/>
<div className={classNames(`${PREFIX}__group-info`, { full: !showButton })}>
<div className={`${PREFIX}__group-title-container`}>
<div className={`${PREFIX}__group-title`}>{data.imGroupNick}</div>
{isFull && <div className={`${PREFIX}__group-full-icon`}></div>}
</div>
<div className={`${PREFIX}__group-desc`}>
<div className={`${PREFIX}__group-job-count`}>{`${data.allJobs}条通告`}</div>
<div className={`${PREFIX}__group-view`} onClick={handleClickView}>
</div>
</div>
</div>
</div>
{showButton && (
<LoginButton className={`${PREFIX}__group-button`} onClick={onClick}>
</LoginButton>
)}
</div>
);
}
export default GroupCard;

View File

@ -0,0 +1,3 @@
@import '@/styles/variables.less';
.group-list {}

View File

@ -0,0 +1,149 @@
import { List, PullRefresh } from '@taroify/core';
import classNames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'react';
import GroupCard from '@/components/group-card';
import ListPlaceholder from '@/components/list-placeholder';
import { GroupInfo, GetGroupsRequest } from '@/types/group';
import { logWithPrefix } from '@/utils/common';
import { requestGroupList } from '@/utils/group';
import './index.less';
interface IRequestProps extends GetGroupsRequest {}
export interface IGroupListProps extends IRequestProps {
refreshDisabled?: boolean;
listHeight?: number;
className?: string;
onListEmpty?: () => void;
onClickInvite: (data: GroupInfo) => void;
}
const FIRST_PAGE = 0;
const PAGE_SIZE = 40;
const PREFIX = 'group-list';
const log = logWithPrefix(PREFIX);
function JobList(props: IGroupListProps) {
const { className, listHeight, refreshDisabled, type, imGroupNick, status, onListEmpty, onClickInvite } = props;
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState(false);
const [dataList, setDataList] = useState<GroupInfo[]>([]);
const currentPage = useRef<number>(FIRST_PAGE);
const requestProps = useRef<IRequestProps>({ type });
const onListEmptyRef = useRef(onListEmpty);
const handleRefresh = useCallback(async () => {
log('start pull refresh');
try {
setRefreshing(true);
setLoadMoreError(false);
const { page, hasMore: more, groupResults } = await requestGroupList({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(groupResults);
currentPage.current = page;
!groupResults.length && onListEmptyRef.current?.();
log('pull refresh success');
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
log('pull refresh failed');
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
log('start load more', hasMore);
if (!hasMore) {
return;
}
setLoadMoreError(false);
setLoadingMore(true);
try {
const {
page,
hasMore: more,
groupResults,
} = await requestGroupList({ ...requestProps.current, page: currentPage.current + 1 });
setDataList([...dataList, ...groupResults]);
setHasMore(more);
currentPage.current = page;
log('load more success');
} catch (e) {
setLoadMoreError(true);
log('load more failed');
} finally {
setLoadingMore(false);
}
}, [dataList, currentPage, hasMore]);
useEffect(() => {
onListEmptyRef.current = onListEmpty;
}, [onListEmpty]);
useEffect(() => {
log('request params changed');
requestProps.current = {
type,
status,
imGroupNick: imGroupNick ? imGroupNick.trim() : undefined,
pageSize: PAGE_SIZE,
};
}, [type, status, imGroupNick]);
useEffect(() => {
const refresh = async () => {
log('props changed, start refresh list data');
try {
setDataList([]);
setLoadingMore(true);
setLoadMoreError(false);
const { page, hasMore: more, groupResults } = await requestGroupList({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(groupResults);
currentPage.current = page;
!groupResults.length && onListEmptyRef.current?.();
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
} finally {
log('props changed, refresh list data end');
setLoadingMore(false);
}
};
refresh();
}, [type, status, imGroupNick]);
return (
<PullRefresh
className={classNames(`${PREFIX}__pull-refresh`, className)}
loading={refreshing}
onRefresh={handleRefresh}
disabled={refreshDisabled}
>
<List
hasMore={hasMore}
onLoad={handleLoadMore}
loading={loadingMore || refreshing}
disabled={loadMoreError}
fixedHeight={typeof listHeight !== 'undefined'}
style={listHeight ? { height: `${listHeight}px` } : undefined}
>
{dataList.map(item => (
<GroupCard type={type} data={item} key={item.blGroupId} onClick={() => onClickInvite(item)} />
))}
<ListPlaceholder hasMore={hasMore} loadingMore={loadingMore} loadMoreError={loadMoreError} />
</List>
</PullRefresh>
);
}
export default JobList;

View File

View File

@ -0,0 +1,18 @@
import React from 'react';
import BaseTabBar from '@/components/tab-bar';
import './index.less';
interface IProps extends React.PropsWithChildren {}
export default function HomePage(props: IProps) {
const { children } = props;
return (
<React.Fragment>
{children}
<BaseTabBar />
</React.Fragment>
);
}

View File

@ -0,0 +1,163 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.job-card {
&__container {
width: 100%;
height: fit-content;
padding: 24px;
box-sizing: border-box;
overflow: hidden;
border-radius: 16px;
background: #FFFFFF;
margin-bottom: 24px;
}
&__header {
width: 100%;
.flex-row();
align-items: flex-start;
}
&__title {
font-size: 30px;
line-height: 32px;
font-weight: 500;
color: @blColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__employment-type {
width: fit-content;
padding: 3px 6px;
font-size: 20px;
line-height: 24px;
font-weight: 400;
background: @blHighlightBg;
color: @blHighlightColor;
white-space: nowrap;
margin-left: 8px;
&__wrapper {
flex: 1;
}
}
&__certification-type {
margin-left: 8px;
}
&__tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
overflow: hidden;
margin-top: 32px;
// 抵消最后一行的 margin-bottom
margin-bottom: -10px;
}
&__tag {
padding: 3px 6px;
font-size: 20px;
line-height: 24px;
font-weight: 400;
background: #F2F2F2;
color: @blColorG2;
white-space: nowrap;
margin-right: 8px;
margin-bottom: 10px;
}
&__salary {
font-size: 30px;
line-height: 32px;
font: 400;
color: @blHighlightColor;
margin-top: 32px;
}
&__content {
margin-top: 20px;
}
&__summary {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__distance-wrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 8px;
}
&__detailed-address {
flex: 1 1;
font-size: 28px;
font-weight: 400;
color: @blColorG1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__distance-icon {
width: 28px;
height: 28px;
margin-left: 15px;
margin-right: 6px;
}
&__distance {
font-size: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG1;
}
&__divider {
width: 100%;
height: 1px;
background: #E0E0E0;
margin-top: 32px;
margin-bottom: 24px;
}
&__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
&__publisher {
display: flex;
flex-direction: row;
align-items: center;
}
&__publisher-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 18px;
}
&__publisher-name,
&__city {
font-size: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG2;
}
}

View File

@ -0,0 +1,111 @@
import { Image } from '@tarojs/components';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { CertificationStatusIcon } from '@/components/certification-status';
import { PageUrl } from '@/constants/app';
import { CITY_CODE_TO_NAME_MAP, COUNTY_CODE_TO_NAME_MAP } from '@/constants/city';
import { CertificationStatusType } from '@/constants/company';
import { EMPLOY_TYPE_TITLE_MAP, EmployType } from '@/constants/job';
import { JobInfo } from '@/types/job';
import { LocationInfo } from '@/types/location';
import { getJobSalary, getJobTitle } from '@/utils/job';
import { calcDistance } from '@/utils/location';
import { navigateTo, redirectTo } from '@/utils/route';
import './index.less';
interface IProps {
data: JobInfo;
redirectOpen?: boolean;
className?: string;
}
const PREFIX = 'job-card';
const getCityDes = (location: LocationInfo) => {
if (!location) {
return '';
}
let des = CITY_CODE_TO_NAME_MAP.get(location.cityCode);
if (location.countyCode) {
des += `-${COUNTY_CODE_TO_NAME_MAP.get(location.countyCode)}`;
}
return des;
};
function JobCard(props: IProps) {
const { className, data, redirectOpen } = props;
const {
id,
tags = [],
employType = EmployType.All,
jobDescription,
sourceText,
publisher,
publisherAvatar,
jobLocation,
distance,
isAuthed = false,
} = data;
const handleClickCard = useCallback(() => {
if (redirectOpen) {
redirectTo(PageUrl.JobDetail, { id });
} else {
navigateTo(PageUrl.JobDetail, { id });
}
}, [id, redirectOpen]);
return (
<div className={classNames(`${PREFIX}__container`, className)} onClick={handleClickCard}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__title`}>{getJobTitle(data)}</div>
<div className={`${PREFIX}__employment-type__wrapper`}>
<div className={`${PREFIX}__employment-type`}>{EMPLOY_TYPE_TITLE_MAP[employType]}</div>
</div>
{isAuthed && (
<CertificationStatusIcon
className={`${PREFIX}__certification-type`}
status={CertificationStatusType.Success}
small
/>
)}
</div>
<div className={`${PREFIX}__tags`}>
{tags.map((keyword: string, index) => (
<div className={`${PREFIX}__tag`} key={index}>
{keyword}
</div>
))}
</div>
<div className={`${PREFIX}__salary`}>{getJobSalary(data) || '见描述'}</div>
<div className={`${PREFIX}__content`}>
<div className={`${PREFIX}__summary`}>{jobDescription || sourceText}</div>
<div className={`${PREFIX}__distance-wrapper`}>
<div className={`${PREFIX}__detailed-address`}>{jobLocation?.address}</div>
{distance && (
<>
<Image className={`${PREFIX}__distance-icon`} src={require('@/statics/svg/location.svg')} />
<div className={`${PREFIX}__distance`}>{calcDistance(distance)}</div>
</>
)}
</div>
</div>
<div className={`${PREFIX}__divider`} />
<div className={`${PREFIX}__footer`}>
<div className={`${PREFIX}__publisher`}>
<Image
mode="aspectFit"
className={`${PREFIX}__publisher-avatar`}
src={publisherAvatar || require('@/statics/svg/wechat.svg')}
/>
<div className={`${PREFIX}__publisher-name`}>{publisher}</div>
</div>
<div className={`${PREFIX}__city`}>{getCityDes(jobLocation)}</div>
</div>
</div>
);
}
export default React.memo(JobCard);

View File

View File

@ -0,0 +1,207 @@
import { List, PullRefresh } from '@taroify/core';
import classNames from 'classnames';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef, useState } from 'react';
import JobCard from '@/components/job-card';
import ListPlaceholder from '@/components/list-placeholder';
import { JobType, EmployType, SortType } from '@/constants/job';
import { JobInfo, GetJobsRequest } from '@/types/job';
import { logWithPrefix } from '@/utils/common';
import { requestJobList as requestData } from '@/utils/job';
import './index.less';
interface IRequestProps extends Partial<GetJobsRequest> {}
export interface IJobListProps extends IRequestProps {
visible?: boolean;
refreshDisabled?: boolean;
listHeight?: number;
className?: string;
onListEmpty?: () => void;
}
const FIRST_PAGE = 0;
const PAGE_SIZE = 40;
const PREFIX = 'job-list';
const log = logWithPrefix(PREFIX);
function JobList(props: IJobListProps) {
const {
className,
listHeight,
refreshDisabled,
visible = true,
cityCode = 'ALL',
category = JobType.All,
employType = EmployType.All,
sortType = SortType.RECOMMEND,
isFollow = false,
isOwner = false,
keyWord,
latitude,
longitude,
minSalary,
maxSalary,
blGroupId,
onListEmpty,
} = props;
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState(false);
const [dataList, setDataList] = useState<JobInfo[]>([]);
const currentPage = useRef<number>(FIRST_PAGE);
const requestProps = useRef<IRequestProps>({});
const prevRequestProps = useRef<IRequestProps>({});
const onListEmptyRef = useRef(onListEmpty);
const handleRefresh = useCallback(async () => {
log('start pull refresh');
try {
setRefreshing(true);
setLoadMoreError(false);
const { page, hasMore: more, jobResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(jobResults);
currentPage.current = page;
!jobResults.length && onListEmptyRef.current?.();
log('pull refresh success');
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
log('pull refresh failed');
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
log('start load more', hasMore);
if (!hasMore) {
return;
}
setLoadMoreError(false);
setLoadingMore(true);
try {
const {
page,
hasMore: more,
jobResults,
} = await requestData({ ...requestProps.current, page: currentPage.current + 1 });
setDataList([...dataList, ...jobResults]);
setHasMore(more);
currentPage.current = page;
log('load more success');
} catch (e) {
setLoadMoreError(true);
log('load more failed');
} finally {
setLoadingMore(false);
}
}, [dataList, currentPage, hasMore]);
useEffect(() => {
onListEmptyRef.current = onListEmpty;
}, [onListEmpty]);
useEffect(() => {
log('request params changed');
requestProps.current = {
category,
cityCode,
employType,
sortType,
isFollow,
isOwner,
keyWord,
latitude,
longitude,
minSalary,
maxSalary,
blGroupId,
pageSize: PAGE_SIZE,
};
}, [
category,
cityCode,
employType,
sortType,
isFollow,
isOwner,
keyWord,
latitude,
longitude,
minSalary,
maxSalary,
blGroupId,
]);
// 初始化数据&配置变更后刷新数据
useEffect(() => {
// 相比前一次可见时没有数据变更时,不再重新请求
if (isEqual(prevRequestProps.current, requestProps.current)) {
log('visible/city changed, but request params not change, ignore');
return;
}
// 列表不可见时,先不做处理
if (!visible) {
log('visible/city changed, but is not visible, only clear list');
return;
}
prevRequestProps.current = requestProps.current;
const refresh = async () => {
log('visible/city changed, start refresh list data');
try {
setDataList([]);
setLoadingMore(true);
setLoadMoreError(false);
const { page, hasMore: more, jobResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(jobResults);
currentPage.current = page;
!jobResults.length && onListEmptyRef.current?.();
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
} finally {
log('visible/city changed, refresh list data end');
setLoadingMore(false);
}
};
refresh();
}, [visible, cityCode, employType, sortType, keyWord, minSalary, maxSalary, blGroupId]);
// log('render', `hasMore: ${hasMore}, loadingMore: ${loadingMore}, refreshing: ${refreshing}`);
return (
<PullRefresh
className={classNames(`${PREFIX}__pull-refresh`, className)}
loading={refreshing}
onRefresh={handleRefresh}
disabled={refreshDisabled}
>
<List
hasMore={hasMore}
onLoad={handleLoadMore}
loading={loadingMore || refreshing}
disabled={loadMoreError}
fixedHeight={typeof listHeight !== 'undefined'}
style={listHeight ? { height: `${listHeight}px` } : undefined}
>
{dataList.map(item => (
<JobCard data={item} key={item.id} />
))}
<ListPlaceholder hasMore={hasMore} loadingMore={loadingMore} loadMoreError={loadMoreError} />
</List>
</PullRefresh>
);
}
export default JobList;

View File

@ -0,0 +1,69 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.job-manage-card {
width: 100%;
height: 152px;
.flex-row();
padding: 24px 32px;
background: #FFF;
box-sizing: border-box;
position: relative;
&::after {
content: "";
height: 2px;
background: #00000026;
position: absolute;
top: 0;
left: 32px;
right: 0;
}
&:first-child {
&::after {
height: 0;
}
}
&__info {
flex: 1;
height: 100%;
&__title {
max-width: 75vw;
font-size: 32px;
line-height: 48px;
font-weight: 400;
color: @blColor;
.noWrap();
}
&__location {
max-width: 75vw;
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColorG1;
margin-top: 16px;
.noWrap();
}
}
&__status {
height: 100%;
.flex-row();
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blColorG1;
&.open {
color: @blHighlightColor;
}
&.error {
color: #FF5051;
}
}
}

View File

@ -0,0 +1,49 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { PageUrl } from '@/constants/app';
import { JOB_MANAGE_STATUS_TITLE_MAP, JobManageStatus } from '@/constants/job';
import { JobManageInfo } from '@/types/job';
import { getJobLocation } from '@/utils/job';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps {
data: JobManageInfo;
className?: string;
}
const PREFIX = 'job-manage-card';
const STATUS_CLASS_MAP = {
[JobManageStatus.WaitVerify]: 'pending',
[JobManageStatus.Open]: 'open',
[JobManageStatus.Pending]: 'pending',
[JobManageStatus.Error]: 'error',
[JobManageStatus.Close]: 'close',
[JobManageStatus.Expire]: 'close',
};
function JobManageCard(props: IProps) {
const { data = {} } = props;
const { id, title, status } = data as JobManageInfo;
const handleClick = useCallback(() => {
navigateTo(PageUrl.JobDetail, { id });
}, [id]);
return (
<div className={PREFIX} onClick={handleClick}>
<div className={`${PREFIX}__info`}>
<div className={`${PREFIX}__info__title`}>{title}</div>
<div className={`${PREFIX}__info__location`}>{getJobLocation(data as JobManageInfo)}</div>
</div>
<div className={classNames(`${PREFIX}__status`, { [STATUS_CLASS_MAP[status]]: true })}>
<div>{JOB_MANAGE_STATUS_TITLE_MAP[status]}</div>
</div>
</div>
);
}
export default React.memo(JobManageCard);

View File

@ -0,0 +1,2 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';

View File

@ -0,0 +1,171 @@
import Taro from '@tarojs/taro';
import { List, PullRefresh } from '@taroify/core';
import classNames from 'classnames';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef, useState } from 'react';
import JobManageCard from '@/components/job-manage-card';
import ListPlaceholder from '@/components/list-placeholder';
import { EventName } from '@/constants/app';
import { GetJobManagesRequest, JobManageInfo } from '@/types/job';
import { logWithPrefix } from '@/utils/common';
import { requestJobManageList as requestData } from '@/utils/job';
import './index.less';
interface IRequestProps extends Partial<GetJobManagesRequest> {}
export interface IJobManageListProps extends IRequestProps {
visible?: boolean;
refreshDisabled?: boolean;
listHeight?: number;
className?: string;
onListEmpty?: () => void;
}
const FIRST_PAGE = 0;
const PAGE_SIZE = 40;
const PREFIX = 'job-manage-list';
const log = logWithPrefix(PREFIX);
function JobManageList(props: IJobManageListProps) {
const { className, listHeight, refreshDisabled, visible = true, status, onListEmpty } = props;
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState(false);
const [dataList, setDataList] = useState<JobManageInfo[]>([]);
const currentPage = useRef<number>(FIRST_PAGE);
const requestProps = useRef<IRequestProps>({});
const prevRequestProps = useRef<IRequestProps>({});
const onListEmptyRef = useRef(onListEmpty);
const handleRefresh = useCallback(async () => {
log('start pull refresh');
try {
setRefreshing(true);
setLoadMoreError(false);
const { page, hasMore: more, jobResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(jobResults);
currentPage.current = page;
!jobResults.length && onListEmptyRef.current?.();
log('pull refresh success');
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
log('pull refresh failed');
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
log('start load more', hasMore);
if (!hasMore) {
return;
}
setLoadMoreError(false);
setLoadingMore(true);
try {
const {
page,
hasMore: more,
jobResults,
} = await requestData({ ...requestProps.current, page: currentPage.current + 1 });
setDataList([...dataList, ...jobResults]);
setHasMore(more);
currentPage.current = page;
log('load more success');
} catch (e) {
setLoadMoreError(true);
log('load more failed');
} finally {
setLoadingMore(false);
}
}, [dataList, currentPage, hasMore]);
useEffect(() => {
onListEmptyRef.current = onListEmpty;
}, [onListEmpty]);
useEffect(() => {
log('request params changed');
requestProps.current = { status: status, pageSize: PAGE_SIZE };
}, [status]);
// 初始化数据&配置变更后刷新数据
useEffect(() => {
// 相比前一次可见时没有数据变更时,不再重新请求
if (isEqual(prevRequestProps.current, requestProps.current)) {
log('visible/city changed, but request params not change, ignore');
return;
}
// 列表不可见时,先不做处理
if (!visible) {
log('visible/city changed, but is not visible, only clear list');
return;
}
prevRequestProps.current = requestProps.current;
const refresh = async () => {
log('visible/city changed, start refresh list data');
try {
setDataList([]);
setLoadingMore(true);
setLoadMoreError(false);
const { page, hasMore: more, jobResults } = await requestData({ ...requestProps.current, page: 1 });
setHasMore(more);
setDataList(jobResults);
currentPage.current = page;
!jobResults.length && onListEmptyRef.current?.();
} catch (e) {
setDataList([]);
setHasMore(false);
setLoadMoreError(true);
currentPage.current = FIRST_PAGE;
} finally {
log('visible/city changed, refresh list data end');
setLoadingMore(false);
}
};
refresh();
}, [visible, status]);
useEffect(() => {
Taro.eventCenter.on(EventName.COMPANY_JOB_PUBLISH_CHANGED, handleRefresh);
return () => {
Taro.eventCenter.off(EventName.COMPANY_JOB_PUBLISH_CHANGED, handleRefresh);
};
}, [handleRefresh]);
// log('render', `hasMore: ${hasMore}, loadingMore: ${loadingMore}, refreshing: ${refreshing}`);
return (
<PullRefresh
className={classNames(`${PREFIX}__pull-refresh`, className)}
loading={refreshing}
onRefresh={handleRefresh}
disabled={refreshDisabled}
>
<List
hasMore={hasMore}
onLoad={handleLoadMore}
loading={loadingMore || refreshing}
disabled={loadMoreError}
fixedHeight={typeof listHeight !== 'undefined'}
style={listHeight ? { height: `${listHeight}px` } : undefined}
>
{dataList.map(item => (
<JobManageCard data={item} key={item.id} />
))}
<ListPlaceholder hasMore={hasMore} loadingMore={loadingMore} loadMoreError={loadMoreError} noMoreText="" />
</List>
</PullRefresh>
);
}
export default JobManageList;

View File

@ -0,0 +1,47 @@
@import '@/styles/variables.less';
.job-type-picker {
background: #FFFFFF;
&__groups-container {
padding: 24px;
}
&__group {
margin-top: 8px;
}
&__group-title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
}
&__group-items-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16px;
}
&__group-item {
min-width: 158px;
height: 64px;
font-size: 28px;
padding: 0 19px;
margin-right: 16px;
margin-bottom: 24px;
box-sizing: border-box;
border-radius: 34px;
line-height: 64px;
font-weight: 400;
text-align: center;
color: @blColor;
background: #F6F6F6;
&.selected {
color: @blHighlightColor;
background: @blHighlightBg;
}
}
}

View File

@ -0,0 +1,90 @@
import classNames from 'classnames';
import { useState } from 'react';
import PickerToolbar from '@/components/picker-toolbar';
import { EMPLOY_TYPE_TITLE_MAP, EmployType } from '@/constants/job';
import Toast from '@/utils/toast';
import './index.less';
interface IProps {
onConfirm: (employType: EmployType) => void;
}
interface IGroupProps {
name: string;
types: string[];
typeTitleMap: Record<string, string>;
selectTypes: string[];
onClickItem: (item: string) => void;
}
const PREFIX = 'job-type-picker';
const TypeGroup = (props: IGroupProps) => {
const { name, types, selectTypes, typeTitleMap, onClickItem } = props;
return (
<div className={`${PREFIX}__group`}>
<div className={`${PREFIX}__group-title`}>{name}</div>
<div className={`${PREFIX}__group-items-container`}>
{types.map(type => (
<div
key={type}
onClick={() => onClickItem(type)}
className={classNames(`${PREFIX}__group-item`, { selected: selectTypes.includes(type) })}
>
{typeTitleMap[type]}
</div>
))}
</div>
</div>
);
};
function JobPicker(props: IProps) {
const [selectedEmployTypes, setSelectedEmployTypes] = useState<EmployType[]>([EmployType.Full, EmployType.Part]);
const { onConfirm } = props;
const handleClickEmployType = (clickType: EmployType) => {
if (selectedEmployTypes.includes(clickType)) {
setSelectedEmployTypes(selectedEmployTypes.filter(type => type !== clickType));
} else {
setSelectedEmployTypes(selectedEmployTypes.concat([clickType]));
}
};
const handleClickReset = () => {
setSelectedEmployTypes([EmployType.Full, EmployType.Part]);
};
const handleClickConfirm = () => {
if (!selectedEmployTypes.length) {
Toast.error('至少选择一个');
return;
}
const newEmployType = selectedEmployTypes.length === 1 ? selectedEmployTypes[0] : EmployType.All;
onConfirm(newEmployType);
};
return (
<div className={PREFIX}>
<div className={`${PREFIX}__groups-container`}>
<TypeGroup
name="职位类型"
types={[EmployType.Full, EmployType.Part]}
typeTitleMap={EMPLOY_TYPE_TITLE_MAP}
selectTypes={selectedEmployTypes}
onClickItem={handleClickEmployType}
/>
</div>
<PickerToolbar
cancelText="重置"
confirmText="确定"
onClickCancel={handleClickReset}
onClickConfirm={handleClickConfirm}
/>
</div>
);
}
export default JobPicker;

View File

@ -0,0 +1,44 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.job-recommend-list {
&__header {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin: 32px 0;
}
&__header-left-line,
&__header-right-line {
width: 88px;
height: 1px;
}
&__header-left-line {
background: linear-gradient(90deg, rgba(109, 61, 245, 0) 0%, #6D3DF5 100%);
}
&__header-right-line {
background: linear-gradient(90deg, #6D3DF5 0%, rgba(109, 61, 245, 0) 100%);
}
&__header-title {
font-size: 32px;
line-height: 48px;
font-weight: 500;
color: @blHighlightColor;
margin: 0 16px;
}
&__header-icon {
width: 40px;
height: 40px;
margin-left: 16px;
}
&__more-button {
.button(@height: 80px; @fontSize: 32px; @fontWeight: 500; @borderRadius: 44px;);
}
}

View File

@ -0,0 +1,83 @@
import { Button, Image } from '@tarojs/components';
import { List } from '@taroify/core';
import classNames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'react';
import JobCard from '@/components/job-card';
import ListPlaceholder from '@/components/list-placeholder';
import { PageUrl } from '@/constants/app';
import { JobInfo, GetJobsRequest } from '@/types/job';
import { logWithPrefix } from '@/utils/common';
import { requestMyRecommendJobList } from '@/utils/job';
import { switchTab } from '@/utils/route';
import './index.less';
interface IRequestProps extends Partial<GetJobsRequest> {}
export interface IJobListProps extends IRequestProps {
className?: string;
}
const PAGE_SIZE = 10;
const PREFIX = 'job-recommend-list';
const log = logWithPrefix(PREFIX);
function JobRecommendList(props: IJobListProps) {
const { className } = props;
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [dataList, setDataList] = useState<JobInfo[]>([]);
const requestProps = useRef<IRequestProps>({});
const handleClickAllJob = useCallback(() => {
switchTab(PageUrl.Job);
}, []);
useEffect(() => {
requestProps.current = { page: 1, pageSize: PAGE_SIZE };
}, []);
useEffect(() => {
const refresh = async () => {
log('start request list data');
try {
setDataList([]);
setLoading(true);
setLoadError(false);
const { jobResults = [] } = await requestMyRecommendJobList({ ...requestProps.current });
setDataList(jobResults);
} catch (e) {
setDataList([]);
setLoadError(true);
} finally {
log('request list data end');
setLoading(false);
}
};
refresh();
}, []);
return (
<div className={classNames(PREFIX, className)}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header-left-line`} />
<Image className={`${PREFIX}__header-icon`} src={require('@/statics/svg/like.svg')} />
<div className={`${PREFIX}__header-title`}></div>
<div className={`${PREFIX}__header-right-line`} />
</div>
<List disabled>
{dataList.map(item => (
<JobCard data={item} key={item.id} redirectOpen />
))}
<ListPlaceholder noMoreText="" loadingMore={loading} loadMoreError={loadError} loadMoreErrorText="加载失败" />
</List>
<Button className={`${PREFIX}__more-button`} onClick={handleClickAllJob}>
</Button>
</div>
);
}
export default JobRecommendList;

View File

@ -0,0 +1,33 @@
import { List, Loading } from '@taroify/core';
import { ReactNode } from 'react';
import './index.less';
interface IPlaceholderProps {
hasMore: boolean;
loadingMore: boolean;
loadMoreError: boolean;
noMoreText: ReactNode;
loadMoreErrorText: ReactNode;
}
function ListPlaceholder(props: Partial<IPlaceholderProps>) {
const { hasMore, loadingMore, loadMoreError, noMoreText, loadMoreErrorText } = props;
let content: ReactNode = '';
if (loadingMore) {
content = <Loading>...</Loading>;
} else if (loadMoreError) {
content = loadMoreErrorText ?? '加载失败,请下拉刷新重试';
} else if (!hasMore) {
content = noMoreText ?? '没有更多了';
}
if (!content) {
return null;
}
return <List.Placeholder>{content}</List.Placeholder>;
}
export default ListPlaceholder;

View File

@ -0,0 +1,52 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.loading-dialog {
&__dialog-content {
.flex-column();
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&__icon-wrapper {
position: relative;
width: 188px;
height: 188px;
}
&__icon-bg {
width: 100%;
height: 100%;
padding: 8px;
background: conic-gradient(#6D3DF5, 30%, #ECE5FF);
border-radius: 50%;
animation: spin 1.5s linear infinite reverse;
}
&__icon {
width: 188px;
height: 188px;
border-radius: 50%;
background: #F2F2F2;
position: absolute;
top: 8px;
left: 8px;
}
&__title {
font-size: 36px;
font-weight: 500;
line-height: 58px;
margin-top: 24px;
}
}
}

View File

@ -0,0 +1,35 @@
import { Image } from '@tarojs/components';
import { Dialog } from '@taroify/core';
import './index.less';
interface IProps {
open: boolean;
text: string;
}
const PREFIX = 'loading-dialog';
function LoadingDialog(props: IProps) {
const { open, text } = props;
return (
<Dialog className={PREFIX} open={open}>
<Dialog.Content>
<div className={`${PREFIX}__dialog-content`}>
<div className={`${PREFIX}__dialog-content__icon-wrapper`}>
<div className={`${PREFIX}__dialog-content__icon-bg`} />
<Image
mode="aspectFit"
className={`${PREFIX}__dialog-content__icon`}
src={require('@/statics/svg/certification-tips-icon.svg')}
/>
</div>
<div className={`${PREFIX}__dialog-content__title`}>{text}</div>
</div>
</Dialog.Content>
</Dialog>
);
}
export default LoadingDialog;

View File

@ -0,0 +1,38 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.location-dialog {
&__container {
.flex-column();
}
&__title {
font-size: 36px;
font-weight: 500;
line-height: 56px;
color: @blColor;
}
&__confirm-button {
.button(@width: 360px, @height: 72px, @fontSize: 28px, @fontWeight: 400, @borderRadius: 44px);
margin-top: 40px;
}
// &__cancel-button {
// min-width: fit-content;
// font-size: 28px;
// line-height: 32px;
// color: @blHighlightColor;
// background: transparent;
// border: none;
// margin-top: 40px;
// &::after {
// border-color: transparent
// }
// }
&__checkbox {
margin-top: 40px;
}
}

View File

@ -0,0 +1,47 @@
import { Button } from '@tarojs/components';
import { Dialog } from '@taroify/core';
import { useCallback, useState } from 'react';
import { ProtocolPrivacyCheckbox } from '@/components/protocol-privacy';
import Toast from '@/utils/toast';
import './index.less';
interface IProps {
open: boolean;
onClick: () => void;
onClose: () => void;
}
const PREFIX = 'location-dialog';
export default function LocationDialog(props: IProps) {
const { open, onClick, onClose } = props;
const [checked, setChecked] = useState(false);
const handleTipCheck = useCallback(() => {
Toast.info('请先阅读并同意协议');
}, []);
return (
<Dialog open={open} onClose={onClose}>
<Dialog.Content>
<div className={`${PREFIX}__container`}>
<div className={`${PREFIX}__title`}>{`我们需要获取您的位置信息\n以便推荐附近的通告或主播`}</div>
{!checked && (
<Button className={`${PREFIX}__confirm-button`} onClick={handleTipCheck}>
</Button>
)}
{checked && (
<Button className={`${PREFIX}__confirm-button`} onClick={onClick}>
</Button>
)}
<ProtocolPrivacyCheckbox checked={checked} onChange={setChecked} className={`${PREFIX}__checkbox`} />
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@ -0,0 +1,4 @@
.login-button {
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,52 @@
import { Button, ButtonProps } from '@tarojs/components';
import classNames from 'classnames';
import { useCallback, useState } from 'react';
import LoginDialog from '@/components/login-dialog';
import useUserInfo from '@/hooks/use-user-info';
import { isNeedLogin } from '@/utils/user';
import './index.less';
export enum BindPhoneStatus {
Success,
Cancel,
Error,
}
export interface ILoginButtonProps extends ButtonProps {
needPhone?: boolean;
}
const PREFIX = 'login-button';
function LoginButton(props: ILoginButtonProps) {
const { className, children, needPhone, onClick, ...otherProps } = props;
const userInfo = useUserInfo();
const [visible, setVisible] = useState(false);
const needLogin = isNeedLogin(userInfo);
const onSuccess = useCallback(
e => {
setVisible(false);
onClick?.(e);
},
[onClick]
);
return (
<>
<Button
{...otherProps}
className={classNames(PREFIX, className)}
onClick={needLogin ? () => setVisible(true) : onClick}
>
{children}
</Button>
{visible && <LoginDialog onCancel={() => setVisible(false)} onSuccess={onSuccess} needPhone={needPhone} />}
</>
);
}
export default LoginButton;

View File

@ -0,0 +1,38 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.login-dialog {
&__container {
.flex-column();
}
&__title {
font-size: 36px;
font-weight: 500;
line-height: 56px;
color: @blColor;
}
&__confirm-button {
.button(@width: 360px, @height: 72px, @fontSize: 28px, @fontWeight: 400, @borderRadius: 44px);
margin-top: 40px;
}
&__cancel-button {
min-width: fit-content;
font-size: 28px;
line-height: 32px;
color: @blHighlightColor;
background: transparent;
border: none;
margin-top: 40px;
&::after {
border-color: transparent
}
}
&__checkbox {
margin-top: 40px;
}
}

View File

@ -0,0 +1,58 @@
import { Button } from '@tarojs/components';
import { Dialog } from '@taroify/core';
import { useCallback, useState } from 'react';
import PhoneButton, { IPhoneButtonProps } from '@/components/phone-button';
import { ProtocolPrivacyCheckbox } from '@/components/protocol-privacy';
import Toast from '@/utils/toast';
import './index.less';
interface IProps {
title?: string;
onCancel: () => void;
needPhone?: IPhoneButtonProps['needPhone'];
onSuccess?: IPhoneButtonProps['onSuccess'];
onBindPhone?: IPhoneButtonProps['onBindPhone'];
}
const PREFIX = 'login-dialog';
export default function LoginDialog(props: IProps) {
const { title = '使用播络服务前,请先登录', needPhone, onSuccess, onCancel, onBindPhone } = props;
const [checked, setChecked] = useState(false);
const handleTipCheck = useCallback(() => {
Toast.info('请先阅读并同意协议');
}, []);
return (
<Dialog open onClose={onCancel}>
<Dialog.Content>
<div className={`${PREFIX}__container`}>
<div className={`${PREFIX}__title`}>{title}</div>
{!checked && (
<Button className={`${PREFIX}__confirm-button`} onClick={handleTipCheck}>
</Button>
)}
{checked && (
<PhoneButton
className={`${PREFIX}__confirm-button`}
onSuccess={onSuccess}
onBindPhone={onBindPhone}
needPhone={needPhone}
>
</PhoneButton>
)}
<Button className={`${PREFIX}__cancel-button`} onClick={onCancel}>
</Button>
<ProtocolPrivacyCheckbox checked={checked} onChange={setChecked} className={`${PREFIX}__checkbox`} />
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@ -0,0 +1,50 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.login-content {
&__container {
.flex-column();
}
&__title {
font-size: 36px;
font-weight: 500;
line-height: 56px;
color: @blColor;
}
&__confirm-button {
.button(@width: 360px, @height: 72px, @fontSize: 28px, @fontWeight: 400, @borderRadius: 44px);
margin-top: 40px;
}
&__cancel-button {
min-width: fit-content;
font-size: 28px;
line-height: 32px;
color: @blHighlightColor;
background: transparent;
border: none;
margin-top: 40px;
&::after {
border-color: transparent
}
}
&__checkbox {
margin-top: 40px;
}
}
.login-guide {
display: flex;
flex-direction: column;
align-items: center;
z-index: 1001;
background: #FFFFFF;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
padding-top: 48px;
padding-bottom: 64px;
}

View File

@ -0,0 +1,119 @@
import { Button } from '@tarojs/components';
import { Popup } from '@taroify/core';
import { useCallback, useEffect, useState } from 'react';
import PhoneButton, { BindPhoneStatus, IPhoneButtonProps } from '@/components/phone-button';
import { ProtocolPrivacyCheckbox } from '@/components/protocol-privacy';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import useUserInfo from '@/hooks/use-user-info';
import { logWithPrefix, sleep } from '@/utils/common';
import { waitLocationAuthorizeHidden } from '@/utils/location';
import Toast from '@/utils/toast';
import { shouldShowLoginGuide } from '@/utils/user';
import './index.less';
interface IContentProps extends Pick<IPhoneButtonProps, 'onBindPhone'> {
onCancel: () => void;
}
interface IProps {
disabled: boolean;
onAfterBind?: () => void;
}
const PREFIX = 'login-content';
const PREFIX_GUIDE = 'login-guide';
const log = logWithPrefix(PREFIX_GUIDE);
function LoginContent(props: IContentProps) {
const { onCancel, onBindPhone } = props;
const [checked, setChecked] = useState(false);
const handleClose = useCallback(() => onCancel(), [onCancel]);
const handleTipCheck = useCallback(() => {
Toast.info('请先阅读并同意协议');
}, []);
return (
<div className={`${PREFIX}__container`}>
<div className={`${PREFIX}__title`}>使</div>
{!checked && (
<Button className={`${PREFIX}__confirm-button`} onClick={handleTipCheck}>
</Button>
)}
{checked && (
<PhoneButton
className={`${PREFIX}__confirm-button`}
onSuccess={handleClose}
onBindPhone={onBindPhone}
needPhone
>
</PhoneButton>
)}
<Button className={`${PREFIX}__cancel-button`} onClick={handleClose}>
</Button>
<ProtocolPrivacyCheckbox checked={checked} onChange={setChecked} className={`${PREFIX}__checkbox`} />
</div>
);
}
export function LoginGuide(props: IProps) {
const { disabled = false, onAfterBind } = props;
const userInfo = useUserInfo();
const [open, setOpen] = useState(false);
const handleBind = useCallback(
(status: BindPhoneStatus) => {
status === BindPhoneStatus.Success && onAfterBind?.();
status === BindPhoneStatus.Success && setOpen(false);
},
[onAfterBind]
);
const handleClose = useCallback(() => setOpen(false), []);
useEffect(() => {
if (disabled || !shouldShowLoginGuide(userInfo)) {
return;
}
let effectCleaned = false;
const showGuide = async () => {
await sleep(1);
await waitLocationAuthorizeHidden();
if (effectCleaned) {
log('ignore login guide, effect changed');
return;
}
setOpen(true);
log('open login guide');
};
showGuide();
return () => {
effectCleaned = true;
};
}, [disabled, userInfo]);
useEffect(() => {
if (disabled) {
log('hide login guide by disabled');
setOpen(false);
}
}, [disabled]);
if (!open) {
return null;
}
return (
<Popup className={PREFIX_GUIDE} placement="bottom" open={open} onClose={handleClose}>
<LoginContent onCancel={handleClose} onBindPhone={handleBind} />
<SafeBottomPadding />
</Popup>
);
}

View File

@ -0,0 +1,117 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.material-card {
padding: 32px 24px;
border-radius: 16px;
background: #FFFFFF;
box-sizing: border-box;
&__header {
.flex-row();
justify-content: space-between;
&__left,
&__right {
.flex-row();
}
&__title {
font-size: 32px;
line-height: 40px;
font-weight: 400;
color: @blColor;
}
&__progress {
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blHighlightColor;
margin-left: 8px;
}
&__status {
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blColorG1;
margin-right: 4px;
}
&__icon {
.flex-row();
height: 48px;
font-size: 32px;
line-height: 48px;
color: #969799;
}
}
&__body {
width: 100%;
height: 156px;
margin-top: 24px;
.flex-column();
justify-content: center;
}
&__placeholder {
height: 100%;
.flex-column();
justify-content: center;
&__tips {
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blColorG1;
}
&__create-button {
.button();
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blHighlightColor;
margin-top: 22px;
background: transparent;
border-radius: 0;
}
}
&__scroll-view {
position: relative;
width: 100%;
height: 100%;
.flex-row();
&::-webkit-scrollbar {
display: none;
}
&::after {
content: '';
position: absolute;
right: 0;
width: 102px;
height: 100%;
background: linear-gradient(91.41deg, rgba(255, 255, 255, 0) 1.86%, #FFFFFF 99.47%);
}
}
&__cover-list {
height: 100%;
.flex-row();
}
&__cover-image {
width: 120px;
height: 100%;
margin-right: 24px;
// 不知道为啥高度不对,可能 scroll-view 默认底部是滚动条高度?
margin-top: 38px;
border-radius: 8px;
}
}

View File

@ -0,0 +1,145 @@
import { Image, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { Loading } from '@taroify/core';
import { ArrowRight } from '@taroify/icons';
import classNames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'react';
import LoginButton from '@/components/login-button';
import { EventName, PageUrl } from '@/constants/app';
import { CollectEventName, ReportEventId } from '@/constants/event';
import useUserInfo from '@/hooks/use-user-info';
import { MaterialProfile } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { collectEvent, reportEvent } from '@/utils/event';
import { requestProfileDetail, sortVideos } from '@/utils/material';
import { navigateTo } from '@/utils/route';
import Toast from '@/utils/toast';
import { isValidUserInfo } from '@/utils/user';
import './index.less';
interface IProps {
className?: string;
}
const PREFIX = 'material-card';
const log = logWithPrefix(PREFIX);
const realtimeLogger = Taro.getRealtimeLogManager();
realtimeLogger.tag(PREFIX);
function MaterialCard(props: IProps) {
const { className } = props;
const userInfo = useUserInfo();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<MaterialProfile | null>(null);
const refreshRef = useRef((_f?: boolean) => { });
const hasMaterial = !!profile;
const handleGoCreateProfile = useCallback(() => {
reportEvent(ReportEventId.CLICK_GO_TO_CREATE_MATERIAL);
navigateTo(PageUrl.MaterialUploadVideo);
}, []);
const handleGoProfile = useCallback(() => {
if (!hasMaterial) {
realtimeLogger.info('handleGoProfile noMaterial')
return;
}
navigateTo(PageUrl.MaterialProfile).catch(err => {
realtimeLogger.error('handleGoProfile Failed', err);
});
}, [hasMaterial]);
useEffect(() => {
refreshRef.current = async (force: boolean = false) => {
collectEvent(CollectEventName.MATERIAL_CARD_VIEW, {
status: 'refresh',
info: { force, isCreateResume: userInfo.isCreateResume },
});
setLoading(true);
if (!userInfo.isCreateResume && !force) {
log('refresh break by is not create resume');
setLoading(false);
return;
}
try {
const profileDetail = await requestProfileDetail();
if (!profileDetail) {
realtimeLogger.info('getProfileDetail no profileDetail')
}
setProfile(profileDetail);
} catch (e) {
realtimeLogger.error('getProfileDetail Failed', e);
Toast.error('加载失败');
}
setLoading(false);
};
}, [userInfo]);
useEffect(() => {
if (!isValidUserInfo(userInfo)) {
return;
}
refreshRef.current?.(true);
}, [userInfo]);
useEffect(() => {
const callback = async () => {
refreshRef.current?.(true);
};
Taro.eventCenter.on(EventName.CREATE_PROFILE, callback);
return () => {
Taro.eventCenter.off(EventName.CREATE_PROFILE, callback);
};
}, [userInfo]);
return (
<div className={classNames(PREFIX, className)} onClick={handleGoProfile}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header__left`}>
<div className={`${PREFIX}__header__title`}></div>
{/* {profile && (
<div
className={`${PREFIX}__header__progress`}
>{`完成度${Math.min((profile.progressBar || 0) * 100, 100)}%`}</div>
)} */}
</div>
{profile && (
<div className={`${PREFIX}__header__right`}>
{/* <div className={`${PREFIX}__header__status`}>{profile?.isOpen ? '开放中' : '关闭'}</div> */}
<ArrowRight className={`${PREFIX}__header__icon`} />
</div>
)}
</div>
<div className={`${PREFIX}__body`}>
{!loading && !hasMaterial && (
<div className={`${PREFIX}__placeholder`}>
<div className={`${PREFIX}__placeholder__tips`}></div>
<LoginButton className={`${PREFIX}__placeholder__create-button`} onClick={handleGoCreateProfile}>
</LoginButton>
</div>
)}
{!loading && hasMaterial && (
<ScrollView className={`${PREFIX}__scroll-view`} showScrollbar={false} enableFlex enhanced scrollX>
<div className={`${PREFIX}__cover-list`}>
{sortVideos(profile?.materialVideoInfoList || []).map(video => (
<Image
className={`${PREFIX}__cover-image`}
mode="aspectFit"
key={video.coverUrl}
src={video.coverUrl}
/>
))}
</div>
</ScrollView>
)}
{loading && <Loading />}
</div>
</div>
);
}
export default MaterialCard;

View File

View File

@ -0,0 +1,43 @@
import { useCallback } from 'react';
import CommonDialog from '@/components/common-dialog';
import { PageUrl } from '@/constants/app';
import { ReportEventId } from '@/constants/event';
import { reportEvent } from '@/utils/event';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps {
onClose: () => void;
}
const PREFIX = 'material-guide';
function MaterialGuide(props: IProps) {
const { onClose } = props;
const handleConfirm = useCallback(() => {
reportEvent(ReportEventId.VIEW_MATERIAL_GUIDE);
navigateTo(PageUrl.MaterialUploadVideo);
onClose();
}, [onClose]);
// useEffect(() => {
// updateLastMaterialGuideTime();
// }, []);
return (
<CommonDialog
className={`${PREFIX}__dialog`}
visible
onClose={onClose}
onCancel={onClose}
onClick={handleConfirm}
content="完善模卡更容易获得老板青睐"
confirm="立刻完善"
/>
);
}
export default MaterialGuide;

View File

@ -0,0 +1,42 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.material-manage-popup {
.flex-column();
&__popup {
background: #FFFFFF;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
}
&__header {
.flex-column();
margin-top: 36px;
}
&__title {
font-size: 32px;
line-height: 32px;
font-weight: 500;
color: @blColor;
}
&__tips {
font-size: 24px;
line-height: 36px;
font-weight: 400;
color: @blColorG1;
margin-top: 16px;
}
&__select {
width: 100%;
margin-top: 40px;
}
&__btn {
.button(@width: 360px; @height: 72px; @fontSize: 28px; @borderRadius: 44px);
margin-top: 32px;
}
}

View File

@ -0,0 +1,58 @@
import { Button } from '@tarojs/components';
import { Popup } from '@taroify/core';
import { useCallback, useEffect, useState } from 'react';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import Select from '@/components/select';
import { MaterialStatus } from '@/constants/material';
import { MaterialProfile } from '@/types/material';
import './index.less';
interface IProps {
open: boolean;
value: MaterialStatus;
onSave: (newValue: MaterialProfile['isOpen']) => void;
onClose: () => void;
}
const PREFIX = 'material-manage-popup';
const OPTIONS = [
{ label: '开放', value: MaterialStatus.Open },
{ label: '关闭', value: MaterialStatus.Close },
];
function MaterialManagePopup(props: IProps) {
const { open, value = MaterialStatus.Open, onSave, onClose } = props;
const [currentValue, setCurrentValue] = useState<MaterialStatus>(value);
const handleSelect = useCallback((v: MaterialStatus) => setCurrentValue(v), []);
const handleSave = useCallback(() => {
onSave(currentValue === MaterialStatus.Open);
onClose();
}, [currentValue, onSave, onClose]);
useEffect(() => {
setCurrentValue(value);
}, [value]);
return (
<Popup className={`${PREFIX}__popup`} placement="bottom" open={open} onClose={onClose}>
<div className={PREFIX}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__tips`}></div>
</div>
<Select className={`${PREFIX}__select`} options={OPTIONS} value={currentValue} onSelect={handleSelect} />
<Button className={`${PREFIX}__btn`} onClick={handleSave}>
</Button>
</div>
<SafeBottomPadding />
</Popup>
);
}
export default MaterialManagePopup;

View File

@ -0,0 +1,96 @@
@import '@/styles/variables.less';
.material-video-card {
margin-top: 24px;
padding: 16px;
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-radius: 16px;
&__cover {
position: relative;
}
&__cover__image,
&__cover__placeholder {
width: 150px;
height: 200px;
border-radius: 16px;
}
&__cover__placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #F7F7F7;
}
&__cover__placeholder__image {
width: 38px;
height: 38px;
}
&__cover__preview-video {
width: 70px;
height: 70px;
position: absolute;
top: 50%;
right: 50%;
transform: translate3d(50%, -50%, 0);
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
margin-left: 24px;
}
&__info__title {
width: 100%;
padding-bottom: 24px;
border-bottom: 1px solid #E0E0E0;
}
&__info__title__input {
font-size: 32px;
color: @blColor;
}
&__info__title__placeholder {
font-size: 32px;
color: #CCCCCC;
}
&__info__operate {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
font-size: 28px;
line-height: 32px;
font-weight: 400;
justify-content: space-between;
}
&__info__operate__checkbox {
--checkbox-label-color: @blColor;
--checkbox-checked-icon-border-color: @blHighlightColor;
--checkbox-checked-icon-background-color: @blHighlightColor;
}
&__info__operate__delete {
color: @blHighlightColor;
margin-right: 8px;
}
&__info__temp-tips {
font-size: 28px;
line-height: 50px;
font-weight: 400;
color: @blColorG1;
}
}

View File

@ -0,0 +1,149 @@
import { BaseEventOrig, Image, Input, InputProps, Text } from '@tarojs/components';
import { navigateTo } from '@/utils/route';
import Taro from '@tarojs/taro';
import { Checkbox } from '@taroify/core';
import { useCallback } from 'react';
import { MaterialVideoInfo } from '@/types/material';
import { logWithPrefix, isDesktop } from '@/utils/common';
import { PageUrl } from '@/constants/app';
import './index.less';
interface IProps {
isTemp?: boolean;
videoInfo: MaterialVideoInfo;
onClickUpload?: () => void;
onClickDelete?: () => void;
onClickSetDefault?: () => void;
onTitleChange?: (newTitle: string) => void;
}
const PREFIX = 'material-video-card';
const log = logWithPrefix(PREFIX);
function MaterialVideoCard(props: IProps) {
const { videoInfo, isTemp = false, onClickUpload, onClickDelete, onClickSetDefault, onTitleChange } = props;
const isVideo = videoInfo.type === 'video';
const handleInput = useCallback(
(e: BaseEventOrig<InputProps.inputValueEventDetail>) => {
const value = e.detail?.value || '';
log('handleInput value', value);
onTitleChange?.(value);
},
[onTitleChange]
);
// const handleInputBlurOrConfirm = useCallback(() => {
// log('newVideoTitle', title);
// if (!title) {
// return;
// }
// onTitleChange?.(videoInfo);
// // ...
// }, [title, videoInfo, onTitleChange]);
const handleCheckboxChange = useCallback(
(checked: boolean) => {
log('handleCheckboxChange', checked);
if (videoInfo.isDefault) {
return;
}
// ...
onClickSetDefault?.();
},
[videoInfo, onClickSetDefault]
);
const handleClickVideo = useCallback(() => {
log('handleClickVideo', videoInfo);
if (!videoInfo.url) {
return;
}
if (isDesktop) {
navigateTo(PageUrl.MaterialWebview, {
source: encodeURIComponent(videoInfo.url)
})
} else {
Taro.previewMedia({
sources: [{ url: videoInfo.url, type: videoInfo.type }],
});
}
}, [videoInfo]);
return (
<div className={PREFIX}>
<div className={`${PREFIX}__cover`}>
{!isTemp && (
<>
<Image
className={`${PREFIX}__cover__image`}
mode="aspectFit"
src={videoInfo.coverUrl}
onClick={handleClickVideo}
/>
{isVideo && (
<Image
className={`${PREFIX}__cover__preview-video`}
mode="aspectFit"
src={require('@/statics/svg/preview_video.svg')}
onClick={handleClickVideo}
/>
)}
</>
)}
{isTemp && (
<div className={`${PREFIX}__cover__placeholder`} onClick={onClickUpload}>
<Image
className={`${PREFIX}__cover__placeholder__image`}
mode="aspectFit"
src={require('@/statics/svg/add.svg')}
/>
</div>
)}
</div>
<div className={`${PREFIX}__info`}>
{!isTemp && (
<>
<div className={`${PREFIX}__info__title`}>
<Input
value={videoInfo.title}
maxlength={20}
confirmType="done"
placeholder="请填写直播产品名称"
onInput={handleInput}
// onBlur={handleInputBlurOrConfirm}
// onConfirm={handleInputBlurOrConfirm}
className={`${PREFIX}__info__title__input`}
placeholderClass={`${PREFIX}__info__title__placeholder`}
/>
</div>
<div className={`${PREFIX}__info__operate`}>
<Checkbox
checked={videoInfo.isDefault}
onChange={handleCheckboxChange}
className={`${PREFIX}__info__operate__checkbox`}
>
</Checkbox>
<div className={`${PREFIX}__info__operate__delete`} onClick={onClickDelete}>
</div>
</div>
</>
)}
{isTemp && (
<Text className={`${PREFIX}__info__temp-tips`}>
{`视频不能超过1000M
视频若太大加载较慢,请耐心等待`}
</Text>
)}
</div>
</div>
);
}
export default MaterialVideoCard;

View File

@ -0,0 +1,85 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.message-card {
position: relative;
width: 100%;
.flex-row();
padding: 20px 32px;
box-sizing: border-box;
background: #FFF;
&::after {
content: "";
height: 2px;
background: #00000026;
position: absolute;
top: 0;
left: calc(32px + 90px + 24px);
right: 0;
}
&:first-child {
&::after {
height: 0;
}
}
&__avatar-container {
position: relative;
}
&__avatar {
width: 90px;
height: 90px;
border-radius: 50%;
}
&__unread {
position: absolute;
top: 0;
right: 0;
min-width: calc(32px - 8px);
height: 32px;
border-radius: 32px;
padding: 4px 8px;
font-size: 24px;
font-weight: 400;
color: #FFFFFF;
text-align: center;
background: #EB5953;
transform: translate3d(20%, -50%, 0);
}
&__body-container {
flex: 1;
.flex-column();
align-items: flex-start;
margin-left: 24px;
}
&__name {
font-size: 32px;
line-height: 48px;
font-weight: 400;
color: #000000;
}
&__content {
max-width: 78vw;
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: #8D8E99;
margin-top: 4px;
.noWrap();
}
&__time {
font-size: 20px;
line-height: 32px;
font-weight: 400;
color: #8D8E99;
margin-top: 6px;
}
}

View File

@ -0,0 +1,42 @@
import { Image } from '@tarojs/components';
import { useCallback } from 'react';
import { PageUrl } from '@/constants/app';
import { MainMessage } from '@/types/message';
import { navigateTo } from '@/utils/route';
import { formatTime } from '@/utils/time';
import './index.less';
interface IProps {
data: MainMessage;
}
const PREFIX = 'message-card';
function MessageCard(props: IProps) {
const { data } = props;
const handleClick = useCallback(() => navigateTo(PageUrl.MessageChat, { chatId: data.chatId }), [data]);
return (
<div className={PREFIX} onClick={handleClick}>
<div className={`${PREFIX}__avatar-container`}>
<Image
mode="aspectFit"
className={`${PREFIX}__avatar`}
src={data.toUserAvatarUrl || require('@/statics/png/default_avatar.png')}
/>
{!!data.unReadMsgCount && <div className={`${PREFIX}__unread`}>{Math.min(data.unReadMsgCount, 999)}</div>}
</div>
<div className={`${PREFIX}__body-container`}>
<div className={`${PREFIX}__name`}>{data.toUserName}</div>
<div className={`${PREFIX}__content`}>{data.lastContactMsgContent}</div>
<div className={`${PREFIX}__time`}>{formatTime(data.lastContactTime, 'MM-DD')}</div>
</div>
</div>
);
}
export default MessageCard;

View File

@ -0,0 +1,54 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.base-message {
width: 100%;
.flex-row();
align-items: flex-start;
margin-top: 40px;
padding: 0 32px;
box-sizing: border-box;
&.is-sender {
flex-direction: row-reverse;
}
&__avatar {
width: 80px;
height: 80px;
border-radius: 50%;
}
&__content-container {
flex: 1;
.flex-column();
align-items: flex-start;
margin: 0 16px;
.is-sender & {
align-items: flex-end;
}
}
&__content {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: #1D2129;
background: #D9D9D9;
padding: 20px 24px;
border-radius: 20px;
}
&__status {
font-size: 20px;
line-height: 28px;
font-weight: 400;
color: @blHighlightColor;
margin-top: 8px;
&.done {
color: #8D8E99;
}
}
}

View File

@ -0,0 +1,66 @@
import { Image } from '@tarojs/components';
import classNames from 'classnames';
import { PropsWithChildren, useEffect, useState, useCallback } from 'react';
import { MaterialViewSource } from '@/constants/material';
import useUserInfo from '@/hooks/use-user-info';
import { IChatMessage } from '@/types/message';
import { getScrollItemId } from '@/utils/common';
import { navigateTo } from '@/utils/route';
import { PageUrl } from '@/constants/app';
import './index.less';
export interface IBaseMessageProps {
id: string;
message: IChatMessage;
}
export interface IUserMessageProps extends PropsWithChildren, IBaseMessageProps {
isRead?: boolean;
}
const PREFIX = 'base-message';
function BaseMessage(props: IUserMessageProps) {
const { id, message, isRead: isReadProps, children } = props;
const { userId } = useUserInfo();
const [isRead, setIsRead] = useState(message.isRead);
const isSender = message.senderUserId === userId;
// useEffect(() => {
// if (isSender) {
// return;
// }
// // 对方发的消息,拉取到消息后,后端会主动已读,这里延迟模拟下
// const timer = setTimeout(() => setIsRead(true), 1200);
// return () => clearTimeout(timer);
// }, [isSender]);
const handleClick = useCallback(
() => navigateTo(PageUrl.MaterialView, { resumeId: message.jobId, source: MaterialViewSource.Chat }),
[message.jobId]
);
useEffect(() => {
if (isRead) {
return;
}
isReadProps && setIsRead(true);
}, [isRead, isReadProps]);
return (
<div className={classNames(PREFIX, { 'is-sender': isSender })} id={getScrollItemId(id)}>
<Image
mode="aspectFit"
className={`${PREFIX}__avatar`}
src={message.senderAvatarUrl || require('@/statics/png/default_avatar.png')}
/>
<div className={`${PREFIX}__content-container`}>
{children}
<div className={classNames(`${PREFIX}__status`, { done: isRead })}>{isRead ? '已读' : '未读'}</div>
</div>
</div>
);
}
export default BaseMessage;

View File

@ -0,0 +1,50 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.exchange-message {
width: 100%;
.flex-column();
margin-top: 40px;
&__content {
padding: 24px 60px;
background: #FFFFFF;
border-radius: 20px;
}
&__title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
}
&__buttons {
.flex-row();
justify-content: center;
margin-top: 32px;
}
&__reject {
.button(@width: 176px; @height: 56px; @fontSize: 28px; @borderRadius: 48px);
border: 2px solid #E0E0E0;
color: @blColor;
background: #FFF;
margin-right: 24px;
}
&__agree {
.button(@width: 176px; @height: 56px; @fontSize: 28px; @borderRadius: 48px);
}
&__disable-btn {
.button(@height: 56px; @fontSize: 28px; @borderRadius: 48px);
padding: 0 74px;
color: #C0C0C0;
background: #F0F0F0;
&:active {
background: #F0F0F0;
}
}
}

View File

@ -0,0 +1,91 @@
import { Button } from '@tarojs/components';
import { useCallback, useEffect, useState } from 'react';
import { IBaseMessageProps } from '@/components/message-chat/base';
import { MessageActionStatus } from '@/constants/message';
import useUserInfo from '@/hooks/use-user-info';
import { getScrollItemId } from '@/utils/common';
import { posConfirmAction, postAddMessageTimes, requestChatActionStatus } from '@/utils/message';
import './index.less';
interface IProps extends IBaseMessageProps {
onClick: () => void;
}
const PREFIX = 'exchange-message';
function ContactMessage(props: IProps) {
const { id, message, onClick } = props;
const { userId } = useUserInfo();
const [status, setStatus] = useState<MessageActionStatus>();
const isSender = message.senderUserId === userId;
const handleClickReject = useCallback(async () => {
if (isSender || status !== MessageActionStatus.Send) {
return;
}
postAddMessageTimes('click_reject_exchange_contact');
await posConfirmAction({ actionId: message.actionId, status: false });
setStatus(MessageActionStatus.Reject);
onClick();
}, [isSender, status, message.actionId, onClick]);
const handleClickAgree = useCallback(async () => {
if (isSender || status !== MessageActionStatus.Send) {
return;
}
postAddMessageTimes('click_agree_exchange_contact');
await posConfirmAction({ actionId: message.actionId, status: true });
setStatus(MessageActionStatus.Agree);
onClick();
}, [isSender, status, message.actionId, onClick]);
useEffect(() => {
const init = async () => {
const res = await requestChatActionStatus(message.actionId);
setStatus(res);
};
init();
}, [message.actionId]);
return (
<div className={PREFIX} id={getScrollItemId(id)}>
<div className={`${PREFIX}__content`}>
<div className={`${PREFIX}__title`}>
{isSender ? '您已向对方请求交换联系方式' : `${message.senderName}申请交换联系方式,沟通面试`}
</div>
<div className={`${PREFIX}__buttons`}>
{isSender && (
<Button className={`${PREFIX}__disable-btn`} onClick={handleClickAgree}>
{status === MessageActionStatus.Agree
? '对方已同意'
: status === MessageActionStatus.Reject
? '对方已拒绝'
: '等待对方同意'}
</Button>
)}
{!isSender && (
<>
{(status === MessageActionStatus.Send || !status) && (
<>
<Button className={`${PREFIX}__reject`} onClick={handleClickReject}>
</Button>
<Button className={`${PREFIX}__agree`} onClick={handleClickAgree}>
</Button>
</>
)}
{status === MessageActionStatus.Agree && <Button className={`${PREFIX}__disable-btn`}></Button>}
{status === MessageActionStatus.Reject && <Button className={`${PREFIX}__disable-btn`}></Button>}
</>
)}
</div>
</div>
</div>
);
}
export default ContactMessage;

View File

@ -0,0 +1,8 @@
import ContactMessage from './contact';
import JobMessage from './job';
import LocationMessage from './location';
import MaterialMessage from './material';
import TextMessage from './text';
import TimeMessage from './time';
export { ContactMessage, JobMessage, LocationMessage, MaterialMessage, TextMessage, TimeMessage };

View File

@ -0,0 +1,29 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.job-message {
width: calc(100% - 64px);
.flex-column();
align-items: flex-start;
margin: 40px 32px 0;
padding: 20px 24px;
background: #FFFFFF;
border-radius: 20px;
box-sizing: border-box;
&__title {
font-size: 28px;
line-height: 40px;
font-weight: 500;
color: @blColor;
}
&__salary {
font-size: 24px;
line-height: 36px;
font-weight: 400;
color: @blColorG2;
margin-top: 8px;
}
}

View File

@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { IBaseMessageProps } from '@/components/message-chat/base';
import { PageUrl } from '@/constants/app';
import { IJobMessage } from '@/types/message';
import { getScrollItemId, safeJsonParse } from '@/utils/common';
import { getJobSalary } from '@/utils/job';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps extends IBaseMessageProps {}
const PREFIX = 'job-message';
function JobMessage(props: IProps) {
const { id, message } = props;
const data = safeJsonParse<IJobMessage>(message.actionObject, null);
const handleClick = useCallback(() => {
if (!data) {
return;
}
navigateTo(PageUrl.JobDetail, { id: data.id });
}, [data]);
if (!data) {
return null;
}
return (
<div className={PREFIX} id={getScrollItemId(id)} onClick={handleClick}>
<div className={`${PREFIX}__title`}>{data.title}</div>
<div className={`${PREFIX}__salary`}>{getJobSalary(data)}</div>
</div>
);
}
export default JobMessage;

View File

@ -0,0 +1,45 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.location-message {
&__map-container {
width: 440px;
.flex-column();
align-items: flex-start;
background: #FFF;
border-radius: 20px;
position: relative;
}
&__name {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
margin: 20px 20px 0;
.noWrap();
}
&__address {
font-size: 24px;
line-height: 36px;
font-weight: 400;
color: @blColorG1;
margin: 4px 20px 20px;
.noWrap();
}
&__map {
width: 100%;
height: 160px;
}
&__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}

View File

@ -0,0 +1,69 @@
import { Map } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useCallback } from 'react';
import BaseMessage, { IUserMessageProps } from '@/components/message-chat/base';
import { ILocationMessage } from '@/types/message';
import { safeJsonParse } from '@/utils/common';
import Toast from '@/utils/toast';
import './index.less';
interface IProps extends IUserMessageProps {}
const PREFIX = 'location-message';
function LocationMessage(props: IProps) {
const { message } = props;
const data = safeJsonParse<ILocationMessage>(message.actionObject, null);
const handleClickMap = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
Taro.openLocation({
name: data.name,
address: data.address,
longitude: Number(data.longitude),
latitude: Number(data.latitude),
scale: 18,
});
},
[data]
);
if (!data) {
return null;
}
return (
<BaseMessage {...props}>
<div className={`${PREFIX}__map-container`} onClick={handleClickMap}>
<div className={`${PREFIX}__name`}>{data.name}</div>
<div className={`${PREFIX}__address`}>{data.address}</div>
<Map
scale={15}
enableZoom={false}
enableScroll={false}
className={`${PREFIX}__map`}
latitude={Number(data.latitude)}
longitude={Number(data.longitude)}
markers={[
{
id: 0,
latitude: Number(data.latitude),
longitude: Number(data.longitude),
iconPath: '',
width: 20,
height: 28,
},
]}
onError={() => Toast.error('地图加载错误')}
/>
<div className={`${PREFIX}__mask`} onClick={handleClickMap} />
</div>
</BaseMessage>
);
}
export default LocationMessage;

View File

@ -0,0 +1,30 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.material-message {
width: calc(100% - 64px);
.flex-column();
align-items: flex-start;
margin: 40px 32px 0;
padding: 20px 24px;
background: #FFFFFF;
border-radius: 20px;
box-sizing: border-box;
&__name {
font-size: 28px;
line-height: 40px;
font-weight: 500;
color: @blColor;
}
&__basic,
&__categories {
font-size: 24px;
line-height: 36px;
font-weight: 400;
color: @blColorG2;
margin-top: 8px;
}
}

View File

@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { IBaseMessageProps } from '@/components/message-chat/base';
import { PageUrl } from '@/constants/app';
import { MaterialViewSource } from '@/constants/material';
import { IMaterialMessage } from '@/types/message';
import { getScrollItemId, safeJsonParse } from '@/utils/common';
import { getBasicInfo } from '@/utils/material';
import { navigateTo } from '@/utils/route';
import './index.less';
interface IProps extends IBaseMessageProps {}
const PREFIX = 'material-message';
function MaterialMessage(props: IProps) {
const { id, message } = props;
const data = safeJsonParse<IMaterialMessage>(message.actionObject, null);
const handleClick = useCallback(() => {
if (!data) {
return;
}
navigateTo(PageUrl.MaterialView, { resumeId: data.id, source: MaterialViewSource.Chat });
}, [data]);
if (!data) {
return null;
}
return (
<div className={PREFIX} id={getScrollItemId(id)} onClick={handleClick}>
<div className={`${PREFIX}__name`}>{data.name}</div>
<div className={`${PREFIX}__basic`}>{getBasicInfo(data)}</div>
<div className={`${PREFIX}__categories`}>{`播过 ${data.workedSecCategoryStr}`}</div>
</div>
);
}
export default MaterialMessage;

View File

@ -0,0 +1,14 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.text-message {
&__content {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: #1D2129;
background: #D9D9D9;
padding: 20px 24px;
border-radius: 20px;
}
}

View File

@ -0,0 +1,32 @@
import { Text } from '@tarojs/components';
import { useCallback } from 'react';
import BaseMessage, { IUserMessageProps } from '@/components/message-chat/base';
import { copy } from '@/utils/common';
import Toast from '@/utils/toast';
import './index.less';
interface IProps extends IUserMessageProps {}
const PREFIX = 'text-message';
function TextMessage(props: IProps) {
const { message } = props;
const handleLongPress = useCallback(async () => {
await copy(message.content);
Toast.success('复制成功');
}, [message.content]);
return (
<BaseMessage {...props}>
<Text className={`${PREFIX}__content`} onLongPress={handleLongPress}>
{message.content}
</Text>
</BaseMessage>
);
}
export default TextMessage;

View File

@ -0,0 +1,12 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.time-message {
width: 100%;
font-size: 28px;
line-height: 40px;
font-weight: 400;
text-align: center;
color: #8D8E99;
margin-top: 40px;
}

View File

@ -0,0 +1,20 @@
import { IBaseMessageProps } from '@/components/message-chat/base';
import { getScrollItemId } from '@/utils/common';
import { formatTime } from '@/utils/time';
import './index.less';
interface IProps extends IBaseMessageProps {}
const PREFIX = 'time-message';
function TimeMessage(props: IProps) {
const { id, message } = props;
return (
<div className={PREFIX} id={getScrollItemId(id)}>
{formatTime(message.content, 'MM-DD HH:mm')}
</div>
);
}
export default TimeMessage;

View File

@ -0,0 +1,83 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.message-dialog {
.title {
font-size: 36px;
line-height: 58px;
font-weight: 500;
color: @blColor;
}
.tips {
font-size: 28px;
line-height: 48px;
font-weight: 400;
text-align: left;
color: @blColorG2;
.highlight {
display: inline;
color: @blHighlightColor;
}
}
&__help {
.flex-column();
&__title {
.title();
}
&__body {
margin-top: 24px
}
&__tips {
.tips();
.highlight {
display: inline;
color: @blHighlightColor;
}
}
&__btn {
.button(@width: 360px; @height: 72px; @fontSize: 28px; @borderRadius: 36px);
margin-top: 24px;
}
}
&__no-times {
&__title {
.title();
}
&__tips {
.tips();
margin-top: 24px;
}
&__body {
width: 100%;
.flex-column();
padding: 40px 0;
background: @blHighlightBg;
margin-top: 20px;
}
&__times {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: #000000;
}
&__btn {
.button(@width: 360px; @height: 72px; @fontSize: 28px; @borderRadius: 36px);
margin-top: 24px;
}
}
}

View File

@ -0,0 +1,70 @@
import { Button } from '@tarojs/components';
import { Dialog } from '@taroify/core';
import './index.less';
const PREFIX = 'message-dialog';
const HELP = `${PREFIX}__help`;
const NO_TIMES = `${PREFIX}__no-times`;
interface IHelpProps {
open: boolean;
onClose: () => void;
}
interface INoTimesProps {
open: boolean;
times: number;
onClick: () => void;
onClose: () => void;
}
export function MessageHelpDialog(props: IHelpProps) {
const { open, onClose } = props;
return (
<Dialog onClose={onClose} open={open}>
<Dialog.Content>
<div className={HELP}>
<div className={`${HELP}__title`}></div>
<div className={`${HELP}__body`}>
<div className={`${HELP}__tips`}>
{`离开小程序后,如果有用户向你发送消息,我们将通过微信的"服务通知"提醒你。\n由于微信服务通知有次数限制次数使用完则无法收到通知。`}
</div>
<div className={`${HELP}__tips`}>
<div className="highlight"></div>
</div>
<div className={`${HELP}__tips`}>
<div className="highlight"></div>
</div>
</div>
<Button className={`${HELP}__btn`} onClick={onClose}>
</Button>
</div>
</Dialog.Content>
</Dialog>
);
}
export function MessageNoTimesDialog(props: INoTimesProps) {
const { open, times = 0, onClick, onClose } = props;
return (
<Dialog className={NO_TIMES} onClose={onClose} open={open}>
<Dialog.Content>
<div className={`${NO_TIMES}__title`}></div>
<div className={`${NO_TIMES}__tips`}><span className="highlight"></span></div>
<div className={`${NO_TIMES}__body`}>
<div className={`${NO_TIMES}__times`}>{`未读消息提醒剩余:${times}`}</div>
<Button className={`${NO_TIMES}__btn`} onClick={onClick}>
</Button>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@ -0,0 +1,18 @@
@import '@/styles/variables.less';
.overlay {
&__container {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
background: @blMaskBg;
}
&__content {
width: fit-content;
height: fit-content;
}
}

View File

@ -0,0 +1,33 @@
import classNames from 'classnames';
import { PropsWithChildren, useCallback } from 'react';
import './index.less';
interface IProps extends PropsWithChildren {
visible: boolean;
onClickOuter: () => void;
outerClassName?: string;
innerClassName?: string;
}
const PREFIX = 'overlay';
function Overlay(props: IProps) {
const { visible, outerClassName, innerClassName, onClickOuter } = props;
const onClickContent = useCallback((e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation(), []);
if (!visible) {
return null;
}
return (
<div className={classNames(`${PREFIX}__container`, outerClassName)} onClick={onClickOuter}>
<div className={classNames(`${PREFIX}__content`, innerClassName)} onClick={onClickContent}>
{props.children}
</div>
</div>
);
}
export default Overlay;

View File

@ -0,0 +1,7 @@
.page-loading {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,21 @@
import { Loading } from '@taroify/core';
import classNames from 'classnames';
import './index.less';
interface IProps {
className?: string;
}
const PREFIX = 'page-loading';
function PageLoading(props: IProps) {
const { className } = props;
return (
<div className={classNames(PREFIX, className)}>
<Loading direction="vertical">...</Loading>
</div>
);
}
export default PageLoading;

View File

@ -0,0 +1,4 @@
.phone-button {
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,75 @@
import { BaseEventOrig, Button, ButtonProps, ITouchEvent } from '@tarojs/components';
import classNames from 'classnames';
import { useCallback } from 'react';
import { logWithPrefix } from '@/utils/common';
import Toast from '@/utils/toast';
import { requestUserInfo, setPhoneNumber } from '@/utils/user';
import './index.less';
export enum BindPhoneStatus {
Success,
Cancel,
Error,
}
export interface IPhoneButtonProps extends ButtonProps {
message?: string;
// 绑定后是否需要刷新接口获取手机号
needPhone?: boolean;
onBindPhone?: (status: BindPhoneStatus) => void;
onSuccess?: (e: ITouchEvent) => void;
}
const PREFIX = 'phone-button';
const log = logWithPrefix(PREFIX);
export default function PhoneButton(props: IPhoneButtonProps) {
const {
className,
children,
message = '绑定成功',
openType,
needPhone,
onSuccess,
onBindPhone,
...otherProps
} = props;
const handleGetPhoneNumber = useCallback(
async (e: BaseEventOrig<ButtonProps.onGetPhoneNumberEventDetail>) => {
log('handleGetPhoneNumber', e.detail.code, e.detail.iv, e.detail.encryptedData);
const encryptedData = e.detail.encryptedData;
const iv = e.detail.iv;
if (!encryptedData || !iv) {
onBindPhone?.(BindPhoneStatus.Cancel);
return Toast.error('取消授权');
}
try {
await setPhoneNumber({ encryptedData, iv });
needPhone && (await requestUserInfo());
Toast.success(message);
onBindPhone?.(BindPhoneStatus.Success);
onSuccess?.(e as ITouchEvent);
} catch (err) {
onBindPhone?.(BindPhoneStatus.Error);
Toast.error('绑定失败');
log('bind phone fail', err);
}
},
[message, needPhone, onSuccess, onBindPhone]
);
return (
<Button
{...otherProps}
className={classNames(PREFIX, className)}
openType="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
>
{children}
</Button>
);
}

View File

@ -0,0 +1,37 @@
@import '@/styles/variables.less';
.picker-toolbar {
width: 100%;
padding: 16px 24px;
border-top: solid 1px #E0E0E0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
.button(@borderColor) {
width: calc(50% - 36px);
height: 80px;
line-height: 78px;
border-radius: 48px;
padding: 0;
margin: 0;
border: solid 1px @borderColor;
}
&__button-cancel {
.button(#E0E0E0);
margin-right: 24px;
color: @blColor;
background: #FFFFFF;
&::after {
border-color: transparent
}
}
&__button-confirm {
.button(@blHighlightColor);
color: #FFFFFF;
background: @blHighlightColor;
}
}

Some files were not shown because too many files have changed in this diff Show More