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

View File

@ -0,0 +1,7 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '',
disableScroll: true,
enableShareAppMessage: true,
usingComponents: {},
});

View File

@ -0,0 +1,91 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-anchor {
position: relative;
&__loading {
position: fixed;
top: 0;
left: 0;
z-index: 1;
background: @pageBg;
}
&__top-search-bar {
.flex-row();
justify-content: space-between;
padding: 0 24px;
margin-top: 34px;
margin-bottom: 42px;
}
&__sort-type {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 32px;
color: @blColor;
.selected {
color: @blHighlightColor;
}
}
&__sort-item {
margin-left: 32px;
&:first-child {
margin-left: 0;
}
}
&__filter {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 32px;
color: @blColor;
.title {
margin-right: 5px;
}
}
&__overlay-outer {
top: 82px;
}
&__overlay-inner {
width: 100%;
}
&__tips-container {
width: 100%;
height: 100vh;
padding-top: 218px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
&__empty-box {
width: 386px;
height: 278px;
}
&__tips-title {
font-size: 28px;
font-weight: 500;
line-height: 40px;
color: @blColor;
margin-top: 50px;
}
}

228
src/pages/anchor/index.tsx Normal file
View File

@ -0,0 +1,228 @@
import { Image } from '@tarojs/components';
import Taro, { NodesRef, useDidShow, useLoad } from '@tarojs/taro';
import { ArrowUp, ArrowDown } from '@taroify/icons';
import classNames from 'classnames';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useState } from 'react';
import AnchorList, { IAnchorListProps } from '@/components/anchor-list';
import AnchorPicker from '@/components/anchor-picker';
import CustomNavigationBar from '@/components/custom-navigation-bar';
import HomePage from '@/components/home-page';
import Overlay from '@/components/overlay';
import PageLoading from '@/components/page-loading';
import SwitchBar from '@/components/switch-bar';
import { APP_TAB_BAR_ID, EventName, OpenSource, PageUrl } from '@/constants/app';
import { EmployType, JobManageStatus } from '@/constants/job';
import { ALL_ANCHOR_SORT_TYPES, ANCHOR_SORT_TYPE_TITLE_MAP, AnchorSortType } from '@/constants/material';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import useLocation from '@/hooks/use-location';
import { JobManageInfo } from '@/types/job';
import { Coordinate } from '@/types/location';
import { IAnchorFilters } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { getLastSelectMyJobId, requestJobManageList, setLastSelectMyJobId } from '@/utils/job';
import { getWxLocation } from '@/utils/location';
import { requestUnreadMessageCount } from '@/utils/message';
import { navigateTo } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-anchor';
const LIST_CONTAINER_CLASS = `${PREFIX}__list-container`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${LIST_CONTAINER_CLASS}`, `#${APP_TAB_BAR_ID}`],
calc: (rects: [NodesRef.BoundingClientRectCallbackResult, NodesRef.BoundingClientRectCallbackResult]) => {
const [rect, diffRect] = rects;
return diffRect.top - rect.top;
},
};
const log = logWithPrefix(PREFIX);
const EmptyTips = (props: { className?: string; height?: number }) => {
const { className, height } = props;
return (
<div className={classNames(`${PREFIX}__tips-container`, className)} style={height ? { height } : undefined}>
<Image className={`${PREFIX}__empty-box`} src={require('@/statics/svg/empty-box.svg')} mode="aspectFit" />
<div className={`${PREFIX}__tips-title`}></div>
</div>
);
};
function ListWrapper(props: IAnchorListProps) {
const { className, jobId, filters, cityCode, sortType, latitude, longitude } = props;
const listHeight = useListHeight(CALC_LIST_PROPS);
const [isEmpty, setIsEmpty] = useState(false);
const handleListEmpty = useCallback(() => {
setIsEmpty(true);
}, []);
useEffect(() => {
setIsEmpty(false);
}, [jobId, filters, cityCode, sortType, latitude, longitude]);
if (isEmpty) {
return <EmptyTips className={className} height={listHeight} />;
}
return <AnchorList listHeight={listHeight} {...props} onListEmpty={handleListEmpty} />;
}
export default function AnchorPage() {
const location = useLocation();
const [loading, setLoading] = useState(true);
const [selectJob, setSelectJob] = useState<JobManageInfo | undefined>();
const [filters, setFilters] = useState<IAnchorFilters>({ employType: EmployType.All });
const [showFilter, setShowFilter] = useState<boolean>(false);
const [sortType, setSortType] = useState<AnchorSortType>(AnchorSortType.Active);
const [coordinate, setCoordinate] = useState<Coordinate>({
latitude: location.latitude,
longitude: location.longitude,
});
log('jobId', selectJob);
const handleChangeSelectJob = useCallback((select?: JobManageInfo) => {
log('select job change', select);
setSelectJob(select);
setLastSelectMyJobId(select?.id || '');
}, []);
const handleClickSwitch = useCallback(
() => navigateTo(PageUrl.JobSelectMyPublish, { id: selectJob?.id, source: OpenSource.AnchorPage }),
[selectJob]
);
const handleClickSalarySelect = useCallback(() => {
setShowFilter(!showFilter);
}, [showFilter]);
const handleHideFilter = useCallback(() => setShowFilter(false), []);
const handleFilterChange = useCallback(
(newFilters: IAnchorFilters) => {
!isEqual(newFilters, filters) && setFilters(newFilters);
setShowFilter(false);
},
[filters]
);
const handleClickSortType = useCallback(async (type: AnchorSortType) => setSortType(type), []);
const handleJobChange = useCallback(
(select: JobManageInfo, source: OpenSource) => {
log('handleJobChange', select, source);
source === OpenSource.AnchorPage && handleChangeSelectJob(select);
},
[handleChangeSelectJob]
);
const handlePublishJobChange = useCallback(async () => {
const { jobResults = [] } = await requestJobManageList({ status: JobManageStatus.Open });
if (!selectJob) {
// 之前没有开发中的通告,自动选中第一个开放中的通告
handleChangeSelectJob(jobResults[0]);
return;
}
const curJob = jobResults.find(j => j.id === selectJob.id);
if (!curJob) {
// 之前选中的通告不再开放了,自动切到第一个开放中的通告
handleChangeSelectJob(jobResults[0]);
} else if (!isEqual(curJob, selectJob)) {
// 之前选中的通告发生了变化,尝试更新
handleChangeSelectJob(curJob);
}
}, [selectJob, handleChangeSelectJob]);
useEffect(() => {
Taro.eventCenter.on(EventName.SELECT_MY_PUBLISH_JOB, handleJobChange);
Taro.eventCenter.on(EventName.COMPANY_JOB_PUBLISH_CHANGED, handlePublishJobChange);
return () => {
Taro.eventCenter.off(EventName.SELECT_MY_PUBLISH_JOB, handleJobChange);
Taro.eventCenter.off(EventName.COMPANY_JOB_PUBLISH_CHANGED, handlePublishJobChange);
};
}, [handleJobChange, handlePublishJobChange]);
useEffect(() => {
const ensureLocation = async () => {
if (location.latitude || !location.longitude) {
const res = await getWxLocation();
if (!res) {
Toast.info('获取位置信息失败,请重试');
return;
}
const { latitude, longitude } = res;
setCoordinate({ latitude, longitude });
}
};
ensureLocation();
}, [location]);
useLoad(async () => {
try {
const { jobResults = [] } = await requestJobManageList({ status: JobManageStatus.Open });
if (!jobResults.length) {
Toast.info('当前是根据定位为您展示主播');
return;
}
const lastSelectJobId = getLastSelectMyJobId();
const lastJob = jobResults.find(job => job.id === lastSelectJobId) || jobResults[0];
log('lastJob', lastSelectJobId, lastJob);
handleChangeSelectJob(lastJob);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
});
useDidShow(() => requestUnreadMessageCount());
return (
<HomePage>
{!!loading && <PageLoading className={`${PREFIX}__loading`} />}
<CustomNavigationBar className={`${PREFIX}__navigation-bar`}>
{selectJob && <SwitchBar title={selectJob.title.substring(0, 4)} onClick={handleClickSwitch} />}
</CustomNavigationBar>
<div className={PREFIX}>
<div className={`${PREFIX}__top-search-bar`}>
<div className={classNames(`${PREFIX}__sort-type`)}>
{ALL_ANCHOR_SORT_TYPES.map(type => (
<div
key={type}
className={classNames(`${PREFIX}__sort-item`, { selected: sortType === type })}
onClick={() => handleClickSortType(type)}
>
{ANCHOR_SORT_TYPE_TITLE_MAP[type]}
</div>
))}
</div>
<div className={classNames(`${PREFIX}__filter`)} onClick={handleClickSalarySelect}>
<div className="title"></div>
{showFilter ? <ArrowUp /> : <ArrowDown />}
</div>
</div>
<ListWrapper
filters={filters}
ready={!loading}
sortType={sortType}
jobId={selectJob?.id}
cityCode={selectJob?.cityCode ?? location.cityCode}
latitude={coordinate.latitude}
longitude={coordinate.longitude}
className={LIST_CONTAINER_CLASS}
/>
<Overlay
visible={showFilter}
onClickOuter={handleHideFilter}
outerClassName={`${PREFIX}__overlay-outer`}
innerClassName={`${PREFIX}__overlay-inner`}
>
<AnchorPicker value={filters} onConfirm={handleFilterChange} />
</Overlay>
</div>
</HomePage>
);
}

View File

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '通告管理',
disableScroll: true,
usingComponents: {},
});

View File

@ -0,0 +1,60 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-certification-manage {
width: 100%;
height: 100vh;
.flex-column();
&__tabs {
flex: 1;
width: 100%;
.taroify-tabs__wrap__scroll {
padding: 0 40px;
}
.taroify-tabs__tab {
--tab-color: @blColorG2;
--tabs-active-color: @blColor;
}
.taroify-tabs__line {
display: none;
}
}
&__empty-tips {
width: 100%;
height: 100vh;
padding-top: 218px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
&__icon {
width: 386px;
height: 278px;
}
&__describe {
font-size: 28px;
font-weight: 500;
line-height: 40px;
color: @blColor;
margin-top: 50px;
}
}
&__footer {
width: 100%;
margin-bottom: 40px;
}
&__button {
.button(@width: calc(100% - 48px); @height: 80px);
margin: 0 24px;
margin-bottom: 40px;
}
}

View File

@ -0,0 +1,130 @@
import { Button, Image } from '@tarojs/components';
import { NodesRef } from '@tarojs/taro';
import { Tabs } from '@taroify/core';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'react';
import JobManageList, { IJobManageListProps } from '@/components/job-manage-list';
import { CompanyPublishJobDialog } from '@/components/product-dialog/publish-job';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { ReportEventId } from '@/constants/event';
import { JOB_MANAGE_TABS, JobManageStatus, JobManageType } from '@/constants/job';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import useUserInfo from '@/hooks/use-user-info';
import { logWithPrefix } from '@/utils/common';
import { reportEvent } from '@/utils/event';
import { ensureUserInfo } from '@/utils/user';
import './index.less';
const PREFIX = 'page-certification-manage';
const LIST_CONTAINER_CLASS = `${PREFIX}__list-container`;
const BUTTON_CLASS = `${PREFIX}__button`;
const SAFE_BOTTOM_PADDING_CLASS = `${PREFIX}__sbpc`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [
`.${PREFIX}`,
`.${PREFIX} .taroify-tabs__wrap__scroll`,
`.${BUTTON_CLASS}`,
`.${SAFE_BOTTOM_PADDING_CLASS}`,
],
calc: (
rects: [
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
]
) => {
const [page, tabs, button, safePadding] = rects;
return page.height - tabs.height - button.height - safePadding.height - 10;
},
};
const log = logWithPrefix(PREFIX);
const tab2Status = (tabType: JobManageType) => {
switch (tabType) {
case JobManageType.Open:
return JobManageStatus.Open;
case JobManageType.Pending:
return JobManageStatus.Pending;
case JobManageType.Error:
return JobManageStatus.Error;
case JobManageType.All:
default:
return;
}
};
const EmptyTips = (props: { className?: string; height?: number }) => {
const { className, height } = props;
return (
<div className={classNames(`${PREFIX}__empty-tips`, className)} style={height ? { height } : undefined}>
<Image className={`${PREFIX}__empty-tips__icon`} src={require('@/statics/svg/empty-box.svg')} mode="aspectFit" />
<div className={`${PREFIX}__empty-tips__describe`}></div>
</div>
);
};
function ListWrapper(props: IJobManageListProps) {
const { className, listHeight, visible } = props;
const [isEmpty, setIsEmpty] = useState(false);
const handleListEmpty = useCallback(() => {
setIsEmpty(true);
}, []);
useEffect(() => {
if (visible) {
setIsEmpty(false);
}
}, [visible]);
if (isEmpty) {
return <EmptyTips className={className} height={listHeight} />;
}
return <JobManageList {...props} onListEmpty={handleListEmpty} />;
}
export default function CertificationManage() {
const userInfo = useUserInfo();
const listHeight = useListHeight(CALC_LIST_PROPS);
const [tabType, setTabType] = useState<JobManageType>(JobManageType.All);
const [showPublish, setShowPublish] = useState(false);
const handleTypeChange = useCallback(value => setTabType(value), []);
const handlePublishJob = useCallback(async () => {
log('handlePublishJob');
reportEvent(ReportEventId.CLICK_GO_TO_PUBLISH_JOB);
if (!(await ensureUserInfo(userInfo))) {
return;
}
setShowPublish(true);
}, [userInfo]);
return (
<div className={PREFIX}>
<Tabs swipeable value={tabType} className={`${PREFIX}__tabs`} onChange={handleTypeChange}>
{JOB_MANAGE_TABS.map(tab => (
<Tabs.TabPane value={tab.type} title={tab.title} key={tab.type}>
<ListWrapper
status={tab2Status(tab.type)}
listHeight={listHeight}
className={LIST_CONTAINER_CLASS}
visible={tabType === tab.type}
/>
</Tabs.TabPane>
))}
</Tabs>
<div className={`${PREFIX}__footer`}>
<Button className={BUTTON_CLASS} onClick={handlePublishJob}>
</Button>
</div>
<SafeBottomPadding className={SAFE_BOTTOM_PADDING_CLASS} />
<div>{showPublish && <CompanyPublishJobDialog userInfo={userInfo} onClose={() => setShowPublish(false)} />}</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '认证',
});

View File

@ -0,0 +1,47 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-certification-start {
.flex-column();
padding: 0 24px;
&__icon {
width: 224px;
height: 224px;
border-radius: 50%;
background: #FFF;
margin-top: 80px;
}
&__title {
font-size: 30px;
line-height: 36px;
font-weight: 400;
color: @blColor;
margin-top: 48px;
}
&__tips {
position: relative;
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blHighlightColor;
margin-top: 24px;
&::after {
content: ' ';
position: absolute;
height: 10px;
left: 0;
right: 0;
bottom: 5px;
background: #6D3DF54D;
}
}
&__button {
.button(@width: 100%; @height: 80px);
margin-top: 48px;
}
}

View File

@ -0,0 +1,33 @@
import { Image } from '@tarojs/components';
import LoginButton from '@/components/login-button';
import { PageUrl } from '@/constants/app';
import { ReportEventId } from '@/constants/event';
import { reportEvent } from '@/utils/event';
import { redirectTo } from '@/utils/route';
import './index.less';
const PREFIX = 'page-certification-start';
export default function CertificationStart() {
const handleClick = () => {
reportEvent(ReportEventId.CLICK_START_CERTIFICATION);
redirectTo(PageUrl.Certification);
};
return (
<div className={PREFIX}>
<Image
mode="aspectFit"
className={`${PREFIX}__icon`}
src={require('@/statics/svg/certification-tips-icon.svg')}
/>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__tips`}></div>
<LoginButton className={`${PREFIX}__button`} onClick={handleClick} needPhone>
</LoginButton>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '个人认证',
});

View File

@ -0,0 +1,79 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-certification {
padding: 40px 24px;
&__id-card-container {
width: 100%;
.flex-row();
}
&__id-card {
flex: 1;
height: 178px;
.flex-column();
justify-content: center;
background: #F2F2F2;
border-radius: 12px;
margin: 24px 0;
&:first-child {
margin-right: 24px;
}
&__image {
width: 100%;
height: 100%;
}
&__icon {
width: 72px;
height: 72px;
}
&__describe {
font-size: 24px;
line-height: 32px;
font-weight: 400;
color: @blColorG1;
margin-top: 24px;
}
}
&__verify {
.flex-column();
&__input {
flex: 1;
height: 100px;
}
&__code-container {
width: 100%;
.flex-row();
}
&__send {
font-size: 32px;
line-height: 32px;
font-weight: 400;
color: @blHighlightColor;
white-space: nowrap;
}
}
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
padding-top: 30px;
}
&__submit {
.button(@width: 100%; @height: 80px);
margin-bottom: 56px;
}
}

View File

@ -0,0 +1,252 @@
import { BaseEventOrig, Button, Image, InputProps } from '@tarojs/components';
import Taro, { UploadTask } from '@tarojs/taro';
import { useCallback, useEffect, useState } from 'react';
import BlFormInput from '@/components/bl-form-input';
import BlFormItem from '@/components/bl-form-item';
import LoadingDialog from '@/components/loading-dialog';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { PageUrl } from '@/constants/app';
import { CertificationStatusType } from '@/constants/company';
import { CollectEventName, ReportEventId } from '@/constants/event';
import useUserInfo from '@/hooks/use-user-info';
import { ICertificationRequest } from '@/types/company';
import { isValidIdCard, isValidPhone, logWithPrefix } from '@/utils/common';
import { postCertification } from '@/utils/company';
import { collectEvent, reportEvent } from '@/utils/event';
import { chooseMedia } from '@/utils/material';
import { redirectTo } from '@/utils/route';
import Toast from '@/utils/toast';
import { dispatchUpdateUser, requestUserInfo } from '@/utils/user';
import { uploadVideo } from '@/utils/video';
import './index.less';
const PREFIX = 'page-certification';
const log = logWithPrefix(PREFIX);
const needIdCard = false;
const isValidCertificationInfo = (data: ICertificationRequest) => {
const {
name,
// code,
phone,
idCardNo: idNumber,
companyName: company,
// idCardSideAUrl: leftIdCardUrl,
// idCardSideBUrl: rightIdCardUrl,
} = data;
// if (!leftIdCardUrl || !rightIdCardUrl) {
// return '请上传身份证照片';
// }
if (!name) {
return '请输入姓名';
}
if (!idNumber || !isValidIdCard(idNumber)) {
return '请输入正确的身份证';
}
if (!phone || !isValidPhone(phone)) {
return '请输入正确的手机号';
}
// if (!code) {
// return '验证码不能为空';
// }
if (!company) {
return '请输入公司名称';
}
};
const uploadIdCard = async () => {
let showLoading = false;
try {
const media = await chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
});
if (!media) {
return;
}
const { tempFiles } = media;
const tempFile = tempFiles[0];
if (!tempFile) {
throw new Error('tempFile is not exist');
}
showLoading = true;
Taro.showLoading({ title: '上传0%' });
const onProgress: UploadTask.OnProgressUpdateCallback = res => {
log('上传视频进度', res.progress, '总长度', res.totalBytesExpectedToSend, '已上传的长度', res.totalBytesSent);
Taro.showLoading({ title: `上传${res.progress}%` });
};
const { url } = await uploadVideo(tempFile.tempFilePath, tempFile.fileType, onProgress, 'id-card');
return url;
} catch (e) {
console.error('upload fail', e);
Toast.error('上传失败');
collectEvent(CollectEventName.UPDATE_ID_CARD_FAILED, e);
} finally {
showLoading && Taro.hideLoading();
}
};
export default function Certification() {
const { phone } = useUserInfo();
const [leftIdCardUrl, setLeftIdCardUrl] = useState('');
const [rightIdCardUrl, setRightIdCardUrl] = useState('');
const [name, setName] = useState('');
const [idNumber, setIdNumber] = useState('');
// const [code, setCode] = useState('');
const [company, setCompany] = useState('');
const [open, setOpen] = useState(false);
const handleClickIdCardLeft = useCallback(async () => {
reportEvent(ReportEventId.CLICK_UPLOAD_ID_CARD, { type: 'left' });
const url = await uploadIdCard();
url && setLeftIdCardUrl(url);
}, []);
const handleClickIdCardRight = useCallback(async () => {
reportEvent(ReportEventId.CLICK_START_CERTIFICATION, { type: 'right' });
const url = await uploadIdCard();
url && setRightIdCardUrl(url);
}, []);
const handleInputName = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setName(value);
}, []);
const handleInputIdNumber = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setIdNumber(value);
}, []);
// const handleInputCode = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
// const value = e.detail.value || '';
// setCode(value);
// }, []);
const handleInputCompany = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setCompany(value);
}, []);
const handleSubmit = useCallback(async () => {
reportEvent(ReportEventId.CLICK_CERTIFICATION_SUBMIT);
const data: ICertificationRequest = {
name,
// code,
phone,
idCardNo: idNumber,
companyName: company,
// idCardSideAUrl: leftIdCardUrl,
// idCardSideBUrl: rightIdCardUrl,
};
const errMsg = isValidCertificationInfo(data);
if (errMsg) {
Toast.info(errMsg);
return;
}
try {
setOpen(true);
const { authSuc, msg } = await postCertification(data);
if (!authSuc) {
Toast.info(msg || '认证失败');
return;
}
dispatchUpdateUser({ bossAuthStatus: CertificationStatusType.Success });
redirectTo(PageUrl.CertificationManage);
} catch (e) {
console.error('submit error', e);
Toast.error('认证失败请重试');
collectEvent(CollectEventName.SUBMIT_CERTIFICATION_FAILED, e);
} finally {
setOpen(false);
}
}, [name, idNumber, phone, company]);
// }, [leftIdCardUrl, rightIdCardUrl, name, idNumber, phone, company]);
useEffect(() => {
if (phone) {
return;
}
const requestPhone = async () => {
collectEvent(CollectEventName.CERTIFICATION_PAGE, { info: 'start requestPhone' });
const userInfo = await requestUserInfo();
collectEvent(CollectEventName.CERTIFICATION_PAGE, { info: 'requestPhone success', phone: userInfo.phone });
};
requestPhone();
}, [phone]);
return (
<div className={PREFIX}>
{needIdCard && (
<BlFormItem title="上传身份证照片" subTitle={false} dynamicHeight>
<div className={`${PREFIX}__id-card-container`}>
<div className={`${PREFIX}__id-card`} onClick={handleClickIdCardLeft}>
{leftIdCardUrl && <Image mode="aspectFit" className={`${PREFIX}__id-card__image`} src={leftIdCardUrl} />}
{!leftIdCardUrl && (
<>
<Image
mode="aspectFit"
className={`${PREFIX}__id-card__icon`}
src={require('@/statics/svg/upload-id-card-icon.svg')}
/>
<div className={`${PREFIX}__id-card__describe`}></div>
</>
)}
</div>
<div className={`${PREFIX}__id-card`} onClick={handleClickIdCardRight}>
{rightIdCardUrl && (
<Image mode="aspectFit" className={`${PREFIX}__id-card__image`} src={rightIdCardUrl} />
)}
{!rightIdCardUrl && (
<>
<Image
mode="aspectFit"
className={`${PREFIX}__id-card__icon`}
src={require('@/statics/svg/upload-id-card-icon.svg')}
/>
<div className={`${PREFIX}__id-card__describe`}></div>
</>
)}
</div>
</div>
</BlFormItem>
)}
<BlFormItem title="姓名" subTitle={false}>
<BlFormInput value={name} onInput={handleInputName} />
</BlFormItem>
<BlFormItem title="身份证号" subTitle={false}>
<BlFormInput value={idNumber} onInput={handleInputIdNumber} type="idcard" maxlength={18} />
</BlFormItem>
<BlFormItem title="手机号" subTitle="请使用本人名下的手机号" contentClassName={`${PREFIX}__verify`} dynamicHeight>
<BlFormInput className={`${PREFIX}__verify__input`} value={phone} type="number" maxlength={11} disabled />
{/* <div className={`${PREFIX}__verify__code-container`}>
<BlFormInput
className={`${PREFIX}__verify__input`}
value={code}
onInput={handleInputCode}
type="number"
maxlength={8}
/>
<div className={`${PREFIX}__verify__send`}>获取验证码</div>
</div> */}
</BlFormItem>
<BlFormItem title="公司全称" subTitle={false}>
<BlFormInput maxlength={200} value={company} onInput={handleInputCompany} />
</BlFormItem>
<SafeBottomPadding />
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__submit`} onClick={handleSubmit}>
</Button>
<SafeBottomPadding />
</div>
<div>
<LoadingDialog open={open} text="认证中" />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'debug',
});

