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

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
# 配置文档参考 https://taro-docs.jd.com/docs/next/env-mode-config
TARO_APP_ID="wxf0724a83f8e377d2"

1
.env.production Normal file
View File

@ -0,0 +1 @@
TARO_APP_ID="wxf0724a83f8e377d2"

1
.env.test Normal file
View File

@ -0,0 +1 @@
# TARO_APP_ID="测试环境下的小程序appid"

11
.eslintignore Normal file
View File

@ -0,0 +1,11 @@
build/*.js
public
dist
node_modules
config
config-overrides.js
prettierrc
.DS_Store
.eslintrc.json
.env.development
.env.production

26
.eslintrc Normal file
View File

@ -0,0 +1,26 @@
{
"extends": ["taro/react", "prettier", "plugin:@typescript-eslint/recommended"],
"plugins": ["prettier", "import", "@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"rules": {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"import/order": [
"error",
{
"groups": ["builtin", "external", ["internal", "parent", "sibling", "index"], "unknown"],
"pathGroups": [{
"pattern": "@/**",
"group": "external",
"position": "after"
}],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
],
"prettier/prettier": "error"
}
}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
dist/
deploy_versions/
.temp/
.rn_temp/
node_modules/
.DS_Store
.swc
project.private.config.json
node_test.js

11
.prettierignore Normal file
View File

@ -0,0 +1,11 @@
build/*.js
public
dist
node_modules
config
config-overrides.js
prettierrc
.DS_Store
.eslintrc.json
.env.development
.env.production

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"proseWrap": "preserve",
"arrowParens": "avoid",
"bracketSpacing": true,
"endOfLine": "auto",
"htmlWhitespaceSensitivity": "strict",
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"trailingComma": "es5"
}

6
README.md Normal file
View File

@ -0,0 +1,6 @@
播络 App
### 开发指引
1. yarn
2. yarn dev:weapp
3. 下载并打开[微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html),然后**选择项目根目录**进行预览。

12
__tests__/index.test.js Normal file
View File

@ -0,0 +1,12 @@
import TestUtils from '@tarojs/test-utils-react'
describe('Testing', () => {
test('Test', async () => {
const testUtils = new TestUtils()
await testUtils.createApp()
await testUtils.PageLifecycle.onShow('pages/home/index')
expect(testUtils.html()).toMatchSnapshot()
})
})

34
babel.config.js Normal file
View File

@ -0,0 +1,34 @@
// babel-preset-taro 更多选项和默认值:
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
module.exports = {
presets: [
[
'taro',
{
framework: 'react',
ts: true,
},
],
],
plugins: [
[
'import',
{
libraryName: '@taroify/core',
libraryDirectory: '',
style: true,
},
'@taroify/core',
],
[
'import',
{
libraryName: '@taroify/icons',
libraryDirectory: '',
camel2DashComponentName: false,
style: () => '@taroify/icons/style',
},
'@taroify/icons',
],
],
};

9
config/dev.ts Normal file
View File

@ -0,0 +1,9 @@
import type { UserConfigExport } from "@tarojs/cli";
export default {
logger: {
quiet: false,
stats: true
},
mini: {},
h5: {}
} satisfies UserConfigExport

124
config/index.ts Normal file
View File

@ -0,0 +1,124 @@
import { ConfigEnv, defineConfig, type UserConfigExport } from '@tarojs/cli';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { PluginItem } from '@tarojs/taro/types/compile/config';
import devConfig from './dev';
import prodConfig from './prod';
const MockPlugin: PluginItem = [
'@tarojs/plugin-mock',
{
host: 'localhost',
port: 9527,
}
];
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig(async (merge, _env: ConfigEnv) => {
const baseConfig: UserConfigExport = {
projectName: 'boluo-app',
date: '2024-6-7',
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html'],
// plugins: ['@tarojs/plugin-html', MockPlugin],
defineConstants: {
},
copy: {
patterns: [
],
options: {
}
},
framework: 'react',
compiler: {
type: 'webpack5',
prebundle: {
exclude: ['@taroify/icons']
}
},
cache: {
enable: false // Webpack 持久化缓存配置建议开启。默认配置请参考https://docs.taro.zone/docs/config-detail#cache
},
mini: {
// debugReact: true,
miniCssExtractPluginOption: {
ignoreOrder: true,
},
postcss: {
pxtransform: {
enable: true,
config: {
}
},
url: {
enable: true,
config: {
limit: 1024 // 设定转换尺寸上限
}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin);
// chain.plugin('analyzer').use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, []);
}
},
h5: {
publicPath: '/',
staticDirectory: 'static',
output: {
filename: 'js/[name].[hash:8].js',
chunkFilename: 'js/[name].[chunkhash:8].js'
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css'
},
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin)
}
},
rn: {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
}
}
}
}
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig)
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig)
})

32
config/prod.ts Normal file
View File

@ -0,0 +1,32 @@
import type { UserConfigExport } from "@tarojs/cli";
export default {
mini: {},
h5: {
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
}
} satisfies UserConfigExport

6
jest.config.ts Normal file
View File

@ -0,0 +1,6 @@
const defineJestConfig = require('@tarojs/test-utils-react/dist/jest.js').default
module.exports = defineJestConfig({
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/__tests__/**/*.(spec|test).[jt]s?(x)']
})

