初始化代码

This commit is contained in:
duhao 2026-09-18 16:54:23 +08:00
commit 5e7b961958
150 changed files with 54730 additions and 0 deletions

28
.gitignore vendored Normal file
View 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

View File

@ -0,0 +1 @@
{"pid":null,"port":53668}

4451
.pai/pai.log Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

96
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
src/assets/didian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
src/assets/dingwei.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
src/assets/fenxiang.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src/assets/gonggao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
src/assets/grid/baoming.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
src/assets/grid/biaoyan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
src/assets/grid/didi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
src/assets/grid/feishou.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
src/assets/grid/kaoshi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/grid/kecheng.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
src/assets/grid/renwu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src/assets/jifen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
src/assets/mp-qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/assets/shijian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
src/assets/sousuo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

BIN
src/assets/tabbar/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

BIN
src/assets/tabbar/task.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

View 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;
}

View 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

View 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;
}

View 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

View 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;
}

View 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;

View 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;
}

View 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

View 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;
}

View 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

View 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;
}

View 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;

View 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;
}

View 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

View 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;
}

View 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

View 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;
}

View 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

View 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;
}

View 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

View 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);
}

View 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

View 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;
}

View 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
View 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
View 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
View 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>

View File

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '公告详情',
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTextStyle: 'black',
})

View 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;
}

View 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

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '通知公告',
})

View 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;
}

View 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

View File

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

View 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;
}

View 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

View File

@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '研学课程',
navigationStyle: 'custom',
})

View 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
View 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

View File

@ -0,0 +1,7 @@
/** 培训考试主页面 */
export default definePageConfig({
navigationBarTitleText: '培训考试',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
})

View 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
View 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

View File

@ -0,0 +1,6 @@
/** 逐题练习 / 模拟考试页面 */
export default definePageConfig({
navigationBarTitleText: '题库练习',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
})

View 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
View 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

View File

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '翼云Hub',
navigationStyle: 'custom',
enablePullDownRefresh: false
})

View 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
View 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

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商家中心'
})

View 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;
}

View 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

View File

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '商家认证',
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTextStyle: 'black'
})

View 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);
}

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