View File

@ -0,0 +1,6 @@
.dev-debug {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,172 @@
import Taro, { useLoad } from '@tarojs/taro';
import { Button } from '@taroify/core';
import { useCallback } from 'react';
import HomePage from '@/components/home-page';
import { PageUrl } from '@/constants/app';
import { copy, logWithPrefix } from '@/utils/common';
import { navigateTo } from '@/utils/route';
import './index.less';
const PREFIX = 'dev-debug';
const log = logWithPrefix(PREFIX);
const marginTopStyle = { marginTop: 20 };
export default function DevDebug() {
// const [lastOrderNo, setLastOrderNo] = useState<string>('');
const handleLogin = useCallback(() => {
Taro.login({
success: res => {
log('login code:', res.code);
copy(res.code);
},
fail: e => log('login fail', e),
});
}, []);
// const handleGetPhoneNumber = useCallback((e: BaseEventOrig<ButtonProps.onGetPhoneNumberEventDetail>) => {
// log(
// 'handleGetPhoneNumber',
// `code: ${e.detail.code}`,
// `\niv: ${e.detail.iv}`,
// `\nencryptedData:${e.detail.encryptedData}`
// );
// const encryptedData = e.detail.encryptedData;
// const iv = e.detail.iv;
// if (!encryptedData || !iv) {
// Toast.error('取消授权');
// return;
// }
// Taro.setClipboardData({
// data: `code: ${e.detail.code}, iv: ${e.detail.iv}, encryptedData:${e.detail.encryptedData}`,
// });
// }, []);
// const handleCopyAndContact = async () => {
// openCustomerServiceChat();
// await copy('测试复制');
// };
// const handleGetLocationInfo = () => {
// requestLocation();
// };
// const handleCreateOrder = async (type: OrderType) => {
// try {
// const { payOrderNo, createPayInfo: payInfo } = await requestCreatePayInfo({
// type,
// amt: 1,
// productCode: type === OrderType.Group ? ProductType.AddGroup : ProductType.BossVip,
// productSpecId: type === OrderType.Group ? ProductSpecId.AddGroup1 : ProductSpecId.BossVip,
// });
// log('handlePay data', payOrderNo, payInfo);
// const res = await Taro.requestPayment({
// timeStamp: payInfo.timeStamp,
// nonceStr: payInfo.nonceStr,
// package: payInfo.packageVal,
// signType: payInfo.signType,
// paySign: payInfo.paySign,
// });
// setLastOrderNo(payOrderNo);
// log('handleBuy requestPayment res', res);
// } catch (e) {
// Toast.error('出错了,请重试');
// log('handleBuy error', e);
// }
// };
// const handleGetOrder = async () => {
// if (!lastOrderNo) {
// return;
// }
// try {
// const result = await requestOrderInfo({
// payOrderNo: lastOrderNo,
// });
// Taro.showToast({ title: JSON.stringify(result), icon: 'none' });
// log('handleGetOrder data', result);
// } catch (e) {
// Toast.error('出错了,请重试');
// log('handleGetOrder error', e);
// }
// };
const handleJumpPage = () => navigateTo(PageUrl.CertificationStart);
// const handleSubscribeJob = async () => {
// const result = await subscribeMessage([SubscribeTempId.SUBSCRIBE_JOB]);
// log('handleSubscribeMessage result', result);
// Toast.info(`订阅结果: ${result[SubscribeTempId.SUBSCRIBE_JOB]}`);
// };
// const handleSubscribeVip = async () => {
// const result = await subscribeMessage([SubscribeTempId.SUBSCRIBE_VIP]);
// log('handleSubscribeMessage result', result);
// Toast.info(`订阅结果: ${result[SubscribeTempId.SUBSCRIBE_VIP]}`);
// };
// const handleQiniu = async () => {
// try {
// const media = await chooseMedia();
// if (!media) {
// return;
// }
// const { tempFiles } = media;
// const tempFile = tempFiles[0];
// if (!tempFile) {
// throw new Error('tempFile is not exist');
// }
// const onProgress: UploadTask.OnProgressUpdateCallback = res => {
// log('上传视频进度', res.progress, '总长度', res.totalBytesExpectedToSend, '已上传的长度', res.totalBytesSent);
// Taro.showLoading({ title: `上传${res.progress}%` });
// };
// await qiniuUpload.upload(tempFile.tempFilePath, onProgress);
// } catch (e) {
// console.error('upload fail', e);
// Toast.error('上传失败');
// } finally {
// Taro.hideLoading();
// }
// };
useLoad(() => {
console.log('Page loaded.');
});
return (
<HomePage>
<div className={PREFIX}>
{/* <div>{`最近一次的订单 ID: ${lastOrderNo}`}</div>
<Button onClick={() => handleCreateOrder(OrderType.Group)} style={marginTopStyle} color="primary">
下单群
</Button>
<Button onClick={() => handleCreateOrder(OrderType.BossVip)} style={marginTopStyle} color="primary">
下单 BossVip
</Button>
<Button onClick={handleGetOrder} style={marginTopStyle} color="primary">
查询最近一次订单
</Button> */}
{/* <Button onClick={() => navigateTo(PageUrl.JobPublish)} style={marginTopStyle} color="primary">
跳转到通告发布
</Button> */}
<Button onClick={handleJumpPage} style={marginTopStyle} color="primary">
</Button>
{/* <Button onClick={handleSubscribeJob} style={marginTopStyle} color="primary">
订阅通告推送
</Button>
<Button onClick={handleSubscribeVip} style={marginTopStyle} color="primary">
订阅主播会员推送
</Button> */}
{/* <Button onClick={handleQiniu} style={marginTopStyle} color="primary">
上传到七牛
</Button> */}
<Button onClick={handleLogin} style={marginTopStyle} color="primary">
code
</Button>
</div>
</HomePage>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我关注群的通告',
});

View File

@ -0,0 +1,29 @@
@import '@/styles/variables.less';
.follow-group {
height: 100vh;
position: relative;
padding: 0 20px 20px;
&__empty-container {
width: 100%;
padding-top: 338px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
&__empty-box {
width: 386px;
height: 278px;
}
&__empty-text {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
margin-top: 34px;
}
}

View File

@ -0,0 +1,60 @@
import { Image } from '@tarojs/components';
import { NodesRef } from '@tarojs/taro';
import { useCallback, useState } from 'react';
import JobList from '@/components/job-list';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import { logWithPrefix } from '@/utils/common';
import './index.less';
const PREFIX = 'follow-group';
const LIST_CONTAINER_CLASS = `${PREFIX}__list-container`;
const SAFE_PADDING_BOTTOM_CLASS = `${PREFIX}__safe-padding-bottom`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${PREFIX}`, `.${SAFE_PADDING_BOTTOM_CLASS}`],
calc: (rects: [NodesRef.BoundingClientRectCallbackResult, NodesRef.BoundingClientRectCallbackResult]) => {
const [pageRect, safePaddingRect] = rects;
return pageRect.height - safePaddingRect.height;
},
};
const log = logWithPrefix(PREFIX);
const NoFollowTips = () => {
return (
<div className={`${PREFIX}__empty-container`}>
<Image className={`${PREFIX}__empty-box`} src={require('@/statics/svg/empty-box.svg')} mode="aspectFit" />
<div className={`${PREFIX}__empty-text`}></div>
</div>
);
};
function FollowGroup() {
const [isEmpty, setIsEmpty] = useState(false);
const listHeight = useListHeight(CALC_LIST_PROPS);
log('list height', listHeight);
const handleListEmpty = useCallback(() => {
setIsEmpty(true);
}, []);
return (
<div className={PREFIX}>
{!isEmpty && (
<JobList
visible
isFollow
className={LIST_CONTAINER_CLASS}
listHeight={listHeight}
onListEmpty={handleListEmpty}
/>
)}
{isEmpty && <NoFollowTips />}
<SafeBottomPadding className={SAFE_PADDING_BOTTOM_CLASS} />
</div>
);
}
export default FollowGroup;

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '',
disableScroll: true,
});

View File

@ -0,0 +1,42 @@
@import '@/styles/variables.less';
.page-group-job {
height: 100vh;
padding: 0 24px;
.page-group-job__type-tabs {
padding: 0 20px;
margin-top: 20px;
.taroify-tabs__wrap {
height: 40px;
}
.taroify-tabs__wrap__scroll {
max-width: 100%;
}
.taroify-tabs__tab {
display: flex;
flex-direction: column;
flex: 0 0 auto !important;
font-size: 28px;
--tab-color: @blColorG1;
--tabs-active-color: @blColor;
&:first-child {
padding-left: 0;
}
}
.taroify-tabs__line {
height: 0;
background-color: transparent;
border-radius: 0;
}
.taroify-tabs__content {
padding: 20px 0;
}
}
}

View File

@ -0,0 +1,95 @@
import Taro, { NodesRef, useLoad } from '@tarojs/taro';
import { Tabs } from '@taroify/core';
import { useCallback, useState } from 'react';
import JobList from '@/components/job-list';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import SearchInput from '@/components/search';
import { JOB_TABS, JobType } from '@/constants/job';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import { logWithPrefix } from '@/utils/common';
import { getPageQuery } from '@/utils/route';
import './index.less';
const PREFIX = 'page-group-job';
const LIST_CLASS = `${PREFIX}__list-container`;
const SAFE_PADDING_BOTTOM_CLASS = `${PREFIX}__safe-padding-bottom`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${LIST_CLASS}`, `.${PREFIX}`, `.${SAFE_PADDING_BOTTOM_CLASS}`],
calc: (
rects: [
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
]
) => {
const [listRect, pageRect, safePaddingRect] = rects;
return pageRect.bottom - listRect.top - safePaddingRect.height;
},
};
const log = logWithPrefix(PREFIX);
export default function GroupJob() {
const listHeight = useListHeight(CALC_LIST_PROPS);
const [tabType, setTabType] = useState<JobType>(JobType.All);
const [value, setValue] = useState<string>('');
const [keyWord, setKeyWord] = useState<string>('');
const [groupId, setGroupId] = useState<string | undefined>();
const handleClickSearch = useCallback(() => {
if (value === keyWord) {
return;
}
setKeyWord(value);
}, [value, keyWord]);
const handleSearchClear = useCallback(() => {
setValue('');
setKeyWord('');
}, []);
const handleSearchChange = useCallback(e => setValue(e.detail.value), []);
const onTypeChange = useCallback(type => setTabType(type), [setTabType]);
useLoad(() => {
const query = getPageQuery<{ groupId: string; title: string }>();
log('query', query);
const title = query.title || '群通告';
Taro.setNavigationBarTitle({ title });
setGroupId(query.groupId);
});
return (
<div className={PREFIX}>
<SearchInput
value={value}
placeholder="搜索通告"
className={`${PREFIX}__search`}
onClear={handleSearchClear}
onSearch={handleClickSearch}
onChange={handleSearchChange}
/>
<Tabs className={`${PREFIX}__type-tabs`} value={tabType} onChange={onTypeChange}>
{JOB_TABS.map(tab => (
<Tabs.TabPane title={tab.title} key={tab.type} value={tab.type}>
{!groupId && <div className={LIST_CLASS} />}
{groupId && (
<JobList
category={tab.type}
keyWord={keyWord}
blGroupId={groupId}
className={LIST_CLASS}
listHeight={listHeight}
visible={tabType === tab.type}
/>
)}
</Tabs.TabPane>
))}
</Tabs>
<SafeBottomPadding className={SAFE_PADDING_BOTTOM_CLASS} />
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '合作群列表',
});

View File

@ -0,0 +1,24 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-group-list {
padding: 24px;
&__group-card {
width: 100%;
height: 150px;
font-size: 32px;
line-height: 150px;
font-weight: 500;
color: @blColor;
background-color: #FFFFFF;
border-radius: 16px;
box-sizing: border-box;
padding: 0 40px;
margin-top: 24px;
&:first-child {
margin-top: 0;
}
}
}

View File

@ -0,0 +1,49 @@
import { useLoad } from '@tarojs/taro';
import { useState } from 'react';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { SimpleGroupInfo } from '@/types/group';
import { requestSimpleGroupList } from '@/utils/group';
import { getPageQuery } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-group-list';
export default function GroupList() {
const [loading, setLoading] = useState(true);
const [groupList, setGroupList] = useState<SimpleGroupInfo[]>([]);
useLoad(async () => {
try {
const query = getPageQuery<{ city: string }>();
const { city: cityCode } = query;
if (!cityCode) {
return;
}
const groups = await requestSimpleGroupList(cityCode);
setLoading(false);
setGroupList(groups);
} catch (e) {
Toast.error('加载失败请重试');
}
});
if (loading) {
return <PageLoading />;
}
return (
<div className={PREFIX}>
{groupList.map(group => (
<div className={`${PREFIX}__group-card`} key={group.blGroupId}>
{group.imGroupNick}
</div>
))}
<SafeBottomPadding />
</div>
);
}

View File

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '通告群',
disableScroll: true,
enableShareAppMessage: true,
usingComponents: {},
});

View File