194
mock/index.ts Normal file
View File

@ -0,0 +1,194 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapperData = (data: any, code: number = 200, message: string = 'success') => {
if (code === 200) {
return data;
}
return {
data,
code,
message,
};
};
let jobId = 0;
const getJobData = (size: number) =>
new Array(size).fill(1).map(() => ({
id: `${jobId++}`,
title: '滨江品牌淑女装兼职主播滨江品牌淑女装兼职主播滨江品牌淑女装兼职主播',
tags: ['女装', '抖音', '快手', '视频号', '直播 4-5h'],
employType: 'ALL',
salary: '时薪 200-300',
jobDescription: 'TOP级账号5年直播团队体系成熟',
publisher: '杭州主播群3-甲子',
publisherAvatar: '',
jobLocation: {
latitude: 39.961,
longitude: 116.4551,
cityCode: '430100',
countyCode: '430121',
address: '朝阳区望京街道',
},
}));
const JOB_DETAIL_DATA = {
id: `abcs`,
title: '滨江品牌淑女装兼职主播滨江品牌淑女装兼职主播滨江品牌淑女装兼职主播',
tags: ['女装', '抖音', '快手', '视频号', '直播 4-5h'],
employType: 'ALL',
salary: '时薪 200-300',
jobDescription: 'TOP级账号5年直播团队体系成熟',
publisher: '杭州主播群3-甲子',
publisherAvatar: '',
jobLocation: {
latitude: 39.961,
longitude: 116.4551,
cityCode: '430100',
countyCode: '430121',
address: '朝阳区望京街道',
},
created: 1719122520631,
updated: 1719122520631,
imGroupId: '12345',
imGroupNick: '服装设计交流群',
isFollow: false,
sourceText: `可兼职可全职\n账号FAIRWHALE时尚旗旗舰店\nTOP级账号!5年直播团队体系成熟!有成熟晋升机制!中少淑穿版好经验不足也可以报名!\n各路女装大神可以来无责分红机制公司背景强大!资金不是问题!!\n时薪:3-800/H(能给),薪资:2W-8W+1-3%提成\n要求:
1.身高160-168。亲和力强站播貌美口齿清晰
2.会控场能带动直播间气氛会拉流量会玩免费流
3.中少淑穿版效果要好
4.场均20-50w的都可以报名都有机会晋升大号
`,
};
let groupId = 0;
const getGroupList = (size: number) =>
new Array(size).fill(1).map(() => ({
id: `${groupId++}`,
imGroupId: '1',
imGroupNick: '广州主播主播🍒经纪群🍒经纪群经纪群',
joinedTime: 1719119060767,
allJobs: Math.floor(Math.random() * 1000),
groupAvatar: 'https://neighbourhood.cn/p_d.png',
}));
const getJobListData = (emptyList: boolean = false) => {
if (emptyList) {
return {
page: 1,
pageSize: 10,
hasMore: false,
jobResults: [],
};
}
return {
page: 1,
pageSize: 10,
hasMore: true,
jobResults: getJobData(10),
};
};
const getUserJobListData = (emptyList: boolean = false) => {
if (emptyList) {
return {
page: 1,
pageSize: 10,
hasMore: false,
data: [],
};
}
return {
page: 1,
pageSize: 10,
hasMore: true,
data: {
'2024-07-23': getJobData(10),
'2024-07-22': getJobData(10),
'2024-07-18': getJobData(10),
},
};
};
export const getGroupListData = (emptyList: boolean = false) => {
if (emptyList) {
return {
allGroups: [],
myJoinedGroups: [],
myCreatedGroups: [],
myFollowedGroups: [],
};
}
return {
allGroups: getGroupList(10),
myJoinedGroups: getGroupList(10),
myCreatedGroups: getGroupList(10),
myFollowedGroups: getGroupList(10),
};
};
const getProductListData = (emptyList: boolean = false) => {
if (emptyList) {
return [];
}
return [
{
productId: 'job',
balance: 3,
created: 1719109060767,
updated: 1719109060767,
},
{
productId: 'group',
balance: 2,
created: 1719109060767,
updated: 1719109060767,
},
];
};
const mockData = {
'POST /api/user/login': wrapperData({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
expires: 3600,
}),
'POST /api/location/get': wrapperData({
provinceCode: '430000',
provinceDesc: '湖南省',
cityCode: '430100',
cityDesc: '长沙市',
}),
'POST /api/user/get': wrapperData({
userId: 'abc',
nickname: '微信用户9627',
avatarUrl: '',
isDefaultAvatar: true,
isDefaultNickname: true,
isBindPhone: false,
// isBindPhone: true,
}),
'POST /api/user/setPhone': wrapperData({}),
'POST /api/userGroup/follow': wrapperData({}),
'POST /api/userGroup/list': wrapperData(getGroupListData(false)),
'POST /api/job/search': wrapperData(getJobListData(false)),
'POST /api/job/searchMyJobs': wrapperData(getJobListData(true)),
'POST /api/job/get': wrapperData(JOB_DETAIL_DATA),
'POST /api/job/user/searchMyDeclared': wrapperData(getUserJobListData(false)),
'POST /api/product/listMyProduct': wrapperData(getProductListData(false)),
'POST /api/product/getMyProductDetail': wrapperData({ balance: 2 }),
'POST /api/product/getProductUseRecord': wrapperData(false),
// 'POST /api/product/getProductUseRecord': wrapperData({}),
// 'POST /api/product/use': wrapperData({}),
// 'POST /api/product/getProductUseRecord': wrapperData({
// declarationTypeResult: {
// type: 0,
// publisherAcctNo: 'yeruth',
// },
// }),
'POST /api/product/use': wrapperData({
declarationTypeResult: {
type: 0,
publisherAcctNo: 'yeruth',
},
}),
};
export default mockData;

