初始化代码
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# node_modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# build
|
||||||
|
/dist
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# cache
|
||||||
|
.cache/
|
||||||
|
.temp/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
1
.pai/pai-preview-server.lock
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"pid":null,"port":53668}
|
||||||
4451
.pai/pai.log
Normal file
12
.pai/skill-update/update.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"generate-mini-app": {
|
||||||
|
"lastCheck": 1788317229913,
|
||||||
|
"checkedAt": "2026-09-02T03:21:45.696Z",
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "skipped",
|
||||||
|
"skill_builtin_version": "1.0.2",
|
||||||
|
"skill_local_version": "",
|
||||||
|
"updatedAt": "",
|
||||||
|
"nextCheckAt": "2026-09-02T05:47:09.913Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
babel.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// babel-preset-taro 更多选项和默认值:
|
||||||
|
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'taro',
|
||||||
|
{
|
||||||
|
framework: 'react',
|
||||||
|
ts: 'true',
|
||||||
|
compiler: 'webpack5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
13
config/dev.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { UserConfigExport } from '@tarojs/cli';
|
||||||
|
export default {
|
||||||
|
logger: {
|
||||||
|
quiet: false,
|
||||||
|
stats: true,
|
||||||
|
},
|
||||||
|
mini: {},
|
||||||
|
h5: {
|
||||||
|
devServer: {
|
||||||
|
open: false, //禁止自动打开浏览器
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies UserConfigExport<'webpack5'>;
|
||||||
128
config/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { defineConfig, type UserConfigExport } from '@tarojs/cli';
|
||||||
|
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
||||||
|
import devConfig from './dev';
|
||||||
|
import prodConfig from './prod';
|
||||||
|
import vitePluginImp from 'vite-plugin-imp';
|
||||||
|
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
|
||||||
|
export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||||
|
const baseConfig: UserConfigExport<'webpack5'> = {
|
||||||
|
projectName: 'taro_template',
|
||||||
|
date: '2025-12-10',
|
||||||
|
designWidth: 375,
|
||||||
|
deviceRatio: {
|
||||||
|
640: 2.34 / 2,
|
||||||
|
750: 1,
|
||||||
|
375: 2,
|
||||||
|
828: 1.81 / 2,
|
||||||
|
},
|
||||||
|
sourceRoot: 'src',
|
||||||
|
outputRoot: process.env.TARO_OUTPUT_DIR || 'dist',
|
||||||
|
plugins: ['@tarojs/plugin-html'],
|
||||||
|
defineConstants: {},
|
||||||
|
copy: {
|
||||||
|
patterns: [],
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
framework: 'react',
|
||||||
|
compiler: {
|
||||||
|
type: 'webpack5',
|
||||||
|
prebundle: {
|
||||||
|
enable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
enable: false, // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache
|
||||||
|
},
|
||||||
|
mini: {
|
||||||
|
// 图片资源不内联为 base64,统一以包内文件路径引用:
|
||||||
|
// 关注引导弹窗的"保存图片"(saveImageToPhotosAlbum)要求真实文件路径,
|
||||||
|
// base64 data URL 会导致保存失败
|
||||||
|
imageUrlLoaderOption: {
|
||||||
|
limit: 0,
|
||||||
|
},
|
||||||
|
postcss: {
|
||||||
|
pxtransform: {
|
||||||
|
enable: true,
|
||||||
|
config: {
|
||||||
|
selectorBlackList: ['nut-'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssModules: {
|
||||||
|
enable: true, // 开启 CSS Modules
|
||||||
|
config: {
|
||||||
|
namingPattern: 'module', // 仅 *.module.scss 生效
|
||||||
|
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webpackChain(chain) {
|
||||||
|
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: true, // 开启 CSS Modules
|
||||||
|
config: {
|
||||||
|
namingPattern: 'module', // 仅 *.module.scss 生效
|
||||||
|
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pxtransform: {
|
||||||
|
enable: true,
|
||||||
|
config: {
|
||||||
|
selectorBlackList: ['body'],
|
||||||
|
baseFontSize: 37.5,
|
||||||
|
unitPrecision: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webpackChain(chain) {
|
||||||
|
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rn: {
|
||||||
|
appName: 'taroDemo',
|
||||||
|
postcss: {
|
||||||
|
cssModules: {
|
||||||
|
enable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// 本地开发构建配置(不混淆压缩)
|
||||||
|
const config = merge({}, baseConfig, devConfig);
|
||||||
|
config.defineConstants = {
|
||||||
|
...baseConfig.defineConstants,
|
||||||
|
'process.env.API_BASE_URL': JSON.stringify('http://localhost:8081/laop'),
|
||||||
|
'process.env.FILE_BASE_URL': JSON.stringify('http://localhost:8081/laop'),
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
// 生产构建配置(默认开启压缩混淆等)
|
||||||
|
const config = merge({}, baseConfig, prodConfig);
|
||||||
|
config.defineConstants = {
|
||||||
|
...baseConfig.defineConstants,
|
||||||
|
// TODO: 上线前替换为真实生产域名(如 https://api.example.com/laop)
|
||||||
|
// 当前使用 127.0.0.1 仅适用于本机微信开发者工具调试;真机预览需改为局域网 IP
|
||||||
|
'process.env.API_BASE_URL': JSON.stringify('http://127.0.0.1:8081/laop'),
|
||||||
|
'process.env.FILE_BASE_URL': JSON.stringify('http://127.0.0.1:8081/laop'),
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
});
|
||||||
5
config/prod.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { UserConfigExport } from '@tarojs/cli';
|
||||||
|
export default {
|
||||||
|
mini: {},
|
||||||
|
h5: {},
|
||||||
|
} satisfies UserConfigExport<'webpack5'>;
|
||||||
32397
package-lock.json
generated
Normal file
96
package.json
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
{
|
||||||
|
"name": "taro_template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "taro_template",
|
||||||
|
"templateInfo": {
|
||||||
|
"name": "react",
|
||||||
|
"typescript": true,
|
||||||
|
"css": "Sass",
|
||||||
|
"framework": "React"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build:weapp": "NODE_ENV=development taro build --type weapp",
|
||||||
|
"build:weapp:dev": "NODE_ENV=development taro build --type weapp",
|
||||||
|
"build:weapp:prod": "NODE_ENV=production taro build --type weapp",
|
||||||
|
"build:swan": "taro build --type swan",
|
||||||
|
"build:alipay": "taro build --type alipay",
|
||||||
|
"build:tt": "taro build --type tt",
|
||||||
|
"build:h5": "NODE_ENV=development taro build --type h5",
|
||||||
|
"build:h5:dev": "NODE_ENV=development taro build --type h5",
|
||||||
|
"build:h5:prod": "NODE_ENV=production 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",
|
||||||
|
"dev:weapp": "NODE_ENV=development 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": "NODE_ENV=development 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"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"last 3 versions",
|
||||||
|
"Android >= 4.1",
|
||||||
|
"ios >= 8"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.5",
|
||||||
|
"@tarojs/components": "4.1.9",
|
||||||
|
"@tarojs/helper": "4.1.9",
|
||||||
|
"@tarojs/plugin-framework-react": "4.1.9",
|
||||||
|
"@tarojs/plugin-html": "4.1.9",
|
||||||
|
"@tarojs/plugin-platform-alipay": "4.1.9",
|
||||||
|
"@tarojs/plugin-platform-h5": "4.1.9",
|
||||||
|
"@tarojs/plugin-platform-tt": "4.1.9",
|
||||||
|
"@tarojs/plugin-platform-weapp": "4.1.9",
|
||||||
|
"@tarojs/react": "4.1.9",
|
||||||
|
"@tarojs/runtime": "4.1.9",
|
||||||
|
"@tarojs/shared": "4.1.9",
|
||||||
|
"@tarojs/taro": "4.1.9",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"ajv-keywords": "^5.1.0",
|
||||||
|
"classnames": "^2.5.0",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.8.0",
|
||||||
|
"@babel/plugin-proposal-class-properties": "7.14.5",
|
||||||
|
"@babel/preset-react": "^7.24.1",
|
||||||
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
|
||||||
|
"@tarojs/cli": "4.1.9",
|
||||||
|
"@tarojs/taro-loader": "4.1.9",
|
||||||
|
"@tarojs/webpack5-runner": "4.1.9",
|
||||||
|
"@types/node": "^18.15.11",
|
||||||
|
"@types/react": "^18.0.0",
|
||||||
|
"@types/webpack-env": "^1.13.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||||
|
"@typescript-eslint/parser": "^6.2.0",
|
||||||
|
"babel-plugin-import": "^1.13.8",
|
||||||
|
"babel-preset-taro": "4.1.9",
|
||||||
|
"eslint": "^8.12.0",
|
||||||
|
"eslint-config-taro": "4.1.9",
|
||||||
|
"eslint-plugin-import": "^2.12.0",
|
||||||
|
"eslint-plugin-react": "^7.8.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
|
"minidev": "^2.2.5",
|
||||||
|
"miniprogram-ci": "^2.1.26",
|
||||||
|
"postcss": "^8.4.18",
|
||||||
|
"react-refresh": "^0.11.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"stylelint": "^14.4.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths-webpack-plugin": "^4.0.1",
|
||||||
|
"tt-ide-cli": "^0.1.31",
|
||||||
|
"typescript": "^5.1.0",
|
||||||
|
"webpack": "5.78.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
project.config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"miniprogramRoot": "dist/",
|
||||||
|
"projectname": "laop-mini",
|
||||||
|
"description": "翼云Hub小程序",
|
||||||
|
"appid": "wx2ad24c8742860e18",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false,
|
||||||
|
"es6": true,
|
||||||
|
"enhance": true,
|
||||||
|
"compileHotReLoad": true,
|
||||||
|
"postcss": true,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"minified": true,
|
||||||
|
"newFeature": true,
|
||||||
|
"autoAudits": false,
|
||||||
|
"coverView": true,
|
||||||
|
"showShadowRootInWxmlPanel": false,
|
||||||
|
"scopeDataCheck": false,
|
||||||
|
"useCompilerModule": true,
|
||||||
|
"uglifyFileName": true,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"bigPackageSizeSupport": true
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"simulatorType": "wechat",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"condition": {}
|
||||||
|
}
|
||||||
13
project.tt.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"miniprogramRoot": "./",
|
||||||
|
"projectname": "taro_template",
|
||||||
|
"description": "taro_template",
|
||||||
|
"appid": "touristappid",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": true,
|
||||||
|
"es6": false,
|
||||||
|
"postcss": false,
|
||||||
|
"minified": false
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram"
|
||||||
|
}
|
||||||
68
src/app.config.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
export default defineAppConfig({
|
||||||
|
navigationBarTitleText: '翼云Hub',
|
||||||
|
backgroundColor: '#F1EEFB',
|
||||||
|
// 微信代码质量扫描:开启组件按需注入(lazyCodeLoading),通过"启用组件按需注入"检查
|
||||||
|
lazyCodeLoading: 'requiredComponents',
|
||||||
|
// 微信小程序隐私合规:声明使用位置相关 API(task-publish 页调用 chooseLocation)
|
||||||
|
requiredPrivateInfos: ['chooseLocation', 'getLocation'],
|
||||||
|
permission: {
|
||||||
|
'scope.userLocation': {
|
||||||
|
desc: '用于选择任务地点与定位'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: [
|
||||||
|
'pages/home/index',
|
||||||
|
'pages/task-center/index',
|
||||||
|
'pages/course/index',
|
||||||
|
'pages/course-detail/index',
|
||||||
|
'pages/exam/index',
|
||||||
|
'pages/exam/practice',
|
||||||
|
'pages/announcement-detail/index',
|
||||||
|
'pages/announcement-list/index',
|
||||||
|
'pages/task-publish/index',
|
||||||
|
'pages/profile/index',
|
||||||
|
'pages/my-task/index',
|
||||||
|
'pages/task-applicants/index',
|
||||||
|
'pages/pilot-certify/index',
|
||||||
|
'pages/pilot-center/index',
|
||||||
|
'pages/merchant-certify/index',
|
||||||
|
'pages/merchant-center/index',
|
||||||
|
'pages/merchant-detail/index',
|
||||||
|
'pages/pilot-detail/index',
|
||||||
|
'pages/merchant-list/index',
|
||||||
|
'pages/pilot-list/index',
|
||||||
|
'pages/points-detail/index',
|
||||||
|
],
|
||||||
|
tabBar: {
|
||||||
|
color: '#9B9BAA',
|
||||||
|
selectedColor: '#5E46F6',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderStyle: 'white',
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/home/index',
|
||||||
|
text: '首页',
|
||||||
|
iconPath: 'assets/tabbar/home.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/home-selected.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/task-center/index',
|
||||||
|
text: '任务中心',
|
||||||
|
iconPath: 'assets/tabbar/task.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/task-selected.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/course/index',
|
||||||
|
text: '研学课程',
|
||||||
|
iconPath: 'assets/tabbar/course.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/course-selected.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/profile/index',
|
||||||
|
text: '我的',
|
||||||
|
iconPath: 'assets/tabbar/profile.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/profile-selected.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
18
src/app.scss
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
@use './styles/variables.scss' as *;
|
||||||
|
|
||||||
|
page {
|
||||||
|
background-color: $color-bg-page;
|
||||||
|
color: $color-text-primary;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
view, text, scroll-view {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
image {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
43
src/app.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Taro, { useLaunch } from '@tarojs/taro';
|
||||||
|
import { login } from '@/services/auth';
|
||||||
|
// 全局样式
|
||||||
|
import './app.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用入口组件。
|
||||||
|
*
|
||||||
|
* <p>核心职责:onLaunch 时触发微信登录,登录 Promise 存入 auth.ts 模块,
|
||||||
|
* 后续需要鉴权的页面通过 {@code ensureLogin()} 等待 token 就绪后再调接口,
|
||||||
|
* 彻底解决异步竞态问题。</p>
|
||||||
|
*
|
||||||
|
* <p><b>推广码进入:</b>好友扫描推广小程序码进入时,启动参数 query.scene 携带
|
||||||
|
* {@code i={推广人userId}};此处解析后写入 Storage,登录请求自动携带邀请人ID,
|
||||||
|
* 新用户注册成功后推广人获得积分奖励(后端登录链路完成)。</p>
|
||||||
|
*/
|
||||||
|
function App(props) {
|
||||||
|
|
||||||
|
useLaunch((options) => {
|
||||||
|
// 扫推广码进入:解析 scene 中的邀请人ID(须在 login() 之前写入,登录请求才会携带)
|
||||||
|
const scene = options?.query?.scene
|
||||||
|
if (scene) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(scene)
|
||||||
|
const match = decoded.match(/i=(\d+)/)
|
||||||
|
if (match) {
|
||||||
|
Taro.setStorageSync('inviteUserId', Number(match[1]))
|
||||||
|
console.info('[Promote] 检测到邀请人 inviteUserId=%s', match[1])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// scene 解析失败不影响正常登录
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即触发登录(auth.ts 内部幂等,多次调用只执行一次)
|
||||||
|
login();
|
||||||
|
});
|
||||||
|
|
||||||
|
return props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
src/assets/banner-rocket.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/assets/didian.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/dingwei.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/assets/feishourenzheng.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/feishouzhongxin.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/assets/fenxiang.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/gonggao.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/grid/baoming.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
src/assets/grid/biaoyan.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/assets/grid/didi.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src/assets/grid/feishou.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/grid/kaoshi.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/assets/grid/kecheng.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src/assets/grid/renwu.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/assets/grid/shangjia.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/assets/grid/tuiguang.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/jifen.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/assets/mini_user_banner.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/assets/mp-qrcode.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/shangjiarenzheng.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/assets/shangjiazhongxin.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src/assets/shijian.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src/assets/sousuo.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/tabbar/course-selected.png
Normal file
|
After Width: | Height: | Size: 716 B |
BIN
src/assets/tabbar/course.png
Normal file
|
After Width: | Height: | Size: 676 B |
BIN
src/assets/tabbar/home-selected.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
src/assets/tabbar/home.png
Normal file
|
After Width: | Height: | Size: 996 B |
BIN
src/assets/tabbar/profile-selected.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/tabbar/profile.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/tabbar/task-selected.png
Normal file
|
After Width: | Height: | Size: 820 B |
BIN
src/assets/tabbar/task.png
Normal file
|
After Width: | Height: | Size: 845 B |
147
src/components/AvatarCropper/index.module.scss
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
/* 全屏遮罩 */
|
||||||
|
.cropperMask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 裁剪容器 */
|
||||||
|
.cropperContainer {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题 */
|
||||||
|
.cropperHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropperTitle {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropperHint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 裁剪工作区:裁剪框固定 250×250 px */
|
||||||
|
.workArea {
|
||||||
|
position: relative;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* movable-area:裁剪框大小 */
|
||||||
|
.movableArea {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* movable-view:图片本体容器 */
|
||||||
|
.movableView {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片本体 */
|
||||||
|
.cropImage {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆形遮罩:覆盖在图片之上,中心圆孔透明 */
|
||||||
|
.cropOverlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropCircle {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部按钮 */
|
||||||
|
.cropperActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 40rpx;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnCancel {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnConfirm {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
background: linear-gradient(135deg, #7C6AF8, #5E46F6);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导出用 canvas,隐藏不展示 */
|
||||||
|
.exportCanvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
209
src/components/AvatarCropper/index.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, MovableArea, MovableView, Canvas } from '@tarojs/components'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像裁剪组件 props
|
||||||
|
*/
|
||||||
|
export interface AvatarCropperProps {
|
||||||
|
/** 待裁剪的原图本地路径(chooseImage 拿到的 tempFilePath) */
|
||||||
|
src: string
|
||||||
|
/** 确认裁剪回调,返回裁剪后的本地临时文件路径 */
|
||||||
|
onConfirm: (croppedFilePath: string) => void
|
||||||
|
/** 取消回调 */
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像裁剪组件:圆形遮罩 + 图片可拖拽定位 + 可缩放,确认时 canvas 导出 300×300 JPG。
|
||||||
|
*
|
||||||
|
* <p><b>小程序 Canvas 2D 实现要点:</b>
|
||||||
|
* <ul>
|
||||||
|
* <li>用 {@link Taro.createSelectorQuery} 查询 canvas 节点,拿到 node 后调用
|
||||||
|
* {@code node.getContext('2d')} 获取 2D 上下文</li>
|
||||||
|
* <li>图片加载用 {@code canvas.createImage()} 而非 {@code new Image()}</li>
|
||||||
|
* <li>导出用 {@link Taro.canvasToTempFilePath},传 canvas 节点</li>
|
||||||
|
* <li>Canvas 组件需设置 {@code type='2d'} 以启用新版 Canvas 2D API</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>典型用法:</b>
|
||||||
|
* <pre>{`
|
||||||
|
* {showCropper && (
|
||||||
|
* <AvatarCropper
|
||||||
|
* src={tempFilePath}
|
||||||
|
* onConfirm={(path) => { setAvatar(path); setShowCropper(false) }}
|
||||||
|
* onCancel={() => setShowCropper(false)}
|
||||||
|
* />
|
||||||
|
* )}
|
||||||
|
* `}</pre>
|
||||||
|
*
|
||||||
|
* @author haisen
|
||||||
|
*/
|
||||||
|
const AvatarCropper: React.FC<AvatarCropperProps> = ({ src, onConfirm, onCancel }) => {
|
||||||
|
// 裁剪框尺寸(屏幕逻辑像素,单位 px)
|
||||||
|
const CROP_SIZE = 250
|
||||||
|
// 图片显示尺寸(初始按比例铺满裁剪框)
|
||||||
|
const [imgSize, setImgSize] = useState({ width: CROP_SIZE, height: CROP_SIZE })
|
||||||
|
// 图片在 movable-area 中的位置(左上角偏移)
|
||||||
|
const [imgPos, setImgPos] = useState({ x: 0, y: 0 })
|
||||||
|
// 双指缩放比例
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
|
// 导出中
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
// 初始化:读取图片真实尺寸,计算初始显示尺寸
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) return
|
||||||
|
Taro.getImageInfo({ src }).then((info) => {
|
||||||
|
const { width: iw, height: ih } = info
|
||||||
|
// 按比例缩放:让图片最短边等于裁剪框尺寸,保证铺满
|
||||||
|
const ratio = Math.max(CROP_SIZE / iw, CROP_SIZE / ih)
|
||||||
|
const displayW = iw * ratio
|
||||||
|
const displayH = ih * ratio
|
||||||
|
setImgSize({ width: displayW, height: displayH })
|
||||||
|
// 初始居中:图片中心对齐裁剪框中心
|
||||||
|
setImgPos({
|
||||||
|
x: (CROP_SIZE - displayW) / 2,
|
||||||
|
y: (CROP_SIZE - displayH) / 2,
|
||||||
|
})
|
||||||
|
setScale(1)
|
||||||
|
}).catch(() => {
|
||||||
|
Taro.showToast({ title: '图片加载失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认裁剪:用旧版 canvas(canvas-id)API 绘制裁剪结果并导出 JPG。
|
||||||
|
*
|
||||||
|
* <p>不使用 Canvas 2D(type='2d')的原因:node 查询在 Taro 自定义组件作用域下
|
||||||
|
* 拿不到真实 canvas 节点(getContext is not a function),旧版 canvas-id API
|
||||||
|
* 直接通过 id 定位画布,无作用域问题,且 drawImage 支持 9 参数源矩形裁剪。</p>
|
||||||
|
*/
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (exporting) return
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
// 1. 取得原图真实尺寸
|
||||||
|
const imgInfo = await Taro.getImageInfo({ src })
|
||||||
|
const realW = imgInfo.width
|
||||||
|
const realH = imgInfo.height
|
||||||
|
|
||||||
|
// 2. 计算裁剪框在原图中的对应区域
|
||||||
|
// 裁剪框在 movable-view 坐标系中的起点 = -imgPos
|
||||||
|
// 显示坐标 → 原图坐标 = 原图宽 / (显示宽 × scale)
|
||||||
|
const displayToReal = realW / (imgSize.width * scale)
|
||||||
|
const cropX = -imgPos.x * displayToReal
|
||||||
|
const cropY = -imgPos.y * displayToReal
|
||||||
|
const cropW = CROP_SIZE * displayToReal
|
||||||
|
const cropH = CROP_SIZE * displayToReal
|
||||||
|
|
||||||
|
// 3. 旧版 canvas 上下文绘制(canvas-id 定位,无组件作用域问题)
|
||||||
|
const ctx = Taro.createCanvasContext('avatarCropperCanvas')
|
||||||
|
// 9 参数 drawImage:从原图 (cropX,cropY,cropW,cropH) 区域绘制到画布 (0,0,300,300)
|
||||||
|
ctx.drawImage(imgInfo.path, cropX, cropY, cropW, cropH, 0, 0, 300, 300)
|
||||||
|
ctx.draw(false, () => {
|
||||||
|
// 4. 导出 JPG 临时文件(draw 回调触发后再导出,确保绘制完成)
|
||||||
|
Taro.canvasToTempFilePath({
|
||||||
|
canvasId: 'avatarCropperCanvas',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
destWidth: 300,
|
||||||
|
destHeight: 300,
|
||||||
|
fileType: 'jpg',
|
||||||
|
quality: 0.9,
|
||||||
|
success: (res) => {
|
||||||
|
setExporting(false)
|
||||||
|
onConfirm(res.tempFilePath)
|
||||||
|
},
|
||||||
|
fail: (err: any) => {
|
||||||
|
console.error('[AvatarCropper] 导出失败:', err)
|
||||||
|
setExporting(false)
|
||||||
|
Taro.showToast({ title: '导出失败,请重试', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[AvatarCropper] 裁剪失败:', err)
|
||||||
|
setExporting(false)
|
||||||
|
Taro.showToast({ title: err?.message || '裁剪失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.cropperMask}>
|
||||||
|
<View className={styles.cropperContainer}>
|
||||||
|
{/* 顶部标题 */}
|
||||||
|
<View className={styles.cropperHeader}>
|
||||||
|
<Text className={styles.cropperTitle}>拖动调整头像位置</Text>
|
||||||
|
<Text className={styles.cropperHint}>双指可缩放</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 裁剪工作区 */}
|
||||||
|
<View className={styles.workArea}>
|
||||||
|
<MovableArea
|
||||||
|
className={styles.movableArea}
|
||||||
|
style={{ width: `${CROP_SIZE}px`, height: `${CROP_SIZE}px` }}
|
||||||
|
>
|
||||||
|
<MovableView
|
||||||
|
className={styles.movableView}
|
||||||
|
direction='all'
|
||||||
|
x={imgPos.x}
|
||||||
|
y={imgPos.y}
|
||||||
|
scale
|
||||||
|
scaleMin={0.5}
|
||||||
|
scaleMax={3}
|
||||||
|
onScale={(e: any) => {
|
||||||
|
if (e.detail?.scale) setScale(e.detail.scale)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Taro Image 组件,小程序端兼容 */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
className={styles.cropImage}
|
||||||
|
style={{
|
||||||
|
width: `${imgSize.width}px`,
|
||||||
|
height: `${imgSize.height}px`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
mode='aspectFit'
|
||||||
|
/>
|
||||||
|
</MovableView>
|
||||||
|
</MovableArea>
|
||||||
|
{/* 圆形遮罩:覆盖在 movable-area 之上,中心圆孔透明 */}
|
||||||
|
<View className={styles.cropOverlay}>
|
||||||
|
<View className={styles.cropCircle} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View className={styles.cropperActions}>
|
||||||
|
<View
|
||||||
|
className={styles.btnCancel}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<Text>取消</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.btnConfirm} ${exporting ? styles.btnDisabled : ''}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
<Text>{exporting ? '处理中...' : '确认'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 旧版 canvas:canvas-id 定位,隐藏不展示(旧版 API 不需要 type='2d') */}
|
||||||
|
<Canvas
|
||||||
|
canvasId='avatarCropperCanvas'
|
||||||
|
className={styles.exportCanvas}
|
||||||
|
style={{ width: '300px', height: '300px' }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvatarCropper
|
||||||
26
src/components/Banner/index.module.scss
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.bannerContainer {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerSwiper {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerItem {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
}
|
||||||
105
src/components/Banner/index.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Swiper, SwiperItem, Image } from '@tarojs/components'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import { resolveFileUrl } from '@/config/env'
|
||||||
|
import bannerRocketImg from '@/assets/banner-rocket.png'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 轮播图接口,与后端 AppBannerVO 对齐 */
|
||||||
|
export interface BannerItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkType?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BannerProps {
|
||||||
|
/** 外部传入的轮播图数据,不传则自动从后端拉取 */
|
||||||
|
banners?: BannerItem[];
|
||||||
|
/** 轮播图点击回调 */
|
||||||
|
onBannerClick?: (item: BannerItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认占位图(API 请求失败或无数据时使用,改成本地静态资源避免真机域名白名单问题) */
|
||||||
|
const FALLBACK_BANNERS: BannerItem[] = [
|
||||||
|
{
|
||||||
|
id: -1,
|
||||||
|
title: '翼云Hub',
|
||||||
|
subtitle: '无人机信息开放平台',
|
||||||
|
imageUrl: bannerRocketImg,
|
||||||
|
linkType: 'course'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -2,
|
||||||
|
title: '商业表演',
|
||||||
|
subtitle: '专业飞手 高客单',
|
||||||
|
imageUrl: bannerRocketImg,
|
||||||
|
linkType: 'performance'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -3,
|
||||||
|
title: '实力商家',
|
||||||
|
subtitle: '开放生态 精准流量',
|
||||||
|
imageUrl: bannerRocketImg,
|
||||||
|
linkType: 'merchant'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页轮播 Banner 组件。
|
||||||
|
*
|
||||||
|
* <p><b>数据来源:</b>优先使用 props.banners(页面级缓存),未传入时自动调用
|
||||||
|
* 小程序端公开接口 {@code /api/mini/home/banner/list} 拉取后端配置的轮播图。
|
||||||
|
* 接口失败或返回空时,降级使用内置的占位 Banner,保证首页体验不中断。</p>
|
||||||
|
*/
|
||||||
|
const Banner: React.FC<BannerProps> = ({ banners: externalBanners, onBannerClick }) => {
|
||||||
|
const [banners, setBanners] = useState<BannerItem[]>(FALLBACK_BANNERS)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 外部传入了 bannerList 则优先使用
|
||||||
|
if (externalBanners && externalBanners.length > 0) {
|
||||||
|
setBanners(externalBanners)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 自动从后端拉取
|
||||||
|
fetchBanners()
|
||||||
|
}, [externalBanners])
|
||||||
|
|
||||||
|
/** 从后端 /api/mini/home/banner/list 拉取轮播图 */
|
||||||
|
async function fetchBanners() {
|
||||||
|
try {
|
||||||
|
const res = await get<BannerItem[]>('/api/mini/home/banner/list')
|
||||||
|
if (res.data && res.data.length > 0) {
|
||||||
|
setBanners(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 请求失败不抛错,保留默认占位图
|
||||||
|
console.warn('[Banner] 从后端拉取轮播图失败,使用默认占位图:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.bannerContainer}>
|
||||||
|
<Swiper
|
||||||
|
className={styles.bannerSwiper}
|
||||||
|
indicatorColor='rgba(255,255,255,0.5)'
|
||||||
|
indicatorActiveColor='#FFFFFF'
|
||||||
|
circular
|
||||||
|
autoplay
|
||||||
|
interval={4000}
|
||||||
|
>
|
||||||
|
{banners.map((item: BannerItem) => (
|
||||||
|
<SwiperItem key={item.id}>
|
||||||
|
<View className={styles.bannerItem} onClick={() => onBannerClick?.(item)}>
|
||||||
|
<Image className={styles.bannerImage} src={resolveFileUrl(item.imageUrl)} mode='aspectFill' />
|
||||||
|
</View>
|
||||||
|
</SwiperItem>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Banner
|
||||||
112
src/components/CertifyGuideModal/index.module.scss
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// 完成飞手认证引导弹窗(按设计稿复刻:蓝色引导系,与业务紫色主色区分)
|
||||||
|
$guide-blue-light: #4f9bff;
|
||||||
|
$guide-blue-deep: #2563eb;
|
||||||
|
$icon-bg: #d9eaff;
|
||||||
|
$icon-orange-top: #ffa53d;
|
||||||
|
$icon-orange-bottom: #ff7a2e;
|
||||||
|
|
||||||
|
// 全屏遮罩
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 居中卡片
|
||||||
|
.card {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 590rpx;
|
||||||
|
padding: 60rpx 48rpx 40rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浅蓝圆角方块图标底座
|
||||||
|
.iconWrap {
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
background: $icon-bg;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 橙色渐变感叹圆
|
||||||
|
.iconDot {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, $icon-orange-top 0%, $icon-orange-bottom 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBang {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2430;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8a8f99;
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去认证:蓝色渐变大按钮
|
||||||
|
.confirmBtn {
|
||||||
|
margin-top: 44rpx;
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: linear-gradient(135deg, $guide-blue-light 0%, $guide-blue-deep 100%);
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 20rpx rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtnText {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稍后再说:弱化文字按钮
|
||||||
|
.laterBtn {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 12rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laterBtnText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
54
src/components/CertifyGuideModal/index.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
|
interface CertifyGuideModalProps {
|
||||||
|
/** 是否显示弹窗 */
|
||||||
|
visible: boolean;
|
||||||
|
/** 去认证:跳转飞手认证页 */
|
||||||
|
onConfirm: () => void;
|
||||||
|
/** 关闭弹窗(稍后再说 / 点击遮罩) */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成飞手认证引导弹窗(按 UI 设计稿 1:1 复刻)。
|
||||||
|
*
|
||||||
|
* <p><b>产品逻辑:</b>未认证用户点击"抢单"时,认证预检(或后端 40301 业务码)触发本弹窗,
|
||||||
|
* 引导先完成飞手认证再接单;"去认证"跳认证页,"稍后再说"关闭弹窗留在列表页。</p>
|
||||||
|
*/
|
||||||
|
const CertifyGuideModal: React.FC<CertifyGuideModalProps> = ({ visible, onConfirm, onClose }) => {
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className={styles.mask} onClick={onClose} />
|
||||||
|
<View className={styles.card}>
|
||||||
|
{/* 顶部警示图标:浅蓝圆角方块 + 橙色感叹圆(设计稿元素) */}
|
||||||
|
<View className={styles.iconWrap}>
|
||||||
|
<View className={styles.iconDot}>
|
||||||
|
<Text className={styles.iconBang}>!</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className={styles.title}>完成飞手认证</Text>
|
||||||
|
|
||||||
|
<Text className={styles.content}>
|
||||||
|
您尚未完成飞手认证,不可抢单。请先完成飞手认证后再来抢单。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className={styles.confirmBtn} onClick={onConfirm}>
|
||||||
|
<Text className={styles.confirmBtnText}>去认证</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.laterBtn} onClick={onClose}>
|
||||||
|
<Text className={styles.laterBtnText}>稍后再说</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertifyGuideModal;
|
||||||
55
src/components/CustomNavBar/index.module.scss
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
// 固定悬浮于页面顶部;默认透明(沉浸式,透出页面渐变背景),滚动后切换为主题色底
|
||||||
|
.navBar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: transparent;
|
||||||
|
transition: background $transition-base, box-shadow $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动态:主题色底 + 浮层阴影,保证白字标题在内容之上仍可读
|
||||||
|
.navBarSolid {
|
||||||
|
background: $color-primary;
|
||||||
|
box-shadow: $shadow-float;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
// 右侧留出胶囊按钮区域(胶囊约 87px 宽 + 边距);返回按钮与胶囊对称占位,标题仍居中
|
||||||
|
padding: 0 200rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTitle {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-white;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回按钮:左箭头圆底,与右侧胶囊按钮视觉对称
|
||||||
|
.backBtn {
|
||||||
|
position: absolute;
|
||||||
|
left: 32rpx;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-white;
|
||||||
|
line-height: 1;
|
||||||
|
// 微调视觉居中(‹ 字符偏上)
|
||||||
|
margin-top: -4rpx;
|
||||||
|
}
|
||||||
97
src/components/CustomNavBar/index.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时导航栏尺寸(px,需内联样式动态应用)。
|
||||||
|
*/
|
||||||
|
export interface NavBarMetrics {
|
||||||
|
/** 状态栏高度(px) */
|
||||||
|
statusBarHeight: number
|
||||||
|
/** 导航内容区高度(px),即胶囊按钮所在行的高度 */
|
||||||
|
navContentHeight: number
|
||||||
|
/** 状态栏 + 内容区总高度(px),页面顶部留白直接使用该值 */
|
||||||
|
totalHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自定义导航栏运行时尺寸。
|
||||||
|
*
|
||||||
|
* <p>计算规则:内容区高度 = (胶囊顶部 - 状态栏高度) × 2 + 胶囊高度,
|
||||||
|
* 保证标题与右上角胶囊按钮垂直居中对齐;非微信端(H5 预览等)
|
||||||
|
* 胶囊 API 不可用时降级为 statusBar 20px + content 44px 的通用值。</p>
|
||||||
|
*/
|
||||||
|
export function useNavBarMetrics(): NavBarMetrics {
|
||||||
|
return useMemo(() => {
|
||||||
|
let statusBarHeight = 20
|
||||||
|
let navContentHeight = 44
|
||||||
|
try {
|
||||||
|
const sysInfo = Taro.getSystemInfoSync()
|
||||||
|
statusBarHeight = sysInfo.statusBarHeight || 20
|
||||||
|
if (typeof Taro.getMenuButtonBoundingClientRect === 'function') {
|
||||||
|
const rect = Taro.getMenuButtonBoundingClientRect()
|
||||||
|
if (rect && rect.top > 0 && rect.height > 0) {
|
||||||
|
navContentHeight = (rect.top - statusBarHeight) * 2 + rect.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[CustomNavBar] 获取系统信息失败,使用默认导航高度:', err)
|
||||||
|
}
|
||||||
|
return { statusBarHeight, navContentHeight, totalHeight: statusBarHeight + navContentHeight }
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomNavBarProps {
|
||||||
|
/** 导航栏标题 */
|
||||||
|
title: string
|
||||||
|
/** 页面滚动后显示底色与阴影(透明 → 主题色),由页面根据滚动位置传入 */
|
||||||
|
solid?: boolean
|
||||||
|
/** 是否显示返回按钮(非 tabBar 子页传入 true);默认 false */
|
||||||
|
showBack?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义导航栏(沉浸式)。
|
||||||
|
*
|
||||||
|
* <p><b>为什么需要自定义:</b>原生导航栏与页面渐变背景之间存在硬分界线,
|
||||||
|
* 无法实现"渐变从状态栏一气呵成"的沉浸式视觉效果;自定义导航栏透明悬浮于
|
||||||
|
* 页面渐变之上,滚动后切换为主题色底 + 阴影,保证标题可读性。</p>
|
||||||
|
*
|
||||||
|
* <p><b>使用约定:</b>页面需设置 {@code navigationStyle: 'custom'},
|
||||||
|
* 并通过 {@link useNavBarMetrics} 将页面顶部内容让出导航高度,
|
||||||
|
* 防止标题遮挡内容。子页面(非 tabBar 页)可开启 {@code showBack} 显示返回按钮。</p>
|
||||||
|
*
|
||||||
|
* @author haisen
|
||||||
|
*/
|
||||||
|
const CustomNavBar: React.FC<CustomNavBarProps> = ({ title, solid = false, showBack = false }) => {
|
||||||
|
const { statusBarHeight, navContentHeight } = useNavBarMetrics()
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
// 优先返回上一页,无历史时兜底跳首页
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
if (pages.length > 1) {
|
||||||
|
Taro.navigateBack()
|
||||||
|
} else {
|
||||||
|
Taro.switchTab({ url: '/pages/home/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={`${styles.navBar} ${solid ? styles.navBarSolid : ''}`}>
|
||||||
|
{/* 状态栏占位:高度因机型而异,运行时动态获取 */}
|
||||||
|
<View style={{ height: `${statusBarHeight}px` }} />
|
||||||
|
{/* 导航内容区:标题与胶囊按钮垂直居中对齐 */}
|
||||||
|
<View className={styles.navContent} style={{ height: `${navContentHeight}px` }}>
|
||||||
|
{showBack && (
|
||||||
|
<View className={styles.backBtn} onClick={handleBack}>
|
||||||
|
<Text className={styles.backArrow}>‹</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text className={styles.navTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomNavBar
|
||||||
239
src/components/FeaturedCourses/index.module.scss
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.sectionContainer {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionBar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
background: $color-primary;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionMore {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片列表容器 —— 纵向堆叠,与研学课程列表同款 */
|
||||||
|
.cardList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 课程卡片(与研学课程列表页同款杂志风全图大卡) ====================
|
||||||
|
|
||||||
|
.courseCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
border: 1rpx solid rgba(94, 70, 246, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 媒体区:300rpx 大图封面 ----------
|
||||||
|
|
||||||
|
.cardMedia {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 300rpx;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
border-bottom: 1rpx solid rgba(94, 70, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPlaceholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPlaceholderText {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: $color-text-white;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左上角:分类徽章(白色玻璃 chip)
|
||||||
|
.badgeCategory {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
left: 20rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上角:适龄徽章(深色半透明 chip)
|
||||||
|
.badgeAge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
right: 20rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: rgba(26, 23, 48, 0.55);
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-white;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 价格丝带标签:钉在封面右下角 ----------
|
||||||
|
|
||||||
|
.priceTag {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
border-radius: $radius-xl 0 0 0;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(94, 70, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceTagFree {
|
||||||
|
background: $color-success;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSymbol {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-right: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-white;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceFree {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-white;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 信息区 ----------
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
position: relative;
|
||||||
|
padding: 16rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameRow {
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.courseName {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-weight: $font-weight-normal;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
border-top: 1rpx solid rgba(94, 70, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 56rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
174
src/components/FeaturedCourses/index.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 精选课程项,与后端 MiniCourseVO 对齐 */
|
||||||
|
interface FeaturedCourseItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
coverUrl: string
|
||||||
|
price: number
|
||||||
|
categoryName: string
|
||||||
|
targetAge: string
|
||||||
|
location: string
|
||||||
|
scheduleType: number
|
||||||
|
scheduleWeekDays: string
|
||||||
|
scheduleDates: string
|
||||||
|
merchantName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页精选研学课程区块:全宽大卡片纵向堆叠,与研学课程列表同款 */
|
||||||
|
const FeaturedCourses: React.FC = () => {
|
||||||
|
const [courses, setCourses] = useState<FeaturedCourseItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeatured()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadFeatured = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await get<FeaturedCourseItem[]>('/api/mini/course/featured', { limit: 6 })
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
setCourses(resp.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[精选课程] 加载失败:', err)
|
||||||
|
// 精选区块加载失败不阻断首页展示,静默兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击卡片跳转课程详情 */
|
||||||
|
const handleCardClick = (id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/course-detail/index?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化排课文本,空值返回空串 */
|
||||||
|
const formatSchedule = (item: FeaturedCourseItem): string => {
|
||||||
|
if (item.scheduleType === 1 && item.scheduleWeekDays) {
|
||||||
|
const dayMap: Record<string, string> = {
|
||||||
|
'1': '周一', '2': '周二', '3': '周三', '4': '周四',
|
||||||
|
'5': '周五', '6': '周六', '7': '周日',
|
||||||
|
}
|
||||||
|
const days = item.scheduleWeekDays.split(';').map(d => dayMap[d] || d)
|
||||||
|
return `每${days.join('、')}`
|
||||||
|
}
|
||||||
|
if (item.scheduleType === 2 && item.scheduleDates) {
|
||||||
|
const dates = item.scheduleDates.split(';')
|
||||||
|
if (dates.length === 1) return dates[0]
|
||||||
|
return `${dates[0]} 等${dates.length}天`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 价格展示:0 元显示"免费"绿色标签,其余紫色渐变 ¥price */
|
||||||
|
const renderPriceTag = (price: number) => {
|
||||||
|
const isFree = !price || price <= 0
|
||||||
|
const priceTagClass = isFree
|
||||||
|
? `${styles.priceTag} ${styles.priceTagFree}`
|
||||||
|
: styles.priceTag
|
||||||
|
return (
|
||||||
|
<View className={priceTagClass}>
|
||||||
|
{isFree ? (
|
||||||
|
<Text className={styles.priceFree}>免费</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className={styles.priceSymbol}>¥</Text>
|
||||||
|
<Text className={styles.priceValue}>{price}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courses.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.sectionContainer}>
|
||||||
|
<View className={styles.sectionTitle}>
|
||||||
|
<View className={styles.sectionTitleLeft}>
|
||||||
|
<View className={styles.sectionBar} />
|
||||||
|
<Text>精选课程</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className={styles.sectionMore}
|
||||||
|
onClick={() => Taro.switchTab({ url: '/pages/course/index' })}
|
||||||
|
>
|
||||||
|
更多 →
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.cardList}>
|
||||||
|
{courses.map(item => {
|
||||||
|
const scheduleText = formatSchedule(item)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={styles.courseCard}
|
||||||
|
onClick={() => handleCardClick(item.id)}
|
||||||
|
>
|
||||||
|
{/* 大图媒体区:封面 + 左上分类徽章 + 右上适龄徽章 + 右下价格丝带 */}
|
||||||
|
<View className={styles.cardMedia}>
|
||||||
|
{item.coverUrl ? (
|
||||||
|
<Image
|
||||||
|
className={styles.mediaImage}
|
||||||
|
src={item.coverUrl}
|
||||||
|
mode='aspectFill'
|
||||||
|
lazyLoad
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className={styles.mediaPlaceholder}>
|
||||||
|
<Text className={styles.mediaPlaceholderText}>研学课程</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.categoryName && (
|
||||||
|
<View className={styles.badgeCategory}>
|
||||||
|
<Text>{item.categoryName}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.targetAge && (
|
||||||
|
<View className={styles.badgeAge}>
|
||||||
|
<Text>{item.targetAge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{renderPriceTag(item.price)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 信息区:标题 + 元信息行 + 底部操作行 */}
|
||||||
|
<View className={styles.cardBody}>
|
||||||
|
<View className={styles.nameRow}>
|
||||||
|
<Text className={styles.courseName}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
{scheduleText && (
|
||||||
|
<View className={styles.metaRow}>
|
||||||
|
<Text className={styles.metaLabel}>课程安排</Text>
|
||||||
|
<Text className={styles.metaValue}>{scheduleText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.location && (
|
||||||
|
<View className={styles.metaRow}>
|
||||||
|
<Text className={styles.metaLabel}>上课地点</Text>
|
||||||
|
<Text className={styles.metaValue}>{item.location}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className={styles.cardFooter}>
|
||||||
|
{item.merchantName && (
|
||||||
|
<Text className={styles.merchantName}>{item.merchantName}</Text>
|
||||||
|
)}
|
||||||
|
<View className={styles.detailBtn}>
|
||||||
|
<Text>查看详情</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeaturedCourses
|
||||||
91
src/components/FollowGuideModal/index.module.scss
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
// 全屏遮罩:压暗背景,与卡片形成明确层级
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 居中卡片:白色圆角 + 投影,与遮罩强区分
|
||||||
|
.card {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 590rpx;
|
||||||
|
padding: $spacing-xl $spacing-lg $spacing-lg;
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: $shadow-popup;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上角关闭按钮
|
||||||
|
.closeBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: $spacing-md;
|
||||||
|
right: $spacing-md;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeIcon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公众号二维码:适中大小(320rpx),浅色描边与白底卡片区分
|
||||||
|
.qrImage {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 320rpx;
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
border: 2rpx solid $color-border;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引导文案(产品约定文案,两行内展示)
|
||||||
|
.prompt {
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
padding: 0 $spacing-xs;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: $line-height-loose;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存按钮:品牌主色渐变,全圆角大按钮
|
||||||
|
.saveBtn {
|
||||||
|
margin-top: $spacing-xl;
|
||||||
|
width: 100%;
|
||||||
|
height: $button-height-md;
|
||||||
|
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
|
||||||
|
border-radius: $radius-button;
|
||||||
|
@include flex-center;
|
||||||
|
box-shadow: 0 8rpx 20rpx rgba($color-primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtnText {
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-white;
|
||||||
|
}
|
||||||
102
src/components/FollowGuideModal/index.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import mpQrcodeImg from '@/assets/mp-qrcode.png';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗使用场景,决定引导文案(产品约定文案,勿改):
|
||||||
|
* - merchant 商家:发布任务后未关注 → "飞手接单即刻获得通知"
|
||||||
|
* - pilot 飞手:申请接单后未关注 → "商家审核后即刻获得通知"
|
||||||
|
*/
|
||||||
|
export type FollowGuideScene = 'merchant' | 'pilot';
|
||||||
|
|
||||||
|
/** 场景 → 引导文案映射 */
|
||||||
|
const SCENE_PROMPTS: Record<FollowGuideScene, string> = {
|
||||||
|
merchant: '长按图片保存,关注公众号,飞手接单即刻获得通知',
|
||||||
|
pilot: '长按图片保存,关注公众号,商家审核后即刻获得通知',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FollowGuideModalProps {
|
||||||
|
/** 是否显示弹窗 */
|
||||||
|
visible: boolean;
|
||||||
|
/** 角色场景(商家/飞手),决定提示文案 */
|
||||||
|
scene: FollowGuideScene;
|
||||||
|
/** 关闭弹窗(保存成功、点击遮罩或关闭按钮时触发) */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关注公众号引导弹窗。
|
||||||
|
*
|
||||||
|
* <p><b>产品逻辑:</b>商家发布任务成功后 / 飞手申请接单成功后,前端先调
|
||||||
|
* subscribe-status 校验关注状态,未关注时弹本弹窗(弹窗不阻断已成功的主流程);
|
||||||
|
* 用户长按二维码或点"保存图片"将公众号二维码存入手机相册,去微信内扫码识别关注,
|
||||||
|
* 关注后平台即可通过服务号模板消息推送接单/审核通知。</p>
|
||||||
|
*
|
||||||
|
* <p><b>保存说明:</b>小程序内长按无法识别二维码,故"长按图片保存"由
|
||||||
|
* 图片长按手势触发保存到相册实现,与文案语义一致。</p>
|
||||||
|
*/
|
||||||
|
const FollowGuideModal: React.FC<FollowGuideModalProps> = ({ visible, scene, onClose }) => {
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存公众号二维码到手机相册(权限被拒时引导去设置页开启) */
|
||||||
|
const handleSaveQrCode = () => {
|
||||||
|
Taro.saveImageToPhotosAlbum({
|
||||||
|
filePath: mpQrcodeImg,
|
||||||
|
success: () => {
|
||||||
|
Taro.showToast({ title: '已保存到相册,去微信扫码关注', icon: 'none', duration: 2500 });
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
const msg = err?.errMsg || '';
|
||||||
|
if (msg.includes('auth deny') || msg.includes('authorize')) {
|
||||||
|
// 相册权限被拒:引导用户去设置页开启后重试
|
||||||
|
Taro.showModal({
|
||||||
|
title: '需要相册权限',
|
||||||
|
content: '请在设置中开启相册保存权限后重试',
|
||||||
|
confirmText: '去设置',
|
||||||
|
success: (r) => {
|
||||||
|
if (r.confirm) {
|
||||||
|
Taro.openSetting();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '保存失败,请长按图片重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className={styles.mask} onClick={onClose} />
|
||||||
|
<View className={styles.card}>
|
||||||
|
{/* 右上角关闭 */}
|
||||||
|
<View className={styles.closeBtn} onClick={onClose}>
|
||||||
|
<Text className={styles.closeIcon}>×</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className={styles.title}>关注公众号</Text>
|
||||||
|
|
||||||
|
{/* 公众号二维码(长按 = 保存,与文案"长按图片保存"语义一致) */}
|
||||||
|
<Image
|
||||||
|
className={styles.qrImage}
|
||||||
|
src={mpQrcodeImg}
|
||||||
|
mode='aspectFit'
|
||||||
|
onLongPress={handleSaveQrCode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className={styles.prompt}>{SCENE_PROMPTS[scene]}</Text>
|
||||||
|
|
||||||
|
<View className={styles.saveBtn} onClick={handleSaveQrCode}>
|
||||||
|
<Text className={styles.saveBtnText}>保存图片</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FollowGuideModal;
|
||||||
60
src/components/GridNav/index.module.scss
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.gridContainer {
|
||||||
|
padding: 16rpx 0 24rpx;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 九宫格金刚区容器 —— 用负 margin 抵消子项 margin,替代 gap 属性
|
||||||
|
* 微信小程序对 CSS Grid 的 gap 支持不稳定,改用 margin 方案兼容性最好
|
||||||
|
* 水平间距 24rpx(每边 12rpx),垂直间距 32rpx(底部 margin)
|
||||||
|
*/
|
||||||
|
.gridItems {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
margin: 0 -12rpx -32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 12rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridIconImg {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridLabel {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6rpx;
|
||||||
|
right: -8rpx;
|
||||||
|
background: $color-error;
|
||||||
|
color: $color-text-white;
|
||||||
|
font-size: 18rpx;
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
28
src/components/GridNav/index.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import { gridNavList } from '@/data/home'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface GridNavProps {
|
||||||
|
onItemClick?: (linkType: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridNav: React.FC<GridNavProps> = ({ onItemClick }) => {
|
||||||
|
return (
|
||||||
|
<View className={styles.gridContainer}>
|
||||||
|
<View className={styles.gridItems}>
|
||||||
|
{gridNavList.map(item => (
|
||||||
|
<View key={item.id} className={styles.gridItem} onClick={() => onItemClick?.(item.linkType)}>
|
||||||
|
<View className={styles.gridIcon}>
|
||||||
|
<Image src={item.icon} className={styles.gridIconImg} mode='aspectFit' />
|
||||||
|
</View>
|
||||||
|
<Text className={styles.gridLabel} style={{ fontWeight: 'bold' }}>{item.label}</Text>
|
||||||
|
{item.badge && <View className={styles.gridBadge}>{item.badge}</View>}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GridNav
|
||||||
71
src/components/Notice/index.module.scss
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.noticeContainer {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 18rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeIcon {
|
||||||
|
position: relative;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeIconRipple {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #6827f7;
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: ripple 1.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.7);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeIconImg {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeLabel {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeContent {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
overflow: hidden;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeTime {
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
82
src/components/Notice/index.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import gonggaoIcon from '@/assets/gonggao.png'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 公告 VO,与后端 AnnouncementVO 对齐(后端 createdAt 即为发布时间) */
|
||||||
|
interface AnnouncementVO {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认占位公告(API 请求失败或无数据时使用) */
|
||||||
|
const FALLBACK_NOTICE: AnnouncementVO = {
|
||||||
|
id: 0,
|
||||||
|
title: '平台公告',
|
||||||
|
content: '欢迎使用翼云Hub无人机信息开放平台',
|
||||||
|
createdAt: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页公告条组件。
|
||||||
|
*
|
||||||
|
* <p><b>数据来源:</b>自动调用小程序端公开接口
|
||||||
|
* {@code /api/mini/announcement/list?limit=5} 拉取最新可见公告列表,
|
||||||
|
* 取第一条展示为公告条内容;接口失败时降级使用内置占位文案。</p>
|
||||||
|
*
|
||||||
|
* <p><b>交互:</b>点击公告条跳转至公告列表页,展示全部公告(近→远排序)。</p>
|
||||||
|
*/
|
||||||
|
const Notice: React.FC = () => {
|
||||||
|
const [notice, setNotice] = useState<AnnouncementVO>(FALLBACK_NOTICE)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotices()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 从后端拉取最新公告列表,取第一条展示 */
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
const res = await get<AnnouncementVO[]>('/api/mini/announcement/list', { limit: 5 })
|
||||||
|
if (res.data && res.data.length > 0) {
|
||||||
|
setNotice(res.data[0])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 请求失败不抛错,保留默认占位文案
|
||||||
|
console.warn('[Notice] 从后端拉取公告失败,使用默认占位:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击公告条,跳转至公告列表页(展示全部公告,近→远排序) */
|
||||||
|
const handleClick = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/announcement-list/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 ISO 日期格式化为「月/日」(如 09/08),无日期时返回空串 */
|
||||||
|
const formatShortDate = (iso: string): string => {
|
||||||
|
if (!iso) return ''
|
||||||
|
// ISO 格式 2026-09-08T10:30:00 → 取月日部分
|
||||||
|
const parts = iso.slice(0, 10).split('-')
|
||||||
|
if (parts.length < 3) return ''
|
||||||
|
return `${parts[1]}/${parts[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.noticeContainer} onClick={handleClick}>
|
||||||
|
<View className={styles.noticeIcon}>
|
||||||
|
<View className={styles.noticeIconRipple} />
|
||||||
|
<Image src={gonggaoIcon} className={styles.noticeIconImg} mode='aspectFit' />
|
||||||
|
</View>
|
||||||
|
<Text className={styles.noticeLabel}>通知公告</Text>
|
||||||
|
<Text className={styles.noticeContent}>{notice.title}</Text>
|
||||||
|
{formatShortDate(notice.createdAt) && (
|
||||||
|
<Text className={styles.noticeTime}>{formatShortDate(notice.createdAt)}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Notice
|
||||||
82
src/components/PromoteModal/index.module.scss
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/* —— 推广二维码弹窗(全屏遮罩 + 居中卡片) —— */
|
||||||
|
.modalMask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalCard {
|
||||||
|
width: 560rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 48rpx 40rpx 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1F1F33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDesc {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8A8AA0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrWrap {
|
||||||
|
width: 360rpx;
|
||||||
|
height: 360rpx;
|
||||||
|
margin-top: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrImage {
|
||||||
|
width: 360rpx;
|
||||||
|
height: 360rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrLoading {
|
||||||
|
width: 360rpx;
|
||||||
|
height: 360rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #F1EEFB;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #A0A0B2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrHint {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #B8B8C8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalCloseBtn {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
background: linear-gradient(135deg, #7C6AF8, #5E46F6);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
114
src/components/PromoteModal/index.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推广二维码弹窗 props
|
||||||
|
*/
|
||||||
|
export interface PromoteModalProps {
|
||||||
|
/** 是否展示弹窗(由父组件控制开关) */
|
||||||
|
open: boolean
|
||||||
|
/** 关闭回调(点击遮罩 / 关闭按钮时触发) */
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞手推广二维码弹窗:展示当前用户专属小程序码,好友扫码注册成功后双方得积分。
|
||||||
|
*
|
||||||
|
* <p>设计要点:
|
||||||
|
* <ul>
|
||||||
|
* <li>二维码不缓存:每次打开重新生成,保证 scene 参数与当前登录用户一致</li>
|
||||||
|
* <li>点击二维码先落盘为本地临时文件再调 previewImage,支持长按保存/转发</li>
|
||||||
|
* <li>生成失败时 toast 提示并自动关闭,避免停留在空白弹窗</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>登录门槛由调用方负责(ensureLogin 通过后再置 open=true),本组件只做展示与生成。</p>
|
||||||
|
*
|
||||||
|
* @author haisen
|
||||||
|
*/
|
||||||
|
const PromoteModal: React.FC<PromoteModalProps> = ({ open, onClose }) => {
|
||||||
|
/** 二维码图片 base64(png),空串表示尚未生成 */
|
||||||
|
const [qrBase64, setQrBase64] = useState('')
|
||||||
|
/** 二维码生成中 */
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 每次打开都重新生成(不缓存),关闭即清空,保证下次打开数据新鲜
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
// 防止卸载/快速开关后异步回调污染状态
|
||||||
|
let cancelled = false
|
||||||
|
setQrBase64('')
|
||||||
|
setLoading(true)
|
||||||
|
get<{ qrCodeBase64: string }>('/api/mini/promote/qrcode')
|
||||||
|
.then((resp) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (resp.code === 200 && resp.data?.qrCodeBase64) {
|
||||||
|
setQrBase64(resp.data.qrCodeBase64)
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: resp.message || '生成推广码失败', icon: 'none' })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[推广] 二维码生成失败:', err)
|
||||||
|
if (cancelled) return
|
||||||
|
Taro.showToast({ title: '生成推广码失败,请稍后重试', icon: 'none' })
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// onClose 来自父组件渲染期闭包,语义稳定,无需纳入依赖
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
/** 点击二维码大图预览,长按可保存/发给好友 */
|
||||||
|
const handlePreviewQr = () => {
|
||||||
|
if (!qrBase64) return
|
||||||
|
const fs = Taro.getFileSystemManager()
|
||||||
|
const qrFilePath = `${Taro.env.USER_DATA_PATH}/promote_qr_${Date.now()}.png`
|
||||||
|
try {
|
||||||
|
// base64 data 无法直接 previewImage,先落盘为本地临时文件
|
||||||
|
fs.writeFileSync(qrFilePath, qrBase64, 'base64')
|
||||||
|
Taro.previewImage({ urls: [qrFilePath] })
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '图片加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.modalMask} onClick={onClose}>
|
||||||
|
<View className={styles.modalCard} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Text className={styles.modalTitle}>邀请好友得积分</Text>
|
||||||
|
<Text className={styles.modalDesc}>好友扫码注册成功,您将获得 4 积分</Text>
|
||||||
|
<View className={styles.qrWrap}>
|
||||||
|
{loading ? (
|
||||||
|
<View className={styles.qrLoading}>
|
||||||
|
<Text>二维码生成中…</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
className={styles.qrImage}
|
||||||
|
src={`data:image/png;base64,${qrBase64}`}
|
||||||
|
mode='aspectFit'
|
||||||
|
onClick={handlePreviewQr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className={styles.qrHint}>点击二维码可大图预览,长按可保存转发</Text>
|
||||||
|
<View className={styles.modalCloseBtn} onClick={onClose}>
|
||||||
|
<Text>关闭</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PromoteModal
|
||||||
108
src/components/SupplyDemand/index.module.scss
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.sectionContainer {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionBar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
background: $color-primary;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionMore {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgContainer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 28rpx;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 200rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgCard::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -20rpx;
|
||||||
|
bottom: -20rpx;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgTextBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgTitle {
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgDesc {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dgBtn {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
color: $color-text-white;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(94, 70, 246, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
48
src/components/SupplyDemand/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
const SupplyDemand: React.FC = () => {
|
||||||
|
const handlePublishClick = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/task-publish/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAcceptClick = () => {
|
||||||
|
Taro.switchTab({ url: '/pages/task-center/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.sectionContainer}>
|
||||||
|
<View className={styles.sectionTitle}>
|
||||||
|
<View className={styles.sectionTitleLeft}>
|
||||||
|
<View className={styles.sectionBar} />
|
||||||
|
<Text>供需广场</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.sectionMore}>更多 →</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.dgContainer}>
|
||||||
|
<View className={styles.dgCard} onClick={handlePublishClick}>
|
||||||
|
<View className={styles.dgTextBlock}>
|
||||||
|
<Text className={styles.dgTitle}>商家发单</Text>
|
||||||
|
<Text className={styles.dgDesc}>发布任务需求,精准匹配专业飞手</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.dgBtn}>
|
||||||
|
<Text>立即发单 →</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.dgCard} onClick={handleAcceptClick}>
|
||||||
|
<View className={styles.dgTextBlock}>
|
||||||
|
<Text className={styles.dgTitle}>飞手接单</Text>
|
||||||
|
<Text className={styles.dgDesc}>海量订单等你来抢,持证变现</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.dgBtn}>
|
||||||
|
<Text>立即接单 →</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupplyDemand
|
||||||
181
src/components/TaskCard/index.module.scss
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.taskCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskTags {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskTag {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
color: $color-tag-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskTagStatus {
|
||||||
|
background: #FEF3C7;
|
||||||
|
color: #B45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskLocation {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskTitle {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskBody {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskPic {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #1A1730 0%, #3D2BB8 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskPicLabel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12rpx;
|
||||||
|
left: 16rpx;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskInfo {
|
||||||
|
flex: 1;
|
||||||
|
border: 2rpx solid $color-border;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
background: #FAFBFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskInfoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskInfoIcon {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: linear-gradient(135deg, $color-primary, $color-primary-dark);
|
||||||
|
color: $color-text-white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskInfoLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
min-width: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskInfoValue {
|
||||||
|
color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
flex: 1;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskFoot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 2rpx dashed $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskPrice {
|
||||||
|
font-size: $font-size-xxl;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-price;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskPriceSym {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskBtnLine {
|
||||||
|
border: 2rpx solid $color-border;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
background: $color-bg-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskBtnPrimary {
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
color: $color-text-white;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
padding: 12rpx 36rpx;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(94, 70, 246, 0.3);
|
||||||
|
}
|
||||||
64
src/components/TaskCard/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import { TaskItem } from '@/data/home'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: TaskItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskCard: React.FC<TaskCardProps> = ({ task }) => {
|
||||||
|
return (
|
||||||
|
<View className={styles.taskCard}>
|
||||||
|
<View className={styles.taskHead}>
|
||||||
|
<View className={styles.taskTags}>
|
||||||
|
{task.tags.map((tag, idx) => (
|
||||||
|
<Text key={idx} className={`${styles.taskTag} ${idx === 1 ? styles.taskTagStatus : ''}`}>{tag}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className={styles.taskLocation}>📍 {task.location}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.taskTitle}>{task.title}</Text>
|
||||||
|
<View className={styles.taskBody}>
|
||||||
|
<View className={styles.taskPic}>
|
||||||
|
<Image
|
||||||
|
src={task.sceneImage}
|
||||||
|
mode='aspectFill'
|
||||||
|
style={{ width: '100%', height: '100%', borderRadius: '8rpx' }}
|
||||||
|
/>
|
||||||
|
<Text className={styles.taskPicLabel}>{task.scene}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.taskInfo}>
|
||||||
|
<View className={styles.taskInfoRow}>
|
||||||
|
<View className={styles.taskInfoIcon}>⏰</View>
|
||||||
|
<Text className={styles.taskInfoLabel}>截止</Text>
|
||||||
|
<Text className={styles.taskInfoValue}>{task.deadline}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.taskInfoRow}>
|
||||||
|
<View className={styles.taskInfoIcon}>🏢</View>
|
||||||
|
<Text className={styles.taskInfoLabel}>发布</Text>
|
||||||
|
<Text className={styles.taskInfoValue}>{task.publisher}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.taskFoot}>
|
||||||
|
<View>
|
||||||
|
<Text className={styles.taskPriceSym}>¥</Text>
|
||||||
|
<Text className={styles.taskPrice}>{task.price.toLocaleString()}</Text>
|
||||||
|
<Text style={{ fontSize: '22rpx', color: '#9D97AE', marginLeft: '8rpx' }}>{task.priceUnit}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.taskActions}>
|
||||||
|
<View className={styles.taskBtnLine}>
|
||||||
|
<Text>💬</Text>
|
||||||
|
<Text>咨询</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.taskBtnPrimary}>
|
||||||
|
<Text>立即接单</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskCard
|
||||||
458
src/components/TaskHallCard/index.module.scss
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
// ============================================================
|
||||||
|
// 任务大厅卡片 — 统一紫色主题(对齐 theme.scss 全局 token)
|
||||||
|
// 全部使用 $color-primary 等紫色系变量,仅优质任务金色点缀($color-gold)
|
||||||
|
// 作为唯一彩色,其余单色相层次避免视觉噪音。
|
||||||
|
// ============================================================
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
任务卡片:左侧紫竖条(优质为金色) + 7 段式结构
|
||||||
|
============================================================ */
|
||||||
|
.taskCard {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden; // 裁切左条贴合卡片圆角
|
||||||
|
margin-bottom: $page-spacing;
|
||||||
|
padding: $page-spacing;
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧竖条:8rpx 主题紫贯穿整卡 */
|
||||||
|
.accentBar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 8rpx;
|
||||||
|
background: $color-primary;
|
||||||
|
border-radius: 0 8rpx 8rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优质任务:左条换金色(唯一彩色点缀,数据层区分) */
|
||||||
|
.accentBarGold {
|
||||||
|
background: $color-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
padding-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 1. 标签行 ---------- */
|
||||||
|
.tagRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优质任务徽章:金渐变底白字(排在品类标签之前) */
|
||||||
|
.tagPremium {
|
||||||
|
margin-right: $spacing-sm;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: linear-gradient(135deg, #ffc53d 0%, $color-gold 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品类标签:白底浅灰描边(兜底,未知类型) */
|
||||||
|
.tagCategory {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1rpx solid $color-border;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. 植保 — 农业绿:浅绿底 + 深绿字 + 绿描边 */
|
||||||
|
.tagTypeZhiBao {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
border-color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 吊运 — 机械蓝:浅蓝底 + 深蓝字 + 蓝描边 */
|
||||||
|
.tagTypeDiaoYun {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. 航拍 — 主题紫:浅紫底 + 深紫字 + 紫描边(呼应品牌主色) */
|
||||||
|
.tagTypeHangPai {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. 巡检 — 警示橙:浅橙底 + 深橙字 + 橙描边 */
|
||||||
|
.tagTypeXunJian {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #c2410c;
|
||||||
|
border-color: #fdba74;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. 清洗 — 清爽青:浅青底 + 深青字 + 青描边 */
|
||||||
|
.tagTypeQingXi {
|
||||||
|
background: #cffafe;
|
||||||
|
color: #0e7490;
|
||||||
|
border-color: #67e8f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6. 表演 — 莫兰迪灰粉(低饱和度,不抢主色) */
|
||||||
|
.tagTypeBiaoYan {
|
||||||
|
background: #f1ebe6;
|
||||||
|
color: #8a7b74;
|
||||||
|
border-color: #ddd3cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 7. 测绘 — 靛青:浅靛底 + 深靛字 + 靛描边 */
|
||||||
|
.tagTypeCeHui {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #4338ca;
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态标签:圆角矩形,色系按状态切换 */
|
||||||
|
.tagStatus {
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 申请人数徽标:浅紫底紫字呼应主题色 */
|
||||||
|
.tagApplicants {
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 待接单:浅橙底橙字 */
|
||||||
|
.statusPending {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进行中/已接单:浅紫底深紫字(主题色系) */
|
||||||
|
.statusRunning {
|
||||||
|
background: $color-primary-bg;
|
||||||
|
color: $color-primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已完成:浅绿底深绿字 */
|
||||||
|
.statusDone {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 草稿/已取消/未知:浅灰底灰字 */
|
||||||
|
.statusDraft,
|
||||||
|
.statusCancelled {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 城市:靠右主题紫加粗 */
|
||||||
|
.city {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-primary;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. 标题 ---------- */
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发布商家名称:任务标题下方浅灰辅助信息 */
|
||||||
|
.publisherName {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9e9e9e;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已接单飞手:商家名称下方,主题紫色调醒目但不抢主标题焦点 */
|
||||||
|
.pilotInfo {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首位申请人:与已接单同位置,但灰色弱一档(待确认状态) */
|
||||||
|
.applicantInfo {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. 中部行:方形实景图 + 主题紫浅底信息面板 ---------- */
|
||||||
|
.midRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 方形实景图:贴合信息面板高度 */
|
||||||
|
.photoWrap {
|
||||||
|
width: 232rpx;
|
||||||
|
min-height: 232rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photoPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息面板:主题紫浅底 */
|
||||||
|
.infoBox {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行文字:值为深色 600,字段标签灰色 400 */
|
||||||
|
.infoText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-weight: $font-weight-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日期两行结构 */
|
||||||
|
.dateCol {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dateLine {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 行内图标:实心圆角方块 + 白色图形 ---------- */
|
||||||
|
.ico {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
margin-right: $spacing-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位:金色(作为唯一彩色点缀,呼应优质任务金色条) */
|
||||||
|
.icoPin {
|
||||||
|
background-color: $color-gold;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23FFFFFF' d='M12 22s-7.5-6.2-7.5-11.7C4.5 6 7.9 2.5 12 2.5s7.5 3.5 7.5 7.8C19.5 15.8 12 22 12 22z'/%3E%3Ccircle cx='12' cy='10' r='2.6' fill='%23F59E0B'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 人数:主题紫方块白色人形 */
|
||||||
|
.icoPerson {
|
||||||
|
background-color: $color-primary;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFFFFF'%3E%3Ccircle cx='12' cy='7.5' r='4'/%3E%3Cpath d='M4 20.5c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日历:主题紫方块白色日历 */
|
||||||
|
.icoCalendar {
|
||||||
|
background-color: $color-primary;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='4.5' width='18' height='16' rx='2.5' fill='%23FFFFFF'/%3E%3Cpath d='M3 9.5h18' stroke='%235E46F6' stroke-width='2'/%3E%3Cpath d='M8 2.5v4M16 2.5v4' stroke='%23FFFFFF' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 4+5. 通栏横带:价格左 + 主按钮右 ---------- */
|
||||||
|
.rewardBand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: $spacing-md (-$page-spacing) 0; // 抵消卡片 padding,通栏贴边
|
||||||
|
padding: $spacing-md $page-spacing;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSymbol {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-primary;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 去抢单:主题紫渐变胶囊 */
|
||||||
|
.btnPrimary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: $button-height-sm;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
border-radius: $radius-round;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 次要按钮(如"撤销申请"):描边弱化样式 */
|
||||||
|
.btnSecondary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: $button-height-sm;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
margin-right: $spacing-sm;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2rpx solid $color-border;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 6. 保险标签:统一主题紫描边 ---------- */
|
||||||
|
.insuranceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insuranceTag {
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
margin-right: $spacing-sm;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
border: 1rpx solid $color-border;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 无保险要求:灰色弱化样式 */
|
||||||
|
.insuranceNone {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #d4d4d8;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 7. 描述框:主题紫浅底,全文展示 ---------- */
|
||||||
|
.descBox {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descText {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: 1.7;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
249
src/components/TaskHallCard/index.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import { resolveFileUrl } from '@/config/env'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务状态码 → 展示文本 + 标签色(对照设计稿:待接单=橙,进行中=蓝,已完成=绿)
|
||||||
|
*/
|
||||||
|
const STATUS_MAP: Record<number, { label: string; className: string }> = {
|
||||||
|
0: { label: '草稿', className: 'statusDraft' },
|
||||||
|
1: { label: '待接单', className: 'statusPending' },
|
||||||
|
2: { label: '已接单', className: 'statusRunning' },
|
||||||
|
3: { label: '进行中', className: 'statusRunning' },
|
||||||
|
4: { label: '已完成', className: 'statusDone' },
|
||||||
|
5: { label: '已取消', className: 'statusCancelled' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请记录状态码 → 展示文本 + 标签色(对照 task_record.status:
|
||||||
|
* 1待商家确认=橙 2已接单=蓝 3已拒绝/4已撤销=灰),
|
||||||
|
* 仅"我的任务-已接单"列表下发,存在时优先于任务状态展示申请进度。
|
||||||
|
*/
|
||||||
|
const RECORD_STATUS_MAP: Record<number, { label: string; className: string }> = {
|
||||||
|
1: { label: '待商家确认', className: 'statusPending' },
|
||||||
|
2: { label: '已接单', className: 'statusRunning' },
|
||||||
|
3: { label: '已拒绝', className: 'statusCancelled' },
|
||||||
|
4: { label: '已撤销', className: 'statusCancelled' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务类型中文名 → 颜色 class(7 种类型各有专属柔和色阶)。
|
||||||
|
* 编码对照后端 Task.type:1植保(绿) 2吊运(蓝) 3航拍(紫) 4巡检(橙)
|
||||||
|
* 5清洗(青) 6表演(玫红) 7测绘(靛),未知类型 fallback 为白底灰边。
|
||||||
|
*/
|
||||||
|
const TYPE_COLOR_MAP: Record<string, string> = {
|
||||||
|
'植保': 'tagTypeZhiBao',
|
||||||
|
'吊运': 'tagTypeDiaoYun',
|
||||||
|
'航拍': 'tagTypeHangPai',
|
||||||
|
'巡检': 'tagTypeXunJian',
|
||||||
|
'清洗': 'tagTypeQingXi',
|
||||||
|
'表演': 'tagTypeBiaoYan',
|
||||||
|
'测绘': 'tagTypeCeHui',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务大厅卡片数据(与后端任务列表 VO 字段对齐,
|
||||||
|
* 多余字段可缺省:组件只读取渲染所需的最小字段集)。
|
||||||
|
*/
|
||||||
|
export interface TaskHallCardData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
/** 发布商家名称(列表页展示于任务名称下方,浅灰色辅助信息) */
|
||||||
|
publisherName?: string
|
||||||
|
typeName: string
|
||||||
|
reward: number
|
||||||
|
status: number
|
||||||
|
isPremium: number
|
||||||
|
requiredCount: number
|
||||||
|
taskStartTime: string
|
||||||
|
taskEndTime: string
|
||||||
|
address: string
|
||||||
|
remark: string
|
||||||
|
regionText: string
|
||||||
|
sceneImageUrls: string[]
|
||||||
|
insuranceNames: string[]
|
||||||
|
/** 待确认申请人数(仅商家"我的发布"列表下发;其他场景缺省不展示徽标) */
|
||||||
|
applicantCount?: number
|
||||||
|
/** 申请记录状态(仅飞手"已接单"列表下发:1待商家确认 2已接单 3已拒绝 4已撤销;存在时优先展示申请进度) */
|
||||||
|
recordStatus?: number
|
||||||
|
/** 已接单飞手姓名(任务 status=2/3/4 且 acceptedPilotId 有值时下发;未接单则缺省) */
|
||||||
|
acceptedPilotName?: string
|
||||||
|
/** 首位待确认申请人姓名(仅商家"已发布"列表下发;有申请人时配合 applicantCount 展示) */
|
||||||
|
firstApplicantName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskHallCardProps {
|
||||||
|
/** 任务数据 */
|
||||||
|
task: TaskHallCardData
|
||||||
|
/** 整卡点击(跳任务详情) */
|
||||||
|
onCardClick: (taskId: number) => void
|
||||||
|
/** 主按钮文案,默认"去抢单" */
|
||||||
|
actionText?: string
|
||||||
|
/** 主按钮点击(如去抢单),不传则点击时冒泡走整卡点击 */
|
||||||
|
onActionClick?: (taskId: number, e: { stopPropagation: () => void }) => void
|
||||||
|
/** 次要按钮文案(如"撤销申请"),不传不渲染 */
|
||||||
|
secondaryActionText?: string
|
||||||
|
/** 次要按钮点击(如撤销申请),与 secondaryActionText 成对使用 */
|
||||||
|
onSecondaryAction?: (taskId: number, e: { stopPropagation: () => void }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期(2026-08-25T00:00:00 → 2026-08-25) */
|
||||||
|
const formatDate = (iso: string) => (iso ? iso.slice(0, 10) : '')
|
||||||
|
|
||||||
|
/** 格式化报酬金额:保留两位小数,不加千分位 */
|
||||||
|
const formatReward = (reward: number) => (reward == null ? '0.00' : reward.toFixed(2))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务大厅卡片:与任务中心抢单大厅 1:1 同款排版(7 段式结构),
|
||||||
|
* 供任务中心 / 商家详情页等场景复用,保证列表视觉一致。
|
||||||
|
*/
|
||||||
|
const TaskHallCard: React.FC<TaskHallCardProps> = ({
|
||||||
|
task,
|
||||||
|
onCardClick,
|
||||||
|
actionText = '去抢单',
|
||||||
|
onActionClick,
|
||||||
|
secondaryActionText,
|
||||||
|
onSecondaryAction,
|
||||||
|
}) => {
|
||||||
|
// "已接单"列表下发 recordStatus 时优先展示申请进度,其余场景展示任务状态
|
||||||
|
const status = task.recordStatus != null
|
||||||
|
? RECORD_STATUS_MAP[task.recordStatus] || { label: '未知', className: 'statusDraft' }
|
||||||
|
: STATUS_MAP[task.status] || { label: '未知', className: 'statusDraft' }
|
||||||
|
const isPremium = task.isPremium === 1
|
||||||
|
const firstImage = resolveFileUrl(task.sceneImageUrls?.[0] || '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
// 整卡点击暂时移除(详情页未开发),避免误导用户
|
||||||
|
<View className={styles.taskCard}>
|
||||||
|
{/* 左侧蓝色竖条(优质任务为金色) */}
|
||||||
|
<View className={`${styles.accentBar} ${isPremium ? styles.accentBarGold : ''}`} />
|
||||||
|
|
||||||
|
<View className={styles.cardBody}>
|
||||||
|
{/* 1. 标签行:优质徽章 + 品类 + 状态 | 城市(蓝色加粗) */}
|
||||||
|
<View className={styles.tagRow}>
|
||||||
|
{isPremium && <Text className={styles.tagPremium}>★ 优质任务</Text>}
|
||||||
|
<Text className={`${styles.tagCategory} ${styles[TYPE_COLOR_MAP[task.typeName] || '']}`}>{task.typeName}</Text>
|
||||||
|
<Text className={`${styles.tagStatus} ${styles[status.className]}`}>{status.label}</Text>
|
||||||
|
{(task.applicantCount ?? 0) > 0 && (
|
||||||
|
<Text className={styles.tagApplicants}>{task.applicantCount}人申请</Text>
|
||||||
|
)}
|
||||||
|
{task.regionText && <Text className={styles.city}>{task.regionText}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 2. 标题 */}
|
||||||
|
<Text className={styles.title}>{task.name}</Text>
|
||||||
|
{/* 发布商家名称(浅灰辅助信息,不抢视觉焦点) */}
|
||||||
|
{task.publisherName && (
|
||||||
|
<Text className={styles.publisherName}>{task.publisherName}</Text>
|
||||||
|
)}
|
||||||
|
{/* 辅助信息行:有已接单飞手优先展示,否则展示首位申请人 */}
|
||||||
|
{task.acceptedPilotName && (
|
||||||
|
<Text className={styles.pilotInfo}>已接单:{task.acceptedPilotName}</Text>
|
||||||
|
)}
|
||||||
|
{!task.acceptedPilotName && task.firstApplicantName && task.applicantCount === 1 && (
|
||||||
|
<Text className={styles.applicantInfo}>{task.firstApplicantName} 已申请</Text>
|
||||||
|
)}
|
||||||
|
{!task.acceptedPilotName && task.firstApplicantName && task.applicantCount && task.applicantCount > 1 && (
|
||||||
|
<Text className={styles.applicantInfo}>{task.firstApplicantName} 等 {task.applicantCount} 人申请</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. 中部行:左实景图 + 右淡蓝信息面板 */}
|
||||||
|
<View className={styles.midRow}>
|
||||||
|
<View className={styles.photoWrap}>
|
||||||
|
{firstImage ? (
|
||||||
|
<Image className={styles.photo} src={firstImage} mode='aspectFill' />
|
||||||
|
) : (
|
||||||
|
<View className={styles.photoPlaceholder} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.infoBox}>
|
||||||
|
{/* 地点 */}
|
||||||
|
{task.address && (
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<View className={`${styles.ico} ${styles.icoPin}`} />
|
||||||
|
<Text className={styles.infoText}>
|
||||||
|
<Text className={styles.infoLabel}>地点: </Text>
|
||||||
|
{task.address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 人数 */}
|
||||||
|
{task.requiredCount != null && (
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<View className={`${styles.ico} ${styles.icoPerson}`} />
|
||||||
|
<Text className={styles.infoText}>
|
||||||
|
<Text className={styles.infoLabel}>人数: </Text>
|
||||||
|
{task.requiredCount}人
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 开始/结束:一个日历图标 + 两行日期 */}
|
||||||
|
{(task.taskStartTime || task.taskEndTime) && (
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<View className={`${styles.ico} ${styles.icoCalendar}`} />
|
||||||
|
<View className={styles.dateCol}>
|
||||||
|
<Text className={styles.dateLine}>
|
||||||
|
<Text className={styles.infoLabel}>开始 </Text>
|
||||||
|
{formatDate(task.taskStartTime)}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.dateLine}>
|
||||||
|
<Text className={styles.infoLabel}>结束 </Text>
|
||||||
|
{formatDate(task.taskEndTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 4+5. 通栏横带:价格左 + 主按钮右 */}
|
||||||
|
<View className={styles.rewardBand}>
|
||||||
|
<View className={styles.priceRow}>
|
||||||
|
<Text className={styles.priceSymbol}>¥</Text>
|
||||||
|
<Text className={styles.price}>{formatReward(task.reward)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.btnRow}>
|
||||||
|
{/* 次要按钮(如"撤销申请"),描边弱化样式,与主按钮形成主次层级 */}
|
||||||
|
{secondaryActionText && onSecondaryAction && (
|
||||||
|
<View
|
||||||
|
className={styles.btnSecondary}
|
||||||
|
onClick={e => onSecondaryAction(task.id, e)}
|
||||||
|
>
|
||||||
|
<Text>{secondaryActionText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={e => (onActionClick ? onActionClick(task.id, e) : onCardClick(task.id))}
|
||||||
|
>
|
||||||
|
<Text>{actionText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 6. 保险标签(有则逐个展示;没有则展示"无保险要求") */}
|
||||||
|
<View className={styles.insuranceRow}>
|
||||||
|
{task.insuranceNames?.length > 0 ? (
|
||||||
|
task.insuranceNames.map((name, idx) => (
|
||||||
|
<Text key={idx} className={styles.insuranceTag}>{name}</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className={`${styles.insuranceTag} ${styles.insuranceNone}`}>无保险要求</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 7. 描述框:淡蓝底,全文展示 */}
|
||||||
|
{task.remark && (
|
||||||
|
<View className={styles.descBox}>
|
||||||
|
<Text className={styles.descText}>{task.remark}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskHallCard
|
||||||
43
src/config/env.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 环境配置
|
||||||
|
* 编译时由 defineConstants 注入 API_BASE_URL / FILE_BASE_URL
|
||||||
|
* NODE_ENV / TARO_ENV 由 Taro CLI 自动注入
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const process: {
|
||||||
|
env: {
|
||||||
|
API_BASE_URL: string;
|
||||||
|
FILE_BASE_URL: string;
|
||||||
|
NODE_ENV: 'development' | 'production';
|
||||||
|
TARO_ENV: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** API 基础路径,dev 指向本地后端,prod 待配置 */
|
||||||
|
export const API_BASE_URL: string = process.env.API_BASE_URL || '';
|
||||||
|
|
||||||
|
/** 文件访问基础路径,dev 指向本地后端,prod 待配置 */
|
||||||
|
export const FILE_BASE_URL: string = process.env.FILE_BASE_URL || '';
|
||||||
|
|
||||||
|
/** 当前是否开发环境(Taro CLI 自动注入 NODE_ENV) */
|
||||||
|
export const IS_DEV: boolean = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/** 当前是否生产环境 */
|
||||||
|
export const IS_PROD: boolean = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
/** 当前编译目标平台(weapp / h5 / alipay 等) */
|
||||||
|
export const TARO_ENV: string = process.env.TARO_ENV || 'h5';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接完整文件访问 URL
|
||||||
|
* @param path 文件相对路径
|
||||||
|
* @returns 完整 URL
|
||||||
|
*/
|
||||||
|
export function resolveFileUrl(path: string): string {
|
||||||
|
if (!path) return '';
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
if (/^data:/.test(path)) return path;
|
||||||
|
// 本地 import 路径(Taro 打包后不以 / 开头)直接返回,不拼后端 URL
|
||||||
|
if (!path.startsWith('/')) return path;
|
||||||
|
return `${FILE_BASE_URL}${path}`;
|
||||||
|
}
|
||||||
136
src/data/home.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import baomingIcon from '@/assets/grid/baoming.png'
|
||||||
|
import biaoyanIcon from '@/assets/grid/biaoyan.png'
|
||||||
|
import shangjiaIcon from '@/assets/grid/shangjia.png'
|
||||||
|
import feishouIcon from '@/assets/grid/feishou.png'
|
||||||
|
import kaoshiIcon from '@/assets/grid/kaoshi.png'
|
||||||
|
import renwuIcon from '@/assets/grid/renwu.png'
|
||||||
|
import kechengIcon from '@/assets/grid/kecheng.png'
|
||||||
|
import tuiguangIcon from '@/assets/grid/tuiguang.png'
|
||||||
|
import didiIcon from '@/assets/grid/didi.png'
|
||||||
|
|
||||||
|
const FILE_BASE = process.env.FILE_BASE_URL || ''
|
||||||
|
|
||||||
|
export interface BannerItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
linkType: 'caac' | 'performance' | 'merchant';
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeItem {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridNavItem {
|
||||||
|
id: number;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
badge?: string;
|
||||||
|
linkType: string;
|
||||||
|
iconSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
location: string;
|
||||||
|
price: number;
|
||||||
|
priceUnit: string;
|
||||||
|
deadline: string;
|
||||||
|
publisher: string;
|
||||||
|
scene: string;
|
||||||
|
sceneImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bannerList: BannerItem[] = [
|
||||||
|
{ id: 1, title: 'CAAC 报名通道开启', subtitle: '超250g无人机须实名,操控员须持证', linkType: 'caac', image: `${FILE_BASE}/static/banner/1.png` },
|
||||||
|
{ id: 2, title: '无人机表演专场', subtitle: '高客单商业订单,专业飞手接单', linkType: 'performance', image: `${FILE_BASE}/static/banner/2.png` },
|
||||||
|
{ id: 3, title: '实力商家招商入驻', subtitle: '开放聚合生态,精准流量分发', linkType: 'merchant', image: `${FILE_BASE}/static/banner/3.png` }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const noticeList: NoticeItem[] = [
|
||||||
|
{ id: 1, label: '政策', content: '民航局发布2024年无人机管理新规,超250g须实名注册', time: '2h前' },
|
||||||
|
{ id: 2, label: '公告', content: '翼云Hub平台V1.0上线,飞手认证通道开放', time: '1天前' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const gridNavList: GridNavItem[] = [
|
||||||
|
// TODO: CAAC 报名功能开发中,暂时隐藏
|
||||||
|
// { id: 1, icon: baomingIcon, label: 'CAAC报名', color: '#5E46F6', linkType: 'caac' },
|
||||||
|
{ id: 2, icon: biaoyanIcon, label: '无人机表演', color: '#F59E0B', linkType: 'performance' },
|
||||||
|
{ id: 3, icon: shangjiaIcon, label: '实力商家', color: '#22C55E', linkType: 'merchant' },
|
||||||
|
{ id: 4, icon: feishouIcon, label: '本地飞手', color: '#EF4444', linkType: 'pilot' },
|
||||||
|
{ id: 5, icon: kaoshiIcon, label: '培训考试', color: '#7C6AF8', linkType: 'training' },
|
||||||
|
{ id: 6, icon: renwuIcon, label: '发布任务', color: '#5E46F6', badge: 'NEW', linkType: 'publish' },
|
||||||
|
{ id: 7, icon: kechengIcon, label: '研学课程', color: '#F59E0B', linkType: 'course' },
|
||||||
|
{ id: 8, icon: tuiguangIcon, label: '飞手推广', color: '#22C55E', badge: 'HOT', linkType: 'promote' },
|
||||||
|
{ id: 9, icon: didiIcon, label: '无人机配送', color: '#EF4444', linkType: 'dispatch' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const taskList: TaskItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '大型展会无人机航拍直播',
|
||||||
|
tags: ['航拍', '高单'],
|
||||||
|
location: '上海·浦东新区',
|
||||||
|
price: 8000,
|
||||||
|
priceUnit: '元/天',
|
||||||
|
deadline: '2026-09-10 前',
|
||||||
|
publisher: '会展服务有限公司',
|
||||||
|
scene: '展会航拍',
|
||||||
|
sceneImage: 'https://picsum.photos/id/1018/200/200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '农田植保作业-500亩水稻',
|
||||||
|
tags: ['植保', '长期'],
|
||||||
|
location: '江苏·南通',
|
||||||
|
price: 120,
|
||||||
|
priceUnit: '元/亩',
|
||||||
|
deadline: '2026-09-05 前',
|
||||||
|
publisher: '绿源农业合作社',
|
||||||
|
scene: '植保作业',
|
||||||
|
sceneImage: 'https://picsum.photos/id/1036/200/200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '高压线路巡检-华东段',
|
||||||
|
tags: ['巡检', '企业单'],
|
||||||
|
location: '浙江·杭州',
|
||||||
|
price: 500,
|
||||||
|
priceUnit: '元/公里',
|
||||||
|
deadline: '长期合作',
|
||||||
|
publisher: '国网华东电力',
|
||||||
|
scene: '电力巡检',
|
||||||
|
sceneImage: 'https://picsum.photos/id/1015/200/200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '楼盘开盘仪式无人机表演',
|
||||||
|
tags: ['表演', '高单'],
|
||||||
|
location: '北京·朝阳',
|
||||||
|
price: 15000,
|
||||||
|
priceUnit: '元/场',
|
||||||
|
deadline: '2026-09-15 前',
|
||||||
|
publisher: '华盛地产集团',
|
||||||
|
scene: '楼盘活动',
|
||||||
|
sceneImage: 'https://picsum.photos/id/1082/200/200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '应急搜救无人机服务',
|
||||||
|
tags: ['应急', '加急'],
|
||||||
|
location: '四川·成都',
|
||||||
|
price: 2000,
|
||||||
|
priceUnit: '元/次',
|
||||||
|
deadline: '随时',
|
||||||
|
publisher: '蓝天救援队',
|
||||||
|
scene: '应急搜救',
|
||||||
|
sceneImage: 'https://picsum.photos/id/1039/200/200'
|
||||||
|
}
|
||||||
|
]
|
||||||
28
src/index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
|
||||||
|
<meta name="viewport" content="width=375,user-scalable=no" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
|
<meta name="format-detection" content="telephone=no,address=no" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="white" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<title>taro_template</title>
|
||||||
|
<script>
|
||||||
|
// 固定 html fontSize,防止 flexible 脚本动态修改
|
||||||
|
(function() {
|
||||||
|
var html = document.documentElement;
|
||||||
|
html.style.fontSize = '18px';
|
||||||
|
Object.defineProperty(html.style, 'fontSize', {
|
||||||
|
get: function() { return '18px'; },
|
||||||
|
set: function() { /* 忽略所有修改 */ }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
<%= htmlWebpackPlugin.options.script %>
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
src/pages/announcement-detail/index.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '公告详情',
|
||||||
|
navigationBarBackgroundColor: '#FFFFFF',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
})
|
||||||
76
src/pages/announcement-detail/index.module.scss
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: $spacing-md $page-padding $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内容卡片 ====================
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-md; // 12rpx
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-xl; // 36rpx
|
||||||
|
font-weight: $font-weight-bold; // 700
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publishTime {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-sm; // 24rpx
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: $color-divider;
|
||||||
|
margin: $spacing-md 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: $font-size-md; // 28rpx
|
||||||
|
font-weight: $font-weight-normal;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: $line-height-loose; // 1.8
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态占位 ====================
|
||||||
|
|
||||||
|
.stateWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryBtn {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
padding: $spacing-xs $spacing-lg;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light, $color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryBtnText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-white;
|
||||||
|
}
|
||||||
123
src/pages/announcement-detail/index.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, RichText } from '@tarojs/components'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 公告 VO,与后端 AnnouncementVO 对齐(createdAt 即为发布时间) */
|
||||||
|
interface AnnouncementVO {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告详情页。
|
||||||
|
*
|
||||||
|
* <p>通过路由参数 {@code id} 调用小程序端公开接口
|
||||||
|
* {@code GET /api/mini/announcement/{id}} 获取公告详情,
|
||||||
|
* 展示标题、正文(富文本)与发布时间。</p>
|
||||||
|
*
|
||||||
|
* <p>首屏展示全屏加载/错误占位;后续从其他页面返回时静默刷新,
|
||||||
|
* 失败则保留已有内容并 toast 提示,避免遮挡阅读。</p>
|
||||||
|
*/
|
||||||
|
const AnnouncementDetailPage: React.FC = () => {
|
||||||
|
const [detail, setDetail] = useState<AnnouncementVO | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
/** 是否已完成过一次加载,用于区分首屏占位与刷新态 */
|
||||||
|
const loadedOnceRef = useRef(false)
|
||||||
|
|
||||||
|
/** 拉取公告详情 */
|
||||||
|
const fetchDetail = useCallback(async () => {
|
||||||
|
const instance = Taro.getCurrentInstance()
|
||||||
|
const id = instance.router?.params?.id
|
||||||
|
if (!id) {
|
||||||
|
setDetail(null)
|
||||||
|
setErrorMsg('公告参数缺失')
|
||||||
|
setLoading(false)
|
||||||
|
loadedOnceRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅首屏展示全屏 loading,刷新时不遮挡已有内容
|
||||||
|
if (!loadedOnceRef.current) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
const res = await get<AnnouncementVO>(`/api/mini/announcement/${id}`)
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
setDetail(res.data)
|
||||||
|
} else if (loadedOnceRef.current) {
|
||||||
|
Taro.showToast({ title: res.message || '刷新失败', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
setDetail(null)
|
||||||
|
setErrorMsg(res.message || '公告不存在或已被删除')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[公告详情] 加载失败:', err)
|
||||||
|
if (loadedOnceRef.current) {
|
||||||
|
Taro.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
setDetail(null)
|
||||||
|
setErrorMsg('加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
loadedOnceRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 页面显示时拉取详情(含首次进入与从其他页面返回) */
|
||||||
|
useDidShow(() => {
|
||||||
|
fetchDetail()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 重新加载(错误态点击) */
|
||||||
|
const handleRetry = () => {
|
||||||
|
loadedOnceRef.current = false
|
||||||
|
fetchDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首屏加载占位
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.stateWrap}>
|
||||||
|
<Text className={styles.stateText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首屏错误占位
|
||||||
|
if (errorMsg) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.stateWrap}>
|
||||||
|
<Text className={styles.stateText}>{errorMsg}</Text>
|
||||||
|
<View className={styles.retryBtn} onClick={handleRetry}>
|
||||||
|
<Text className={styles.retryBtnText}>重新加载</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公告详情内容
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.card}>
|
||||||
|
<Text className={styles.title}>{detail?.title}</Text>
|
||||||
|
<Text className={styles.publishTime}>{detail?.createdAt ? detail.createdAt.slice(0, 10) : ''}</Text>
|
||||||
|
<View className={styles.divider} />
|
||||||
|
<View className={styles.content}>
|
||||||
|
<RichText nodes={detail?.content || ''} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementDetailPage
|
||||||
3
src/pages/announcement-list/index.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '通知公告',
|
||||||
|
})
|
||||||
64
src/pages/announcement-list/index.module.scss
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollView {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: $spacing-md $page-padding $spacing-lg;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公告列表卡片
|
||||||
|
.card {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
border: 1rpx solid rgba(94, 70, 246, 0.10);
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态占位
|
||||||
|
.stateWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
113
src/pages/announcement-list/index.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 公告 VO,与后端 AnnouncementVO 对齐(createdAt 即为发布时间) */
|
||||||
|
interface AnnouncementVO {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告列表页。
|
||||||
|
*
|
||||||
|
* <p>调用小程序端公开接口 {@code /api/mini/announcement/list} 拉取全部可见公告,
|
||||||
|
* 后端已按创建时间倒序返回(近→远)。列表每条展示标题 + 完整日期(年-月-日),
|
||||||
|
* 点击进入公告详情页查看正文。</p>
|
||||||
|
*
|
||||||
|
* <p>首屏展示全屏加载占位;后续从详情页返回时静默刷新,失败保留已有内容并 toast 提示。</p>
|
||||||
|
*/
|
||||||
|
const AnnouncementListPage: React.FC = () => {
|
||||||
|
const [list, setList] = useState<AnnouncementVO[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const loadedOnceRef = useRef(false)
|
||||||
|
|
||||||
|
/** 拉取全部可见公告(后端按 createdAt 倒序返回) */
|
||||||
|
const fetchList = useCallback(async (isRefresh = false) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true)
|
||||||
|
} else if (!loadedOnceRef.current) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await get<AnnouncementVO[]>('/api/mini/announcement/list', { limit: 20 })
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
setList(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[公告列表] 加载失败:', err)
|
||||||
|
if (loadedOnceRef.current) {
|
||||||
|
Taro.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
loadedOnceRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 页面显示时拉取列表 */
|
||||||
|
useDidShow(() => {
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 格式化完整日期:ISO → 年-月-日 */
|
||||||
|
const formatFullDate = (iso: string): string => {
|
||||||
|
if (!iso) return ''
|
||||||
|
return iso.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击公告条目,跳转至详情页 */
|
||||||
|
const handleClick = (id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/announcement-detail/index?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首屏加载占位
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.stateWrap}>
|
||||||
|
<Text className={styles.stateText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<ScrollView
|
||||||
|
className={styles.scrollView}
|
||||||
|
scrollY
|
||||||
|
refresherEnabled
|
||||||
|
refresherTriggered={refreshing}
|
||||||
|
onRefresherRefresh={() => fetchList(true)}
|
||||||
|
>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<View className={styles.stateWrap}>
|
||||||
|
<Text className={styles.stateText}>暂无公告</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className={styles.list}>
|
||||||
|
{list.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={styles.card}
|
||||||
|
onClick={() => handleClick(item.id)}
|
||||||
|
>
|
||||||
|
<Text className={styles.title}>{item.title}</Text>
|
||||||
|
<Text className={styles.date}>{formatFullDate(item.createdAt)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementListPage
|
||||||
3
src/pages/course-detail/index.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
472
src/pages/course-detail/index.module.scss
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 加载 / 错误状态 ====================
|
||||||
|
|
||||||
|
.loadingPage,
|
||||||
|
.errorPage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内容区 ====================
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 封面图区 ====================
|
||||||
|
// 自定义导航悬浮其上;导航透明时封面与导航融为一体,滚动后导航切换实底
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 500rpx;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
@include flex-center;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverPlaceholderText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 课程基本信息卡片 ====================
|
||||||
|
// 与列表页卡片保持一致的显性边界
|
||||||
|
|
||||||
|
.infoCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
margin: -40rpx $page-padding 24rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
border: 1rpx solid rgba(94, 70, 246, 0.12);
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.courseName {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
background: rgba(94, 70, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ageTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSymbol {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-price;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin-right: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 44rpx;
|
||||||
|
color: $color-price;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantName {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 通用信息区块卡片 ====================
|
||||||
|
// 与列表页卡片风格统一:显性边界 + 统一圆角 + 阴影
|
||||||
|
|
||||||
|
.sectionCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
margin: 0 $page-padding 24rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
border: 1rpx solid rgba(94, 70, 246, 0.12);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionContent {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: $line-height-loose;
|
||||||
|
// RichText 渲染的 HTML 富文本内部标签可能自带 margin/padding,统一归一
|
||||||
|
:global(p) {
|
||||||
|
margin: 0 0 12rpx 0;
|
||||||
|
}
|
||||||
|
:global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
// 富文本图片:后台上传时可能带原始宽高,必须等比例缩小到容器内
|
||||||
|
:global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 16rpx 0;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
// 超宽图片兜底:即使 img 标签带了内联 width 也强制压回容器内
|
||||||
|
:global(img[width]) {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionPlaceholder {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactText {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 课程图片画廊 ====================
|
||||||
|
|
||||||
|
.galleryScroll {
|
||||||
|
@include scroll-x-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.galleryItem {
|
||||||
|
@include scroll-x-item(280rpx);
|
||||||
|
height: 200rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.galleryImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 底部预约按钮 ====================
|
||||||
|
|
||||||
|
.bottomSpacer {
|
||||||
|
height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 120rpx;
|
||||||
|
background: $color-bg-card;
|
||||||
|
box-shadow: $shadow-float;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointBtn {
|
||||||
|
width: 100%;
|
||||||
|
height: $button-height-lg;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
color: $color-text-white;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointBtnDisabled {
|
||||||
|
background: #D5D5E0;
|
||||||
|
color: $color-text-disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 预约弹窗 ====================
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
padding: 28rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(94, 70, 246, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
font-size: $font-size-xl;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx $page-padding;
|
||||||
|
max-height: 60vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 表单项 ====================
|
||||||
|
|
||||||
|
.formItem {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-primary;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formTextarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 120rpx;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-primary;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 日期选择器 ====================
|
||||||
|
|
||||||
|
.pickerValue {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 80rpx;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerPlaceholder {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerArrow {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 步进器(人数) ====================
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperBtn {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
@include flex-center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperValue {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 金额预览 ====================
|
||||||
|
|
||||||
|
.amountPreview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
border-top: 1rpx solid rgba(94, 70, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountLabel {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountValue {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountSymbol {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-price;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin-right: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountNumber {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: $color-price;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 弹窗按钮 ====================
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
padding: 20rpx $page-padding;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-top: 1rpx solid rgba(94, 70, 246, 0.1);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
flex: 1;
|
||||||
|
height: $button-height-md;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
background: $color-tag-bg;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn {
|
||||||
|
flex: 2;
|
||||||
|
height: $button-height-md;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
color: $color-text-white;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtnDisabled {
|
||||||
|
background: #D5D5E0;
|
||||||
|
}
|
||||||
411
src/pages/course-detail/index.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView, Image, Input, Textarea, Picker, RichText } from '@tarojs/components'
|
||||||
|
import { get, post } from '@/services/request'
|
||||||
|
import { ensureLogin } from '@/services/auth'
|
||||||
|
import CustomNavBar, { useNavBarMetrics } from '@/components/CustomNavBar'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 课程详情 VO,与后端 MiniCourseVO 对齐 */
|
||||||
|
interface CourseDetail {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
coverUrl: string
|
||||||
|
price: number
|
||||||
|
category: number
|
||||||
|
categoryName: string
|
||||||
|
targetAge: string
|
||||||
|
location: string
|
||||||
|
description: string
|
||||||
|
scheduleType: number
|
||||||
|
scheduleWeekDays: string
|
||||||
|
scheduleDates: string
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
imageUrls: string[]
|
||||||
|
merchantName: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预约表单草稿值 */
|
||||||
|
interface AppointmentForm {
|
||||||
|
appointmentDate: string
|
||||||
|
personCount: number
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM: AppointmentForm = {
|
||||||
|
appointmentDate: '',
|
||||||
|
personCount: 1,
|
||||||
|
contactName: '',
|
||||||
|
contactPhone: '',
|
||||||
|
remark: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CourseDetailPage: React.FC = () => {
|
||||||
|
const [detail, setDetail] = useState<CourseDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAppointment, setShowAppointment] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<AppointmentForm>(INITIAL_FORM)
|
||||||
|
// 滚动超过阈值后导航栏切换为主题色实底
|
||||||
|
const [navSolid, setNavSolid] = useState(false)
|
||||||
|
// 自定义导航运行时高度,封面顶部让出对应空间
|
||||||
|
const { totalHeight: navTotalHeight } = useNavBarMetrics()
|
||||||
|
|
||||||
|
const courseId = Taro.getCurrentInstance().router?.params?.id
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (courseId) {
|
||||||
|
fetchDetail(courseId)
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '课程参数缺失', icon: 'none' })
|
||||||
|
setTimeout(() => Taro.navigateBack(), 1500)
|
||||||
|
}
|
||||||
|
}, [courseId])
|
||||||
|
|
||||||
|
/** 拉取课程详情 */
|
||||||
|
async function fetchDetail(id: string) {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await get<CourseDetail>(`/api/mini/course/${id}`)
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
setDetail(resp.data)
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: resp.message || '课程不存在', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[课程详情] 加载失败:', err)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化排课文本 */
|
||||||
|
const formatSchedule = (): string => {
|
||||||
|
if (!detail) return ''
|
||||||
|
if (detail.scheduleType === 1 && detail.scheduleWeekDays) {
|
||||||
|
const dayMap: Record<string, string> = {
|
||||||
|
'1': '周一', '2': '周二', '3': '周三', '4': '周四',
|
||||||
|
'5': '周五', '6': '周六', '7': '周日',
|
||||||
|
}
|
||||||
|
const days = detail.scheduleWeekDays.split(';').map(d => dayMap[d] || d)
|
||||||
|
return `每周 ${days.join('、')}`
|
||||||
|
}
|
||||||
|
if (detail.scheduleType === 2 && detail.scheduleDates) {
|
||||||
|
const dates = detail.scheduleDates.split(';')
|
||||||
|
return dates.join('、')
|
||||||
|
}
|
||||||
|
return '排课待定'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击"立即预约" */
|
||||||
|
const handleAppointment = async () => {
|
||||||
|
const isLoggedIn = await ensureLogin()
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
setShowAppointment(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交预约 */
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!detail) return
|
||||||
|
|
||||||
|
// 表单校验
|
||||||
|
if (!form.appointmentDate) {
|
||||||
|
Taro.showToast({ title: '请选择预约时间', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.contactName.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入联系人姓名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.contactPhone.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入联系电话', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const resp = await post<number>('/api/mini/course/appointment', {
|
||||||
|
courseId: detail.id,
|
||||||
|
appointmentDate: `${form.appointmentDate} 09:00:00`,
|
||||||
|
personCount: form.personCount,
|
||||||
|
contactName: form.contactName.trim(),
|
||||||
|
contactPhone: form.contactPhone.trim(),
|
||||||
|
remark: form.remark.trim() || undefined,
|
||||||
|
})
|
||||||
|
if (resp.code === 200) {
|
||||||
|
Taro.showToast({ title: '预约成功', icon: 'success' })
|
||||||
|
setShowAppointment(false)
|
||||||
|
setForm(INITIAL_FORM)
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: resp.message || '预约失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Taro.showToast({ title: err.message || '预约失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览课程图片 */
|
||||||
|
const handleImagePreview = (urls: string[], index: number) => {
|
||||||
|
if (urls.length === 0) return
|
||||||
|
Taro.previewImage({ urls, current: urls[index] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className={styles.loadingPage}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<View className={styles.errorPage}>
|
||||||
|
<Text>课程不存在</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryImages = detail.imageUrls || []
|
||||||
|
const canBook = detail.status === 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* 自定义导航:子页显示返回按钮,封面区透明融入,滚动后切换实底 */}
|
||||||
|
<CustomNavBar title={detail?.name ? '课程详情' : ''} solid={navSolid} showBack />
|
||||||
|
|
||||||
|
<ScrollView scrollY className={styles.content} onScroll={(e) => setNavSolid(e.detail.scrollTop > 200)}>
|
||||||
|
{/* 封面图:顶部让出导航高度,导航悬浮其上 */}
|
||||||
|
<View className={styles.coverWrap} style={{ paddingTop: `${navTotalHeight}px` }}>
|
||||||
|
{detail.coverUrl ? (
|
||||||
|
<Image className={styles.coverImage} src={detail.coverUrl} mode='aspectFill' />
|
||||||
|
) : (
|
||||||
|
<View className={styles.coverPlaceholder}>
|
||||||
|
<Text className={styles.coverPlaceholderText}>课程封面</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 课程基本信息 */}
|
||||||
|
<View className={styles.infoCard}>
|
||||||
|
<Text className={styles.courseName}>{detail.name}</Text>
|
||||||
|
<View className={styles.tagRow}>
|
||||||
|
{detail.categoryName && (
|
||||||
|
<View className={styles.categoryTag}>
|
||||||
|
<Text>{detail.categoryName}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{detail.targetAge && (
|
||||||
|
<View className={styles.ageTag}>
|
||||||
|
<Text>{detail.targetAge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className={styles.priceRow}>
|
||||||
|
<View className={styles.priceWrap}>
|
||||||
|
<Text className={styles.priceSymbol}>¥</Text>
|
||||||
|
<Text className={styles.priceValue}>{detail.price}</Text>
|
||||||
|
</View>
|
||||||
|
{detail.merchantName && (
|
||||||
|
<Text className={styles.merchantName}>{detail.merchantName}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 排课信息 */}
|
||||||
|
<View className={styles.sectionCard}>
|
||||||
|
<View className={styles.sectionHeader}>
|
||||||
|
<Text className={styles.sectionTitle}>排课信息</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.sectionContent}>{formatSchedule()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 上课地点 */}
|
||||||
|
{detail.location && (
|
||||||
|
<View className={styles.sectionCard}>
|
||||||
|
<View className={styles.sectionHeader}>
|
||||||
|
<Text className={styles.sectionTitle}>上课地点</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.sectionContent}>{detail.location}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 课程图片 */}
|
||||||
|
{galleryImages.length > 0 && (
|
||||||
|
<View className={styles.sectionCard}>
|
||||||
|
<View className={styles.sectionHeader}>
|
||||||
|
<Text className={styles.sectionTitle}>课程图片</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollX className={styles.galleryScroll} enhanced showScrollbar={false}>
|
||||||
|
{galleryImages.map((url, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
className={styles.galleryItem}
|
||||||
|
onClick={() => handleImagePreview(galleryImages, idx)}
|
||||||
|
>
|
||||||
|
<Image className={styles.galleryImage} src={url} mode='aspectFill' lazyLoad />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 课程简介:始终展示,空值时显示占位提示;内容为 HTML 富文本,用 RichText 渲染 */}
|
||||||
|
<View className={styles.sectionCard}>
|
||||||
|
<View className={styles.sectionHeader}>
|
||||||
|
<Text className={styles.sectionTitle}>课程简介</Text>
|
||||||
|
</View>
|
||||||
|
{detail.description ? (
|
||||||
|
<RichText className={styles.descriptionContent} nodes={detail.description} />
|
||||||
|
) : (
|
||||||
|
<Text className={styles.descriptionPlaceholder}>课程简介待商家补充</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 咨询联系 */}
|
||||||
|
{(detail.contactName || detail.contactPhone) && (
|
||||||
|
<View className={styles.sectionCard}>
|
||||||
|
<View className={styles.sectionHeader}>
|
||||||
|
<Text className={styles.sectionTitle}>咨询联系</Text>
|
||||||
|
</View>
|
||||||
|
{detail.contactName && (
|
||||||
|
<Text className={styles.contactText}>联系人:{detail.contactName}</Text>
|
||||||
|
)}
|
||||||
|
{detail.contactPhone && (
|
||||||
|
<Text className={styles.contactText}>联系电话:{detail.contactPhone}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部占位,防止被固定按钮遮挡 */}
|
||||||
|
<View className={styles.bottomSpacer} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部预约按钮 */}
|
||||||
|
<View className={styles.bottomBar}>
|
||||||
|
<View
|
||||||
|
className={`${styles.appointBtn} ${!canBook ? styles.appointBtnDisabled : ''}`}
|
||||||
|
onClick={canBook ? handleAppointment : undefined}
|
||||||
|
>
|
||||||
|
<Text>{canBook ? '立即预约' : '暂不可预约'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 预约弹窗 */}
|
||||||
|
{showAppointment && (
|
||||||
|
<View className={styles.mask} onClick={() => setShowAppointment(false)}>
|
||||||
|
<View className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<View className={styles.modalHeader}>
|
||||||
|
<Text className={styles.modalTitle}>课程预约</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollY className={styles.modalBody}>
|
||||||
|
{/* 预约日期 */}
|
||||||
|
<View className={styles.formItem}>
|
||||||
|
<Text className={styles.formLabel}>预约日期</Text>
|
||||||
|
<Picker
|
||||||
|
mode='date'
|
||||||
|
value={form.appointmentDate}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, appointmentDate: e.detail.value }))}
|
||||||
|
>
|
||||||
|
<View className={styles.pickerValue}>
|
||||||
|
<Text className={form.appointmentDate ? styles.pickerText : styles.pickerPlaceholder}>
|
||||||
|
{form.appointmentDate || '请选择日期'}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.pickerArrow}>▼</Text>
|
||||||
|
</View>
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 预约人数 */}
|
||||||
|
<View className={styles.formItem}>
|
||||||
|
<Text className={styles.formLabel}>预约人数</Text>
|
||||||
|
<View className={styles.stepper}>
|
||||||
|
<View
|
||||||
|
className={styles.stepperBtn}
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, personCount: Math.max(1, prev.personCount - 1) }))}
|
||||||
|
>
|
||||||
|
<Text>−</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.stepperValue}>{form.personCount}</Text>
|
||||||
|
<View
|
||||||
|
className={styles.stepperBtn}
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, personCount: prev.personCount + 1 }))}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 联系人姓名 */}
|
||||||
|
<View className={styles.formItem}>
|
||||||
|
<Text className={styles.formLabel}>联系人姓名</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.formInput}
|
||||||
|
placeholder='请输入姓名'
|
||||||
|
value={form.contactName}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, contactName: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 联系电话 */}
|
||||||
|
<View className={styles.formItem}>
|
||||||
|
<Text className={styles.formLabel}>联系电话</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.formInput}
|
||||||
|
type='number'
|
||||||
|
placeholder='请输入手机号'
|
||||||
|
maxlength={11}
|
||||||
|
value={form.contactPhone}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, contactPhone: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<View className={styles.formItem}>
|
||||||
|
<Text className={styles.formLabel}>备注(选填)</Text>
|
||||||
|
<Textarea
|
||||||
|
className={styles.formTextarea}
|
||||||
|
placeholder='如有特殊需求请备注'
|
||||||
|
maxlength={200}
|
||||||
|
value={form.remark}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, remark: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 金额预览 */}
|
||||||
|
<View className={styles.amountPreview}>
|
||||||
|
<Text className={styles.amountLabel}>预计金额</Text>
|
||||||
|
<View className={styles.amountValue}>
|
||||||
|
<Text className={styles.amountSymbol}>¥</Text>
|
||||||
|
<Text className={styles.amountNumber}>
|
||||||
|
{(detail.price * form.personCount).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<View className={styles.modalFooter}>
|
||||||
|
<View className={styles.cancelBtn} onClick={() => setShowAppointment(false)}>
|
||||||
|
<Text>取消</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.confirmBtn} ${submitting ? styles.confirmBtnDisabled : ''}`}
|
||||||
|
onClick={submitting ? undefined : handleSubmit}
|
||||||
|
>
|
||||||
|
<Text>{submitting ? '提交中...' : '确认预约'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourseDetailPage
|
||||||
4
src/pages/course/index.config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '研学课程',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
543
src/pages/course/index.module.scss
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 渐变 Hero 头部 ====================
|
||||||
|
// 自定义导航(透明)悬浮其上,渐变从状态栏起一气呵成;
|
||||||
|
// 顶部内边距 = 导航运行时总高度,由页面以内联样式动态注入
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 $page-padding 96rpx;
|
||||||
|
background: linear-gradient(180deg, $color-primary 0%, $color-primary-light 100%);
|
||||||
|
border-radius: 0 0 32rpx 32rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
// flex column 中防止被 flex:1 的子元素压缩
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上角半透明装饰圆,纯视觉层次
|
||||||
|
.heroDecor {
|
||||||
|
position: absolute;
|
||||||
|
top: -80rpx;
|
||||||
|
right: -60rpx;
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSubtitle {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 76rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: $radius-round;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
flex: 1;
|
||||||
|
height: 76rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPlaceholder {
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 课程列表容器 ====================
|
||||||
|
// flex:1 填满 hero + tabBar 以下剩余空间,负 margin 不再适用(tabBar 已插在 hero 与 listContainer 之间)
|
||||||
|
|
||||||
|
.listContainer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
// 之前的 margin-top: -48rpx 是为了上叠 Hero 圆角制造悬浮感,
|
||||||
|
// 但 tabBar 已插在中间,负 margin 会让 ScrollView 顶部钻到 tabBar 下方造成遮挡,
|
||||||
|
// 改为 0,让 tabBar 和 ScrollView 正常衔接
|
||||||
|
margin-top: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardList {
|
||||||
|
padding: 8rpx $page-padding 48rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 课程卡片(杂志风全图大卡) ====================
|
||||||
|
|
||||||
|
.courseCard {
|
||||||
|
background: $color-bg-card;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
// 显性卡片边界:不依赖阴影,半透明主题紫在任何背景上都可感知
|
||||||
|
border: 1rpx solid rgba(94, 70, 246, 0.12);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 媒体区:300rpx 大封面 + 徽章 ----------
|
||||||
|
|
||||||
|
.cardMedia {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 300rpx;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
// 媒体区 ↔ 信息区分割线:半透明主题紫,任何底图上都清晰可辨
|
||||||
|
border-bottom: 1rpx solid rgba(94, 70, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
@include flex-center;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPlaceholderText {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: $color-text-white;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左上角:分类徽章(白色玻璃 chip)
|
||||||
|
.badgeCategory {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
left: 20rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上角:适龄徽章(深色半透明 chip,与白色分类徽章形成对比)
|
||||||
|
.badgeAge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
right: 20rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: rgba(26, 23, 48, 0.55);
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-white;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 价格丝带标签:钉在封面右下角 ----------
|
||||||
|
|
||||||
|
.priceTag {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||||
|
border-radius: $radius-xl 0 0 0; // 仅左上圆角,与卡片右上圆角呼应成丝带切角
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(94, 70, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 免费课程:绿色标签,与付费紫色区分
|
||||||
|
.priceTagFree {
|
||||||
|
background: $color-success;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSymbol {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-right: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-white;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceFree {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-white;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 信息区 ----------
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
position: relative;
|
||||||
|
padding: 16rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 课程名称行:直接展示名称正文
|
||||||
|
.nameRow {
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.courseName {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元信息行:统一 key-value 布局——固定宽度灰色标签 + 可截断值文本
|
||||||
|
.metaRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-weight: $font-weight-normal;
|
||||||
|
// 宽度由内容撑开,与右侧值之间保持固定小间距
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部操作行:商家名 + 查看详情胶囊
|
||||||
|
.cardFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
// 元信息 ↔ 底部操作行:半透明主题紫,强化信息区与操作区的区块边界
|
||||||
|
border-top: 1rpx solid rgba(94, 70, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 56rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-primary;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
// 商家名缺失时按钮依然右对齐,保持操作行视觉稳定
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态占位 ====================
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 140rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同心圆装饰占位图形
|
||||||
|
.emptyCircle {
|
||||||
|
width: 132rpx;
|
||||||
|
height: 132rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCircleInner {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-primary-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingMore,
|
||||||
|
.noMore {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tab 栏 ====================
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
// flex column 中防止被 flex:1 的 ScrollView 压缩
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 0 16rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.tabText {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tabItemActive .tabText {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIndicator {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background: linear-gradient(90deg, $color-primary, $color-primary-light);
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 我的预约卡片 ====================
|
||||||
|
|
||||||
|
.apptCardList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 0 $page-padding 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptCard {
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(94, 70, 246, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptMedia {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #c0c0c0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptBody {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptCourseName {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
flex: 1;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptStatus {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
|
||||||
|
&.apptStatusActive {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.apptStatusCancelled {
|
||||||
|
background: #f2f2f2;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptInfoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptInfoLabel {
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
width: 120rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptInfoValue {
|
||||||
|
color: $color-text-secondary;
|
||||||
|
flex: 1;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptAmount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptAmountSymbol {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptAmountValue {
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptCancelBtn {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apptDisabledBtn {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 底部占位(避开原生 tabBar) ====================
|
||||||
|
|
||||||
|
// 原生 tabBar 页面,滚动列表底部必须有占位,否则最后几张卡片被 tabBar 盖住
|
||||||
|
.pageBottom {
|
||||||
|
height: 180rpx;
|
||||||
|
}
|
||||||
515
src/pages/course/index.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
|
||||||
|
import { get, post } from '@/services/request'
|
||||||
|
import CustomNavBar, { useNavBarMetrics } from '@/components/CustomNavBar'
|
||||||
|
import sousuoIcon from '@/assets/sousuo.png'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 课程列表项,与后端 MiniCourseVO 对齐 */
|
||||||
|
interface CourseItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
coverUrl: string
|
||||||
|
price: number
|
||||||
|
category: number
|
||||||
|
categoryName: string
|
||||||
|
targetAge: string
|
||||||
|
location: string
|
||||||
|
scheduleType: number
|
||||||
|
scheduleWeekDays: string
|
||||||
|
scheduleDates: string
|
||||||
|
merchantName: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预约列表项,与后端 CourseAppointmentVO 对齐(二态:1已预约 2已取消) */
|
||||||
|
interface AppointmentItem {
|
||||||
|
id: number
|
||||||
|
courseId: number
|
||||||
|
courseName: string
|
||||||
|
coverUrl: string
|
||||||
|
appointmentDate: string
|
||||||
|
personCount: number
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
amount: number
|
||||||
|
status: number
|
||||||
|
statusName: string
|
||||||
|
remark: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应结构 */
|
||||||
|
interface PageData<T> {
|
||||||
|
list: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
pageNo: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
/** 预约状态编码(与后端二态模型对齐) */
|
||||||
|
const APPT_STATUS = {
|
||||||
|
ACTIVE: 1,
|
||||||
|
CANCELLED: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预约状态 → 样式 class 后缀(绿色=已预约 / 灰色=已取消) */
|
||||||
|
const APPT_STATUS_CLASS: Record<number, string> = {
|
||||||
|
[APPT_STATUS.ACTIVE]: 'apptStatusActive',
|
||||||
|
[APPT_STATUS.CANCELLED]: 'apptStatusCancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoursePage: React.FC = () => {
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [list, setList] = useState<CourseItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [pageNo, setPageNo] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
// 列表滚动超过阈值后导航栏由透明切换为主题色底,保证标题可读
|
||||||
|
const [navSolid, setNavSolid] = useState(false)
|
||||||
|
// 自定义导航运行时高度,Hero 顶部让出对应空间
|
||||||
|
const { totalHeight: navTotalHeight } = useNavBarMetrics()
|
||||||
|
|
||||||
|
// ==================== Tab:课程列表 / 我的预约 ====================
|
||||||
|
const [activeTab, setActiveTab] = useState<'course' | 'appointment'>('course')
|
||||||
|
|
||||||
|
// ==================== 我的预约状态 ====================
|
||||||
|
const [apptList, setApptList] = useState<AppointmentItem[]>([])
|
||||||
|
const [apptLoading, setApptLoading] = useState(false)
|
||||||
|
const [apptRefreshing, setApptRefreshing] = useState(false)
|
||||||
|
const [apptPageNo, setApptPageNo] = useState(0)
|
||||||
|
const [apptHasMore, setApptHasMore] = useState(true)
|
||||||
|
// 标记预约列表是否已至少加载过一次(避免切到 tab 就重复首屏请求)
|
||||||
|
const apptLoadedOnceRef = useRef(false)
|
||||||
|
|
||||||
|
/** 加载课程列表 */
|
||||||
|
const loadList = useCallback(async (page: number, isRefresh: boolean) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
pageNo: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}
|
||||||
|
if (searchText.trim()) {
|
||||||
|
params.name = searchText.trim()
|
||||||
|
}
|
||||||
|
const resp = await get<PageData<CourseItem>>('/api/mini/course/list', params)
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
const newList = resp.data.list || []
|
||||||
|
setList(prev => isRefresh ? newList : [...prev, ...newList])
|
||||||
|
setPageNo(resp.data.pageNo)
|
||||||
|
setHasMore(resp.data.pageNo < resp.data.totalPages - 1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[研学课程] 加载失败:', err)
|
||||||
|
Taro.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [searchText])
|
||||||
|
|
||||||
|
/** 搜索变化时重新加载(防抖 400ms);首帧跳过,由 useDidShow 统一负责首次加载,避免重复请求 */
|
||||||
|
const skipFirstSearchRef = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipFirstSearchRef.current) {
|
||||||
|
skipFirstSearchRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
loadList(0, true)
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchText])
|
||||||
|
|
||||||
|
/** 每次页面显示时刷新(从详情页返回时自动更新) */
|
||||||
|
useDidShow(() => {
|
||||||
|
loadList(0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 下拉刷新 */
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadList(0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上拉加载更多 */
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (!hasMore || loading || refreshing) return
|
||||||
|
loadList(pageNo + 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击课程卡片跳转详情 */
|
||||||
|
const handleCardClick = (id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/course-detail/index?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 我的预约:加载 / 刷新 / 加载更多 ====================
|
||||||
|
|
||||||
|
/** 加载预约列表(复用列表的分页模式) */
|
||||||
|
const loadApptList = useCallback(async (page: number, isRefresh: boolean) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
setApptRefreshing(true)
|
||||||
|
} else {
|
||||||
|
setApptLoading(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await get<PageData<AppointmentItem>>('/api/mini/course/appointments', {
|
||||||
|
pageNo: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
const newList = resp.data.list || []
|
||||||
|
setApptList(prev => isRefresh ? newList : [...prev, ...newList])
|
||||||
|
setApptPageNo(resp.data.pageNo)
|
||||||
|
setApptHasMore(resp.data.pageNo < resp.data.totalPages - 1)
|
||||||
|
apptLoadedOnceRef.current = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[我的预约] 加载失败:', err)
|
||||||
|
Taro.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setApptLoading(false)
|
||||||
|
setApptRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 切换到"我的预约" Tab 且尚未加载过时,触发首屏请求 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'appointment' && !apptLoadedOnceRef.current) {
|
||||||
|
loadApptList(0, true)
|
||||||
|
}
|
||||||
|
}, [activeTab, loadApptList])
|
||||||
|
|
||||||
|
/** 下拉刷新(预约列表) */
|
||||||
|
const handleApptRefresh = () => {
|
||||||
|
loadApptList(0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上拉加载更多(预约列表) */
|
||||||
|
const handleApptLoadMore = () => {
|
||||||
|
if (!apptHasMore || apptLoading || apptRefreshing) return
|
||||||
|
loadApptList(apptPageNo + 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消我的预约:带二次确认,调用后刷新列表。
|
||||||
|
* 仅允许取消 status=1(已预约)的预约;已取消或非本人预约由后端拦截。
|
||||||
|
*/
|
||||||
|
const handleCancelAppointment = async (appointmentId: number) => {
|
||||||
|
try {
|
||||||
|
await Taro.showModal({
|
||||||
|
title: '取消预约',
|
||||||
|
content: '确定要取消这条课程预约吗?取消后可以重新预约。',
|
||||||
|
confirmText: '取消预约',
|
||||||
|
confirmColor: '#f56c6c',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 用户点"再想想"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await post<void>(`/api/mini/course/appointment/${appointmentId}/cancel`)
|
||||||
|
if (resp.code === 200) {
|
||||||
|
Taro.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
// 刷新预约列表(从第一页开始)
|
||||||
|
loadApptList(0, true)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: err?.data?.msg || '取消失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化排课文本,无排课信息返回空串(行隐藏) */
|
||||||
|
const formatSchedule = (item: CourseItem): string => {
|
||||||
|
if (item.scheduleType === 1 && item.scheduleWeekDays) {
|
||||||
|
const days = item.scheduleWeekDays.split(';').map(d => {
|
||||||
|
const dayMap: Record<string, string> = {
|
||||||
|
'1': '周一', '2': '周二', '3': '周三', '4': '周四',
|
||||||
|
'5': '周五', '6': '周六', '7': '周日',
|
||||||
|
}
|
||||||
|
return dayMap[d] || d
|
||||||
|
})
|
||||||
|
return `每周 ${days.join('、')}`
|
||||||
|
}
|
||||||
|
if (item.scheduleType === 2 && item.scheduleDates) {
|
||||||
|
const dates = item.scheduleDates.split(';')
|
||||||
|
if (dates.length === 1) return dates[0]
|
||||||
|
return `${dates[0]} 等${dates.length}天`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 价格展示:0 元显示"免费"(绿色标签),其余显示 ¥xxx(紫色渐变标签) */
|
||||||
|
const renderPriceTag = (price: number) => {
|
||||||
|
const isFree = !price || price <= 0
|
||||||
|
return (
|
||||||
|
<View className={isFree ? `${styles.priceTag} ${styles.priceTagFree}` : styles.priceTag}>
|
||||||
|
{isFree ? (
|
||||||
|
<Text className={styles.priceFree}>免费</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className={styles.priceSymbol}>¥</Text>
|
||||||
|
<Text className={styles.priceValue}>{price}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* 自定义导航:顶部透明融入渐变,滚动后切换主题色底 */}
|
||||||
|
<CustomNavBar title='研学课程' solid={navSolid} />
|
||||||
|
|
||||||
|
{/* 渐变 Hero 头部:从状态栏起一气呵成,内嵌副标题 + 搜索框 */}
|
||||||
|
<View className={styles.hero} style={{ paddingTop: `${navTotalHeight + 8}px` }}>
|
||||||
|
<View className={styles.heroDecor} />
|
||||||
|
<Text className={styles.heroSubtitle}>探索优质研学资源 · 点亮孩子的科技梦想</Text>
|
||||||
|
<View className={styles.searchInput}>
|
||||||
|
<Image src={sousuoIcon} className={styles.searchIcon} mode='aspectFit' />
|
||||||
|
<Input
|
||||||
|
className={styles.searchField}
|
||||||
|
placeholder='搜索课程名称'
|
||||||
|
placeholderClass={styles.searchPlaceholder}
|
||||||
|
value={searchText}
|
||||||
|
onInput={(e) => setSearchText(e.detail.value)}
|
||||||
|
confirmType='search'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 顶部 Tab 栏:课程列表 / 我的预约 */}
|
||||||
|
<View className={styles.tabBar}>
|
||||||
|
<View
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'course' ? styles.tabItemActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('course')}
|
||||||
|
>
|
||||||
|
<Text className={styles.tabText}>课程列表</Text>
|
||||||
|
{activeTab === 'course' && <View className={styles.tabIndicator} />}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'appointment' ? styles.tabItemActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('appointment')}
|
||||||
|
>
|
||||||
|
<Text className={styles.tabText}>我的预约</Text>
|
||||||
|
{activeTab === 'appointment' && <View className={styles.tabIndicator} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 课程列表 Tab:负 margin 上叠 Hero,制造卡片悬浮纵深 */}
|
||||||
|
{activeTab === 'course' && (
|
||||||
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className={styles.listContainer}
|
||||||
|
refresherEnabled
|
||||||
|
refresherTriggered={refreshing}
|
||||||
|
onRefresherRefresh={handleRefresh}
|
||||||
|
onScrollToLower={handleLoadMore}
|
||||||
|
onScroll={(e) => setNavSolid(e.detail.scrollTop > 60)}
|
||||||
|
lowerThreshold={100}
|
||||||
|
>
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className={styles.emptyState}>
|
||||||
|
<View className={styles.emptyCircle}>
|
||||||
|
<View className={styles.emptyCircleInner} />
|
||||||
|
</View>
|
||||||
|
<Text className={styles.emptyText}>暂无相关课程</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className={styles.cardList}>
|
||||||
|
{list.map(item => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={styles.courseCard}
|
||||||
|
onClick={() => handleCardClick(item.id)}
|
||||||
|
>
|
||||||
|
{/* 大图媒体区:封面 + 分类/适龄徽章 */}
|
||||||
|
<View className={styles.cardMedia}>
|
||||||
|
{item.coverUrl ? (
|
||||||
|
<Image
|
||||||
|
className={styles.mediaImage}
|
||||||
|
src={item.coverUrl}
|
||||||
|
mode='aspectFill'
|
||||||
|
lazyLoad
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className={styles.mediaPlaceholder}>
|
||||||
|
<Text className={styles.mediaPlaceholderText}>研学课程</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.categoryName && (
|
||||||
|
<View className={styles.badgeCategory}>
|
||||||
|
<Text>{item.categoryName}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.targetAge && (
|
||||||
|
<View className={styles.badgeAge}>
|
||||||
|
<Text>{item.targetAge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 价格丝带标签:固定于封面右下角,任何底图上都清晰可辨 */}
|
||||||
|
{renderPriceTag(item.price)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 信息区:标题 + 带标签的 key-value 信息行 + 底部操作行 */}
|
||||||
|
<View className={styles.cardBody}>
|
||||||
|
{/* 课程名称:直接展示名称正文,不再冗余标签 */}
|
||||||
|
<View className={styles.nameRow}>
|
||||||
|
<Text className={styles.courseName}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
{/* 课程安排:标签 + 排课值 */}
|
||||||
|
{formatSchedule(item) && (
|
||||||
|
<View className={styles.metaRow}>
|
||||||
|
<Text className={styles.metaLabel}>课程安排</Text>
|
||||||
|
<Text className={styles.metaValue}>{formatSchedule(item)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 上课地点:标签 + 地点值 */}
|
||||||
|
{item.location && (
|
||||||
|
<View className={styles.metaRow}>
|
||||||
|
<Text className={styles.metaLabel}>上课地点</Text>
|
||||||
|
<Text className={styles.metaValue}>{item.location}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className={styles.cardFooter}>
|
||||||
|
{item.merchantName && (
|
||||||
|
<Text className={styles.merchantName}>{item.merchantName}</Text>
|
||||||
|
)}
|
||||||
|
<View className={styles.detailBtn}>
|
||||||
|
<Text>查看详情</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<View className={styles.loadingMore}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!hasMore && list.length > 0 && (
|
||||||
|
<View className={styles.noMore}>
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 底部占位:避开原生 tabBar,保证最后一张卡片滚到 tabBar 上方 */}
|
||||||
|
<View className={styles.pageBottom} />
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 我的预约 Tab */}
|
||||||
|
{activeTab === 'appointment' && (
|
||||||
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className={styles.listContainer}
|
||||||
|
refresherEnabled
|
||||||
|
refresherTriggered={apptRefreshing}
|
||||||
|
onRefresherRefresh={handleApptRefresh}
|
||||||
|
onScrollToLower={handleApptLoadMore}
|
||||||
|
lowerThreshold={100}
|
||||||
|
>
|
||||||
|
{apptList.length === 0 && !apptLoading && apptLoadedOnceRef.current ? (
|
||||||
|
<View className={styles.emptyState}>
|
||||||
|
<View className={styles.emptyCircle}>
|
||||||
|
<View className={styles.emptyCircleInner} />
|
||||||
|
</View>
|
||||||
|
<Text className={styles.emptyText}>还没有预约记录</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className={styles.apptCardList}>
|
||||||
|
{apptList.map(appt => {
|
||||||
|
const isActive = appt.status === APPT_STATUS.ACTIVE
|
||||||
|
return (
|
||||||
|
<View key={appt.id} className={styles.apptCard}>
|
||||||
|
{/* 左侧封面 */}
|
||||||
|
<View className={styles.apptMedia}>
|
||||||
|
{appt.coverUrl ? (
|
||||||
|
<Image
|
||||||
|
className={styles.apptImage}
|
||||||
|
src={appt.coverUrl}
|
||||||
|
mode='aspectFill'
|
||||||
|
lazyLoad
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className={styles.apptPlaceholder}>
|
||||||
|
<Text>课程</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧信息 */}
|
||||||
|
<View className={styles.apptBody}>
|
||||||
|
<View className={styles.apptHeader}>
|
||||||
|
<Text className={styles.apptCourseName}>{appt.courseName}</Text>
|
||||||
|
<Text className={`${styles.apptStatus} ${styles[APPT_STATUS_CLASS[appt.status] || 'apptStatusCancelled']}`}>
|
||||||
|
{appt.statusName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.apptInfoRow}>
|
||||||
|
<Text className={styles.apptInfoLabel}>预约时间</Text>
|
||||||
|
<Text className={styles.apptInfoValue}>{appt.appointmentDate?.slice(0, 16).replace('T', ' ')}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.apptInfoRow}>
|
||||||
|
<Text className={styles.apptInfoLabel}>预约人数</Text>
|
||||||
|
<Text className={styles.apptInfoValue}>{appt.personCount} 人</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.apptFooter}>
|
||||||
|
<View className={styles.apptAmount}>
|
||||||
|
<Text className={styles.apptAmountSymbol}>¥</Text>
|
||||||
|
<Text className={styles.apptAmountValue}>{appt.amount}</Text>
|
||||||
|
</View>
|
||||||
|
{isActive ? (
|
||||||
|
<View
|
||||||
|
className={styles.apptCancelBtn}
|
||||||
|
onClick={() => handleCancelAppointment(appt.id)}
|
||||||
|
>
|
||||||
|
<Text>取消预约</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className={styles.apptDisabledBtn}>
|
||||||
|
<Text>已取消</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{apptLoading && (
|
||||||
|
<View className={styles.loadingMore}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!apptHasMore && apptList.length > 0 && (
|
||||||
|
<View className={styles.noMore}>
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* 底部占位:避开原生 tabBar */}
|
||||||
|
<View className={styles.pageBottom} />
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoursePage
|
||||||
7
src/pages/exam/index.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** 培训考试主页面 */
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '培训考试',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
})
|
||||||
417
src/pages/exam/index.module.scss
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
/* 培训考试主页面 */
|
||||||
|
|
||||||
|
/* ========== 主题色 Token(与项目全局紫色体系统一) ========== */
|
||||||
|
/* 主色 #5E46F6 / 亮紫 #7C6AF8 / 背景 #F5F3FF / 卡片白 #FFFFFF */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #F5F3FF;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Hero(深紫渐变,与项目主色呼应) ========== */
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #3D2AA8 0%, #5E46F6 55%, #7C6AF8 100%);
|
||||||
|
padding: 32rpx 40rpx 48rpx;
|
||||||
|
border-bottom-left-radius: 40rpx;
|
||||||
|
border-bottom-right-radius: 40rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* 左上装饰光晕 */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -120rpx; left: -100rpx;
|
||||||
|
width: 340rpx; height: 340rpx;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,0.18), transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
/* 右下装饰光晕 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -100rpx; bottom: -100rpx;
|
||||||
|
width: 360rpx; height: 360rpx;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,0.12), transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSubtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroDesc {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Tab 胶囊(兄弟节点,负 margin 侵入 hero 圆角区域) ========== */
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
box-shadow: 0 8rpx 28rpx rgba(61, 42, 168, 0.18);
|
||||||
|
margin: -24rpx 40rpx 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
flex: 1;
|
||||||
|
height: 68rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6B6680;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
&.tabActive {
|
||||||
|
background: linear-gradient(135deg, #5E46F6, #7C6AF8);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(94, 70, 246, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 内容区 ========== */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28rpx 32rpx 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingWrap {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 160rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 搜索框 ========== */
|
||||||
|
.searchWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(94, 70, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
margin-right: 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPlaceholder {
|
||||||
|
color: #B4B0C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 标题卡片(淡紫渐变 + 左竖线 ========== */
|
||||||
|
.sectionHead {
|
||||||
|
background: linear-gradient(135deg, rgba(94,70,246,0.08), rgba(124,106,248,0.04));
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border-left: 6rpx solid #5E46F6;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F1335;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8B85A8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 培训门店卡片 ========== */
|
||||||
|
.storeCard {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(94, 70, 246, 0.08);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeCover {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeCoverPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
background: linear-gradient(135deg, #EDE9FE, #DDD6FE);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5E46F6;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeInfo {
|
||||||
|
padding: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeName {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F1335;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
color: #B4B0C4;
|
||||||
|
width: 120rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
color: #374151;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaPhone {
|
||||||
|
font-family: Menlo, Monaco, monospace;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeContent {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-top: 18rpx;
|
||||||
|
border-top: 1rpx solid #F3F0FA;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B6680;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 题库卡片 ========== */
|
||||||
|
.bankList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankCard {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 26rpx;
|
||||||
|
display: flex;
|
||||||
|
gap: 22rpx;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(94, 70, 246, 0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(94, 70, 246, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankIcon {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* 色块上的柔光 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx; left: -20rpx;
|
||||||
|
width: 80rpx; height: 80rpx;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankIconText {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankInfo {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankName {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F1335;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankFreeTag {
|
||||||
|
background: linear-gradient(135deg, #5E46F6, #7C6AF8);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(94, 70, 246, 0.3);
|
||||||
|
|
||||||
|
Text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankDesc {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8B85A8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankQuestionStats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statTag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: rgba(94, 70, 246, 0.08);
|
||||||
|
color: #5E46F6;
|
||||||
|
|
||||||
|
.statNum {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPractice {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
border: 1.5rpx solid rgba(94, 70, 246, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(94, 70, 246, 0.04);
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #5E46F6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(94, 70, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnMock {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: linear-gradient(135deg, #5E46F6, #7C6AF8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(94, 70, 246, 0.35);
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(1rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/pages/exam/index.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import sousuoIcon from '@/assets/sousuo.png'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface BankItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
difficulty: number
|
||||||
|
difficultyName: string
|
||||||
|
questionCount: number
|
||||||
|
singleCount: number
|
||||||
|
multiCount: number
|
||||||
|
judgeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrainingStore {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
trainingContent: string
|
||||||
|
coverUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamPage: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'org' | 'bank'>('org')
|
||||||
|
const [banks, setBanks] = useState<BankItem[]>([])
|
||||||
|
const [banksLoading, setBanksLoading] = useState(false)
|
||||||
|
const [store, setStore] = useState<TrainingStore | null>(null)
|
||||||
|
const [storeLoading, setStoreLoading] = useState(false)
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
|
const loadBanks = useCallback(async () => {
|
||||||
|
setBanksLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await get<BankItem[]>('/api/mini/exam/banks')
|
||||||
|
if (resp.code === 200 && resp.data) setBanks(resp.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[培训考试] 加载题库失败:', err)
|
||||||
|
} finally { setBanksLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadStore = useCallback(async () => {
|
||||||
|
setStoreLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await get<TrainingStore>('/api/mini/exam/training')
|
||||||
|
if (resp.code === 200) setStore(resp.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[培训考试] 加载门店失败:', err)
|
||||||
|
} finally { setStoreLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useDidShow(() => { loadBanks(); loadStore() })
|
||||||
|
|
||||||
|
const handlePractice = (bankId: number) =>
|
||||||
|
Taro.navigateTo({ url: `/pages/exam/practice?bankId=${bankId}&mode=practice` })
|
||||||
|
const handleMockExam = (bankId: number) =>
|
||||||
|
Taro.navigateTo({ url: `/pages/exam/practice?bankId=${bankId}&mode=exam` })
|
||||||
|
|
||||||
|
const renderBankIcon = (bank: BankItem) => {
|
||||||
|
const gradients: Record<number, string> = {
|
||||||
|
1: 'linear-gradient(135deg, #F97316, #FB923C)', // CAAC 橙
|
||||||
|
2: 'linear-gradient(135deg, #3B82F6, #60A5FA)', // AOPA 蓝
|
||||||
|
3: 'linear-gradient(135deg, #10B981, #34D399)', // UTC 青绿
|
||||||
|
4: 'linear-gradient(135deg, #5E46F6, #8B7CF6)', // ASDC 紫
|
||||||
|
}
|
||||||
|
const textMap: Record<number, string> = {
|
||||||
|
1: 'CAAC', 2: 'AOPA', 3: 'UTC', 4: 'ASDC',
|
||||||
|
}
|
||||||
|
const bg = gradients[bank.id] || gradients[4]
|
||||||
|
const txt = textMap[bank.id] || 'DRONE'
|
||||||
|
return (
|
||||||
|
<View className={styles.bankIcon} style={{ background: bg }}>
|
||||||
|
<Text className={styles.bankIconText}>{txt}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* Hero */}
|
||||||
|
<View className={styles.hero}>
|
||||||
|
<Text className={styles.heroSubtitle}>专业赋能 · 持证上岗</Text>
|
||||||
|
<Text className={styles.heroTitle}>培训与考试</Text>
|
||||||
|
<Text className={styles.heroDesc}>权威机构认证 · 理论题库 · 模拟考试一站式服务</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 胶囊:兄弟节点,负 margin 向上侵入 hero 的 padding 区域(不触发 overflow:hidden 裁剪) */}
|
||||||
|
<View className={styles.tabBar}>
|
||||||
|
<View
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'org' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('org')}
|
||||||
|
>
|
||||||
|
<Text>机构列表</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'bank' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('bank')}
|
||||||
|
>
|
||||||
|
<Text>题库练习</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 滚动区 */}
|
||||||
|
{activeTab === 'org' && (
|
||||||
|
<ScrollView scrollY className={styles.content}>
|
||||||
|
<View className={styles.searchWrap}>
|
||||||
|
<Image src={sousuoIcon} className={styles.searchIcon} mode='aspectFit' />
|
||||||
|
<Input
|
||||||
|
className={styles.searchField}
|
||||||
|
placeholder='搜索机构名称'
|
||||||
|
placeholderClass={styles.searchPlaceholder}
|
||||||
|
value={searchText}
|
||||||
|
onInput={(e) => setSearchText(e.detail.value)}
|
||||||
|
confirmType='search'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{store && (
|
||||||
|
<View className={styles.storeCard}>
|
||||||
|
{store.coverUrl ? (
|
||||||
|
<Image src={store.coverUrl} className={styles.storeCover} mode='aspectFill' />
|
||||||
|
) : (
|
||||||
|
<View className={styles.storeCoverPlaceholder}>
|
||||||
|
<Text>翼云Hub培训中心</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className={styles.storeInfo}>
|
||||||
|
<Text className={styles.storeName}>{store.name}</Text>
|
||||||
|
<View className={styles.storeMeta}>
|
||||||
|
<Text className={styles.metaLabel}>门店地址</Text>
|
||||||
|
<Text className={styles.metaValue}>{store.address || '暂未填写'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.storeMeta}>
|
||||||
|
<Text className={styles.metaLabel}>联系人</Text>
|
||||||
|
<Text className={styles.metaValue}>{store.contactName || '暂未填写'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.storeMeta}>
|
||||||
|
<Text className={styles.metaLabel}>联系电话</Text>
|
||||||
|
<Text className={`${styles.metaValue} ${styles.metaPhone}`}>
|
||||||
|
{store.contactPhone || '暂未填写'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{store.trainingContent && (
|
||||||
|
<Text className={styles.storeContent}>{store.trainingContent}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!store && !storeLoading && (
|
||||||
|
<View className={styles.emptyState}>
|
||||||
|
<Text className={styles.emptyText}>暂无培训机构信息</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'bank' && (
|
||||||
|
<ScrollView scrollY className={styles.content}>
|
||||||
|
{/* 选择题库 标题卡片 */}
|
||||||
|
<View className={styles.sectionHead}>
|
||||||
|
<Text className={styles.sectionTitle}>选择题库</Text>
|
||||||
|
<Text className={styles.sectionSub}>理论练习 · 模拟考试</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{banksLoading && (
|
||||||
|
<View className={styles.loadingWrap}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!banksLoading && banks.length === 0 && (
|
||||||
|
<View className={styles.emptyState}>
|
||||||
|
<Text className={styles.emptyText}>暂无可用题库</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className={styles.bankList}>
|
||||||
|
{banks.map(bank => (
|
||||||
|
<View key={bank.id} className={styles.bankCard}>
|
||||||
|
{renderBankIcon(bank)}
|
||||||
|
|
||||||
|
<View className={styles.bankInfo}>
|
||||||
|
<View className={styles.bankHeader}>
|
||||||
|
<Text className={styles.bankName}>{bank.name}</Text>
|
||||||
|
<View className={styles.bankFreeTag}>
|
||||||
|
<Text>免费</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className={styles.bankDesc}>
|
||||||
|
目前题库已更新至 {bank.questionCount} 题
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 题型细分标签 */}
|
||||||
|
<View className={styles.bankQuestionStats}>
|
||||||
|
{bank.singleCount > 0 && (
|
||||||
|
<Text className={styles.statTag}>
|
||||||
|
<Text className={styles.statNum}>{bank.singleCount}</Text>单选
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{bank.multiCount > 0 && (
|
||||||
|
<Text className={styles.statTag}>
|
||||||
|
<Text className={styles.statNum}>{bank.multiCount}</Text>多选
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{bank.judgeCount > 0 && (
|
||||||
|
<Text className={styles.statTag}>
|
||||||
|
<Text className={styles.statNum}>{bank.judgeCount}</Text>判断
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.bankActions}>
|
||||||
|
<View className={styles.btnPractice} onClick={() => handlePractice(bank.id)}>
|
||||||
|
<Text>逐题练习</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.btnMock} onClick={() => handleMockExam(bank.id)}>
|
||||||
|
<Text>模拟考试</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamPage
|
||||||
6
src/pages/exam/practice.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/** 逐题练习 / 模拟考试页面 */
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '题库练习',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
})
|
||||||
573
src/pages/exam/practice.module.scss
Normal file
|
|
@ -0,0 +1,573 @@
|
||||||
|
/* 逐题练习 / 模拟考试页面样式 */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #ffffff !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 顶部进度 ========== */
|
||||||
|
.progressBar {
|
||||||
|
height: 6rpx;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #2d8b5e, #3da975);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressInfo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressAnswered {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 答题区 ========== */
|
||||||
|
.questionScroll {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionCard {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qTypeTag {
|
||||||
|
display: inline-flex;
|
||||||
|
background: linear-gradient(135deg, #e6f5ed, #d4ecd9);
|
||||||
|
border-radius: 6rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
color: #1e6b4a;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qContent {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 选项列表 ========== */
|
||||||
|
.optionsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14rpx;
|
||||||
|
padding: 18rpx 18rpx;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
border: 2rpx solid #e5e7eb;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSelected {
|
||||||
|
background: linear-gradient(135deg, #e6f5ed, #d4ecd9);
|
||||||
|
border-color: #2d8b5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionRight {
|
||||||
|
background: linear-gradient(135deg, #e6f5ed, #d4ecd9) !important;
|
||||||
|
border-color: #22C55E !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionWrong {
|
||||||
|
background: #fef2f2 !important;
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionKey {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSelected .optionKey,
|
||||||
|
.optionKeySel {
|
||||||
|
background: #2d8b5e !important;
|
||||||
|
Text { color: #ffffff !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionVal {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.judgeTip {
|
||||||
|
padding: 10rpx 14rpx;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 题型标签(+分值) ========== */
|
||||||
|
.qScore {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 悬浮答题卡按钮(FAB) ========== */
|
||||||
|
.cardFab {
|
||||||
|
position: fixed;
|
||||||
|
right: 32rpx;
|
||||||
|
bottom: 260rpx;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: linear-gradient(135deg, #5E46F6, #7C6AF8);
|
||||||
|
box-shadow: 0 8rpx 28rpx rgba(94, 70, 246, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFabText {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFabBadge {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 答题卡弹出面板 ========== */
|
||||||
|
.cardMask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanel {
|
||||||
|
position: fixed;
|
||||||
|
left: 32rpx;
|
||||||
|
right: 32rpx;
|
||||||
|
bottom: 260rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 201;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-bottom: 1rpx solid #F3F0FA;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelTitle {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F1335;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelStat {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8B85A8;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelClose {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #F5F3FF;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B6680;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelGridWrap {
|
||||||
|
height: calc(60vh - 96rpx);
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPanelGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardNum {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #F5F3FF;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B6680;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardAnswered {
|
||||||
|
background: linear-gradient(135deg, #5E46F6, #7C6AF8);
|
||||||
|
Text { color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCurrent {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2rpx solid #5E46F6;
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(94, 70, 246, 0.15);
|
||||||
|
|
||||||
|
Text { color: #5E46F6; font-weight: 700; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 结果页答题卡(三色) ========== */
|
||||||
|
.cardStats {
|
||||||
|
display: flex;
|
||||||
|
gap: 28rpx;
|
||||||
|
padding: 0 28rpx 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.statItemCorrect { color: #16a34a; font-weight: 600; }
|
||||||
|
.statItemWrong { color: #dc2626; font-weight: 600; }
|
||||||
|
|
||||||
|
.cardGridScroll { height: calc(60vh - 96rpx); padding: 0 28rpx 24rpx; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.cardGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardGridItem {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #F5F3FF;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B6680;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { transform: scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridRight {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
Text { color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridPartial {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
Text { color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridWrong {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
Text { color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridCurrent {
|
||||||
|
border: 2rpx solid #3D2AA8;
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(61, 42, 168, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyResult {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400rpx;
|
||||||
|
padding: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部按钮 ========== */
|
||||||
|
.bottomBar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { background: #e5e7eb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.navDisabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
flex: 1.2;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: linear-gradient(135deg, #1e6b4a, #2d8b5e);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 结果页 ========== */
|
||||||
|
.resultHeader {
|
||||||
|
background: linear-gradient(135deg, #1e6b4a, #2d8b5e);
|
||||||
|
padding: 40rpx 28rpx 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreCircle {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20rpx;
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorePct {
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreUnit {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-left: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreDetail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultScroll {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCard {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
border-left: 5rpx solid #22C55E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultWrong {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
|
||||||
|
.resultCorrect & {
|
||||||
|
background: #dcfce7;
|
||||||
|
Text { color: #16a34a; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultWrong & {
|
||||||
|
background: #fef2f2;
|
||||||
|
Text { color: #dc2626; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerCompare {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-top: 1rpx solid #f3f4f6;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareLabel {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareVal {
|
||||||
|
font-family: Menlo, Monaco, monospace;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valRight { color: #16a34a; }
|
||||||
|
.valWrong { color: #dc2626; }
|
||||||
|
.compareVal.partial, .valPartial { color: #d97706; }
|
||||||
|
|
||||||
|
.explanation {
|
||||||
|
padding: 16rpx;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explText {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
602
src/pages/exam/practice.tsx
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { get, post } from '@/services/request'
|
||||||
|
import styles from './practice.module.scss'
|
||||||
|
|
||||||
|
/** 题目(小程序端,不含答案解析) */
|
||||||
|
interface Question {
|
||||||
|
id: number
|
||||||
|
bankId: number
|
||||||
|
content: string
|
||||||
|
options: Array<{ key: string; val: string }>
|
||||||
|
questionType: number
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判分明细 */
|
||||||
|
interface ResultItem {
|
||||||
|
questionId: number
|
||||||
|
/** 是否完全正确。多选题部分对也算 false。 */
|
||||||
|
correct: boolean
|
||||||
|
userAnswer: string
|
||||||
|
correctAnswer: string
|
||||||
|
explanation: string
|
||||||
|
/** 满分值。 */
|
||||||
|
score: number
|
||||||
|
/** 用户实际得分(多选题可能部分得分)。 */
|
||||||
|
userScore: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判分结果 */
|
||||||
|
interface ExamResult {
|
||||||
|
results: ResultItem[]
|
||||||
|
totalScore: number
|
||||||
|
userScore: number
|
||||||
|
correctCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const QUESTION_TYPE = { SINGLE: 1, MULTI: 2, JUDGE: 3 }
|
||||||
|
|
||||||
|
const PracticePage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const bankId = Number(router.params.bankId || 1)
|
||||||
|
const mode = router.params.mode === 'exam' ? 'exam' : 'practice'
|
||||||
|
const pageTitle = mode === 'exam' ? '模拟考试' : '逐题练习'
|
||||||
|
|
||||||
|
/** 答题卡悬浮面板展开/收起 */
|
||||||
|
const [cardOpen, setCardOpen] = useState(false)
|
||||||
|
|
||||||
|
/** 题目列表 */
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
/** 当前题目下标 */
|
||||||
|
const [currentIdx, setCurrentIdx] = useState(0)
|
||||||
|
|
||||||
|
/** 用户答案:questionId → answer string */
|
||||||
|
const [userAnswers, setUserAnswers] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
/** 答题状态:answering / submitting / result */
|
||||||
|
const [status, setStatus] = useState<'answering' | 'submitting' | 'result'>('answering')
|
||||||
|
|
||||||
|
/** 判分结果 */
|
||||||
|
const [examResult, setExamResult] = useState<ExamResult | null>(null)
|
||||||
|
|
||||||
|
/** 逐题练习:分页拉全部 */
|
||||||
|
const loadAllQuestions = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
let all: Question[] = []
|
||||||
|
let pageNo = 0
|
||||||
|
let totalPages = 1
|
||||||
|
while (pageNo < totalPages) {
|
||||||
|
const resp = await get<{ list: Question[]; totalPages: number }>(
|
||||||
|
`/api/mini/exam/banks/${bankId}/questions`,
|
||||||
|
{ pageNo, pageSize: PAGE_SIZE }
|
||||||
|
)
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
all = all.concat(resp.data.list || [])
|
||||||
|
totalPages = resp.data.totalPages
|
||||||
|
pageNo++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setQuestions(all.sort((a, b) => {
|
||||||
|
const orderA = a.questionType === 1 ? 0 : a.questionType === 2 ? 1 : 2
|
||||||
|
const orderB = b.questionType === 1 ? 0 : b.questionType === 2 ? 1 : 2
|
||||||
|
return orderA - orderB
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[练习] 加载题目失败:', err)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [bankId])
|
||||||
|
|
||||||
|
/** 模拟考试:调 mock 接口直接拿随机抽好的 75 题 */
|
||||||
|
const loadMockQuestions = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await get<Question[]>(`/api/mini/exam/banks/${bankId}/mock`)
|
||||||
|
if (resp.code === 200 && resp.data && resp.data.length > 0) {
|
||||||
|
setQuestions(resp.data.sort((a, b) => {
|
||||||
|
const orderA = a.questionType === 1 ? 0 : a.questionType === 2 ? 1 : 2
|
||||||
|
const orderB = b.questionType === 1 ? 0 : b.questionType === 2 ? 1 : 2
|
||||||
|
return orderA - orderB
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '题库题目不足', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[模拟考试] 抽题失败:', err)
|
||||||
|
Taro.showToast({ title: '抽题失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [bankId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'exam') {
|
||||||
|
loadMockQuestions()
|
||||||
|
} else {
|
||||||
|
loadAllQuestions()
|
||||||
|
}
|
||||||
|
}, [mode, loadAllQuestions, loadMockQuestions])
|
||||||
|
|
||||||
|
/** 设置页面标题 */
|
||||||
|
useEffect(() => {
|
||||||
|
Taro.setNavigationBarTitle({ title: pageTitle })
|
||||||
|
}, [pageTitle])
|
||||||
|
|
||||||
|
/** 当前题目 */
|
||||||
|
const current = questions[currentIdx]
|
||||||
|
|
||||||
|
/** 选择答案 */
|
||||||
|
const handleSelect = (key: string) => {
|
||||||
|
if (!current || status !== 'answering') return
|
||||||
|
|
||||||
|
const qid = current.id
|
||||||
|
const qtype = current.questionType
|
||||||
|
|
||||||
|
setUserAnswers(prev => {
|
||||||
|
const prevAnswer = prev[qid] || ''
|
||||||
|
if (qtype === QUESTION_TYPE.SINGLE || qtype === QUESTION_TYPE.JUDGE) {
|
||||||
|
// 单选/判断:直接覆盖
|
||||||
|
return { ...prev, [qid]: key }
|
||||||
|
} else {
|
||||||
|
// 多选:切换(字母数组排序后拼接)
|
||||||
|
const arr = (prevAnswer || '').split('').filter(Boolean)
|
||||||
|
if (arr.includes(key)) {
|
||||||
|
const filtered = arr.filter(k => k !== key)
|
||||||
|
return { ...prev, [qid]: filtered.sort().join('') }
|
||||||
|
} else {
|
||||||
|
arr.push(key)
|
||||||
|
return { ...prev, [qid]: arr.sort().join('') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断选项是否被选中 */
|
||||||
|
const isSelected = (key: string) => {
|
||||||
|
if (!current) return false
|
||||||
|
const ans = userAnswers[current.id] || ''
|
||||||
|
return ans.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上一题 */
|
||||||
|
const prevQuestion = () => {
|
||||||
|
if (currentIdx > 0) setCurrentIdx(currentIdx - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下一题 */
|
||||||
|
const nextQuestion = () => {
|
||||||
|
if (currentIdx < questions.length - 1) setCurrentIdx(currentIdx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳到指定题号(从悬浮答题卡点击) */
|
||||||
|
const goToQuestion = (idx: number) => {
|
||||||
|
setCurrentIdx(idx)
|
||||||
|
setCardOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交判分 */
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const answered = Object.keys(userAnswers).length
|
||||||
|
|
||||||
|
// 一题未答,拦截
|
||||||
|
if (answered === 0) {
|
||||||
|
Taro.showToast({ title: '请至少作答一题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟考试:弹确认框
|
||||||
|
if (mode === 'exam') {
|
||||||
|
const res = await Taro.showModal({
|
||||||
|
title: '交卷确认',
|
||||||
|
content: `已答 ${answered} / ${questions.length} 题,确定交卷吗?`,
|
||||||
|
confirmText: '交卷',
|
||||||
|
confirmColor: '#5E46F6',
|
||||||
|
})
|
||||||
|
if (!res.confirm) return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('submitting')
|
||||||
|
try {
|
||||||
|
const answers = Object.entries(userAnswers).map(([qid, ua]) => ({
|
||||||
|
questionId: Number(qid),
|
||||||
|
userAnswer: ua,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resp = await post<ExamResult>(
|
||||||
|
`/api/mini/exam/banks/${bankId}/submit`,
|
||||||
|
{ answers }
|
||||||
|
)
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
setExamResult(resp.data)
|
||||||
|
setStatus('result')
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '提交失败', icon: 'none' })
|
||||||
|
setStatus('answering')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[练习] 提交失败:', err)
|
||||||
|
Taro.showToast({ title: '网络异常', icon: 'none' })
|
||||||
|
setStatus('answering')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果页中显示的当前题目 */
|
||||||
|
const [resultIdx, setResultIdx] = useState(0)
|
||||||
|
const [resultCardOpen, setResultCardOpen] = useState(false)
|
||||||
|
const currentResultItem = examResult?.results[resultIdx]
|
||||||
|
const resultQuestion = examResult ? questions.find(q => q.id === currentResultItem?.questionId) : null
|
||||||
|
|
||||||
|
/** 重新答题(清空答案回到首页) */
|
||||||
|
const handleRetry = () => {
|
||||||
|
setUserAnswers({})
|
||||||
|
setCurrentIdx(0)
|
||||||
|
setStatus('answering')
|
||||||
|
setExamResult(null)
|
||||||
|
setResultIdx(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 渲染 ====================
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.loading}>
|
||||||
|
<Text>{mode === 'exam' ? '正在组卷...' : '加载题目中...'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current && questions.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.empty}>
|
||||||
|
<Text>题库暂无题目</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 答题状态 ==========
|
||||||
|
if (status === 'answering' && current) {
|
||||||
|
const total = questions.length
|
||||||
|
const answeredCount = Object.keys(userAnswers).length
|
||||||
|
const unansweredCount = total - answeredCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* 顶部进度 */}
|
||||||
|
<View className={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${((currentIdx + 1) / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className={styles.progressInfo}>
|
||||||
|
<Text className={styles.progressText}>
|
||||||
|
{currentIdx + 1} / {total}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.progressAnswered}>
|
||||||
|
已答 {answeredCount} 题
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 题目区 */}
|
||||||
|
<ScrollView scrollY className={styles.questionScroll}>
|
||||||
|
<View className={styles.questionCard}>
|
||||||
|
{/* 题型标签 */}
|
||||||
|
<View className={styles.qTypeTag}>
|
||||||
|
<Text>
|
||||||
|
{current.questionType === QUESTION_TYPE.SINGLE && '单选题'}
|
||||||
|
{current.questionType === QUESTION_TYPE.MULTI && '多选题'}
|
||||||
|
{current.questionType === QUESTION_TYPE.JUDGE && '判断题'}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.qScore}>({current.score} 分)</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 题干 */}
|
||||||
|
<Text className={styles.qContent}>{current.content}</Text>
|
||||||
|
|
||||||
|
{/* 选项列表 */}
|
||||||
|
<View className={styles.optionsList}>
|
||||||
|
{(current.options || []).map(opt => {
|
||||||
|
const selected = isSelected(opt.key)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={opt.key}
|
||||||
|
className={`${styles.optionItem} ${selected ? styles.optionSelected : ''}`}
|
||||||
|
onClick={() => handleSelect(opt.key)}
|
||||||
|
>
|
||||||
|
<View className={`${styles.optionKey} ${selected ? styles.optionKeySel : ''}`}>
|
||||||
|
<Text>{opt.key}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.optionVal}>{opt.val}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ========== 悬浮答题卡(右下浮动按钮 + 点击弹出面板) ========== */}
|
||||||
|
{!cardOpen && (
|
||||||
|
<View
|
||||||
|
className={styles.cardFab}
|
||||||
|
onClick={() => setCardOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className={styles.cardFabText}>答题卡</Text>
|
||||||
|
<Text className={styles.cardFabBadge}>
|
||||||
|
{answeredCount}/{total}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开的答题卡面板 */}
|
||||||
|
{cardOpen && (
|
||||||
|
<>
|
||||||
|
{/* 遮罩 */}
|
||||||
|
<View className={styles.cardMask} onClick={() => setCardOpen(false)} />
|
||||||
|
|
||||||
|
{/* 面板 */}
|
||||||
|
<View className={styles.cardPanel}>
|
||||||
|
<View className={styles.cardPanelHeader}>
|
||||||
|
<Text className={styles.cardPanelTitle}>答题卡</Text>
|
||||||
|
<Text className={styles.cardPanelStat}>
|
||||||
|
已答 {answeredCount} · 未答 {unansweredCount}
|
||||||
|
</Text>
|
||||||
|
<View className={styles.cardPanelClose} onClick={() => setCardOpen(false)}>
|
||||||
|
<Text>✕</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollY className={styles.cardPanelGridWrap}>
|
||||||
|
<View className={styles.cardPanelGrid}>
|
||||||
|
{questions.map((q, idx) => {
|
||||||
|
const answered = !!userAnswers[q.id]
|
||||||
|
const isCurrent = idx === currentIdx
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={q.id}
|
||||||
|
className={`${styles.cardNum}
|
||||||
|
${answered ? styles.cardAnswered : ''}
|
||||||
|
${isCurrent ? styles.cardCurrent : ''}`}
|
||||||
|
onClick={() => goToQuestion(idx)}
|
||||||
|
>
|
||||||
|
<Text>{idx + 1}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View className={styles.bottomBar}>
|
||||||
|
<View
|
||||||
|
className={`${styles.navBtn} ${currentIdx === 0 ? styles.navDisabled : ''}`}
|
||||||
|
onClick={prevQuestion}
|
||||||
|
>
|
||||||
|
<Text>上一题</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<Text>{mode === 'exam' ? '交卷' : '提交'}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.navBtn} ${currentIdx === total - 1 ? styles.navDisabled : ''}`}
|
||||||
|
onClick={nextQuestion}
|
||||||
|
>
|
||||||
|
<Text>下一题</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 判分中 ==========
|
||||||
|
if (status === 'submitting') {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.loading}>
|
||||||
|
<Text>判分中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 结果页 ==========
|
||||||
|
if (status === 'result' && examResult) {
|
||||||
|
const scorePct = examResult.totalScore === 0 ? 0
|
||||||
|
: Math.round((examResult.userScore / examResult.totalScore) * 100)
|
||||||
|
const hasResults = examResult.results && examResult.results.length > 0
|
||||||
|
const currentResultItem = hasResults ? examResult.results[Math.min(resultIdx, examResult.results.length - 1)] : null
|
||||||
|
const resultQuestion = hasResults ? questions.find(q => q.id === currentResultItem?.questionId) : null
|
||||||
|
const effectiveIdx = hasResults ? Math.min(resultIdx, examResult.results.length - 1) : 0
|
||||||
|
|
||||||
|
// 判断每题状态:0=完全对(绿) / 1=部分对(橙) / 2=错/未答(红)
|
||||||
|
const getItemStatus = (item: ResultItem): number => {
|
||||||
|
if (item.correct) return 0
|
||||||
|
if (item.userScore > 0) return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* 结果头部 */}
|
||||||
|
<View className={styles.resultHeader}>
|
||||||
|
<Text className={styles.resultTitle}>答题结果</Text>
|
||||||
|
<View className={styles.scoreCircle}>
|
||||||
|
<Text className={styles.scorePct}>{scorePct}</Text>
|
||||||
|
<Text className={styles.scoreUnit}>分</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.scoreDetail}>
|
||||||
|
<Text>共 {examResult.totalCount} 题</Text>
|
||||||
|
<Text>答对 {examResult.correctCount} 题</Text>
|
||||||
|
<Text>{examResult.userScore} / {examResult.totalScore} 分</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 空结果兜底 */}
|
||||||
|
{!hasResults && (
|
||||||
|
<View className={styles.emptyResult}>
|
||||||
|
<Text>一题未作答,请返回至少答一题</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 正常题目结果 */}
|
||||||
|
{hasResults && currentResultItem && resultQuestion && (
|
||||||
|
<>
|
||||||
|
<ScrollView scrollY className={styles.resultScroll}>
|
||||||
|
<View className={`${styles.resultCard}
|
||||||
|
${currentResultItem.correct ? styles.resultCorrect : styles.resultWrong}`}>
|
||||||
|
|
||||||
|
<View className={styles.resultBadge}>
|
||||||
|
<Text>{currentResultItem.correct ? '✅ 回答正确' : currentResultItem.userScore > 0 ? '🟡 部分正确' : '❌ 回答错误'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className={styles.qContent}>{resultQuestion.content}</Text>
|
||||||
|
|
||||||
|
<View className={styles.optionsList}>
|
||||||
|
{(resultQuestion.options || []).map(opt => {
|
||||||
|
const isCorrectKey = currentResultItem.correctAnswer.includes(opt.key)
|
||||||
|
const isUserPicked = currentResultItem.userAnswer.includes(opt.key)
|
||||||
|
let cls = styles.optionItem
|
||||||
|
if (isCorrectKey) cls += ` ${styles.optionRight}`
|
||||||
|
else if (isUserPicked) cls += ` ${styles.optionWrong}`
|
||||||
|
return (
|
||||||
|
<View key={opt.key} className={cls}>
|
||||||
|
<View className={styles.optionKey}>
|
||||||
|
<Text>{opt.key}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.optionVal}>{opt.val}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.answerCompare}>
|
||||||
|
<Text className={styles.compareLabel}>你的答案</Text>
|
||||||
|
<Text className={`${styles.compareVal} ${currentResultItem.correct ? styles.valRight : currentResultItem.userScore > 0 ? styles.valPartial : styles.valWrong}`}>
|
||||||
|
{currentResultItem.userAnswer || '未作答'}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.compareLabel}>正确答案</Text>
|
||||||
|
<Text className={`${styles.compareVal} ${styles.valRight}`}>
|
||||||
|
{currentResultItem.correctAnswer}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.compareLabel}>本题得分</Text>
|
||||||
|
<Text className={`${styles.compareVal} ${currentResultItem.userScore === currentResultItem.score ? styles.valRight : styles.valWrong}`}>
|
||||||
|
{currentResultItem.userScore} / {currentResultItem.score}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{currentResultItem.explanation && (
|
||||||
|
<View className={styles.explanation}>
|
||||||
|
<Text className={styles.explTitle}>答案解析</Text>
|
||||||
|
<Text className={styles.explText}>{currentResultItem.explanation}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ========== 结果页悬浮答题卡 ========== */}
|
||||||
|
{!resultCardOpen && (
|
||||||
|
<View
|
||||||
|
className={styles.cardFab}
|
||||||
|
onClick={() => setResultCardOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className={styles.cardFabText}>答题卡</Text>
|
||||||
|
<Text className={styles.cardFabBadge}>
|
||||||
|
{examResult.correctCount}/{examResult.totalCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{resultCardOpen && (
|
||||||
|
<>
|
||||||
|
<View className={styles.cardMask} onClick={() => setResultCardOpen(false)} />
|
||||||
|
<View className={styles.cardPanel}>
|
||||||
|
<View className={styles.cardPanelHeader}>
|
||||||
|
<Text className={styles.cardPanelTitle}>答题卡</Text>
|
||||||
|
<Text className={styles.cardPanelClose} onClick={() => setResultCardOpen(false)}>✕</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.cardStats}>
|
||||||
|
<Text className={styles.statItemCorrect}>答对 {examResult.correctCount}</Text>
|
||||||
|
<Text className={styles.statItemWrong}>答错 {examResult.totalCount - examResult.correctCount}</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollY className={styles.cardGridScroll}>
|
||||||
|
<View className={styles.cardGrid}>
|
||||||
|
{examResult.results.map((item, idx) => {
|
||||||
|
const status = getItemStatus(item)
|
||||||
|
const isCurrent = idx === effectiveIdx
|
||||||
|
let cls = styles.cardGridItem
|
||||||
|
if (status === 0) cls += ` ${styles.gridRight}`
|
||||||
|
else if (status === 1) cls += ` ${styles.gridPartial}`
|
||||||
|
else cls += ` ${styles.gridWrong}`
|
||||||
|
if (isCurrent) cls += ` ${styles.gridCurrent}`
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.questionId}
|
||||||
|
className={cls}
|
||||||
|
onClick={() => { setResultIdx(idx); setResultCardOpen(false) }}
|
||||||
|
>
|
||||||
|
<Text>{idx + 1}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部 */}
|
||||||
|
<View className={styles.bottomBar}>
|
||||||
|
<View
|
||||||
|
className={`${styles.navBtn} ${effectiveIdx === 0 ? styles.navDisabled : ''}`}
|
||||||
|
onClick={() => effectiveIdx > 0 && setResultIdx(effectiveIdx - 1)}
|
||||||
|
>
|
||||||
|
<Text>上一题</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.submitBtn} onClick={handleRetry}>
|
||||||
|
<Text>重新练习</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`${styles.navBtn} ${effectiveIdx === examResult.results.length - 1 ? styles.navDisabled : ''}`}
|
||||||
|
onClick={() => effectiveIdx < examResult.results.length - 1 && setResultIdx(effectiveIdx + 1)}
|
||||||
|
>
|
||||||
|
<Text>下一题</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空结果时的底部 */}
|
||||||
|
{!hasResults && (
|
||||||
|
<View className={styles.bottomBar}>
|
||||||
|
<View className={styles.submitBtn} onClick={handleRetry}>
|
||||||
|
<Text>返回答题</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PracticePage
|
||||||
5
src/pages/home/index.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '翼云Hub',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
enablePullDownRefresh: false
|
||||||
|
})
|
||||||
256
src/pages/home/index.module.scss
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #E6E0F4;
|
||||||
|
padding-bottom: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 $page-padding 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citySelector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityIconImg {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityText {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityArrow {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索栏
|
||||||
|
.searchBar {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 72rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
padding: 0 8rpx 0 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIconImg {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-primary;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPlaceholder {
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBtn {
|
||||||
|
height: 56rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
border-radius: $radius-button;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(94, 70, 246, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerWrapper {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeWrapper {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridWrapper {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContainer {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionBar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
background: $color-primary;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionMore {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskListFeatured {
|
||||||
|
padding: 0 $page-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 实力商家(横向滑动卡片) ---------- */
|
||||||
|
.merchantScroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantCard {
|
||||||
|
display: inline-block;
|
||||||
|
width: 280rpx;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(31, 31, 51, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantDoor {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 180rpx;
|
||||||
|
background: #f1eefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantDoorPlaceholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 180rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #b0aec6;
|
||||||
|
background: #f1eefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantInfo {
|
||||||
|
padding: 16rpx 20rpx 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantName {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f33;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchantAddr {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999999;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 本地飞手(列表行) ---------- */
|
||||||
|
.pilotList {
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(31, 31, 51, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pilotRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 96rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 2rpx solid #f2f2f7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pilotName {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f1f33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pilotTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pilotTag {
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
border: 2rpx solid #5e46f6;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #5e46f6;
|
||||||
|
background: #f1eefb;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
175
src/pages/home/index.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import Banner from '@/components/Banner'
|
||||||
|
import Notice from '@/components/Notice'
|
||||||
|
import GridNav from '@/components/GridNav'
|
||||||
|
import SupplyDemand from '@/components/SupplyDemand'
|
||||||
|
import FeaturedCourses from '@/components/FeaturedCourses'
|
||||||
|
import PromoteModal from '@/components/PromoteModal'
|
||||||
|
import { BannerItem } from '@/components/Banner'
|
||||||
|
import { ensureLogin } from '@/services/auth'
|
||||||
|
import dingweiIcon from '@/assets/dingwei.png'
|
||||||
|
import sousuoIcon from '@/assets/sousuo.png'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
const [city] = useState('上海')
|
||||||
|
const [menuBarTop, setMenuBarTop] = useState(0)
|
||||||
|
const [menuBarHeight, setMenuBarHeight] = useState(0)
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
// 飞手推广二维码弹窗
|
||||||
|
const [showPromoteModal, setShowPromoteModal] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞手推广:登录校验通过后打开推广二维码弹窗,与个人中心入口行为一致。
|
||||||
|
* 好友扫码注册成功后双方各得 4 积分(后端登录链路自动完成)。
|
||||||
|
*/
|
||||||
|
const handlePromote = async () => {
|
||||||
|
const token = await ensureLogin()
|
||||||
|
if (!token) {
|
||||||
|
Taro.showToast({ title: '登录失败,请重启应用', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowPromoteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
try {
|
||||||
|
const rect = Taro.getMenuButtonBoundingClientRect()
|
||||||
|
setMenuBarTop(rect.top)
|
||||||
|
setMenuBarHeight(rect.height)
|
||||||
|
} catch {
|
||||||
|
const sysInfo = Taro.getSystemInfoSync()
|
||||||
|
setMenuBarTop(sysInfo.statusBarHeight || 20)
|
||||||
|
setMenuBarHeight(32)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 9 宫格金刚区点击处理,未实现功能统一 toast 兜底 */
|
||||||
|
const handleGridItemClick = (linkType: string) => {
|
||||||
|
switch (linkType) {
|
||||||
|
case 'publish':
|
||||||
|
Taro.navigateTo({ url: '/pages/task-publish/index' })
|
||||||
|
break
|
||||||
|
case 'caac':
|
||||||
|
Taro.showToast({ title: 'CAAC报名功能开发中', icon: 'none' })
|
||||||
|
break
|
||||||
|
case 'course':
|
||||||
|
Taro.switchTab({ url: '/pages/course/index' })
|
||||||
|
break
|
||||||
|
case 'performance':
|
||||||
|
Taro.navigateTo({ url: '/pages/task-publish/index?type=6' })
|
||||||
|
break
|
||||||
|
case 'merchant':
|
||||||
|
Taro.navigateTo({ url: '/pages/merchant-list/index' })
|
||||||
|
break
|
||||||
|
case 'pilot':
|
||||||
|
Taro.navigateTo({ url: '/pages/pilot-list/index' })
|
||||||
|
break
|
||||||
|
case 'training':
|
||||||
|
Taro.navigateTo({ url: '/pages/exam/index' })
|
||||||
|
break
|
||||||
|
case 'promote':
|
||||||
|
handlePromote()
|
||||||
|
break
|
||||||
|
case 'dispatch':
|
||||||
|
Taro.showToast({ title: '无人机配送功能开发中', icon: 'none' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
Taro.showToast({ title: '功能开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮播图点击处理:根据后端配置的 linkType / linkUrl 跳转不同模块 */
|
||||||
|
const handleBannerClick = (item: BannerItem) => {
|
||||||
|
// 优先处理自定义链接(运营后台配置 linkUrl 的场景)
|
||||||
|
if (item.linkUrl) {
|
||||||
|
Taro.navigateTo({ url: item.linkUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 按 linkType 跳转预设模块
|
||||||
|
switch (item.linkType) {
|
||||||
|
case 'caac':
|
||||||
|
Taro.showToast({ title: 'CAAC报名功能开发中', icon: 'none' })
|
||||||
|
break
|
||||||
|
case 'course':
|
||||||
|
Taro.switchTab({ url: '/pages/course/index' })
|
||||||
|
break
|
||||||
|
case 'performance':
|
||||||
|
Taro.navigateTo({ url: '/pages/task-publish/index?type=6' })
|
||||||
|
break
|
||||||
|
case 'merchant':
|
||||||
|
Taro.navigateTo({ url: '/pages/merchant-certify/index' })
|
||||||
|
break
|
||||||
|
case 'task':
|
||||||
|
Taro.switchTab({ url: '/pages/task-center/index' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pxToRpx = (px: number) => `${Math.round((px / 375) * 750)}rpx`
|
||||||
|
|
||||||
|
const topBarPaddingTop = pxToRpx(menuBarTop + menuBarHeight / 2 - 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView scrollY className={styles.page}>
|
||||||
|
{/* 顶部城市定位 — 与微信胶囊按钮垂直居中对齐 */}
|
||||||
|
<View className={styles.topBar} style={{ paddingTop: topBarPaddingTop }}>
|
||||||
|
<View className={styles.citySelector}>
|
||||||
|
<Image src={dingweiIcon} className={styles.cityIconImg} mode='aspectFit' />
|
||||||
|
<Text className={styles.cityText}>{city}</Text>
|
||||||
|
<Text className={styles.cityArrow}>▼</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 搜索栏 — 图标和按钮都在框内 */}
|
||||||
|
<View className={styles.searchBar}>
|
||||||
|
<View className={styles.searchInput}>
|
||||||
|
<Image src={sousuoIcon} className={styles.searchIconImg} mode='aspectFit' />
|
||||||
|
<input
|
||||||
|
className={styles.searchField}
|
||||||
|
placeholder='搜索您感兴趣的任务'
|
||||||
|
placeholderClass={styles.searchPlaceholder}
|
||||||
|
value={searchText}
|
||||||
|
onInput={(e) => setSearchText(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<View className={styles.searchBtn}>
|
||||||
|
<Text>搜索</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 轮播 Banner */}
|
||||||
|
<View className={styles.bannerWrapper}>
|
||||||
|
<Banner onBannerClick={handleBannerClick} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 公告条 */}
|
||||||
|
<View className={styles.noticeWrapper}>
|
||||||
|
<Notice />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 9 宫格金刚区(含研学课程) */}
|
||||||
|
<View className={styles.gridWrapper}>
|
||||||
|
<GridNav onItemClick={handleGridItemClick} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 供需双广场 */}
|
||||||
|
<SupplyDemand />
|
||||||
|
|
||||||
|
{/* 精选研学课程 —— 管理员在 Web 端开启 featured 开关后,小程序首页展示 */}
|
||||||
|
<FeaturedCourses />
|
||||||
|
|
||||||
|
{/* —— 飞手推广二维码弹窗(与个人中心共享同一组件) —— */}
|
||||||
|
<PromoteModal
|
||||||
|
open={showPromoteModal}
|
||||||
|
onClose={() => setShowPromoteModal(false)}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
3
src/pages/merchant-center/index.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '商家中心'
|
||||||
|
})
|
||||||
216
src/pages/merchant-center/index.module.scss
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// ============================================================
|
||||||
|
// 商家中心 — 查看认证信息 + 编辑入口
|
||||||
|
// 与飞手中心同构:主题紫视觉体系,状态色带区分认证状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24rpx 24rpx 0;
|
||||||
|
background: #F1EEFB;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶部认证状态卡 ---------- */
|
||||||
|
.heroCard {
|
||||||
|
padding: 40rpx 36rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 各状态顶部色带区分:绿=已认证 橙=审核中 红=未通过 灰=未认证 */
|
||||||
|
.heroSuccess {
|
||||||
|
border-top: 8rpx solid #34c77b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroPending {
|
||||||
|
border-top: 8rpx solid #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroRejected {
|
||||||
|
border-top: 8rpx solid #ee4f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroNone {
|
||||||
|
border-top: 8rpx solid #c6c9d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBadge {
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #7c6af8 0%, #5e46f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSuccess .heroBadge {
|
||||||
|
background: linear-gradient(135deg, #4fd894 0%, #34c77b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroPending .heroBadge {
|
||||||
|
background: linear-gradient(135deg, #ffc25e 0%, #f5a623 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroRejected .heroBadge {
|
||||||
|
background: linear-gradient(135deg, #f37b7b 0%, #ee4f4f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroNone .heroBadge {
|
||||||
|
background: #9fa3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroDesc {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #666677;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 76rpx;
|
||||||
|
border-radius: 38rpx;
|
||||||
|
background: linear-gradient(135deg, #7c6af8 0%, #5e46f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBtnText {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 区块标题 ---------- */
|
||||||
|
.section {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBar {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
margin-right: 14rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #5e46f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleText {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleExtra {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 基本信息卡 ---------- */
|
||||||
|
.infoCard {
|
||||||
|
padding: 8rpx 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 96rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
& + .infoRow {
|
||||||
|
border-top: 2rpx solid #f2f2f7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #888899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoValue {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f1f33;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 商家简介卡 ---------- */
|
||||||
|
.descCard {
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #444455;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 证照信息卡 ---------- */
|
||||||
|
.imageCard {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageBlock {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageLabel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #888899;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 证照照片缩略图:点击全屏预览 */
|
||||||
|
.certImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 240rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f2f2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBottom {
|
||||||
|
height: 60rpx;
|
||||||
|
}
|
||||||
210
src/pages/merchant-center/index.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { get } from '@/services/request'
|
||||||
|
import { ensureLogin } from '@/services/auth'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
/** 认证状态编码(与后端 CertConstants 对齐) */
|
||||||
|
const CERT_STATUS_NONE = 0
|
||||||
|
const CERT_STATUS_PENDING = 1
|
||||||
|
const CERT_STATUS_CERTIFIED = 2
|
||||||
|
const CERT_STATUS_REJECTED = 3
|
||||||
|
|
||||||
|
/** 后端商家认证信息回显结构 */
|
||||||
|
interface MerchantCertInfo {
|
||||||
|
certStatus: number
|
||||||
|
name: string | null
|
||||||
|
address: string | null
|
||||||
|
contactName: string | null
|
||||||
|
contactPhone: string | null
|
||||||
|
description: string | null
|
||||||
|
doorImageUrl: string | null
|
||||||
|
licenseImageUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机号脱敏展示:138****8000,避免完整号码在查看页直接暴露 */
|
||||||
|
const maskPhone = (phone: string): string =>
|
||||||
|
phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone
|
||||||
|
|
||||||
|
const MerchantCenterPage: React.FC = () => {
|
||||||
|
const [info, setInfo] = useState<MerchantCertInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadCert = async () => {
|
||||||
|
const token = await ensureLogin()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const resp = await get<MerchantCertInfo>('/api/mini/cert/merchant')
|
||||||
|
if (resp.code === 200 && resp.data) {
|
||||||
|
setInfo(resp.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[商家中心] 加载失败:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useDidShow:从认证页编辑返回后自动刷新最新数据
|
||||||
|
useDidShow(() => {
|
||||||
|
loadCert()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 预览证照照片(全屏大图,可左右滑动切换) */
|
||||||
|
const previewImage = (current: string) => {
|
||||||
|
const urls = [info?.licenseImageUrl, info?.doorImageUrl]
|
||||||
|
.filter((url): url is string => !!url)
|
||||||
|
if (urls.length === 0) return
|
||||||
|
Taro.previewImage({ current, urls })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑/去认证统一入口 */
|
||||||
|
const goCertify = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/merchant-certify/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const certStatus = info?.certStatus ?? CERT_STATUS_NONE
|
||||||
|
|
||||||
|
/** 顶部状态卡文案与样式 */
|
||||||
|
const hero = (() => {
|
||||||
|
switch (certStatus) {
|
||||||
|
case CERT_STATUS_CERTIFIED:
|
||||||
|
return {
|
||||||
|
cls: styles.heroSuccess,
|
||||||
|
badge: '已认证',
|
||||||
|
desc: '您已通过商家认证,可以发布任务招募飞手',
|
||||||
|
btnText: '编辑认证信息',
|
||||||
|
}
|
||||||
|
case CERT_STATUS_PENDING:
|
||||||
|
return {
|
||||||
|
cls: styles.heroPending,
|
||||||
|
badge: '审核中',
|
||||||
|
desc: '认证材料已提交,请耐心等待审核结果',
|
||||||
|
btnText: '查看/修改认证信息',
|
||||||
|
}
|
||||||
|
case CERT_STATUS_REJECTED:
|
||||||
|
return {
|
||||||
|
cls: styles.heroRejected,
|
||||||
|
badge: '未通过',
|
||||||
|
desc: '认证未通过,请修改信息后重新提交',
|
||||||
|
btnText: '重新提交认证',
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
cls: styles.heroNone,
|
||||||
|
badge: '未认证',
|
||||||
|
desc: '完成商家认证后即可发布任务招募飞手',
|
||||||
|
btnText: '去认证',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.loadingWrap}>
|
||||||
|
<Text className={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务类型已从商家认证中移除,基本信息不再渲染服务类型标签 */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
{/* 顶部认证状态卡 */}
|
||||||
|
<View className={`${styles.heroCard} ${hero.cls}`}>
|
||||||
|
<View className={styles.heroTop}>
|
||||||
|
<Text className={styles.heroBadge}>{hero.badge}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.heroDesc}>{hero.desc}</Text>
|
||||||
|
<View className={styles.heroBtn} onClick={goCertify}>
|
||||||
|
<Text className={styles.heroBtnText}>{hero.btnText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 基本信息卡片(未认证时无数据不展示) */}
|
||||||
|
{certStatus !== CERT_STATUS_NONE && info && (
|
||||||
|
<View className={styles.section}>
|
||||||
|
<View className={styles.sectionTitle}>
|
||||||
|
<View className={styles.titleBar} />
|
||||||
|
<Text className={styles.titleText}>基本信息</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.infoCard}>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>商家名称</Text>
|
||||||
|
<Text className={styles.infoValue}>{info.name || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>商家地址</Text>
|
||||||
|
<Text className={styles.infoValue}>{info.address || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>联系人</Text>
|
||||||
|
<Text className={styles.infoValue}>{info.contactName || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>联系电话</Text>
|
||||||
|
<Text className={styles.infoValue}>
|
||||||
|
{info.contactPhone || '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 商家简介 */}
|
||||||
|
{certStatus !== CERT_STATUS_NONE && info?.description && (
|
||||||
|
<View className={styles.section}>
|
||||||
|
<View className={styles.sectionTitle}>
|
||||||
|
<View className={styles.titleBar} />
|
||||||
|
<Text className={styles.titleText}>商家简介</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.descCard}>
|
||||||
|
<Text className={styles.descText}>{info.description}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 证照信息(营业执照 + 门头照) */}
|
||||||
|
{certStatus !== CERT_STATUS_NONE && (info?.licenseImageUrl || info?.doorImageUrl) && (
|
||||||
|
<View className={styles.section}>
|
||||||
|
<View className={styles.sectionTitle}>
|
||||||
|
<View className={styles.titleBar} />
|
||||||
|
<Text className={styles.titleText}>证照信息</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.imageCard}>
|
||||||
|
{info?.licenseImageUrl && (
|
||||||
|
<View className={styles.imageBlock}>
|
||||||
|
<Text className={styles.imageLabel}>营业执照</Text>
|
||||||
|
<Image
|
||||||
|
className={styles.certImage}
|
||||||
|
src={info.licenseImageUrl}
|
||||||
|
mode='aspectFill'
|
||||||
|
onClick={() => previewImage(info.licenseImageUrl as string)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{info?.doorImageUrl && (
|
||||||
|
<View className={styles.imageBlock}>
|
||||||
|
<Text className={styles.imageLabel}>门头照片</Text>
|
||||||
|
<Image
|
||||||
|
className={styles.certImage}
|
||||||
|
src={info.doorImageUrl}
|
||||||
|
mode='aspectFill'
|
||||||
|
onClick={() => previewImage(info.doorImageUrl as string)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className={styles.pageBottom} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MerchantCenterPage
|
||||||
5
src/pages/merchant-certify/index.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '商家认证',
|
||||||
|
navigationBarBackgroundColor: '#FFFFFF',
|
||||||
|
navigationBarTextStyle: 'black'
|
||||||
|
})
|
||||||
276
src/pages/merchant-certify/index.module.scss
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
// 商家认证页样式,与飞手认证/发布任务页表单风格保持一致(主题紫 #5E46F6)
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #F1EEFB;
|
||||||
|
padding: 24rpx 24rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 状态横幅 ——
|
||||||
|
.banner {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerSuccess {
|
||||||
|
background: #EAF8F0;
|
||||||
|
color: #16A34A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerPending {
|
||||||
|
background: #E8F0FE;
|
||||||
|
color: #3B82F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerRejected {
|
||||||
|
background: #FEECEC;
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 分区标题 ——
|
||||||
|
.section {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBar {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: linear-gradient(180deg, #7C6AF8, #5E46F6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleText {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1F1F33;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 表单卡片 ——
|
||||||
|
.formCard {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 8rpx 28rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(94, 70, 246, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 104rpx;
|
||||||
|
border-bottom: 2rpx solid #F3F3F8;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRowBlock {
|
||||||
|
padding: 24rpx 0;
|
||||||
|
border-bottom: 2rpx solid #F3F3F8;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 160rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1F1F33;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1F1F33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 160rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #F8F8FC;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #1F1F33;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #B8B8C8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1F1F33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectValue {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #C4C4D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formHint {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #A0A0B2;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 服务类型多选标签 ——
|
||||||
|
.tagGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 10rpx 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
border: 2rpx solid #E4E2F2;
|
||||||
|
background: #F8F8FC;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #5A5A72;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagActive {
|
||||||
|
border-color: #5E46F6;
|
||||||
|
background: #F1EEFB;
|
||||||
|
color: #5E46F6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 单图上传 ——
|
||||||
|
.imageUploader {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem {
|
||||||
|
position: relative;
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageMask {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageMaskError {
|
||||||
|
background: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageProgress {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageErrorText {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageRemove {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0 0 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton {
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 2rpx dashed #C9C2F0;
|
||||||
|
background: #F7F5FD;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadIcon {
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: #5E46F6;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadText {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8A7FD6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 底部提交 ——
|
||||||
|
.submitArea {
|
||||||
|
margin: 40rpx 0 0;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom) + 60rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
background: linear-gradient(135deg, #7C6AF8, #5E46F6);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBottom {
|
||||||
|
height: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||