@ -0,0 +1,78 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.group-v2-page {
padding: 24px;
&__header {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-bottom: 32px;
&__left-line,
&__right-line {
width: 88px;
height: 1px;
}
&__left-line {
background: linear-gradient(270deg, #CCCCCC -0.05%, rgba(204, 204, 204, 0) 99.95%);
}
&__right-line {
background: linear-gradient(90deg, #CCCCCC 0%, rgba(204, 204, 204, 0) 100%);
}
&__title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColorG2;
margin: 0 16px;
}
}
&__group-card {
.flex-row();
width: 100%;
padding: 32px;
background: #FFF;
border-radius: 16px;
margin-top: 24px;
box-sizing: border-box;
&:first-child {
margin-top: 0;
}
&__avatar {
width: 88px;
height: 88px;
border-radius: 6px;
border: 4px solid #D9D9D9;
}
&__title {
flex: 1;
font-size: 32px;
line-height: 40px;
font-weight: 500;
color: @blColor;
align-self: flex-start;
margin-left: 36px;
}
&__button {
.button(@width: 176px; @height: 56px; @fontSize: 28px; @fontWeight: 500);
}
}
&__bottom-padding {
width: 100%;
height: 24px;
}
}

View File

@ -0,0 +1,90 @@
import { Image } from '@tarojs/components';
import { NodesRef, useShareAppMessage } from '@tarojs/taro';
import { List } from '@taroify/core';
import { useCallback } from 'react';
import HomePage from '@/components/home-page';
import LoginButton from '@/components/login-button';
import { APP_TAB_BAR_ID } from '@/constants/app';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import { openCustomerServiceChat } from '@/utils/common';
import { getCommonShareMessage } from '@/utils/share';
import './index.less';
interface GroupItem {
title: string;
serviceUrl: string;
}
const PREFIX = 'group-v2-page';
const LIST_CONTAINER_CLASS = `${PREFIX}__list-container`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${LIST_CONTAINER_CLASS}`, `#${APP_TAB_BAR_ID}`],
calc: (rects: [NodesRef.BoundingClientRectCallbackResult, NodesRef.BoundingClientRectCallbackResult]) => {
const [rect, diffRect] = rects;
return diffRect.top - rect.top;
},
};
const GROUPS: GroupItem[] = [
{ title: '【广州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcb4b88b8abb7a7c8b' },
{ title: '【深圳】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcfe70d8736e14bb64' },
{ title: '【佛山】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcfac1132df386fac8' },
{ title: '【东莞】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcb2b0e39026f7dddc' },
{ title: '【杭州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc223f495e159af95e' },
{ title: '【温州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcb0ea5f197a18b335' },
{ title: '【上海】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc4189e68429cf07f8' },
{ title: '【厦门】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc2007a895cb48464b' },
{ title: '【福州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc126483dedadde82b' },
{ title: '【泉州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc4c8c42b1a9337aaf' },
{ title: '【长沙】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc76be8f2b3f8aa437' },
{ title: '【成都】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcf75cefbdc62946fa' },
{ title: '【重庆】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcd7008f747d545f83' },
{ title: '【郑州】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcd1c53b7bf8ecdb97' },
{ title: '【西安】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc34768971b7354220' },
{ title: '【武汉】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc047c94f8c709b395' },
{ title: '【南京】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcc6dc8d0a9692b70e' },
{ title: '【合肥】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfc41c9785cc2035277' },
{ title: '【北京】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcb119c94575e91262' },
{ title: '【青岛】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfce8d7a68190f6a1d2' },
{ title: '【其他】', serviceUrl: 'https://work.weixin.qq.com/kfid/kfcc60ac7b6420787a8' },
];
export default function GroupV2() {
const listHeight = useListHeight(CALC_LIST_PROPS);
const handleClick = useCallback((group: GroupItem) => openCustomerServiceChat(group.serviceUrl), []);
useShareAppMessage(() => getCommonShareMessage());
return (
<HomePage>
<div className={PREFIX}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header__left-line`} />
<div className={`${PREFIX}__header__title`}></div>
<div className={`${PREFIX}__header__right-line`} />
</div>
<div className={LIST_CONTAINER_CLASS}>
<List style={{ height: `${listHeight}px` }} disabled fixedHeight>
{GROUPS.map(group => (
<div className={`${PREFIX}__group-card`} key={group.serviceUrl}>
<Image
mode="aspectFit"
className={`${PREFIX}__group-card__avatar`}
src="https://neighbourhood.cn/addGroup.jpg"
/>
<div className={`${PREFIX}__group-card__title`}>{group.title}</div>
<LoginButton className={`${PREFIX}__group-card__button`} onClick={() => handleClick(group)}>
</LoginButton>
</div>
))}
<div className={`${PREFIX}__bottom-padding`} />
</List>
</div>
</div>
</HomePage>
);
}

View File

@ -0,0 +1,7 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '',
disableScroll: true,
enableShareAppMessage: true,
usingComponents: {},
});

View File

@ -0,0 +1,27 @@
@import '@/styles/variables.less';
.group-page {
&__tabs {
.taroify-tabs__wrap__scroll {
max-width: 70%;
}
.taroify-tabs__tab {
display: flex;
flex-direction: column;
flex: 0 0 auto !important;
font-size: 40px;
--tab-color: @blColorG2;
--tabs-active-color: @blColor;
}
.taroify-tabs__line {
width: 100%;
position: relative;
margin-top: -5px;
height: 8px;
background-color: @blTabLineColor;
border-radius: 0;
}
}
}

41
src/pages/group/index.tsx Normal file
View File

@ -0,0 +1,41 @@
import { useShareAppMessage } from '@tarojs/taro';
import { Tabs } from '@taroify/core';
import { useCallback, useState } from 'react';
import HomePage from '@/components/home-page';
import { GroupType, GROUP_PAGE_TABS } from '@/constants/group';
import GroupFragment from '@/fragments/group';
import useNavigation from '@/hooks/use-navigation';
import { getCommonShareMessage } from '@/utils/share';
import './index.less';
const PREFIX = 'group-page';
export default function Group() {
const { barHeight, statusBarHeight } = useNavigation();
const [tabType, setTabType] = useState<GroupType>(GroupType.All);
const handleTypeChange = useCallback(value => setTabType(value), []);
useShareAppMessage(() => getCommonShareMessage());
return (
<HomePage>
<Tabs
swipeable
value={tabType}
className={`${PREFIX}__tabs`}
onChange={handleTypeChange}
style={{ height: barHeight.current, paddingTop: statusBarHeight.current }}
>
{GROUP_PAGE_TABS.map(tab => (
<Tabs.TabPane value={tab.type} title={tab.title} key={tab.type}>
<GroupFragment type={tab.type} />
</Tabs.TabPane>
))}
</Tabs>
</HomePage>
);
}

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '',
enableShareAppMessage: true,
});

View File

@ -0,0 +1,274 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.job-detail {
&__container {
padding: 0 24px;
}
&__header {
padding: 24px;
border-radius: 16px;
background: #FFFFFF;
}
&__header-info {
display: flex;
flex-direction: row;
align-items: flex-start;
overflow: hidden;
line-height: 48px;
}
&__header-title {
font-size: 36px;
line-height: 48px;
font-weight: 500;
color: @blColor;
display: -webkit-box;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
&__employ-type {
width: fit-content;
padding: 3px 6px;
font-size: 20px;
line-height: 24px;
font-weight: 400;
background: @blHighlightBg;
color: @blHighlightColor;
white-space: nowrap;
margin-top: 6px;
margin-left: 22px;
}
&__salary {
font-size: 36px;
font: 500;
color: @blHighlightColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 16px;
}
&__update-time {
margin: 16px 0;
font-size: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG1;
}
&__tips {
padding: 18px 32px;
font-size: 26px;
line-height: 28px;
font-weight: 400;
color: #946724;
background: #FFF4F0;
border-radius: 4px;
}
&__group,
&__publisher,
&__company {
font-size: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG2;
margin-top: 16px;
.flex-row();
justify-content: flex-start;
}
&__group-name,
&__publisher-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__group__not-follow {
height: 36px;
padding: 0 12rpx;
font-size: 24px;
line-height: 36px;
color: @blHighlightColor;
background: transparent;
border: 2px solid @blHighlightColor;
border-radius: 32px;
margin-left: 14px;
&::after {
border-color: transparent
}
}
&__group__followed {
height: 36px;
padding: 0 12rpx;
font-size: 24px;
line-height: 36px;
color: @blHighlightColor;
background: @blHighlightBg;
border-radius: 4px;
margin-left: 14px;
}
&__certification-type {
margin-left: 10px;
}
&__publisher-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
margin-left: 10px;
}
&__content {
padding: 24px;
margin-top: 24px;
border-radius: 16px;
background: #FFFFFF;
}
&__content-title {
font-size: 36px;
line-height: 48px;
font-weight: 500;
color: @blColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__tags {
max-width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 23px;
// 抵消最后一行的 margin-bottom
margin-bottom: -10px;
}
&__tag {
padding: 3px 6px;
font-size: 20px;
line-height: 24px;
font-weight: 400;
background: #F2F2F2;
white-space: nowrap;
color: @blColorG2;
margin-right: 8px;
margin-bottom: 10px;
}
&__description {
font-size: 28px;
line-height: 56px;
margin-top: 16px;
margin-bottom: 20px;
}
&__address-wrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 10px;
}
&__detailed-address {
font-size: 28px;
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;
}
&__distance-icon {
width: 28px;
height: 28px;
margin-right: 6px;
}
&__distance {
font-size: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG1;
}
&__map__wrapper {
position: relative;
width: 100%;
height: 270px;
margin-top: 20px;
}
&__map {
width: 100%;
height: 100%;
}
&__map__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&__bottom-space {
// 按钮的高度 + 底部边距
height: calc(88px + 26px);
padding-bottom: calc(constant(safe-area-inset-bottom) + 12px);
/* 兼容 iOS < 11.2 */
padding-bottom: calc(env(safe-area-inset-bottom) + 12px);
/* 兼容 iOS >= 11.2 */
}
&__footer {
position: fixed;
bottom: 0;
width: 100%;
background: #FFFFFF;
padding: 12px 32px;
padding-bottom: calc(constant(safe-area-inset-bottom) + 12px);
/* 兼容 iOS < 11.2 */
padding-bottom: calc(env(safe-area-inset-bottom) + 12px);
/* 兼容 iOS >= 11.2 */
box-shadow: 0px -4px 20px 0px #00000014;
display: flex;
flex-direction: row;
box-sizing: border-box;
}
&__share-button {
flex: 1 1;
.button(@height: 88px; @fontSize: 32px; @fontWeight: 500; @borderRadius: 44px; @highlight: 0);
}
&__contact-publisher {
flex: 2 2;
.button(@height: 88px; @fontSize: 32px; @fontWeight: 500; @borderRadius: 44px;);
margin-left: 32px;
}
}

View File

@ -0,0 +1,330 @@
import { Map, MapProps, Text, Image, Button } from '@tarojs/components';
import Taro, { useLoad, useShareAppMessage } from '@tarojs/taro';
import React, { useCallback, useEffect, useState } from 'react';
import { CertificationStatusIcon } from '@/components/certification-status';
import CommonDialog from '@/components/common-dialog';
import DevDiv from '@/components/dev-div';
import JobRecommendList from '@/components/job-recommend-list';
import LoginButton from '@/components/login-button';
import MaterialGuide from '@/components/material-guide';
import PageLoading from '@/components/page-loading';
import ProductJobDialog from '@/components/product-dialog/job';
import { RoleType, EventName, PageUrl } from '@/constants/app';
import { CertificationStatusType } from '@/constants/company';
import { CollectEventName, ReportEventId } from '@/constants/event';
import { EMPLOY_TYPE_TITLE_MAP } from '@/constants/job';
import useUserInfo from '@/hooks/use-user-info';
import useRoleType from '@/hooks/user-role-type';
import { RESPONSE_ERROR_CODE } from '@/http/constant';
import { HttpError } from '@/http/error';
import { JobDetails } from '@/types/job';
import { IMaterialMessage } from '@/types/message';
import { copy, logWithPrefix } from '@/utils/common';
import { collectEvent, reportEvent } from '@/utils/event';
import { getJobTitle, getJobSalary, postPublishJob, requestJobDetail } from '@/utils/job';
import { calcDistance, isValidLocation } from '@/utils/location';
import { requestProfileDetail } from '@/utils/material';
import { isChatWithSelf, postCreateChat } from '@/utils/message';
import { getJumpUrl, getPageQuery, navigateTo } from '@/utils/route';
import { getCommonShareMessage } from '@/utils/share';
import { formatDate } from '@/utils/time';
import Toast from '@/utils/toast';
import { isNeedCreateMaterial } from '@/utils/user';
import './index.less';
const PREFIX = 'job-detail';
const log = logWithPrefix(PREFIX);
const getMapCallout = (data: JobDetails): MapProps.callout | undefined => {
if (!data.jobLocation?.address) {
return;
}
return {
display: 'ALWAYS',
content: data.jobLocation.address,
color: '#000000',
bgColor: '#FFFFFF',
fontSize: 12,
textAlign: 'center',
anchorX: 0,
anchorY: 0,
borderRadius: 4,
borderWidth: 0,
borderColor: '#FFFFFF',
padding: 4,
};
};
const AnchorFooter = (props: { data: JobDetails }) => {
const { data } = props;
const [errorTips, setErrorTips] = useState<string>('');
const [dialogVisible, setDialogVisible] = useState(false);
const [showMaterialGuide, setShowMaterialGuide] = useState(false);
const handleClickContact = useCallback(async () => {
log('handleClickContact');
if (!data) {
return;
}
reportEvent(ReportEventId.CLICK_JOB_CONTACT);
try {
const needCreateMaterial = await isNeedCreateMaterial();
if (needCreateMaterial) {
setShowMaterialGuide(true);
return;
}
if (data.isAuthed) {
const toUserId = data.userId;
if (isChatWithSelf(toUserId)) {
Toast.error('不能与自己聊天');
return;
}
const profile = await requestProfileDetail();
const chat = await postCreateChat(toUserId);
const materialMessage: IMaterialMessage = {
id: profile.id,
name: profile.name,
age: profile.age,
height: profile.height,
weight: profile.weight,
shoeSize: profile.shoeSize,
gender: profile.gender,
workedSecCategoryStr: profile.workedSecCategoryStr,
};
navigateTo(PageUrl.MessageChat, { chatId: chat.chatId, material: materialMessage, jobId: data.id });
} else {
setDialogVisible(true);
}
} catch (error) {
const e = error as HttpError;
const errorCode = e.errorCode;
if (errorCode === RESPONSE_ERROR_CODE.INSUFFICIENT_BALANCE) {
setErrorTips('今日开聊次数已用完,请明日再来');
} else {
Toast.error('请求失败请重试');
}
}
}, [data]);
const handleDialogHidden = useCallback(() => setDialogVisible(false), []);
return (
<>
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__share-button`} openType="share">
</Button>
<LoginButton className={`${PREFIX}__contact-publisher`} onClick={handleClickContact}>
{data.isAuthed ? '在线沟通' : '立即联系'}
</LoginButton>
</div>
<div>
{dialogVisible && <ProductJobDialog data={data} onClose={handleDialogHidden} />}
{showMaterialGuide && <MaterialGuide onClose={() => setShowMaterialGuide(false)} />}
<CommonDialog
content={errorTips}
confirm="确定"
visible={!!errorTips}
onClose={() => setErrorTips('')}
onClick={() => setErrorTips('')}
/>
</div>
</>
);
};
const CompanyFooter = (props: { data: JobDetails }) => {
const { data } = props;
const handleClickEdit = useCallback(() => navigateTo(PageUrl.JobPublish, { jobId: data.id }), [data]);
const handlePublishJob = useCallback(async () => {
try {
Taro.showLoading();
await postPublishJob(data.id);
Taro.eventCenter.trigger(EventName.COMPANY_JOB_PUBLISH_CHANGED);
Toast.success('发布成功');
Taro.hideLoading();
} catch (error) {
Taro.hideLoading();
const e = error as HttpError;
const errorCode = e.errorCode;
collectEvent(CollectEventName.PUBLISH_OPEN_JOB_FAILED, { jobId: data.id, error: e.info?.() || e.message });
if (errorCode === RESPONSE_ERROR_CODE.BOSS_VIP_EXPIRED) {
Toast.info('该通告已到期,请创建新通告', 3000);
} else {
Toast.error(e.message || '发布失败请重试', 3000);
}
console.error(e);
}
}, [data]);
return (
<>
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__share-button`} onClick={handleClickEdit}>
</Button>
<Button className={`${PREFIX}__contact-publisher`} onClick={handlePublishJob}>
</Button>
</div>
</>
);
};
export default function JobDetail() {
const roleType = useRoleType();
const userInfo = useUserInfo();
const [data, setData] = useState<JobDetails | null>(null);
const isOwner = roleType === RoleType.Company && userInfo.userId === data?.userId;
const onDev = useCallback(async () => data && copy(data.id), [data]);
const handleClickMap = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!data?.jobLocation) {
return;
}
Taro.openLocation({
longitude: Number(data.jobLocation.longitude),
latitude: Number(data.jobLocation.latitude),
address: data.jobLocation.address,
});
},
[data]
);
useEffect(() => {
const callback = async (jobId: string) => {
try {
const res = await requestJobDetail(jobId);
setData(res);
} catch (e) {
console.error(e);
}
};
Taro.eventCenter.on(EventName.JOB_UPDATE, callback);
return () => {
Taro.eventCenter.off(EventName.JOB_UPDATE, callback);
};
}, []);
useLoad(async () => {
const query = getPageQuery<Pick<JobDetails, 'id'>>();
const jobId = query?.id;
if (!jobId) {
return;
}
try {
const res = await requestJobDetail(jobId);
setData(res);
} catch (e) {
console.error(e);
Toast.error('出错了,请重试');
}
});
useShareAppMessage(() => {
if (!data) {
return getCommonShareMessage();
}
return {
title: getJobTitle(data) || '',
path: getJumpUrl(PageUrl.JobDetail, { id: data.id, share: true }),
};
});
if (!data) {
return <PageLoading />;
}
return (
<div className={PREFIX}>
<div className={`${PREFIX}__container`}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header-info`}>
<DevDiv className={`${PREFIX}__header-title`} OnDev={onDev}>
{getJobTitle(data)}
</DevDiv>
<div className={`${PREFIX}__employ-type`}>{EMPLOY_TYPE_TITLE_MAP[data.employType]}</div>
</div>
<div className={`${PREFIX}__salary`}>{getJobSalary(data)}</div>
<div className={`${PREFIX}__update-time`}>{`${formatDate(data.updated)}更新`}</div>
<div className={`${PREFIX}__tips`}></div>
<div className={`${PREFIX}__publisher`}>
<div className={`${PREFIX}__publisher-name`}>{`发布人:${data.publisher}`}</div>
{data.isAuthed && (
<CertificationStatusIcon
className={`${PREFIX}__certification-type`}
status={CertificationStatusType.Success}
small
/>
)}
<Image
mode="aspectFit"
className={`${PREFIX}__publisher-avatar`}
src={data.publisherAvatar || require('@/statics/svg/wechat.svg')}
/>
</div>
{data.companyName && <div className={`${PREFIX}__company`}>{`公司:${data.companyName}`}</div>}
</div>
<div className={`${PREFIX}__content`}>
<div className={`${PREFIX}__content-title`}></div>
<div className={`${PREFIX}__tags`}>
{(data.tags || []).map((keyword: string, index) => (
<div className={`${PREFIX}__tag`} key={index}>
{keyword}
</div>
))}
</div>
<Text className={`${PREFIX}__description`}>{data.sourceText}</Text>
<div className={`${PREFIX}__address-wrapper`}>
<Image className={`${PREFIX}__distance-icon`} src={require('@/statics/svg/location.svg')} />
<div className={`${PREFIX}__detailed-address`}>{data.jobLocation?.address}</div>
</div>
{data.distance && (
<div className={`${PREFIX}__distance-wrapper`}>
<Image className={`${PREFIX}__distance-icon`} src={require('@/statics/svg/location.svg')} />
<div className={`${PREFIX}__distance`}>{calcDistance(data.distance)}</div>
</div>
)}
{isValidLocation(data.jobLocation) && (
<div className={`${PREFIX}__map__wrapper`} onClick={handleClickMap}>
<Map
enableZoom={false}
enableScroll={false}
className={`${PREFIX}__map`}
latitude={Number(data.jobLocation.latitude)}
longitude={Number(data.jobLocation.longitude)}
markers={[
{
id: 0,
latitude: Number(data.jobLocation.latitude),
longitude: Number(data.jobLocation.longitude),
callout: getMapCallout(data),
iconPath: '',
width: 20,
height: 36,
},
]}
onError={() => Toast.error('地图加载错误')}
/>
<div className={`${PREFIX}__map__mask`} onClick={handleClickMap} />
</div>
)}
</div>
{!isOwner && <JobRecommendList />}
<div className={`${PREFIX}__bottom-space`} />
</div>
{!isOwner && <AnchorFooter data={data} />}
{isOwner && <CompanyFooter data={data} />}
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '工作地址',
});

View File

@ -0,0 +1,32 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-job-publish-address {
padding: 24px;
&__tips {
width: 100%;;
padding: 16px 32px;
font-size: 26px;
line-height: 36px;
font-weight: 400;
color: #946724;
background: #FFF4F0;
box-sizing: border-box;
margin-bottom: 40px;
}
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
.flex-column();
&__submit {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
margin-bottom: 40px;
}
}
}

View File