108
package.json Normal file
View File

@ -0,0 +1,108 @@
{
"name": "boluo-app",
"version": "1.0.0",
"private": true,
"description": "boluo app",
"templateInfo": {
"name": "default",
"typescript": true,
"css": "Less",
"framework": "React"
},
"scripts": {
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
"build:tt": "taro build --type tt",
"build:h5": "taro build --type h5",
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:quickapp": "taro build --type quickapp",
"build:harmony-hybrid": "taro build --type harmony-hybrid",
"dev:weapp": "npm run build:weapp -- --watch",
"dev:swan": "npm run build:swan -- --watch",
"dev:alipay": "npm run build:alipay -- --watch",
"dev:tt": "npm run build:tt -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch",
"dev:jd": "npm run build:jd -- --watch",
"dev:quickapp": "npm run build:quickapp -- --watch",
"dev:harmony-hybrid": "npm run build:harmony-hybrid -- --watch",
"test": "jest",
"lint:format": "npx prettier --write",
"lint:format-all": "npx prettier --write 'src/**/*.{ts,js,css,jsx,html,vue,tsx}'"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"author": "",
"dependencies": {
"@babel/runtime": "^7.21.5",
"@taroify/core": "^0.3.2-alpha.0",
"@taroify/icons": "0.6.4-alpha.0",
"@tarojs/components": "3.6.31",
"@tarojs/helper": "3.6.31",
"@tarojs/plugin-framework-react": "3.6.31",
"@tarojs/plugin-html": "^3.6.31",
"@tarojs/plugin-platform-alipay": "3.6.31",
"@tarojs/plugin-platform-h5": "3.6.31",
"@tarojs/plugin-platform-harmony-hybrid": "3.6.31",
"@tarojs/plugin-platform-jd": "3.6.31",
"@tarojs/plugin-platform-qq": "3.6.31",
"@tarojs/plugin-platform-swan": "3.6.31",
"@tarojs/plugin-platform-tt": "3.6.31",
"@tarojs/plugin-platform-weapp": "3.6.31",
"@tarojs/react": "3.6.31",
"@tarojs/runtime": "3.6.31",
"@tarojs/shared": "3.6.31",
"@tarojs/taro": "3.6.31",
"classnames": "^2.5.1",
"dayjs": "^1.11.11",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"lodash-es": "^4.17.21",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-redux": "^8.1.2",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^3.1.0"
},
"devDependencies": {
"@babel/core": "^7.8.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
"@tarojs/cli": "3.6.31",
"@tarojs/plugin-mock": "^0.0.9",
"@tarojs/taro-loader": "3.6.31",
"@tarojs/test-utils-react": "^0.1.1",
"@tarojs/webpack5-runner": "3.6.31",
"@types/jest": "^29.3.1",
"@types/node": "^18.15.11",
"@types/react": "^18.0.0",
"@types/webpack-env": "^1.13.6",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"babel-plugin-import": "^1.13.8",
"babel-preset-taro": "3.6.31",
"eslint": "^8.12.0",
"eslint-config-taro": "3.6.31",
"eslint-plugin-import": "^2.12.0",
"eslint-plugin-react": "^7.8.2",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.5.0",
"postcss": "^8.4.18",
"prettier": "^3.3.1",
"react-refresh": "^0.11.0",
"stylelint": "^14.4.0",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"typescript": "^5.1.0",
"webpack": "5.78.0",
"webpack-bundle-analyzer": "^4.10.2"
}
}

16524
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

34
project.config.json Normal file
View File

@ -0,0 +1,34 @@
{
"miniprogramRoot": "dist/",
"description": "boluo app",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"coverView": false,
"showShadowRootInWxmlPanel": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true
},
"compileType": "miniprogram",
"srcMiniprogramRoot": "dist/",
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"libVersion": "3.4.6",
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxf0724a83f8e377d2"
}

9
project.tt.json Normal file
View File

@ -0,0 +1,9 @@
{
"miniprogramRoot": "./",
"projectname": "boluo-app",
"appid": "testAppId",
"setting": {
"es6": false,
"minified": false
}
}

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

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

15
src/app.less Normal file
View File

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

29
src/app.tsx Normal file
View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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