@ -0,0 +1,57 @@
import { BaseEventOrig, Button, InputProps } from '@tarojs/components';
import { useLoad } from '@tarojs/taro';
import { useCallback, useState } from 'react';
import BlFormCell from '@/components/bl-form-cell';
import BlFormInput from '@/components/bl-form-input';
import BlFormItem from '@/components/bl-form-item';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { PageUrl } from '@/constants/app';
import { navigateTo } from '@/utils/route';
import './index.less';
const PREFIX = 'page-job-publish-address';
export default function JobPublishAddress() {
const [city, setCity] = useState('');
const [county, setCounty] = useState('');
const [detail, setDetail] = useState('');
const handleClickEditDescribe = useCallback(() => navigateTo(PageUrl.JobPublishDescribe), []);
const handleClickEditAddress = useCallback(() => navigateTo(PageUrl.JobPublishAddress), []);
const handleInputAddressDetail = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setDetail(value);
}, []);
const handleSubmit = useCallback(() => {}, []);
useLoad(() => {
console.log('Page loaded.');
});
return (
<div className={PREFIX}>
<div className={`${PREFIX}__tips`}></div>
<BlFormItem title="城市">
<BlFormCell text={city} placeholder="请选择" onClick={handleClickEditDescribe} />
</BlFormItem>
<BlFormItem title="区">
<BlFormCell text={county} placeholder="请选择" onClick={handleClickEditAddress} />
</BlFormItem>
<BlFormItem title="区">
<BlFormInput value={detail} onInput={handleInputAddressDetail} />
</BlFormItem>
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__footer__submit`} onClick={handleSubmit}>
</Button>
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '通告描述',
});

View File

@ -0,0 +1,68 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-job-publish-describe {
padding: 24px;
&__tips {
width: 100%;;
padding: 16px 32px;
font-size: 26px;
line-height: 36px;
font-weight: 400;
color: #946724;
background: #FFF4F0;
box-sizing: border-box;
}
&__title {
font-size: 32px;
line-height: 34px;
font-weight: 400;
color: #000000;
margin-top: 32px;
}
&__input-container {
.flex-column();
padding: 32px;
margin-top: 24px;
background-color: #FFFFFF;
border-radius: 16px;
}
&__input {
width: 100%;
height: 700px;
font-size: 28px;
line-height: 56px;
font-weight: 400;
color: @blColor;
}
&__input-placeholder {
font-size: 32px;
line-height: 40px;
color: #CCCCCC;
}
&__paste-btn {
margin-top: 40px;
align-self: flex-end;
.button(@width: 176px, @height: 56px, @fontSize: 28px, @fontWeight: 400, @borderRadius: 48px);
}
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
.flex-column();
&__submit {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
margin-bottom: 40px;
}
}
}

View File

@ -0,0 +1,79 @@
import { BaseEventOrig, Button, Textarea, TextareaProps } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import { useCallback, useState } from 'react';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName } from '@/constants/app';
import { getPageQuery, navigateBack } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-job-publish-describe';
const TEMPLATE = `直播品类:
直播时长:
岗位要求:
其他说明:`;
export default function JobPublishDetail() {
const [info, setInfo] = useState(TEMPLATE);
const handleInput = useCallback((e: BaseEventOrig<TextareaProps.onInputEventDetail>) => {
const value = e.detail.value || '';
setInfo(value);
}, []);
const handleClickPaste = useCallback(async () => {
try {
const { data } = await Taro.getClipboardData();
data && setInfo(data);
} catch (e) {
Toast.error('读取剪切板失败');
}
}, []);
const handleSubmit = useCallback(() => {
Taro.eventCenter.trigger(EventName.EDIT_JOB_DESCRIBE, info);
navigateBack();
}, [info]);
useLoad(() => {
const query = getPageQuery<{ describe: string }>();
if (!query.describe) {
return;
}
setInfo(query.describe);
});
return (
<div className={PREFIX}>
<div className={`${PREFIX}__tips`}>
</div>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__input-container`}>
<Textarea
maxlength={-1}
value={info}
confirmType="return"
onInput={handleInput}
className={`${PREFIX}__input`}
placeholder="介绍工作内容、通告要求、加分项"
placeholderClass={`${PREFIX}__input-placeholder`}
cursorSpacing={100}
/>
<Button className={`${PREFIX}__paste-btn`} onClick={handleClickPaste}>
</Button>
</div>
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__footer__submit`} onClick={handleSubmit}>
</Button>
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '发布通告',
});

View File

@ -0,0 +1,60 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-job-publish {
padding: 24px;
// 底部高度(paddingTop: 30 + tips: 30 + margin: 40 + button: 80 + margin: 40);
padding-bottom: 220px;
&__reason {
width: 100%;;
padding: 16px 32px;
font-size: 26px;
line-height: 36px;
font-weight: 400;
color: #946724;
background: #FFF4F0;
box-sizing: border-box;
margin-bottom: 40px;
}
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
padding-top: 30px;
.flex-column();
&__tips {
font-size: 28px;
line-height: 30px;
font-weight: 400;
color: @blColorG1;
margin-bottom: 40px;
}
&__buttons {
width: 100%;
.flex-row();
margin-bottom: 40px;
}
&__button {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
flex: 1;
margin-left: 24px;
&:first-child {
margin-left: 0;
}
&.lowLight {
color: @blHighlightColor;
background: @blHighlightBg;
}
}
}
}

View File

@ -0,0 +1,312 @@
import { BaseEventOrig, Button, InputProps } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'react';
import BlFormCell from '@/components/bl-form-cell';
import BlFormInput from '@/components/bl-form-input';
import BlFormItem from '@/components/bl-form-item';
import { BlFormRadio, BlFormRadioGroup } from '@/components/bl-form-radio';
import BlFormSelect from '@/components/bl-form-select';
import BlSalaryInput, { BlSalaryValue } from '@/components/bl-salary-input';
import { CityPickerPopup } from '@/components/city-picker';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName, PageUrl } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { EMPLOY_TYPE_TITLE_MAP, EmployType, JOB_TYPE_SELECT_OPTIONS, JobType } from '@/constants/job';
import { CreateJobInfo, JobDetails } from '@/types/job';
import { logWithPrefix } from '@/utils/common';
import { collectEvent } from '@/utils/event';
import { postCloseJob, postCreateJob, postUpdateJob, postPublishJob, requestJobDetail, isFullTimePriceRequired, isPartTimePriceRequired } from '@/utils/job';
import { getCityValues } from '@/utils/location';
import { getPageQuery, navigateBack, navigateTo } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-job-publish';
const log = logWithPrefix(PREFIX);
const isInvalidCreateJobInfo = (data: CreateJobInfo) => {
const {
title,
category,
employType,
provinceCode,
cityCode,
countyCode,
address,
jobDescription,
lowPriceForFullTime,
highPriceForFullTime,
lowPriceForPartyTime,
highPriceForPartyTime,
} = data;
if (!category) {
return '请选择通告品类';
}
if (!title) {
return '请选择通告标题';
}
if (!jobDescription) {
return '请输入通告描述';
}
if (isFullTimePriceRequired(employType)) {
if (!lowPriceForFullTime || !highPriceForFullTime) {
return '薪资范围不能为空';
}
if (lowPriceForFullTime > highPriceForFullTime) {
return '薪资最高值不能低于最低值';
}
}
if (isPartTimePriceRequired(employType)) {
if (!lowPriceForPartyTime || !highPriceForPartyTime) {
return '薪资范围不能为空';
}
if (lowPriceForPartyTime > highPriceForPartyTime) {
return '薪资最高值不能低于最低值';
}
}
if (!provinceCode || !cityCode || !countyCode) {
return '请输入工作地址';
}
if (!address) {
return '请输入详细地址';
}
};
export default function JobPublish() {
const [loading, setLoading] = useState(false);
const [isUpdate, setIsUpdate] = useState(false);
const [job, setJob] = useState<JobDetails | null>(null);
const [reason, setReason] = useState('');
const [employType, setEmployType] = useState(EmployType.Full);
const [category, setCategory] = useState<JobType>(JobType.Finery);
const [title, setTitle] = useState('');
const [describe, setDescribe] = useState('');
const [salary, setSalary] = useState<BlSalaryValue>(['', '', '', ''] as unknown as BlSalaryValue);
const [city, setCity] = useState<string[] | undefined>();
const [showCityPicker, setShowCityPicker] = useState(false);
const [address, setAddress] = useState('');
const handleEmployTypeChange = useCallback((value: EmployType) => {
setEmployType(value);
}, []);
const handleSelectCategory = useCallback((value: JobType) => {
setCategory(value);
}, []);
const handleInputTitle = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setTitle(value);
}, []);
const handleClickEditDescribe = useCallback(() => navigateTo(PageUrl.JobPublishDescribe, { describe }), [describe]);
const handleSalaryChange = useCallback((value: BlSalaryValue) => {
setSalary(value);
}, []);
const handleClickCity = useCallback(() => setShowCityPicker(true), []);
const handleConfirmCityPicker = useCallback((areaValues: string[]) => {
log('handleConfirmCityPicker', areaValues);
setShowCityPicker(false);
setCity(areaValues);
setAddress('');
}, []);
const handleInputAddress = useCallback((e: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = e.detail.value || '';
setAddress(value);
}, []);
const handleClose = useCallback(async () => {
if (!job) {
Toast.error('数据出错请重试');
return;
}
try {
Taro.showLoading();
await postCloseJob(job.id);
Taro.eventCenter.trigger(EventName.COMPANY_JOB_PUBLISH_CHANGED);
navigateBack();
} catch (e) {
console.error('submit error', e);
Toast.error('关闭失败请重试');
collectEvent(CollectEventName.CLOSE_JOB_FAILED, e);
} finally {
Taro.hideLoading();
}
}, [job]);
const handleSubmit = useCallback(async () => {
const cityCodes = city || [];
const data: CreateJobInfo = {
title,
employType,
category: category!,
jobDescription: describe,
provinceCode: cityCodes[0],
cityCode: cityCodes[1],
countyCode: cityCodes[2],
lowPriceForFullTime: !isFullTimePriceRequired(employType) ? 0 : salary[0] * 1000,
highPriceForFullTime: !isFullTimePriceRequired(employType) ? 0 : salary[1] * 1000,
lowPriceForPartyTime: !isPartTimePriceRequired(employType) ? 0 : salary[2],
highPriceForPartyTime: !isPartTimePriceRequired(employType) ? 0 : salary[3],
address: address,
};
const errMsg = isInvalidCreateJobInfo(data);
if (errMsg) {
Toast.info(errMsg);
return;
}
try {
Taro.showLoading();
// 将省市区拼到最前面
const cityValues = getCityValues(cityCodes);
if (!data.address.startsWith(cityValues)) {
data.address = `${cityValues}${data.address}`;
}
const isUpdateJob = isUpdate && job;
if (isUpdateJob) {
data.jobId = job!.id;
await postUpdateJob(data);
Taro.eventCenter.trigger(EventName.JOB_UPDATE, job!.id);
} else {
await postCreateJob(data);
}
Taro.eventCenter.trigger(EventName.COMPANY_JOB_PUBLISH_CHANGED);
await Toast.success(isUpdateJob ? '更新成功' : '创建成功', 1500, true);
navigateBack();
} catch (e) {
console.error('submit error', e);
Toast.error('审核失败请重试');
collectEvent(CollectEventName.PUBLISH_JOB_FAILED, e);
} finally {
Taro.hideLoading();
}
}, [isUpdate, job, title, employType, category, describe, city, salary, address]);
useEffect(() => {
const callback = (d: string) => setDescribe(d);
Taro.eventCenter.on(EventName.EDIT_JOB_DESCRIBE, callback);
return () => {
Taro.eventCenter.off(EventName.EDIT_JOB_DESCRIBE, callback);
};
}, []);
useLoad(async () => {
const query = getPageQuery<{ jobId: string }>();
const jobId = query?.jobId;
if (!jobId) {
return;
}
try {
setLoading(true);
setIsUpdate(true);
const details = await requestJobDetail(jobId);
setJob(details);
details.title && setTitle(details.title);
details.employType && setEmployType(details.employType);
details.category && setCategory(details.category);
details.jobDescription && setDescribe(details.jobDescription);
details.jobLocation.provinceCode &&
details.jobLocation.cityCode &&
details.jobLocation.countyCode &&
setCity([details.jobLocation.provinceCode, details.jobLocation.cityCode, details.jobLocation.countyCode]);
details.lowPriceForFullTime &&
details.highPriceForFullTime &&
details.lowPriceForPartyTime &&
details.highPriceForPartyTime &&
setSalary([
details.lowPriceForFullTime / 1000,
details.highPriceForFullTime / 1000,
details.lowPriceForPartyTime,
details.highPriceForPartyTime,
]);
details.jobLocation.address && setAddress(details.jobLocation.address);
details.verifyFailReason && setReason(details.verifyFailReason);
} catch (e) {
console.error(e);
Toast.error('出错了,请重试');
} finally {
setLoading(false);
}
});
if (loading) {
return <PageLoading />;
}
return (
<div className={PREFIX}>
{reason && <div className={`${PREFIX}__reason`}>{reason}</div>}
<BlFormItem title="通告标题" subTitle="不能填写微信、电话等联系方式">
<BlFormInput
value={title}
maxlength={20}
placeholder="如“招聘女装主播”"
onInput={handleInputTitle}
maxLengthTips
/>
</BlFormItem>
<BlFormItem title="工作类型" subTitle={false}>
<BlFormRadioGroup direction="horizontal" value={employType} onChange={handleEmployTypeChange}>
<BlFormRadio name={EmployType.Full} text={EMPLOY_TYPE_TITLE_MAP[EmployType.Full]} value={employType} />
<BlFormRadio name={EmployType.Part} text={EMPLOY_TYPE_TITLE_MAP[EmployType.Part]} value={employType} />
<BlFormRadio name={EmployType.All} text={EMPLOY_TYPE_TITLE_MAP[EmployType.All]} value={employType} />
</BlFormRadioGroup>
</BlFormItem>
<BlFormItem title="品类" subTitle={false}>
<BlFormSelect title="品类" value={category} options={JOB_TYPE_SELECT_OPTIONS} onSelect={handleSelectCategory} />
</BlFormItem>
<BlFormItem title="薪资范围" subTitle={false} dynamicHeight>
<BlSalaryInput value={salary} employType={employType} onChange={handleSalaryChange} />
</BlFormItem>
<BlFormItem title="通告描述" subTitle={false}>
<BlFormCell text={describe} placeholder="介绍工作内容、通告要求、加分项" onClick={handleClickEditDescribe} />
</BlFormItem>
<BlFormItem title="工作城市" subTitle={false}>
<BlFormCell text={getCityValues(city, '-')} placeholder="工作所在省-城-区" onClick={handleClickCity} />
</BlFormItem>
<BlFormItem title="详细地址" subTitle={false}>
<BlFormInput value={address} placeholder="请填写详细地址" onInput={handleInputAddress} />
</BlFormItem>
<SafeBottomPadding />
<div className={`${PREFIX}__footer`}>
<div className={`${PREFIX}__footer__tips`}></div>
<div className={`${PREFIX}__footer__buttons`}>
{!isUpdate && (
<Button className={`${PREFIX}__footer__button`} onClick={handleSubmit}>
</Button>
)}
{isUpdate && (
<>
<Button className={classNames(`${PREFIX}__footer__button`, 'lowLight')} onClick={handleClose}>
</Button>
<Button className={`${PREFIX}__footer__button`} onClick={handleSubmit}>
</Button>
</>
)}
</div>
<SafeBottomPadding />
</div>
<div>
<CityPickerPopup
areaValues={city}
open={showCityPicker}
onConfirm={handleConfirmCityPicker}
onCancel={() => setShowCityPicker(false)}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '搜索',
disableScroll: true,
});

View File

@ -0,0 +1,137 @@
@import '@/styles/variables.less';
.page-search-job {
height: 100vh;
padding: 0 24px;
.page-search-job__type-tabs {
padding: 0 20px;
margin-top: 20px;
.taroify-tabs__wrap {
height: 40px;
}
.taroify-tabs__wrap__scroll {
max-width: 100%;
}
.taroify-tabs__tab {
display: flex;
flex-direction: column;
flex: 0 0 auto !important;
font-size: 28px;
--tab-color: @blColorG1;
--tabs-active-color: @blColor;
&:first-child {
padding-left: 0;
}
}
.taroify-tabs__line {
height: 0;
background-color: transparent;
border-radius: 0;
}
.taroify-tabs__content {
padding: 20px 0;
}
}
&__search-button {
padding: 0;
margin: 0;
border: 0;
color: @blColor;
border: none;
&::after {
border: none;
}
&:active {
color: @blColorG1;
}
}
&__search-filter {
width: 100%;
display: flex;
flex-direction: row;
}
&__search-filter-menu {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
height: 64px;
padding: 0 24px;
margin-top: 24px;
border-radius: 32px;
&:first-child {
margin-right: 24px;
}
&.show {
color: @blHighlightColor;
background: @blHighlightBg;
}
.title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
}
}
&__overlay-outer {
top: 180px;
}
&__overlay-inner {
width: 100%;
}
&__search-history {
position: fixed;
top: 70px;
left: 0;
right: 0;
padding: 0 24px;
}
&__search-history-title {
font-size: 28px;
line-height: 40px;
font-weight: 400;
color: @blColor;
margin-top: 32px;
}
&__search-history-items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
// 24 - 16
margin-top: 8px;
}
&__search-history-item {
height: 56px;
font-size: 24px;
line-height: 56px;
color: @blColorG2;
border-radius: 36px;
padding: 0 24px;
margin-right: 12px;
margin-top: 16px;
border: 2px solid @blBorderColor;
}
}

View File

@ -0,0 +1,217 @@
import { Button } from '@tarojs/components';
import Taro, { NodesRef, useLoad } from '@tarojs/taro';
import { Tabs } from '@taroify/core';
import { ArrowDown, ArrowUp } from '@taroify/icons';
import classNames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'react';
import JobList from '@/components/job-list';
import JobPicker from '@/components/job-picker';
import Overlay from '@/components/overlay';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import SearchInput from '@/components/search';
import { EventName, OpenSource, PageUrl } from '@/constants/app';
import { CacheKey } from '@/constants/cache-key';
import { CITY_CODE_TO_NAME_MAP } from '@/constants/city';
import { JOB_TABS, JobType, EmployType } from '@/constants/job';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import { logWithPrefix } from '@/utils/common';
import { getPageQuery, navigateTo } from '@/utils/route';
import './index.less';
const PREFIX = 'page-search-job';
const LIST_CLASS = 'search-job-list-container';
const SAFE_PADDING_BOTTOM_CLASS = `${PREFIX}__safe-padding-bottom`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${LIST_CLASS}`, `.${PREFIX}`, `.${SAFE_PADDING_BOTTOM_CLASS}`],
calc: (
rects: [
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
]
) => {
const [listRect, pageRect, safePaddingRect] = rects;
return pageRect.bottom - listRect.top - safePaddingRect.height;
},
};
const log = logWithPrefix(PREFIX);
export default function JobSearch() {
const listHeight = useListHeight(CALC_LIST_PROPS);
const [focus, setFocus] = useState(true);
const [employType, setEmployType] = useState(EmployType.All);
const [tabType, setTabType] = useState<JobType>(JobType.All);
const [value, setValue] = useState<string>('');
const [keyWord, setKeyWord] = useState<string>('');
const [cityCode, setCityCode] = useState<string>('');
const [showMenu, setShowMenu] = useState<boolean>(false);
const historyRef = useRef<string[]>([]);
const showContent = keyWord.length > 0;
const handleUpdateHistory = useCallback((historyKeyword: string) => {
const newHistory = [...new Set([historyKeyword, ...historyRef.current])].slice(0, 20);
historyRef.current = newHistory;
Taro.setStorageSync(CacheKey.JOB_SEARCH_HISTORY, newHistory);
log('handleUpdateHistory', newHistory);
}, []);
const handleClickHistoryItem = useCallback(
(historyKeyword: string) => {
setValue(historyKeyword);
setKeyWord(historyKeyword);
handleUpdateHistory(historyKeyword);
},
[handleUpdateHistory]
);
const handleClickSearch = useCallback(() => {
if (value === keyWord) {
return;
}
setFocus(false);
setKeyWord(value);
handleUpdateHistory(value);
}, [value, keyWord, handleUpdateHistory]);
const handleSearchClear = useCallback(() => {
setValue('');
setKeyWord('');
}, []);
const handleSearchBlur = useCallback(() => setFocus(false), []);
const handleSearchFocus = useCallback(() => setFocus(true), []);
const handleSearchChange = useCallback(e => setValue(e.detail.value), []);
const handleClickCityMenu = useCallback(() => {
navigateTo(PageUrl.CitySearch, { city: cityCode, source: OpenSource.JobSearch });
}, [cityCode]);
const handleClickTypeMenu = useCallback(() => {
setShowMenu(!showMenu);
}, [showMenu]);
const handleHideMenu = useCallback(() => setShowMenu(false), []);
const handleCityChange = useCallback(data => {
log('handleCityChange', data);
const { openSource, cityCode: code } = data;
if (openSource !== OpenSource.JobSearch) {
return;
}
setCityCode(code);
}, []);
const handleTypePickerConfirm = useCallback((newEmployType: EmployType) => {
log('picker confirm', newEmployType);
setEmployType(newEmployType);
setShowMenu(false);
}, []);
const onTypeChange = useCallback(type => setTabType(type), [setTabType]);
useLoad(() => {
const query = getPageQuery<{ city: string }>();
const currentCity = query.city;
if (!currentCity) {
return;
}
setCityCode(currentCity);
});
useLoad(() => {
const cacheHistory = Taro.getStorageSync(CacheKey.JOB_SEARCH_HISTORY);
log('useLoad', cacheHistory);
if (!cacheHistory) {
return;
}
historyRef.current = cacheHistory;
});
useEffect(() => {
Taro.eventCenter.on(EventName.SELECT_CITY, handleCityChange);
return () => {
Taro.eventCenter.off(EventName.SELECT_CITY, handleCityChange);
};
}, [handleCityChange]);
const searchAction = (
<Button className={`${PREFIX}__search-button`} hoverClass="none" onClick={handleClickSearch}>
</Button>
);
return (
<div className={PREFIX}>
<SearchInput
focus={focus}
value={value}
placeholder="你想搜什么"
searchAction={searchAction}
className={`${PREFIX}__search`}
onClear={handleSearchClear}
onBlur={handleSearchBlur}
onFocus={handleSearchFocus}
onSearch={handleClickSearch}
onChange={handleSearchChange}
/>
<div className={`${PREFIX}__page-content`} style={{ visibility: showContent ? 'visible' : 'hidden' }}>
<div className={`${PREFIX}__search-filter`}>
<div className={classNames(`${PREFIX}__search-filter-menu`)} onClick={handleClickCityMenu}>
<div className="title">{CITY_CODE_TO_NAME_MAP.get(cityCode)}</div>
<ArrowDown />
</div>
<div
className={classNames(`${PREFIX}__search-filter-menu`, { show: showMenu })}
onClick={handleClickTypeMenu}
>
<div className="title"></div>
{showMenu ? <ArrowUp /> : <ArrowDown />}
</div>
</div>
<Overlay
visible={showMenu}
onClickOuter={handleHideMenu}
outerClassName={`${PREFIX}__overlay-outer`}
innerClassName={`${PREFIX}__overlay-inner`}
>
<JobPicker onConfirm={handleTypePickerConfirm} />
</Overlay>
<Tabs className={`${PREFIX}__type-tabs`} value={tabType} onChange={onTypeChange}>
{JOB_TABS.map(tab => (
<Tabs.TabPane title={tab.title} key={tab.type} value={tab.type}>
<JobList
cityCode={cityCode}
category={tab.type}
employType={employType}
keyWord={keyWord}
visible={tabType === tab.type}
className={LIST_CLASS}
listHeight={listHeight}
/>
</Tabs.TabPane>
))}
</Tabs>
</div>
<div className={`${PREFIX}__search-history`} style={{ visibility: showContent ? 'hidden' : 'visible' }}>
<div className={`${PREFIX}__search-history-title`}></div>
<div className={`${PREFIX}__search-history-items`}>
{historyRef.current.map(historyKeyword => (
<div
key={historyKeyword}
className={`${PREFIX}__search-history-item`}
onClick={() => handleClickHistoryItem(historyKeyword)}
>
{historyKeyword}
</div>
))}
</div>
</div>
<SafeBottomPadding className={SAFE_PADDING_BOTTOM_CLASS} />
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '选择沟通通告',
});

View File

@ -0,0 +1,62 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-job-select-my-publish {
width: 100%;
&__card {
width: 100%;
height: 144px;
.flex-row();
padding: 24px;
background: #FFF;
box-sizing: border-box;
&.selected {
background: #6D3DF514;
}
}
&__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: 24px;
line-height: 40px;
font-weight: 400;
color: @blColorG2;
margin-top: 8px;
.noWrap();
}
}
&__right {
height: 100%;
.flex-column();
align-items: flex-end;
&__time {
font-size: 24px;
line-height: 36px;
font-weight: 400;
color: @blColorG1;
}
&__icon {
width: 48px;
height: 48px;
margin-top: 24px;
}
}
}

View File

@ -0,0 +1,74 @@
import { Image } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import { List } from '@taroify/core';
import classNames from 'classnames';
import { useCallback, useState } from 'react';
import { EventName, OpenSource } from '@/constants/app';
import { JobManageStatus } from '@/constants/job';
import { JobManageInfo } from '@/types/job';
import { getJobLocation, requestJobManageList } from '@/utils/job';
import { getPageQuery, navigateBack } from '@/utils/route';
import { formatTime } from '@/utils/time';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-job-select-my-publish';
export default function JobSelectMyPublish() {
const [list, setList] = useState<JobManageInfo[]>([]);
const [jobId, setJobId] = useState<string | null>('1');
const [source, setSource] = useState<OpenSource>();
const handleClick = useCallback(
(info: JobManageInfo) => {
Taro.eventCenter.trigger(EventName.SELECT_MY_PUBLISH_JOB, info, source);
navigateBack();
},
[source]
);
useLoad(async () => {
const query = getPageQuery<{ id: string; source: OpenSource }>();
query?.id && setJobId(query.id);
try {
const res = await requestJobManageList({ status: JobManageStatus.Open });
setList(res.jobResults);
setSource(query.source);
} catch (e) {
console.error(e);
Toast.error('出错了,请重试');
}
});
return (
<div className={PREFIX}>
<List disabled>
{list.map(item => (
<div
key={item.id}
onClick={() => handleClick(item)}
className={classNames(`${PREFIX}__card`, { selected: item.id === jobId })}
>
<div className={`${PREFIX}__info`}>
<div className={`${PREFIX}__info__title`}>{item.title}</div>
<div className={`${PREFIX}__info__location`}>{getJobLocation(item)}</div>
</div>
<div className={`${PREFIX}__right`}>
<div className={`${PREFIX}__right__time`}>{formatTime(item.updated)}</div>
{item.id === jobId && (
<Image
mode="aspectFit"
className={`${PREFIX}__right__icon`}
src={require('@/statics/svg/success.svg')}
/>
)}
</div>
</div>
))}
</List>
</div>
);
}

View File

@ -0,0 +1,7 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '',
disableScroll: true,
enableShareAppMessage: true,
usingComponents: {},
});

27
src/pages/job/index.less Normal file
View File

@ -0,0 +1,27 @@
@import '@/styles/variables.less';
.job {
&__tabs {
.taroify-tabs__wrap__scroll {
max-width: 70%;
}
.taroify-tabs__tab {
display: flex;
flex-direction: column;
flex: 0 0 auto !important;
font-size: 40px;
--tab-color: @blColorG2;
--tabs-active-color: @blColor;
}
.taroify-tabs__line {
width: 100%;
position: relative;
margin-top: -5px;
height: 8px;
background-color: @blTabLineColor;
border-radius: 0;
}
}
}

159
src/pages/job/index.tsx Normal file
View File

@ -0,0 +1,159 @@
import Taro, { useDidShow, useLoad, useShareAppMessage } from '@tarojs/taro';
import { Tabs } from '@taroify/core';
import { useCallback, useEffect, useRef, useState } from 'react';
import HomePage from '@/components/home-page';
import LocationDialog from '@/components/location-dialog';
import { LoginGuide } from '@/components/login-guide';
import MaterialGuide from '@/components/material-guide';
import { EventName, OpenSource, PageUrl } from '@/constants/app';
import { EmployType, JOB_PAGE_TABS, SortType } from '@/constants/job';
import JobFragment from '@/fragments/job/base';
import useLocation from '@/hooks/use-location';
import useNavigation from '@/hooks/use-navigation';
import { Coordinate } from '@/types/location';
import { logWithPrefix } from '@/utils/common';
import { getWxLocation, isNotNeedAuthorizeLocation, requestLocation } from '@/utils/location';
import { requestUnreadMessageCount } from '@/utils/message';
import { getJumpUrl, getPageQuery, navigateTo } from '@/utils/route';
import { getCommonShareMessage } from '@/utils/share';
import Toast from '@/utils/toast';
import { isNeedCreateMaterial } from '@/utils/user';
import './index.less';
const PREFIX = 'job';
const log = logWithPrefix(PREFIX);
export default function Job() {
const location = useLocation();
const { barHeight, statusBarHeight } = useNavigation();
const [tabType, setTabType] = useState<EmployType>(EmployType.All);
const [sortType, setSortType] = useState<SortType>(SortType.RECOMMEND);
const [cityCode, setCityCode] = useState<string>(location.cityCode);
const [coordinate, setCoordinate] = useState<Coordinate>({
latitude: location.latitude,
longitude: location.longitude,
});
const [showMaterialGuide, setShowMaterialGuide] = useState(false);
const [showAuthorize, setShowAuthorize] = useState(false);
const cityValuesChangedRef = useRef(false);
const handleTypeChange = useCallback(value => setTabType(value), []);
const handleClickCity = useCallback(
() => navigateTo(PageUrl.CitySearch, { city: cityCode, source: OpenSource.JobPage }),
[cityCode]
);
const handleClickSortType = useCallback(
async (type: SortType) => {
if (type === SortType.DISTANCE && (!location.latitude || !location.longitude)) {
const res = await getWxLocation();
if (!res) {
Toast.info('获取位置信息失败,请重试');
return;
}
const { latitude, longitude } = res;
setCoordinate({ latitude, longitude });
}
setSortType(type);
},
[location]
);
const handleCityChange = useCallback(data => {
log('handleCityChange', data);
const { openSource, cityCode: code } = data;
if (openSource !== OpenSource.JobPage) {
return;
}
cityValuesChangedRef.current = true;
setCityCode(code);
}, []);
const handleAfterBindPhone = useCallback(async () => {
if (await isNeedCreateMaterial()) {
setShowMaterialGuide(true);
}
}, []);
const handleCloseAuthorize = useCallback(() => setShowAuthorize(false), []);
const handleClickAuthorize = useCallback(() => {
requestLocation(true);
setShowAuthorize(false);
}, []);
useEffect(() => {
Taro.eventCenter.on(EventName.SELECT_CITY, handleCityChange);
return () => {
Taro.eventCenter.off(EventName.SELECT_CITY, handleCityChange);
};
}, [handleCityChange]);
useEffect(() => {
if (cityValuesChangedRef.current) {
return;
}
setCityCode(location.cityCode);
}, [location]);
useLoad(async () => {
const query = getPageQuery<{ sortType: SortType }>();
const type = query.sortType;
if (type === SortType.CREATE_TIME) {
setSortType(type);
}
if (await isNotNeedAuthorizeLocation()) {
log('not need authorize location');
requestLocation();
} else {
log('show authorize location dialog');
setShowAuthorize(true);
}
});
useDidShow(() => requestUnreadMessageCount());
useShareAppMessage(() => {
if (sortType === SortType.CREATE_TIME) {
return {
title: '这里有今日全城新增通告,快来看看',
path: getJumpUrl(PageUrl.Job, { sortType }),
};
}
return getCommonShareMessage();
});
return (
<HomePage>
<Tabs
swipeable
value={tabType}
className={`${PREFIX}__tabs`}
onChange={handleTypeChange}
style={{ height: barHeight.current, paddingTop: statusBarHeight.current }}
>
{JOB_PAGE_TABS.map(tab => (
<Tabs.TabPane value={tab.type} title={tab.title} key={tab.type}>
<JobFragment
cityCode={cityCode}
sortType={sortType}
employType={tab.type}
coordinate={coordinate}
onClickCity={handleClickCity}
onClickSort={handleClickSortType}
/>
</Tabs.TabPane>
))}
</Tabs>
<div>
<LocationDialog open={showAuthorize} onClose={handleCloseAuthorize} onClick={handleClickAuthorize} />
<LoginGuide disabled={showAuthorize} onAfterBind={handleAfterBindPhone} />
{showMaterialGuide && <MaterialGuide onClose={() => setShowMaterialGuide(false)} />}
</div>
</HomePage>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '',
});

View File

@ -0,0 +1,20 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-material-create-profile {
// bottom: 24px + 100px + 20px 内边距+按钮高度+按钮上边距
padding: 24px 24px 144px;
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
}
&__submit-btn {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
bottom: 40px;
}
}

View File

@ -0,0 +1,134 @@
import { Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { ProfileGroupType, ProfileTitleMap } from '@/constants/material';
import ProfileAdvantagesFragment from '@/fragments/profile/advantages';
import ProfileBasicFragment from '@/fragments/profile/basic';
import ProfileExperienceFragment from '@/fragments/profile/experience';
import ProfileIntentionFragment from '@/fragments/profile/intention';
import { MaterialProfile } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { collectEvent } from '@/utils/event';
import { isFullTimePriceRequired, isPartTimePriceRequired } from '@/utils/job';
import { updateProfile, subscribeMaterialMessage } from '@/utils/material';
import { navigateBack } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-material-create-profile';
const log = logWithPrefix(PREFIX);
const REQUIRE_KEYS = {
[ProfileGroupType.Basic]: ['name', 'gender', 'age', 'height', 'weight'],
[ProfileGroupType.Intention]: [
'cityCodes',
'employType',
'acceptWorkForSit',
'fullTimeMinPrice',
'fullTimeMaxPrice',
'partyTimeMinPrice',
'partyTimeMaxPrice',
],
[ProfileGroupType.Experience]: ['workedYear'],
[ProfileGroupType.Advantages]: [],
};
const CONDITIONAL_REQUIRED_KEYS = {
[ProfileGroupType.Intention]: [
['fullTimeMinPrice', (data) => isFullTimePriceRequired(data.employType)],
['fullTimeMaxPrice', (data) => isFullTimePriceRequired(data.employType)],
['partyTimeMinPrice', (data) => isPartTimePriceRequired(data.employType)],
['partyTimeMaxPrice', (data) => isPartTimePriceRequired(data.employType)],
],
}
const getNextStepGroupType = (curType: ProfileGroupType) => {
switch (curType) {
case ProfileGroupType.Basic:
return ProfileGroupType.Advantages;
case ProfileGroupType.Intention:
return ProfileGroupType.Experience;
case ProfileGroupType.Experience:
return ProfileGroupType.Basic;
default:
return null;
}
};
const isValidFormData = (type: ProfileGroupType, data: Partial<MaterialProfile>) => {
const requireKeys = REQUIRE_KEYS[type] || [];
const conditionalKeys = CONDITIONAL_REQUIRED_KEYS[type] || []
const requiredValidator = (key: any) => typeof data[key] !== 'undefined' && data[key] !== ''
return requireKeys.every(requiredValidator) && conditionalKeys.every(([key, validator]) => {
return !validator(data) || requiredValidator(key)
});
};
export default function MaterialCreateProfile() {
const [groupType, setGroupType] = useState<ProfileGroupType>(ProfileGroupType.Intention);
const ref = useRef<{ getData: () => Partial<MaterialProfile> } | null>(null);
const ProfileFragment =
groupType === ProfileGroupType.Basic
? ProfileBasicFragment
: groupType === ProfileGroupType.Intention
? ProfileIntentionFragment
: groupType === ProfileGroupType.Experience
? ProfileExperienceFragment
: groupType === ProfileGroupType.Advantages
? ProfileAdvantagesFragment
: Fragment;
const handleSubmit = useCallback(async () => {
try {
const data = ref.current?.getData();
log('handleSubmit data:', data);
if (!data) {
throw new Error('数据异常');
}
if (!isValidFormData(groupType, data)) {
Toast.error('重要选项必填!');
return;
}
const nextType = getNextStepGroupType(groupType);
log('handleSubmit nextType:', nextType);
if (nextType) {
await updateProfile(data);
} else {
// 发起订阅不能在异步任务中,保证是第一个
await Promise.all([subscribeMaterialMessage(), updateProfile(data)]);
}
Taro.eventCenter.trigger(EventName.CREATE_PROFILE);
nextType ? setGroupType(nextType) : navigateBack(2);
} catch (e) {
Toast.error('保存失败请重试');
collectEvent(CollectEventName.CREATE_MATERIAL_FAILED, e);
}
}, [ref, groupType]);
useEffect(() => {
const title = ProfileTitleMap[groupType];
Taro.setNavigationBarTitle({ title });
}, [groupType]);
return (
<div className={PREFIX}>
<ProfileFragment ref={ref} profile={{}} />
<SafeBottomPadding />
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__submit-btn`} onClick={handleSubmit}>
{groupType === ProfileGroupType.Advantages ? '完成' : '下一步'}
</Button>
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '',
});

View File

@ -0,0 +1,20 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-material-edit-profile {
// bottom: 24px + 100px + 20px 内边距+按钮高度+按钮上边距
padding: 24px 24px 144px;
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
}
&__submit-btn {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
bottom: 40px;
}
}

View File

@ -0,0 +1,98 @@
import { Button } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import { Fragment, useCallback, useRef, useState } from 'react';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { ProfileGroupType, ProfileTitleMap } from '@/constants/material';
import ProfileAdvantagesFragment from '@/fragments/profile/advantages';
import ProfileBasicFragment from '@/fragments/profile/basic';
import ProfileExperienceFragment from '@/fragments/profile/experience';
import ProfileIntentionFragment from '@/fragments/profile/intention';
import { MaterialProfile } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { collectEvent } from '@/utils/event';
import { isProfileNotChange, requestProfileDetail, updateProfile } from '@/utils/material';
import { getPageQuery, navigateBack } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-material-edit-profile';
const log = logWithPrefix(PREFIX);
export default function MaterialEdit() {
const [profile, setProfile] = useState<MaterialProfile | null>(null);
const [groupType, setGroupType] = useState<ProfileGroupType | null>(null);
const ref = useRef<{ getData: () => Partial<MaterialProfile> } | null>(null);
const ProfileFragment =
groupType === ProfileGroupType.Basic
? ProfileBasicFragment
: groupType === ProfileGroupType.Intention
? ProfileIntentionFragment
: groupType === ProfileGroupType.Experience
? ProfileExperienceFragment
: groupType === ProfileGroupType.Advantages
? ProfileAdvantagesFragment
: Fragment;
log('MaterialEdit', groupType, ref);
const handleSubmit = useCallback(async () => {
try {
const data = ref.current?.getData();
if (!data || !profile) {
throw new Error('数据异常');
}
if (isProfileNotChange(profile, data)) {
log('profile not change');
navigateBack();
return;
}
data.id = profile.id;
await updateProfile(data);
Taro.eventCenter.trigger(EventName.UPDATE_PROFILE);
Toast.success('保存成功');
navigateBack();
log('handleSubmit', data);
} catch (e) {
Toast.error('保存失败请重试');
collectEvent(CollectEventName.UPDATE_MATERIAL_FAILED, e);
}
}, [ref, profile]);
useLoad(async () => {
const query = getPageQuery<{ type: ProfileGroupType }>();
const type = query.type || ProfileGroupType.Intention;
const title = ProfileTitleMap[type];
Taro.setNavigationBarTitle({ title });
setGroupType(type);
try {
const profileInfo = await requestProfileDetail();
setProfile(profileInfo);
} catch (e) {
console.error(e);
Toast.error('出错了,请重试');
}
});
if (!groupType || !profile) {
return <PageLoading />;
}
return (
<div className={PREFIX}>
<ProfileFragment ref={ref} profile={profile} />
<SafeBottomPadding />
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__submit-btn`} onClick={handleSubmit}>
</Button>
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的模卡',
});

View File

@ -0,0 +1,31 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-material-profile {
&__footer {
position: fixed;
bottom: 0;
width: 100%;
background: #FFFFFF;
padding: 12px 32px;
box-shadow: 0px -4px 20px 0px #00000014;
box-sizing: border-box;
&__buttons {
.flex-row();
&__share {
.button(@height: 88px, @fontSize: 32px, @fontWeight: 500, @borderRadius: 48px);
flex: 1 1;
color: @blHighlightColor;
background: @blHighlightBg;
margin-right: 32px;
}
&__manager {
.button(@height: 88px, @fontSize: 32px, @fontWeight: 500, @borderRadius: 48px);
flex: 2 2;
}
}
}
}

View File

@ -0,0 +1,104 @@
import { Button } from '@tarojs/components';
import Taro, { useLoad, useShareAppMessage } from '@tarojs/taro';
import { useCallback, useEffect, useState } from 'react';
import MaterialManagePopup from '@/components/material-manage-popup';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { MaterialStatus } from '@/constants/material';
import ProfileViewFragment from '@/fragments/profile/view';
import { MaterialProfile } from '@/types/material';
import { collectEvent } from '@/utils/event';
import { getMaterialShareMessage, requestProfileDetail, updateProfileStatus } from '@/utils/material';
import { getCommonShareMessage } from '@/utils/share';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-material-profile';
export default function MaterialProfilePage() {
const [profile, setProfile] = useState<MaterialProfile | null>(null);
const [showManage, setShowManage] = useState(false);
const handleClickManage = useCallback(() => setShowManage(true), []);
const handleChangeStatus = useCallback(
async (newStatus: boolean) => {
if (!profile || newStatus === profile.isOpen) {
return;
}
try {
await updateProfileStatus({ resumeId: profile.id, userOpen: newStatus });
profile.isOpen = newStatus;
} catch (e) {
Toast.error('保存失败请重试');
collectEvent(CollectEventName.UPDATE_MATERIAL_FAILED, e);
}
},
[profile]
);
useEffect(() => {
const callback = async () => {
try {
const profileDetail = await requestProfileDetail();
setProfile(profileDetail);
} catch (e) {
Toast.error('加载失败');
}
};
Taro.eventCenter.on(EventName.CREATE_PROFILE, callback);
Taro.eventCenter.on(EventName.UPDATE_PROFILE, callback);
return () => {
Taro.eventCenter.off(EventName.CREATE_PROFILE, callback);
Taro.eventCenter.off(EventName.UPDATE_PROFILE, callback);
};
}, []);
useLoad(async () => {
try {
const profileDetail = await requestProfileDetail();
setProfile(profileDetail);
} catch (e) {
Toast.error('加载失败');
}
});
useShareAppMessage(async () => {
const shareMessage = await getMaterialShareMessage(profile, false);
return shareMessage || getCommonShareMessage(false);
});
if (!profile) {
return <PageLoading />;
}
return (
<div className={PREFIX}>
<ProfileViewFragment profile={profile} editable />
<div className={`${PREFIX}__footer`}>
<div className={`${PREFIX}__footer__buttons`}>
<Button className={`${PREFIX}__footer__buttons__share`} openType="share">
</Button>
<Button className={`${PREFIX}__footer__buttons__manager`} onClick={handleClickManage}>
</Button>
</div>
<SafeBottomPadding />
</div>
<div>
<MaterialManagePopup
open={showManage}
onSave={handleChangeStatus}
onClose={() => setShowManage(false)}
value={profile.userOpen ? MaterialStatus.Open : MaterialStatus.Close}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '上传录屏',
});

View File

@ -0,0 +1,39 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-material-upload-video {
// bottom: 40px + 80px + 20px 内边距+按钮高度+按钮上边距
padding: 40px 24px 140px;
&__header-title {
font-size: 40px;
line-height: 48px;
font-weight: 400;
color: @blColor;
}
&__header-tips {
font-size: 28px;
line-height: 32px;
font-weight: 400;
color: @blColor;
margin-top: 24px;
}
&__video-list {
margin-top: 16px;
}
&__footer {
position: fixed;
left: 24px;
right: 24px;
bottom: 0;
background: #F5F6FA;
}
&__submit-btn {
.button(@width: 100%, @height: 80px, @fontSize: 32px, @fontWeight: 400, @borderRadius: 48px);
bottom: 40px;
}
}

View File

@ -0,0 +1,204 @@
import { Button } from '@tarojs/components';
import Taro, { UploadTask, useDidHide, useLoad, useUnload } from '@tarojs/taro';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef, useState } from 'react';
import MaterialVideoCard from '@/components/material-video-card';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName, OpenSource, PageUrl } from '@/constants/app';
import { CollectEventName, ReportEventId } from '@/constants/event';
import { MaterialVideoInfo } from '@/types/material';
import { logWithPrefix } from '@/utils/common';
import { collectEvent, reportEvent } from '@/utils/event';
import { chooseMedia, postVideos, requestVideoList } from '@/utils/material';
import { getPageQuery, navigateBack, navigateTo } from '@/utils/route';
import Toast from '@/utils/toast';
import { uploadVideo } from '@/utils/video';
import './index.less';
// 限制 500M
const MAX_FILE_SIZE_LIMIT = 1024 * 1024 * 1000;
const PREFIX = 'page-material-upload-video';
const log = logWithPrefix(PREFIX);
const TEMP_DATA: MaterialVideoInfo = { url: '', coverUrl: '', title: '', type: 'image', isDefault: false };
export default function MaterialUploadVideo() {
const [source, setSource] = useState(OpenSource.None);
const [videoList, setVideoList] = useState<MaterialVideoInfo[]>([]);
const saveRef = useRef<(videos: MaterialVideoInfo[]) => Promise<void>>();
const lastSaveVideosRef = useRef<MaterialVideoInfo[]>([]);
const initVideoList = useCallback(async () => {
try {
const res = await requestVideoList();
lastSaveVideosRef.current = res;
setVideoList(res);
} catch (e) {
console.error(e);
Toast.error('加载失败请重试');
}
}, []);
const handleClickDelete = useCallback(
(video: MaterialVideoInfo) => {
log('handleClickDelete', video);
const newVideoList = videoList.filter(v => v.coverUrl !== video.coverUrl);
setVideoList(newVideoList);
},
[videoList]
);
const handleDefaultChange = useCallback(
(video: MaterialVideoInfo) => {
log('handleDefaultChange', video);
const newVideoList = videoList.map(v => ({ ...v, isDefault: v.coverUrl === video.coverUrl }));
setVideoList(newVideoList);
},
[videoList]
);
const handleTitleChange = useCallback(
(video: MaterialVideoInfo, newTitle: string) => {
// log('handleTitleChange', video, newTitle);
const newVideoList = [...videoList];
const index = newVideoList.findIndex(v => v.coverUrl === video.coverUrl);
if (index < 0) {
return;
}
newVideoList.splice(index, 1, { ...video, title: newTitle });
setVideoList(newVideoList);
},
[videoList]
);
const handleClickUpload = useCallback(async () => {
log('click upload');
let showLoading = false;
try {
reportEvent(ReportEventId.CLICK_UPLOAD_VIDEO);
const media = await chooseMedia();
if (!media) {
return;
}
const { type, tempFiles } = media;
log('upload result', type, tempFiles);
const tempFile = tempFiles[0];
if (!tempFile) {
throw new Error('tempFile is not exist');
}
if (tempFile.size > MAX_FILE_SIZE_LIMIT) {
Toast.info('视频超过1000m请更换', 3000);
collectEvent(CollectEventName.VIDEO_EXCEEDING_LIMITS);
return;
}
showLoading = true;
Taro.showLoading({ title: '准备上传' });
const onProgress: UploadTask.OnProgressUpdateCallback = res => {
log('上传视频进度', res.progress, '总长度', res.totalBytesExpectedToSend, '已上传的长度', res.totalBytesSent);
Taro.showLoading({ title: `上传${res.progress}%` });
};
const { url, coverUrl } = await uploadVideo(tempFile.tempFilePath, tempFile.fileType, onProgress);
const newVideo: MaterialVideoInfo = {
title: '',
isDefault: false,
url: url,
coverUrl: coverUrl,
type: tempFile.fileType === 'video' ? 'video' : 'image',
};
setVideoList([...videoList, newVideo]);
} catch (e) {
console.error('upload fail', e);
Toast.error('上传失败');
collectEvent(CollectEventName.UPLOAD_VIDEO_FAILED, e);
} finally {
showLoading && Taro.hideLoading();
}
}, [videoList]);
const handleClickSubmit = useCallback(async () => {
log('handleClickSubmit', videoList);
reportEvent(ReportEventId.CLICK_SAVE_VIDEOS);
if (videoList.length < 2) {
Toast.info('请至少上传 2 个录屏');
return;
}
try {
Taro.showLoading();
await saveRef.current?.(videoList);
Taro.eventCenter.trigger(EventName.CREATE_PROFILE);
if (source === OpenSource.None) {
navigateTo(PageUrl.MaterialCreateProfile);
} else {
navigateBack();
}
} catch (e) {
Toast.error('保存失败请重试');
collectEvent(CollectEventName.SAVE_VIDEO_LIST_FAILED, e);
} finally {
Taro.hideLoading();
}
}, [videoList, source]);
useEffect(() => {
saveRef.current = async (videos: MaterialVideoInfo[]) => {
if (!videos.length) {
log('长度为空不保存');
return;
}
if (isEqual(lastSaveVideosRef.current, videos)) {
log('没变化不保存');
return;
}
lastSaveVideosRef.current = videos;
await postVideos(videos);
};
}, []);
useLoad(() => {
const query = getPageQuery<{ source: OpenSource }>();
log('query', query);
const { source: openSource } = query;
openSource && setSource(openSource);
initVideoList();
});
useDidHide(() => {
log('didHide', videoList);
saveRef.current?.(videoList);
});
useUnload(() => {
log('unload', videoList);
saveRef.current?.(videoList);
});
return (
<div className={PREFIX}>
<div className={`${PREFIX}__header`}>
<div className={`${PREFIX}__header-title`}></div>
<div className={`${PREFIX}__header-tips`}></div>
</div>
<div className={`${PREFIX}__video-list`}>
{videoList.map(video => (
<MaterialVideoCard
key={video.coverUrl}
videoInfo={video}
onClickDelete={() => handleClickDelete(video)}
onClickSetDefault={() => handleDefaultChange(video)}
onTitleChange={(newTitle: string) => handleTitleChange(video, newTitle)}
/>
))}
{videoList.length < 6 && <MaterialVideoCard videoInfo={TEMP_DATA} onClickUpload={handleClickUpload} isTemp />}
</div>
<SafeBottomPadding />
<div className={`${PREFIX}__footer`}>
<Button className={`${PREFIX}__submit-btn`} onClick={handleClickSubmit}>
{source === OpenSource.None ? '下一步' : '保存'}
</Button>
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '',
});

View File

@ -0,0 +1,39 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-material-view {
&__no-time-tips {
font-size: 28px;
font-weight: 400;
color: @blColor;
margin-top: 16px;
}
&__footer {
position: fixed;
bottom: 0;
width: 100%;
background: #FFFFFF;
padding: 12px 32px;
box-shadow: 0px -4px 20px 0px #00000014;
box-sizing: border-box;
&__buttons {
.flex-row();
&__share {
.button(@height: 88px, @fontSize: 32px, @fontWeight: 500, @borderRadius: 48px);
flex: 1 1;
color: @blHighlightColor;
background: @blHighlightBg;
}
&__contact {
.button(@height: 88px, @fontSize: 32px, @fontWeight: 500, @borderRadius: 48px);
flex: 2 2;
margin-left: 32px;
}
}
}
}

View File

@ -0,0 +1,259 @@
import { Button } from '@tarojs/components';
import Taro, { useLoad, useShareAppMessage } from '@tarojs/taro';
import { useCallback, useEffect, useState } from 'react';
import CommonDialog from '@/components/common-dialog';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName, OpenSource, PageUrl } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { MaterialViewSource } from '@/constants/material';
import ProfileViewFragment from '@/fragments/profile/view';
import { RESPONSE_ERROR_CODE } from '@/http/constant';
import { HttpError } from '@/http/error';
import { JobManageInfo } from '@/types/job';
import { MaterialProfile } from '@/types/material';
import { IJobMessage } from '@/types/message';
import { copy } from '@/utils/common';
import { collectEvent } from '@/utils/event';
import { requestHasPublishedJob, requestJobDetail } from '@/utils/job';
import { getMaterialShareMessage, requestReadProfile, requestShareProfile } from '@/utils/material';
import { isChatWithSelf, postCreateChat } from '@/utils/message';
import { getPageQuery, navigateBack, navigateTo, redirectTo } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
const PREFIX = 'page-material-view';
interface IViewContext {
resumeId: string;
source: MaterialViewSource.AnchorList | MaterialViewSource.Chat;
jobId?: string;
}
interface IShareContext {
resumeId: string;
source: MaterialViewSource.Share;
shareCode: string;
}
const isShareContext = (context: IViewContext | IShareContext): context is IShareContext => {
return !!(context as IShareContext).shareCode;
};
const requestProfile = async (context: IViewContext | IShareContext) => {
if (!context.resumeId) {
throw new Error('参数错误');
}
if (isShareContext(context)) {
const { resumeId, shareCode } = context;
const profileDetail = await requestShareProfile({ resumeId, shareCode });
return profileDetail;
} else {
const { resumeId, jobId } = context;
const profileDetail = await requestReadProfile({ resumeId, jobId });
return profileDetail;
}
};
export default function MaterialViewPage() {
const [contactEnable, setContactEnable] = useState(true);
const [profile, setProfile] = useState<MaterialProfile | null>(null);
const [jobId, setJobId] = useState<string>();
const [errorTips, setErrorTips] = useState<string>('');
const [publishDialogVisible, setPublishDialogVisible] = useState(false);
const [certificationDialogVisible, setCertificationDialogVisible] = useState(false);
const [noTimeDialogVisible, setNoTimeDialogVisible] = useState(false);
const [noVipLimitVisible, setNoVipLimitVisible] = useState(false);
const [vipExpiredVisible, setVipExpiredVisible] = useState(false);
const onDev = useCallback(async () => profile && copy(profile.userId), [profile]);
const handleClickContact = useCallback(async () => {
if (!profile) {
return;
}
try {
if (jobId) {
const toUserId = profile.userId;
if (isChatWithSelf(toUserId)) {
Toast.error('不能与自己聊天');
return;
}
const jobDetail = await requestJobDetail(jobId);
const chat = await postCreateChat(toUserId);
const jobMessage: IJobMessage = {
id: jobDetail.id,
title: jobDetail.title,
employType: jobDetail.employType,
salary: jobDetail.salary,
lowPriceForFullTime: jobDetail.lowPriceForFullTime,
highPriceForFullTime: jobDetail.highPriceForFullTime,
lowPriceForPartyTime: jobDetail.lowPriceForPartyTime,
highPriceForPartyTime: jobDetail.highPriceForPartyTime,
};
navigateTo(PageUrl.MessageChat, { chatId: chat.chatId, job: jobMessage, jobId });
return;
}
if (!(await requestHasPublishedJob())) {
setPublishDialogVisible(true);
return;
}
navigateTo(PageUrl.JobSelectMyPublish, { source: OpenSource.MaterialViewPage });
} catch (error) {
const e = error as HttpError;
const errorCode = e.errorCode;
if (errorCode === RESPONSE_ERROR_CODE.INSUFFICIENT_BALANCE) {
setErrorTips('今日10次开聊次数已用完请明日再来');
} else {
Toast.error('请求失败请重试');
}
}
}, [profile, jobId]);
const handleClickNoViewTimes = useCallback(() => {
setNoTimeDialogVisible(false);
navigateBack();
}, []);
const handleClickGoPublish = useCallback(() => {
setPublishDialogVisible(false);
redirectTo(PageUrl.CertificationManage);
}, []);
const handleClickGoCertification = useCallback(() => {
setCertificationDialogVisible(false);
redirectTo(PageUrl.CertificationStart);
}, []);
useEffect(() => {
const callback = (select: JobManageInfo, source: OpenSource) =>
source === OpenSource.MaterialViewPage && setJobId(select.id);
Taro.eventCenter.on(EventName.SELECT_MY_PUBLISH_JOB, callback);
return () => {
Taro.eventCenter.off(EventName.SELECT_MY_PUBLISH_JOB, callback);
};
}, []);
useLoad(async () => {
const context = getPageQuery<IViewContext | IShareContext>();
try {
const profileDetail = await requestProfile(context);
setProfile(profileDetail);
if (!isShareContext(context)) {
setJobId(context.jobId);
Taro.eventCenter.trigger(EventName.VIEW_MATERIAL_SUCCESS, profileDetail.id);
}
if (context.source === MaterialViewSource.Chat) {
setContactEnable(false);
}
Taro.setNavigationBarTitle({ title: profileDetail.name || '主播模卡' });
} catch (error) {
const e = error as HttpError;
const errorCode = e.errorCode;
collectEvent(CollectEventName.VIEW_MATERIAL_FAILED, { context, error: e.info?.() || e.message });
console.error(e);
if (errorCode === RESPONSE_ERROR_CODE.BOSS_NOT_AUTH) {
setCertificationDialogVisible(true);
} else if (errorCode === RESPONSE_ERROR_CODE.NO_PUBLISHED_JOB) {
setPublishDialogVisible(true);
} else if (errorCode === RESPONSE_ERROR_CODE.INSUFFICIENT_BALANCE) {
setNoTimeDialogVisible(true);
} else if (errorCode === RESPONSE_ERROR_CODE.INSUFFICIENT_FREE_BALANCE) {
setNoVipLimitVisible(true);
} else if (errorCode === RESPONSE_ERROR_CODE.BOSS_VIP_EXPIRED) {
setVipExpiredVisible(true);
} else {
Toast.error(e.message || '加载失败');
}
}
});
useShareAppMessage(async () => {
const shareMessage = await getMaterialShareMessage(profile);
return shareMessage as BL.Anything;
});
if (!profile) {
return (
<>
<PageLoading />
<CommonDialog
content="要查看主播详情,请先完成实人认证"
confirm="去认证"
visible={certificationDialogVisible}
onClose={() => setCertificationDialogVisible(false)}
onClick={handleClickGoCertification}
/>
<CommonDialog
content="请先发布一个认证通告"
confirm="去发布"
visible={publishDialogVisible}
onClose={() => setPublishDialogVisible(false)}
onClick={handleClickGoPublish}
/>
<CommonDialog
content="请先发布一个认证通告"
confirm="去发布"
visible={noVipLimitVisible}
onClose={() => setNoVipLimitVisible(false)}
onClick={handleClickGoPublish}
>
<div className={`${PREFIX}__no-time-tips`}></div>
</CommonDialog>
<CommonDialog
content="请先发布一个认证通告"
confirm="去发布"
visible={vipExpiredVisible}
onClose={() => setVipExpiredVisible(false)}
onClick={handleClickGoPublish}
/>
<CommonDialog
content="今日查看模卡详情次数已用完"
confirm="确定"
visible={noTimeDialogVisible}
onClick={handleClickNoViewTimes}
>
<div className={`${PREFIX}__no-time-tips`}> 20 </div>
</CommonDialog>
</>
);
}
return (
<div className={PREFIX}>
<ProfileViewFragment profile={profile} editable={false} onDev={onDev} />
<div className={`${PREFIX}__footer`}>
<div className={`${PREFIX}__footer__buttons`}>
<Button className={`${PREFIX}__footer__buttons__share`} openType="share">
</Button>
{contactEnable && (
<Button className={`${PREFIX}__footer__buttons__contact`} onClick={handleClickContact}>
</Button>
)}
</div>
<SafeBottomPadding />
</div>
<div>
<CommonDialog
content="请先发布一个认证通告"
confirm="去发布"
visible={publishDialogVisible}
onClose={() => setPublishDialogVisible(false)}
onClick={handleClickGoPublish}
/>
<CommonDialog
content={errorTips}
confirm="确定"
visible={!!errorTips}
onClose={() => setErrorTips('')}
onClick={() => setErrorTips('')}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '模卡录屏',
});

View File

View File

@ -0,0 +1,26 @@
import { WebView } from '@tarojs/components';
import { getPageQuery } from '@/utils/route';
import React, { useState } from 'react';
import { useLoad } from '@tarojs/taro';
import Toast from '@/utils/toast';
import './index.less';
export default function MaterialWebview() {
const [src, setSrc] = useState('')
useLoad(() => {
const { source } = getPageQuery<{ source: string }>()
setSrc(`https://neighbourhood.cn/material-preview.html?source=${source}`)
})
if (src) {
return <WebView src={src} onError={() => Toast.error('加载失败请重试')} />;
}
return '加载中...'
}

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '',
disableScroll: true,
});

View File

@ -0,0 +1,123 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-message-chat {
height: 100vh;
background: #FFFFFF;
&__loading {
position: fixed;
top: 0;
background: #F5F6FA;
z-index: 999;
}
&__header {
position: fixed;
top: 0;
width: 100%;
height: 120px;
.flex-row();
padding: 0 32px;
box-sizing: border-box;
background: #FFFFFF;
&__reject {
.button(@height: 72px; @fontSize: 28px; @borderRadius: 48px; @highlight: 0);
flex: 1;
margin-right: 26px;
&.highlight {
color: @blColorG1;
background: #F7F7F7;
}
}
&__exchange {
.button(@height: 72px; @fontSize: 28px; @borderRadius: 48px;);
flex: 1;
}
}
&__chat {
width: 100%;
height: calc(100vh - 120px - 112px - env(safe-area-inset-bottom));
background: #F5F6FA;
margin-top: 120px;
padding-top: 20px;
padding-bottom: 30px;
box-sizing: border-box;
}
&__chat-list {
height: 100%;
}
&__footer {
position: fixed;
bottom: 0;
width: 100%;
}
&__input-container {
.flex-row();
padding: 20px 32px;
background: #FFFFFF;
}
&__expand-icon {
width: 64px;
height: 64px;
margin-right: 32px;
}
&__input {
min-height: 40px;
font-size: 28px;
line-height: 40px;
font-weight: 400;
padding: 16px;
color: @blColor;
background: #F7F7F8;
border-radius: 16px;
box-sizing: border-box;
white-space: wrap;
}
&__send-button {
.button(@width: 120px; @height: 64px; @fontSize: 28px; @borderRadius: 48px;);
margin-left: 32px;
}
&__more {
.flex-row();
padding: 12px 32px 32px;
background: #FFFFFF;
&__item {
.flex-column();
&__icon-wrapper {
width: 124px;
height: 124px;
.flex-row();
justify-content: center;
background: #F3F3F5;
border-radius: 20px;
}
&__icon {
width: 56px;
height: 56px;
}
&__text {
font-size: 24px;
line-height: 34px;
font-weight: 400;
color: @blColor;
margin-top: 8px;
}
}
}
}

View File

@ -0,0 +1,430 @@
import { BaseEventOrig, Button, Image, ScrollView, ScrollViewProps, Textarea, TextareaProps } from '@tarojs/components';
import Taro, { NodesRef, useDidHide, useDidShow, useLoad, useUnload } from '@tarojs/taro';
import classNames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ContactMessage,
JobMessage,
LocationMessage,
MaterialMessage,
TextMessage,
TimeMessage,
} from '@/components/message-chat';
import PageLoading from '@/components/page-loading';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { EventName } from '@/constants/app';
import { CollectEventName } from '@/constants/event';
import { ChatWatchType, MessageType, PULL_NEW_MESSAGES_TIME } from '@/constants/message';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import { RESPONSE_ERROR_CODE } from '@/http/constant';
import { HttpError } from '@/http/error';
import {
IChatUser,
IChatInfo,
IChatMessage,
IJobMessage,
ILocationMessage,
IMaterialMessage,
IMessageStatus,
PostMessageRequest,
} from '@/types/message';
import { isAnchorMode } from '@/utils/app';
import { getScrollItemId, last, logWithPrefix } from '@/utils/common';
import { collectEvent } from '@/utils/event';
import {
isExchangeMessage,
isJobMessage,
isLocationMessage,
isMaterialMessage,
isTextMessage,
isTimeMessage,
openLocationSelect,
postAddMessageTimes,
postChatRejectWatch,
postSendMessage,
requestActionDetail,
requestChatDetail,
requestChatWatch,
requestMessageStatusList,
requestNewChatMessages,
} from '@/utils/message';
import { getPageQuery, parseQuery } from '@/utils/route';
import Toast from '@/utils/toast';
import { getUserId } from '@/utils/user';
import './index.less';
const PREFIX = 'page-message-chat';
const LIST_CONTAINER_CLASS = `${PREFIX}__chat-list`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${LIST_CONTAINER_CLASS}`],
calc: (rects: [NodesRef.BoundingClientRectCallbackResult]) => {
const [rect] = rects;
return rect.height;
},
};
const log = logWithPrefix(PREFIX);
const chooseLocation = Taro.requirePlugin('chooseLocation');
interface ILoadProps {
chatId: string;
jobId?: string;
job?: string;
material?: string;
}
const getHeaderLeftButtonText = (job?: IJobMessage, material?: IMaterialMessage) => {
if (job) {
return '不感兴趣';
}
if (material) {
return '标记为不合适';
}
return isAnchorMode() ? '不感兴趣' : '标记为不合适';
};
export default function MessageChat() {
const listHeight = useListHeight(CALC_LIST_PROPS);
const [input, setInput] = useState('');
const [showMore, setShowMore] = useState(false);
const [chat, setChat] = useState<IChatInfo | null>(null);
const [reject, setReject] = useState<boolean>(false);
const [receiver, setReceiver] = useState<IChatUser | null>(null);
const [messages, setMessages] = useState<IChatMessage[]>([]);
const [messageStatusList, setMessageStatusList] = useState<IMessageStatus[]>([]);
const [jobId, setJobId] = useState<string>();
const [job, setJob] = useState<IJobMessage>();
const [material, setMaterial] = useState<IMaterialMessage>();
const [scrollItemId, setScrollItemId] = useState<string>();
const scrollToLowerRef = useRef(false);
const autoSendRef = useRef({ sendJob: false, sendMaterial: false });
const loadMoreRef = useRef(async (chatId: string, currentMessages: IChatMessage[], forceScroll?: boolean) => {
try {
const lastMsgId = last(currentMessages)?.msgId;
const newMessages = await requestNewChatMessages({ chatId: chatId, lastMsgId });
log('requestNewChatMessages', newMessages, forceScroll);
if (newMessages.length) {
setMessages([...currentMessages, ...newMessages]);
(forceScroll || scrollToLowerRef.current) && setScrollItemId(getScrollItemId(last(newMessages)?.msgId));
}
} catch (e) {
console.error(e);
}
});
const handleInput = useCallback((e: BaseEventOrig<TextareaProps.onInputEventDetail>) => {
const value = e.detail.value || '';
setInput(value);
}, []);
const handleClickExpand = useCallback(() => setShowMore(true), []);
const handleScroll = useCallback(
(e: BaseEventOrig<ScrollViewProps.onScrollDetail>) => {
// log('handleScroll', e);
const { scrollTop, scrollHeight } = e.detail;
scrollToLowerRef.current = listHeight + scrollTop >= scrollHeight - 40;
},
[listHeight]
);
const handleClickSendLocation = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
openLocationSelect();
}, []);
const handleClickMoreOuter = () => showMore && setShowMore(false);
const handleClickContactButton = useCallback(async () => {
if (!chat) {
return;
}
await loadMoreRef.current(chat.chatId, messages, true);
}, [chat, messages]);
const handleSendMessage = useCallback(
async (newMessage: Omit<PostMessageRequest, 'chatId' | 'bizId'>) => {
if (!chat) {
return;
}
try {
Taro.showLoading();
await postSendMessage({ chatId: chat.chatId, bizId: jobId || chat.lastJobId, ...newMessage });
await loadMoreRef.current(chat.chatId, messages, true);
Taro.hideLoading();
} catch (error) {
const e = error as HttpError;
const errorCode = e.errorCode;
collectEvent(CollectEventName.MESSAGE_DEV_LOG, { action: 'send-message', e, message: newMessage });
let tips = '发送失败请重试';
let duration = 1500;
if (
errorCode === RESPONSE_ERROR_CODE.INSUFFICIENT_BALANCE &&
newMessage.type === MessageType.RequestCompanyContact
) {
tips = '今日申请交换联系方式次数已用完当前每日限制为5次';
duration = 3000;
}
tips.length > 7 ? Toast.info(tips, duration) : Toast.error(tips, duration);
}
},
[chat, jobId, messages]
);
const handleClickReject = useCallback(async () => {
if (!chat || !receiver || reject) {
return;
}
const watchType = isAnchorMode() ? ChatWatchType.AnchorReject : ChatWatchType.CompanyReject;
await postChatRejectWatch({
type: watchType,
toUserId: receiver.userId,
jobId: jobId || chat.lastJobId,
status: false,
});
setReject(true);
}, [jobId, chat, receiver, reject]);
const handleSendExchangeContact = useCallback(async () => {
postAddMessageTimes('click_request_exchange_contact');
const type = isAnchorMode() ? MessageType.RequestCompanyContact : MessageType.RequestAnchorContact;
handleSendMessage({ type, actionObject: '' });
}, [handleSendMessage]);
const handleSendJobMessage = useCallback(async () => {
if (!job || !receiver || autoSendRef.current.sendJob) {
return;
}
const detail = await requestActionDetail({ type: MessageType.Job, bizId: job.id, toUserId: receiver.userId });
if (!detail) {
handleSendMessage({ type: MessageType.Job, actionObject: JSON.stringify(job) });
}
autoSendRef.current.sendJob = true;
}, [job, receiver, handleSendMessage]);
const handleSendMaterialMessage = useCallback(async () => {
if (!material || !receiver || autoSendRef.current.sendMaterial) {
return;
}
const detail = await requestActionDetail({
type: MessageType.Material,
bizId: material.id,
toUserId: receiver.userId,
});
if (!detail) {
handleSendMessage({ type: MessageType.Material, actionObject: JSON.stringify(material) });
}
autoSendRef.current.sendMaterial = true;
}, [material, receiver, handleSendMessage]);
const handleSendLocationMessage = useCallback(
(location: Omit<ILocationMessage, 'id'>) => {
setShowMore(false);
handleSendMessage({ type: MessageType.Location, actionObject: JSON.stringify(location) });
},
[handleSendMessage]
);
const handleSendTextMessage = useCallback(async () => {
if (!input) {
return;
}
postAddMessageTimes('send_message_button');
await handleSendMessage({ type: MessageType.Text, content: input });
setInput('');
}, [input, handleSendMessage]);
// useEffect(() => {
// loadMoreRef.current = async (chatId: string, currentMessages: IChatMessage[], forceScroll: boolean) => {
// try {
// const lastMsgId = last(currentMessages)?.msgId;
// const newMessages = await requestNewChatMessages({ chatId: chatId, lastMsgId });
// log('requestNewChatMessages', newMessages);
// if (newMessages.length) {
// setMessages([...currentMessages, ...newMessages]);
// (forceScroll || scrollToLowerRef.current) && setScrollItemId(getScrollItemId(last(newMessages)?.msgId));
// }
// } catch (e) {
// console.error(e);
// }
// };
// }, []);
useEffect(() => {
if (!chat) {
return;
}
const intervalId = setInterval(async () => {
loadMoreRef.current(chat.chatId, messages);
const statusList = await requestMessageStatusList(chat.chatId);
setMessageStatusList(statusList);
}, PULL_NEW_MESSAGES_TIME);
return () => {
clearInterval(intervalId);
};
}, [chat, messages]);
useEffect(() => {
if (!chat) {
return;
}
job && handleSendJobMessage();
material && handleSendMaterialMessage();
}, [chat, job, material, handleSendJobMessage, handleSendMaterialMessage]);
useLoad(async () => {
const query = getPageQuery<ILoadProps>();
const chatId = query.chatId;
if (!chatId) {
return;
}
try {
const currentUserId = getUserId();
const watchType = isAnchorMode() ? ChatWatchType.AnchorReject : ChatWatchType.CompanyReject;
const chatDetail = await requestChatDetail(chatId);
const toUserInfo = chatDetail.participants.find(u => u.userId !== currentUserId);
if (!toUserInfo) {
throw new Error('not receiver');
}
const watchStatus = await requestChatWatch({
type: watchType,
toUserId: toUserInfo.userId,
jobId: query.jobId || chatDetail.lastJobId,
});
const parseJob = query.job ? parseQuery<IJobMessage>(query.job) : null;
const parseMaterial = query.material ? parseQuery<IMaterialMessage>(query.material) : null;
// log('requestChatDetail', chatDetail, parseJob, parseMaterial);
setChat(chatDetail);
setJobId(query.jobId);
setMessages(chatDetail.messages);
setScrollItemId(getScrollItemId(last(chatDetail.messages)?.msgId));
parseJob && setJob(parseJob);
parseMaterial && setMaterial(parseMaterial);
Taro.setNavigationBarTitle({ title: toUserInfo.nickName });
setReceiver(toUserInfo);
setReject(!watchStatus);
} catch (e) {
console.error(e);
collectEvent(CollectEventName.MESSAGE_DEV_LOG, { action: 'init-chat-message', e });
Toast.error('加载失败请重试');
}
});
useDidShow(() => {
const location = chooseLocation?.getLocation() as Omit<ILocationMessage, 'id'>;
log('useDidShow', location);
if (!location) {
return;
}
// 发送定位消息
handleSendLocationMessage(location);
chooseLocation?.setLocation(null);
});
useDidHide(() => chooseLocation?.setLocation(null));
useUnload(() => {
chooseLocation?.setLocation(null);
Taro.eventCenter.trigger(EventName.EXIT_CHAT_PAGE);
});
log('render', scrollItemId, scrollToLowerRef.current);
return (
<div className={PREFIX}>
{!chat && <PageLoading className={`${PREFIX}__loading`} />}
<div className={`${PREFIX}__header`} onTouchStart={handleClickMoreOuter}>
<Button className={classNames(`${PREFIX}__header__reject`, { highlight: reject })} onClick={handleClickReject}>
{getHeaderLeftButtonText(job, material)}
</Button>
<Button className={`${PREFIX}__header__exchange`} onClick={handleSendExchangeContact}>
</Button>
</div>
<div className={`${PREFIX}__chat`} onTouchStart={handleClickMoreOuter}>
<ScrollView className={LIST_CONTAINER_CLASS} scrollIntoView={scrollItemId} onScroll={handleScroll} scrollY>
{messages.map((message: IChatMessage) => {
if (isTextMessage(message)) {
return (
<TextMessage
id={message.msgId}
key={message.msgId}
message={message}
isRead={messageStatusList.some(m => m.msgId === message.msgId && !!m.isRead)}
/>
);
} else if (isTimeMessage(message)) {
return <TimeMessage key={message.msgId} id={message.msgId} message={message} />;
} else if (isJobMessage(message)) {
return <JobMessage key={message.msgId} id={message.msgId} message={message} />;
} else if (isMaterialMessage(message)) {
return <MaterialMessage key={message.msgId} id={message.msgId} message={message} />;
} else if (isExchangeMessage(message)) {
return (
<ContactMessage
key={message.msgId}
id={message.msgId}
message={message}
onClick={handleClickContactButton}
/>
);
} else if (isLocationMessage(message)) {
return (
<LocationMessage
id={message.msgId}
key={message.msgId}
message={message}
isRead={messageStatusList.some(m => m.msgId === message.msgId && !!m.isRead)}
/>
);
}
})}
</ScrollView>
</div>
<div className={`${PREFIX}__footer`}>
<div className={`${PREFIX}__input-container`} onTouchStart={handleClickMoreOuter}>
<Image
mode="aspectFit"
className={`${PREFIX}__expand-icon`}
src={require('@/statics/svg/chat_expand.svg')}
onTouchStart={e => e.stopPropagation()}
onClick={handleClickExpand}
/>
<Textarea
fixed
autoHeight
value={input}
maxlength={100}
cursorSpacing={20}
confirmType="return"
onInput={handleInput}
showConfirmBar={false}
className={`${PREFIX}__input`}
placeholderClass={`${PREFIX}__input-placeholder`}
/>
<Button className={`${PREFIX}__send-button`} onClick={handleSendTextMessage}>
</Button>
</div>
{showMore && (
<div className={`${PREFIX}__more`}>
<div className={`${PREFIX}__more__item`} onClick={handleClickSendLocation}>
<div className={`${PREFIX}__more__item__icon-wrapper`}>
<Image
mode="aspectFit"
className={`${PREFIX}__more__item__icon`}
src={require('@/statics/svg/location_black.svg')}
/>
</div>
<div className={`${PREFIX}__more__item__text`}></div>
</div>
</div>
)}
<SafeBottomPadding />
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '消息',
usingComponents: {},
disableScroll: true,
});

View File

@ -0,0 +1,35 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-message {
width: 100vw;
&__header {
.flex-row();
justify-content: space-between;
padding: 16px 32px;
background: @blHighlightBg;
&__times {
.flex-row();
font-size: 28px;
font-weight: 400;
color: #6F7686;
.highlight {
color: @blHighlightColor;
}
}
&__help-icon {
width: 40px;
height: 40px;
margin-left: 16px;
}
&__btn {
.button(@width: 146px; @height: 60px; @fontSize: 24px; @borderRadius: 44px);
}
}
}

137
src/pages/message/index.tsx Normal file
View File

@ -0,0 +1,137 @@
import { Button, Image } from '@tarojs/components';
import Taro, { NodesRef, useDidHide, useDidShow, useLoad } from '@tarojs/taro';
import { List } from '@taroify/core';
import { useCallback, useEffect, useRef, useState } from 'react';
import HomePage from '@/components/home-page';
import MessageCard from '@/components/message-card';
import { MessageHelpDialog, MessageNoTimesDialog } from '@/components/message-dialog';
import { APP_TAB_BAR_ID, EventName } from '@/constants/app';
import { REFRESH_CHAT_LIST_TIME } from '@/constants/message';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import useRoleType from '@/hooks/user-role-type';
import { MainMessage } from '@/types/message';
import { logWithPrefix } from '@/utils/common';
import {
postAddMessageTimes,
requestMessageList,
requestRemainPushTime,
requestUnreadMessageCount,
} from '@/utils/message';
import './index.less';
const PREFIX = 'page-message';
const HEADER_CLASS = `${PREFIX}__header`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${HEADER_CLASS}`, `#${APP_TAB_BAR_ID}`],
calc: (rects: [NodesRef.BoundingClientRectCallbackResult, NodesRef.BoundingClientRectCallbackResult]) => {
const [headerRect, tabBarRect] = rects;
return tabBarRect.top - headerRect.bottom;
},
};
const log = logWithPrefix(PREFIX);
export default function Message() {
const roleType = useRoleType();
const listHeight = useListHeight(CALC_LIST_PROPS);
const [times, setTimes] = useState(0);
const [messages, setMessages] = useState<MainMessage[]>([]);
const [showHelp, setShowHelp] = useState(false);
const [showTips, setShowTips] = useState(false);
const pageVisibleRef = useRef(true);
const refreshRef = useRef(async (needTips: boolean = false) => {
try {
const [list, remain] = await Promise.all([
requestMessageList(),
requestRemainPushTime(),
requestUnreadMessageCount(),
]);
setMessages(list);
setTimes(Number(remain));
needTips && remain >= 0 && remain <= 3 && setShowTips(true);
} catch (e) {
console.error(e);
}
});
const handleClickHelp = useCallback(() => setShowHelp(true), []);
const handleClickAddMessageTimes = useCallback(async () => {
await postAddMessageTimes('message_page');
const remain = await requestRemainPushTime();
setTimes(remain);
}, []);
useDidHide(() => (pageVisibleRef.current = false));
useDidShow(() => {
pageVisibleRef.current = true;
refreshRef.current();
});
useLoad(async () => {
refreshRef.current(true);
});
useEffect(() => {
const intervalId = setInterval(async () => {
if (!pageVisibleRef.current) {
log('ignore refresh message list by page hidden');
return;
}
refreshRef.current();
}, REFRESH_CHAT_LIST_TIME);
return () => {
clearInterval(intervalId);
};
}, []);
useEffect(() => {
const callback = () => refreshRef.current();
Taro.eventCenter.on(EventName.EXIT_CHAT_PAGE, callback);
return () => {
Taro.eventCenter.off(EventName.EXIT_CHAT_PAGE, callback);
};
}, []);
useEffect(() => {
refreshRef.current();
}, [roleType]);
return (
<HomePage>
<div className={PREFIX}>
<div className={HEADER_CLASS}>
<div className={`${HEADER_CLASS}__times`}>
<div className="highlight">{`${times}`}</div>
<Image
className={`${HEADER_CLASS}__help-icon`}
src={require('@/statics/svg/help.svg')}
onClick={handleClickHelp}
/>
</div>
<Button className={`${HEADER_CLASS}__btn`} onClick={handleClickAddMessageTimes}>
</Button>
</div>
<List className={`${PREFIX}__message-list`} style={{ height: `${listHeight}px` }} disabled fixedHeight>
{messages.map(message => (
<MessageCard key={message.toUserName} data={message} />
))}
</List>
</div>
<div>
<MessageHelpDialog open={showHelp} onClose={() => setShowHelp(false)} />
<MessageNoTimesDialog
times={times}
open={showTips}
onClose={() => setShowTips(false)}
onClick={handleClickAddMessageTimes}
/>
</div>
</HomePage>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我应聘的',
});

View File

@ -0,0 +1,8 @@
.my-declaration {
height: 100vh;
padding: 0 24px;
&__search {
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,72 @@
import { NodesRef } from '@tarojs/taro';
import { useCallback, useState } from 'react';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import SearchInput from '@/components/search';
import UserJobList from '@/components/user-job-list';
import { UserJobType } from '@/constants/job';
import useListHeight, { IUseListHeightProps } from '@/hooks/use-list-height';
import './index.less';
const PREFIX = 'my-declaration';
const SEARCH_CLASS = `${PREFIX}__search`;
const SAFE_PADDING_BOTTOM_CLASS = `${PREFIX}__safe-padding-bottom`;
const CALC_LIST_PROPS: IUseListHeightProps = {
selectors: [`.${SEARCH_CLASS}`, `.${PREFIX}`, `.${SAFE_PADDING_BOTTOM_CLASS}`],
calc: (
rects: [
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
NodesRef.BoundingClientRectCallbackResult,
]
) => {
const [searchRect, pageRect, safePaddingRect] = rects;
return pageRect.bottom - searchRect.height - safePaddingRect.height;
},
};
export default function Declaration() {
const listHeight = useListHeight(CALC_LIST_PROPS);
const [focus, setFocus] = useState(false);
const [value, setValue] = useState<string>('');
const [keyWord, setKeyWord] = useState<string>('');
const handleClickSearch = useCallback(() => {
if (value === keyWord) {
return;
}
setFocus(false);
setKeyWord(value);
}, [value, keyWord]);
const handleSearchClear = useCallback(() => {
setValue('');
setKeyWord('');
}, []);
const handleSearchBlur = useCallback(() => setFocus(false), []);
const handleSearchFocus = useCallback(() => setFocus(true), []);
const handleSearchChange = useCallback(e => setValue(e.detail.value), []);
return (
<div className={PREFIX}>
<SearchInput
focus={focus}
value={value}
placeholder="搜索我应聘的"
className={SEARCH_CLASS}
onClear={handleSearchClear}
onBlur={handleSearchBlur}
onFocus={handleSearchFocus}
onSearch={handleClickSearch}
onChange={handleSearchChange}
/>
<UserJobList type={UserJobType.MyDeclared} keyWord={keyWord} listHeight={listHeight} />
<SafeBottomPadding className={SAFE_PADDING_BOTTOM_CLASS} />
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})

View File

View File

@ -0,0 +1,16 @@
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import './index.less'
export default function MyPublish() {
useLoad(() => {
console.log('Page loaded.')
})
return (
<View className='my-publish'>
<Text>Hello world!</Text>
</View>
)
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '隐私协议',
});

View File

View File

@ -0,0 +1,9 @@
import { WebView } from '@tarojs/components';
import Toast from '@/utils/toast';
import './index.less';
export default function PrivacyWebview() {
return <WebView src="https://neighbourhood.cn/protocol.html" onError={() => Toast.error('加载失败请重试')} />;
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '用户协议',
});

View File

View File

@ -0,0 +1,9 @@
import { WebView } from '@tarojs/components';
import Toast from '@/utils/toast';
import './index.less';
export default function ProtocolWebview() {
return <WebView src="https://neighbourhood.cn/user-agreement.html" onError={() => Toast.error('加载失败请重试')} />;
}

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '选择城市',
disableScroll: true,
});

View File

@ -0,0 +1,123 @@
@import '@/styles/variables.less';
.search-city {
background: #FFF;
&__position-title {
font-size: 24px;
color: @blColorG1;
padding: 0 24px;
margin-top: 18px;
}
&__position-city {
font-size: 30px;
font-weight: bold;
color: @blColor;
padding: 0 24px;
margin-top: 18px;
}
&__hot-city-title {
height: 48px;
font-size: 24px;
line-height: 48px;
padding: 0 24px;
color: #999;
background: #f2f5f7;
margin-top: 18px;
}
&__hot-city-container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: space-between;
width: 630px;
padding: 12px 90px 26px 30px;
background: #FFFFFF;
}
&__hot-city-item {
width: 140px;
height: 58px;
font-size: 28px;
line-height: 58px;
text-align: center;
border-radius: 58px;
border: 2px solid @blColorG1;
margin-top: 18px;
}
&__indexes-list {
width: 100%;
/* 兼容 iOS < 11.2 */
padding-bottom: constant(safe-area-inset-bottom);
/* 兼容 iOS >= 11.2 */
padding-bottom: env(safe-area-inset-bottom);
}
&__indexes-fragment {}
&__indexes-anchor {
height: 48px;
font-size: 24px;
line-height: 48px;
padding: 0 24px;
color: @blColorG1;
background: #f2f5f7;
}
&__indexes-cell {
position: relative;
font-size: 28px;
padding: 30px 24px;
color: @blColor;
&::after {
content: '';
position: absolute;
border-bottom: 1rpx solid #eaeef1;
transform: scaleY(0.5);
bottom: 0;
right: 0;
left: 24px;
}
}
&__indexes-bar {
width: 44rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
position: fixed;
right: 10px;
}
&__indexes-bar-item {
font-size: 22px;
color: @blColor;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
&__indexes-index-alert {
position: absolute;
z-index: 20;
width: 160px;
height: 160px;
left: 50%;
top: 50%;
margin-left: -80px;
margin-top: -80px;
border-radius: 80px;
text-align: center;
line-height: 160px;
font-size: 70px;
color: #FFFFFF;
background-color: rgba(0, 0, 0, 0.5);
}
}

View File

@ -0,0 +1,229 @@
import { BaseEventOrig, InputProps, ScrollView } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import { Search } from '@taroify/core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { EventName, OpenSource } from '@/constants/app';
import { CITY_CODE_TO_NAME_MAP, CITY_INDEXES_LIST } from '@/constants/city';
import { logWithPrefix } from '@/utils/common';
import { getPageQuery, navigateBack } from '@/utils/route';
import './index.less';
interface Item {
cityCode: number | string;
cityName: string;
keyword: string;
}
const PREFIX = 'search-city';
const HOT_CITY = [
{ cityCode: 110100, cityName: '北京' },
{ cityCode: 310100, cityName: '上海' },
{ cityCode: 440100, cityName: '广州' },
{ cityCode: 440300, cityName: '深圳' },
{ cityCode: 330100, cityName: '杭州' },
{ cityCode: 430100, cityName: '长沙' },
{ cityCode: 420100, cityName: '武汉' },
{ cityCode: 350200, cityName: '厦门' },
{ cityCode: 610100, cityName: '西安' },
{ cityCode: 410100, cityName: '郑州' },
{ cityCode: 510100, cityName: '成都' },
{ cityCode: 340100, cityName: '合肥' },
];
const OFFSET_INDEX_SIZE = 2;
const log = logWithPrefix(PREFIX);
const useHeight = () => {
const [winHeight, setWinHeight] = useState(0);
const [indexItemHeight, setIndexItemHeight] = useState(0);
useEffect(() => {
const windowInfo = Taro.getWindowInfo();
const windowHeight = windowInfo.windowHeight;
setWinHeight(windowHeight);
// 上下预留两个选项高度的空白
setIndexItemHeight(Math.floor(windowHeight / (26 + OFFSET_INDEX_SIZE * 2)));
}, []);
return [winHeight, indexItemHeight];
};
export default function SearchCity() {
const [winHeight, indexItemHeight] = useHeight();
const [currentCity, setCurrentCity] = useState<string>('');
const [touchAnchor, setTouchAnchor] = useState<string | undefined>();
const [touchMoving, setTouchMoving] = useState(false);
const [searchResult, setSearchResult] = useState<Item[]>([]);
const openSourceRef = useRef<OpenSource>(OpenSource.None);
const showSearchList = searchResult.length > 0;
const handleSearchChange = useCallback((event: BaseEventOrig<InputProps.inputEventDetail>) => {
const value = event.detail.value;
log('handleSearchChange', value);
if (!value) {
setSearchResult([]);
return;
}
const result: Item[] = [];
CITY_INDEXES_LIST.forEach(obj => {
obj.data.forEach(city => {
if (city.keyword.includes(value.toLocaleUpperCase())) {
result.push({ ...city });
}
});
});
setSearchResult(result);
}, []);
const handleSelectCity = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const cityCode = e.currentTarget.dataset.code;
Taro.eventCenter.trigger(EventName.SELECT_CITY, { openSource: openSourceRef.current, cityCode: String(cityCode) });
navigateBack(1);
}, []);
const handleTouchStart = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
const pageY = e.touches[0].pageY;
const index = Math.floor(pageY / indexItemHeight) - OFFSET_INDEX_SIZE;
if (index < 0 || index >= CITY_INDEXES_LIST.length) {
return;
}
const item = CITY_INDEXES_LIST[index];
if (item) {
setTouchMoving(true);
setTouchAnchor(item.letter);
}
},
[indexItemHeight]
);
const handleTouchMove = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
const pageY = e.touches[0].pageY;
const index = Math.floor(pageY / indexItemHeight) - OFFSET_INDEX_SIZE;
if (index < 0 || index >= CITY_INDEXES_LIST.length) {
return;
}
const item = CITY_INDEXES_LIST[index];
item && setTouchAnchor(item.letter);
},
[indexItemHeight]
);
const handleTouchEnd = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
e.stopPropagation();
setTouchMoving(false);
log('touch end');
}, []);
const handleClickAnchor = useCallback((anchor: string) => {
setTouchAnchor(anchor);
log('click anchor', anchor);
}, []);
useLoad(() => {
const query = getPageQuery<{ city: string; source: OpenSource }>();
log('query', query);
const { city: cityCode, source: openSource } = query;
if (!cityCode) {
return;
}
setCurrentCity(cityCode);
openSourceRef.current = openSource || OpenSource.None;
});
return (
<div className={PREFIX}>
<ScrollView scrollY style={{ height: winHeight }} scrollIntoView={touchAnchor}>
<Search
className={`${PREFIX}__search`}
placeholder="输入城市名称"
shape="rounded"
onChange={handleSearchChange}
/>
{showSearchList && (
<div className={`${PREFIX}__search-list`}>
{searchResult.map(city => (
<div
key={city.cityCode}
className={`${PREFIX}__indexes-cell`}
data-code={city.cityCode}
onClick={handleSelectCity}
>
{city.cityName}
</div>
))}
</div>
)}
{!showSearchList && (
<div>
<div className={`${PREFIX}__position-title`}></div>
<div className={`${PREFIX}__position-city`}>{CITY_CODE_TO_NAME_MAP.get(currentCity)}</div>
<div className={`${PREFIX}__hot-city-title`}></div>
<div className={`${PREFIX}__hot-city-container`}>
{HOT_CITY.map(city => (
<div
key={city.cityCode}
className={`${PREFIX}__hot-city-item`}
data-code={city.cityCode}
onClick={handleSelectCity}
>
{city.cityName}
</div>
))}
</div>
<div className={`${PREFIX}__indexes-list`}>
{CITY_INDEXES_LIST.map(item => {
return (
<div key={item.letter} className={`${PREFIX}__indexes-fragment`}>
<div className={`${PREFIX}__indexes-anchor`} id={item.letter}>
{item.letter}
</div>
{item.data.map(city => (
<div
key={city.cityCode}
className={`${PREFIX}__indexes-cell`}
data-code={city.cityCode}
onClick={handleSelectCity}
>
{city.cityName}
</div>
))}
</div>
);
})}
</div>
</div>
)}
</ScrollView>
<div>
{!showSearchList && (
<div
className={`${PREFIX}__indexes-bar`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
style={{ top: indexItemHeight * OFFSET_INDEX_SIZE }}
>
{CITY_INDEXES_LIST.map(item => {
return (
<div
key={item.letter}
className={`${PREFIX}__indexes-bar-item`}
style={{ height: indexItemHeight }}
onClick={() => handleClickAnchor(item.letter)}
>
{item.letter}
</div>
);
})}
</div>
)}
{touchAnchor && touchMoving && <div className={`${PREFIX}__indexes-index-alert`}>{touchAnchor}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '',
});

View File

@ -0,0 +1,25 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-start {
width: 100vw;
height: 100vh;
.flex-column();
&__app {
margin-top: 50%;
}
&__icon {
width: 312px;
height: 152px;
}
&__text {
font-size: 30px;
line-height: 32px;
font-weight: 400;
color: @blColorG2;
margin-top: 32px;
}
}

23
src/pages/start/index.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Image } from '@tarojs/components';
import { useLoad } from '@tarojs/taro';
import { switchDefaultTab } from '@/utils/app';
import './index.less';
const PREFIX = 'page-start';
export default function Start() {
useLoad(() => {
switchDefaultTab();
});
return (
<div className={PREFIX}>
<div className={`${PREFIX}__app`}>
<Image className={`${PREFIX}__icon`} mode="aspectFit" src={require('@/statics/svg/slogan.svg')} />
<div className={`${PREFIX}__text`}> </div>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我要群发',
});

View File

@ -0,0 +1,90 @@
@import '@/styles/common.less';
@import '@/styles/variables.less';
.page-user-batch-publish {
padding: 24px;
padding-bottom: 200px;
&__header-image {
width: 100%;
height: 120px;
margin-top: 24px;
}
&__title {
font-size: 32px;
line-height: 48px;
font-weight: 500;
color: @blColor;
margin-top: 24px;
&:first-child {
margin-top: 0;
}
}
&__cell {
height: 100px;
padding-left: 32px;
padding-right: 32px;
border-radius: 16px;
margin-top: 24px;
}
&__cost-describe {
height: 100px;
padding: 0 32px;
border-radius: 16px;
.flex-row();
justify-content: space-between;
background: #FFFFFF;
margin-top: 24px;
&__price {
font-size: 48px;
line-height: 48px;
font-weight: 500;
color: @blHighlightColor;
}
&__original_price {
flex: 1;
font-size: 32px;
line-height: 34px;
font-weight: 400;
color: @blColorG1;
margin-left: 16px;
text-decoration: line-through;
}
}
&__illustrate {
padding: 24px 32px;
margin-top: 24px;
font-size: 28px;
line-height: 48px;
font-weight: 400;
color: @blColorG2;
background: #FFFFFF;
border-radius: 16px;
&__describe {
.flex-row();
font-size: 28px;
line-height: 48px;
font-weight: 400;
color: @blColorG2;
margin-top: 8px;
&__view {
color: @blHighlightColor;
margin-left: 4px;
}
}
}
&__buy-button {
.button(@width: 100%; @height: 80px; @fontSize: 32px);
margin-top: 40px;
}
}

View File

@ -0,0 +1,198 @@
import { Button, Image, Text } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import { Cell } from '@taroify/core';
import { useCallback, useState } from 'react';
import HomePage from '@/components/home-page';
import PageLoading from '@/components/page-loading';
import { PublishJobQrCodeDialog } from '@/components/product-dialog/publish-job';
import SafeBottomPadding from '@/components/safe-bottom-padding';
import { ISelectOption, PopupSelect } from '@/components/select';
import { PageUrl } from '@/constants/app';
import { OrderStatus, OrderType, ProductSpecId, ProductType } from '@/constants/product';
import { BatchPublishGroup } from '@/types/group';
import { logWithPrefix } from '@/utils/common';
import {
getOrderPrice,
isCancelPay,
requestAllBuyProduct,
requestCreatePayInfo,
requestOrderInfo,
requestPayment,
} from '@/utils/product';
import { navigateTo } from '@/utils/route';
import Toast from '@/utils/toast';
import './index.less';
interface CityValue extends BatchPublishGroup {
cityName: string;
}
interface CityOption extends ISelectOption<CityValue> {
value: CityValue;
}
const PREFIX = 'page-user-batch-publish';
const log = logWithPrefix(PREFIX);
const SERVICE_ILLUSTRATE = `群发次数每日一次连发3天
群发内容:仅限主播招聘通告,违规内容不发
联系方法:通告中留通告主联系方式,主播直接联系`;
const cityValues: CityValue[] = [
{ cityCode: '440100', cityName: '广州', count: 300 },
{ cityCode: '440300', cityName: '深圳', count: 100 },
{ cityCode: '330100', cityName: '杭州', count: 300 },
{ cityCode: '110100', cityName: '北京', count: 100 },
{ cityCode: '510100', cityName: '成都', count: 50 },
{ cityCode: '430100', cityName: '长沙', count: 50 },
{ cityCode: '350200', cityName: '厦门', count: 50 },
{ cityCode: '310100', cityName: '上海', count: 100 },
{ cityCode: '420100', cityName: '武汉', count: 50 },
{ cityCode: '610100', cityName: '西安', count: 50 },
{ cityCode: '410100', cityName: '郑州', count: 100 },
].sort((a, b) => b.count - a.count);
const MIN_GROUP_SIZE = 20;
const GROUP_OPTIONS = [
{ value: MIN_GROUP_SIZE, productSpecId: ProductSpecId.GroupBatchPublish20, label: '20', price: 18 },
{ value: 50, productSpecId: ProductSpecId.GroupBatchPublish50, label: '50', price: 40 },
{ value: 100, productSpecId: ProductSpecId.GroupBatchPublish100, label: '100', price: 68 },
{ value: 300, productSpecId: ProductSpecId.GroupBatchPublish300, label: '300', price: 128 },
{ value: 500, productSpecId: ProductSpecId.GroupBatchPublish500, label: '500', price: 188 },
{ value: 1000, productSpecId: ProductSpecId.GroupBatchPublish1000, label: '1000', price: 288 },
];
const calcPrice = (city: CityValue | null) => {
if (!city) {
return {};
}
const { count } = city;
const originalPrice = count * 1;
const price = GROUP_OPTIONS.find(o => o.value === count)?.price || 18;
const productSpecId = GROUP_OPTIONS.find(o => o.value === count)?.productSpecId || ProductSpecId.GroupBatchPublish20;
return { price, originalPrice, productSpecId };
};
export default function UserBatchPublish() {
const [loading, setLoading] = useState(true);
const [showCitySelect, setShowCitySelect] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const [city, setCity] = useState<CityOption['value'] | null>(null);
const [cityOptions, setCityOptions] = useState<CityOption[]>([]);
const { price, originalPrice, productSpecId } = calcPrice(city);
const handleClickCity = useCallback(() => setShowCitySelect(true), []);
const handleSelectCity = useCallback(value => {
setCity(value);
setShowCitySelect(false);
}, []);
const handleClickViewGroup = useCallback(() => navigateTo(PageUrl.GroupList, { city: city?.cityCode }), [city]);
const handleClickBuy = useCallback(async () => {
// if (1 < 2) {
// await new Promise(r => setTimeout(r, 3000));
// setShowQrCode(true);
// return;
// }
if (!price || !productSpecId) {
return;
}
try {
Taro.showLoading();
const allowBuy = await requestAllBuyProduct(ProductType.GroupBatchPublish);
if (!allowBuy) {
Taro.hideLoading();
Toast.info('您最近已购买过,可直接联系客服');
setShowQrCode(true);
return;
}
const { payOrderNo, createPayInfo } = await requestCreatePayInfo({
type: OrderType.GroupBatchPublish,
amt: getOrderPrice(price),
// amt: 1,
productCode: ProductType.GroupBatchPublish,
productSpecId: productSpecId,
});
log('handleBuy payInfo', payOrderNo, createPayInfo);
await requestPayment({
timeStamp: createPayInfo.timeStamp,
nonceStr: createPayInfo.nonceStr,
package: createPayInfo.packageVal,
signType: createPayInfo.signType,
paySign: createPayInfo.paySign,
});
const { status } = await requestOrderInfo({ payOrderNo });
log('handleBuy orderInfo', status);
if (status !== OrderStatus.Success) {
throw new Error('order status error');
}
Taro.hideLoading();
setShowQrCode(true);
} catch (e) {
Taro.hideLoading();
Toast.error(isCancelPay(e) ? '取消购买' : '购买失败请重试');
log('handleBuy error', e);
}
}, [price, productSpecId]);
useLoad(async () => {
try {
const cOptions: CityOption[] = cityValues.map(value => ({ value, label: value.cityName }));
const initCity = cOptions[0].value;
setLoading(false);
setCity(initCity);
setCityOptions(cOptions);
log('init data done', cOptions);
} catch (e) {
Toast.error('加载失败请重试');
}
});
if (loading) {
return <PageLoading />;
}
return (
<HomePage>
<div className={PREFIX}>
<Image mode="widthFix" className={`${PREFIX}__header-image`} src="https://neighbourhood.cn/pubJob.png" />
<div className={`${PREFIX}__title`}></div>
<Cell isLink align="center" className={`${PREFIX}__cell`} title={city?.cityName} onClick={handleClickCity} />
<div className={`${PREFIX}__title`}></div>
<Cell align="center" className={`${PREFIX}__cell`} title={city?.count} />
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__cost-describe`}>
<div className={`${PREFIX}__cost-describe__price`}>{`${price}`}</div>
<div className={`${PREFIX}__cost-describe__original_price`}>{`原价:${originalPrice}`}</div>
</div>
<div className={`${PREFIX}__title`}></div>
<div className={`${PREFIX}__illustrate`}>
<Text>{SERVICE_ILLUSTRATE}</Text>
<div className={`${PREFIX}__illustrate__describe`}>
<div></div>
<div className={`${PREFIX}__illustrate__describe__view`} onClick={handleClickViewGroup}>
</div>
</div>
</div>
<Button className={`${PREFIX}__buy-button`} onClick={handleClickBuy}>
</Button>
<SafeBottomPadding />
<div>
<PopupSelect
value={city}
options={cityOptions}
open={showCitySelect}
onSelect={handleSelectCity}
onClose={() => setShowCitySelect(false)}
/>
<PublishJobQrCodeDialog onClose={() => setShowQrCode(false)} open={showQrCode} />
</div>
</div>
</HomePage>
);
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '个人信息',
});

View File

@ -0,0 +1,21 @@
.user-info {
&__avatar-cell {
position: relative;
}
&__avatar {
width: 96px;
height: 96px;
border-radius: 50%;
}
&__avatar-button {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 0;
}
}

View File

@ -0,0 +1,77 @@
import { BaseEventOrig, Button, Input, InputProps, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { Cell } from '@taroify/core';
import { useCallback, useRef } from 'react';
import useUserInfo from '@/hooks/use-user-info';
import { logWithPrefix } from '@/utils/common';
import Toast from '@/utils/toast';
import { updateUserInfo } from '@/utils/user';
import { commonUploadProgress, uploadVideo } from '@/utils/video';
import './index.less';
const PREFIX = 'user-info';
const log = logWithPrefix(PREFIX);
export default function UserInfo() {
const userInfo = useUserInfo();
const nameRef = useRef(userInfo.nickName);
const handleChooseAvatar = useCallback(async (e: BaseEventOrig) => {
// const { avatarUrl } = e.detail;
// log('handleChooseAvatar', avatarUrl, e.detail);
// const { url } = await uploadVideo(avatarUrl, 'image', commonUploadProgress, 'user-avatar');
// url && updateUserInfo({ avatarUrl: url });
Taro.chooseMedia({
mediaType: ['image'],
sourceType: ['album'],
count: 1,
success: async ({ tempFiles }) => {
log('handleChooseAvatar', tempFiles[0]);
const { url } = await uploadVideo(tempFiles[0].tempFilePath, 'image', commonUploadProgress, 'user-avatar');
url && updateUserInfo({ avatarUrl: url });
}
})
}, []);
const handleInput = useCallback((e: BaseEventOrig<InputProps.inputValueEventDetail>) => {
const value = e.detail?.value || '';
nameRef.current = value;
}, []);
const handleInputBlurOrConfirm = useCallback(() => {
const newNickName = nameRef.current;
if (!newNickName) {
Toast.error('昵称不能为空');
}
log('confirm nickname changed:', newNickName, userInfo.nickName);
newNickName !== userInfo.nickName && updateUserInfo({ nickName: newNickName });
}, [userInfo]);
return (
<div className={PREFIX}>
<Cell className={`${PREFIX}__avatar-cell`} title="头像" align="center" isLink>
<Image
mode="aspectFit"
className={`${PREFIX}__avatar`}
src={userInfo.avatarUrl || require('@/statics/png/default_avatar.png')}
/>
<Button className={`${PREFIX}__avatar-button`} onClick={handleChooseAvatar} />
</Cell>
<Cell title="昵称" align="center" isLink>
<Input
type="nickname"
confirmType="done"
placeholder="请输入昵称"
value={nameRef.current}
onInput={handleInput}
onBlur={handleInputBlurOrConfirm}
onConfirm={handleInputBlurOrConfirm}
/>
</Cell>
</div>
);
}

View File

@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '',
enableShareAppMessage: true,
usingComponents: {},
});

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