创赢未来评分系统 - 初始化提交(移除大文件)
37
miniprogram/API.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Mini Program API Documentation
|
||||
|
||||
## Authentication
|
||||
### Login
|
||||
- **URL**: `/api/wechat/login/`
|
||||
- **Method**: `POST`
|
||||
- **Body**: `{ "code": "wx_login_code" }`
|
||||
- **Response**: `{ "token": "...", "openid": "..." }`
|
||||
|
||||
### Update User Info
|
||||
- **URL**: `/api/wechat/update/`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <token>`
|
||||
- **Body**: `{ "nickname": "...", "avatar_url": "..." }`
|
||||
|
||||
## Distributor
|
||||
### Registerz
|
||||
- **URL**: `/api/distributor/register/`
|
||||
- **Method**: `POST`
|
||||
- **Body**: `{ "invite_code": "optional" }`
|
||||
|
||||
### Info
|
||||
- **URL**: `/api/distributor/info/`
|
||||
- **Method**: `GET`
|
||||
- **Response**: `{ "level": 1, "commission_rate": 0.1, ... }`
|
||||
|
||||
### Withdraw
|
||||
- **URL**: `/api/distributor/withdraw/`
|
||||
- **Method**: `POST`
|
||||
- **Body**: `{ "amount": 100 }`
|
||||
|
||||
## Orders & Payment
|
||||
### Prepay (Mini Program)
|
||||
- **URL**: `/api/orders/{id}/prepay_miniprogram/`
|
||||
- **Method**: `POST`
|
||||
- **Response**: `{ "timeStamp": "...", "nonceStr": "...", "package": "...", "paySign": "..." }`
|
||||
- **Use with**: `wx.requestPayment`
|
||||
16
miniprogram/DEPLOY.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Backend (Django)
|
||||
1. **Migrations**: Run `python manage.py migrate shop` to create `WeChatUser`, `Distributor` tables.
|
||||
2. **Config**: Ensure `WeChatPayConfig` is active in Admin Panel with correct `AppID`, `MchID`, `APIv3 Key`, and `Certificates`.
|
||||
3. **Domain**: Add `https://market.quant-speed.com` to WeChat Admin -> Development Settings -> Server Domain.
|
||||
|
||||
## Frontend (Taro Mini Program)
|
||||
1. **Install**: `npm install` in `market_page/miniprogram`.
|
||||
2. **Build**: `npm run build:weapp`.
|
||||
3. **Upload**: Open `dist/` in WeChat Developer Tools.
|
||||
4. **AppID**: Ensure `project.config.json` has the correct AppID.
|
||||
|
||||
## WeChat Admin Configuration
|
||||
1. **Request Domain**: Add `https://market.quant-speed.com`.
|
||||
2. **Payment**: Link the Mini Program AppID to the Merchant ID in WeChat Pay Platform.
|
||||
91
miniprogram/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Market Miniprogram
|
||||
|
||||
Taro + React + TypeScript 微信小程序项目,对接 Django 后端,支持 AI 服务、AR 体验、硬件商品购买及分销功能。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `src/pages`: 主包页面 (首页、商品、订单、AI服务、AR体验)
|
||||
- `src/subpackages`: 分包页面 (分销中心)
|
||||
- `src/api`: API 定义
|
||||
- `src/utils`: 工具函数
|
||||
- `src/assets`: 静态资源
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Taro 3.6 (React)
|
||||
- **语言**: TypeScript
|
||||
- **样式**: SCSS
|
||||
- **UI**: Taro UI / Ant Design (Design Reference)
|
||||
- **后端**: Django REST Framework
|
||||
|
||||
## 快速开始
|
||||
|
||||
小程序id
|
||||
|
||||
wxdf2ca73e6c0929f0
|
||||
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
确保已安装 Node.js (>=16) 和 npm。
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
### 3. 配置环境
|
||||
|
||||
复制 `.env` 模板并配置后端地址:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TARO_APP_API_URL=http://localhost:8000/api
|
||||
```
|
||||
|
||||
### 4. 启动开发
|
||||
|
||||
```bash
|
||||
# 微信小程序开发
|
||||
npm run dev:weapp
|
||||
```
|
||||
|
||||
启动后打开 **微信开发者工具**,导入 `dist` 目录即可预览。
|
||||
|
||||
## 功能列表
|
||||
|
||||
1. **商品交易**: 浏览 ESP32 硬件配置,下单购买,微信支付。
|
||||
2. **AI 服务**: 浏览 AI 解决方案,提交定制需求。
|
||||
3. **AR 体验**: 展示 AR 案例,模拟启动体验。
|
||||
4. **分销中心**: 申请成为分销员,生成推广码,查看收益,申请提现。
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 支付测试
|
||||
- 确保后端 `WeChatPayConfig` 已配置有效的沙箱或正式参数。
|
||||
- 在小程序中下单后,点击支付将调用 `wx.requestPayment`。
|
||||
- 本地开发需确保手机与电脑在同一局域网,并将后端地址改为局域网 IP。
|
||||
|
||||
### 分销测试
|
||||
1. 进入 "我的" -> "分销中心"。
|
||||
2. 点击 "立即申请" (后端自动通过或需审核)。
|
||||
3. 进入分销中心,点击 "推广二维码" 获取小程序码。
|
||||
4. 模拟下单:在其他账号下单时填写 `ref_code` (或通过带参二维码进入)。
|
||||
5. 查看收益:订单支付后,分销中心自动更新余额。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 依赖安装失败?**
|
||||
A: 使用 `npm install --legacy-peer-deps` 忽略版本冲突。
|
||||
|
||||
**Q: 接口请求 404/Network Error?**
|
||||
A: 检查 `.env` 中的 `TARO_APP_API_URL` 是否正确,真机调试时请勿使用 `localhost`,应使用本机局域网 IP (如 `192.168.1.x`),并确保手机能访问该 IP。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 新建特性分支 `git checkout -b feature/AmazingFeature`
|
||||
3. 提交修改 `git commit -m 'Add some AmazingFeature'`
|
||||
4. 推送到分支 `git push origin feature/AmazingFeature`
|
||||
5. 提交 Pull Request
|
||||
9
miniprogram/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// babel.config.js
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', {
|
||||
framework: 'react',
|
||||
ts: true
|
||||
}]
|
||||
]
|
||||
}
|
||||
9
miniprogram/config/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
NODE_ENV: '"development"'
|
||||
},
|
||||
defineConstants: {
|
||||
},
|
||||
mini: {},
|
||||
h5: {}
|
||||
}
|
||||
87
miniprogram/config/index.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const path = require('path')
|
||||
|
||||
const config = {
|
||||
projectName: 'market-miniprogram',
|
||||
date: '2023-10-27',
|
||||
designWidth: 750,
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
828: 1.81 / 2
|
||||
},
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
plugins: [],
|
||||
defineConstants: {
|
||||
},
|
||||
copy: {
|
||||
patterns: [
|
||||
{ from: 'src/assets', to: 'dist/assets' }
|
||||
],
|
||||
options: {
|
||||
}
|
||||
},
|
||||
framework: 'react',
|
||||
compiler: {
|
||||
type: 'webpack5',
|
||||
prebundle: {
|
||||
enable: false
|
||||
}
|
||||
},
|
||||
cache: {
|
||||
enable: false // Disable cache to fix prebundle error
|
||||
},
|
||||
mini: {
|
||||
compile: {
|
||||
include: [
|
||||
path.resolve(__dirname, '..', 'node_modules/marked')
|
||||
]
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
config: {
|
||||
|
||||
}
|
||||
},
|
||||
url: {
|
||||
enable: true,
|
||||
config: {
|
||||
limit: 1024 // 设定转换尺寸上限
|
||||
}
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
h5: {
|
||||
publicPath: '/',
|
||||
staticDirectory: 'static',
|
||||
postcss: {
|
||||
autoprefixer: {
|
||||
enable: true,
|
||||
config: {
|
||||
}
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function (merge) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return merge({}, config, require('./dev'))
|
||||
}
|
||||
return merge({}, config, require('./prod'))
|
||||
}
|
||||
18
miniprogram/config/prod.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
NODE_ENV: '"production"'
|
||||
},
|
||||
defineConstants: {
|
||||
},
|
||||
mini: {},
|
||||
h5: {
|
||||
/**
|
||||
* 如果h5端编译后体积过大,可以使用webpack-bundle-analyzer插件对打包体积进行分析。
|
||||
* 参考代码如下:
|
||||
* webpackChain (chain) {
|
||||
* chain.plugin('analyzer')
|
||||
* .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
|
||||
* }
|
||||
*/
|
||||
}
|
||||
}
|
||||
22
miniprogram/e2e/home.spec.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const automator = require('miniprogram-automator')
|
||||
|
||||
describe('Home Page', () => {
|
||||
let miniProgram
|
||||
|
||||
beforeAll(async () => {
|
||||
miniProgram = await automator.launch({
|
||||
projectPath: '../' // Relative path to miniprogram root
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
afterAll(async () => {
|
||||
await miniProgram.close()
|
||||
})
|
||||
|
||||
it('should render title', async () => {
|
||||
const page = await miniProgram.reLaunch('/pages/index/index')
|
||||
await page.waitFor(2000)
|
||||
const element = await page.$('.title-text')
|
||||
expect(await element.text()).toContain('未来已来') // Assuming typed text starts or contains this
|
||||
})
|
||||
})
|
||||
68
miniprogram/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "market-miniprogram",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Quant Speed Market Mini Program",
|
||||
"templateInfo": {
|
||||
"name": "default-ts",
|
||||
"typescript": true,
|
||||
"css": "sass"
|
||||
},
|
||||
"scripts": {
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"build:swan": "taro build --type swan",
|
||||
"build:alipay": "taro build --type alipay",
|
||||
"build:tt": "taro build --type tt",
|
||||
"build:h5": "taro build --type h5",
|
||||
"build:rn": "taro build --type rn",
|
||||
"build:qq": "taro build --type qq",
|
||||
"build:quickapp": "taro build --type quickapp",
|
||||
"dev:weapp": "npm run build:weapp -- --watch",
|
||||
"dev:swan": "npm run build:swan -- --watch",
|
||||
"dev:alipay": "npm run build:alipay -- --watch",
|
||||
"dev:tt": "npm run build:tt -- --watch",
|
||||
"dev:h5": "npm run build:h5 -- --watch",
|
||||
"dev:rn": "npm run build:rn -- --watch",
|
||||
"dev:qq": "npm run build:qq -- --watch",
|
||||
"dev:quickapp": "npm run build:quickapp -- --watch"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 3 versions",
|
||||
"Android >= 4.1",
|
||||
"ios >= 8"
|
||||
],
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.7",
|
||||
"@tarojs/components": "3.6.20",
|
||||
"@tarojs/helper": "3.6.20",
|
||||
"@tarojs/plugin-framework-react": "3.6.20",
|
||||
"@tarojs/plugin-platform-weapp": "3.6.20",
|
||||
"@tarojs/react": "3.6.20",
|
||||
"@tarojs/runtime": "3.6.20",
|
||||
"@tarojs/shared": "3.6.20",
|
||||
"@tarojs/taro": "3.6.20",
|
||||
"marked": "^4.0.18",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"taro-ui": "^3.0.0-alpha.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.8.0",
|
||||
"@tarojs/cli": "3.6.20",
|
||||
"@tarojs/mini-runner": "3.6.20",
|
||||
"@tarojs/webpack5-runner": "3.6.20",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/webpack-env": "^1.13.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.20.0",
|
||||
"@typescript-eslint/parser": "^5.20.0",
|
||||
"babel-preset-taro": "3.6.20",
|
||||
"eslint": "^8.12.0",
|
||||
"eslint-config-taro": "3.6.20",
|
||||
"eslint-plugin-react": "^7.8.2",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"stylelint": "^14.4.0",
|
||||
"typescript": "^4.1.0",
|
||||
"webpack": "^5.78.0"
|
||||
}
|
||||
}
|
||||
63
miniprogram/project.config.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "market-miniprogram",
|
||||
"description": "Quant Speed Market Mini Program",
|
||||
"appid": "wxdf2ca73e6c0929f0",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhancement": false,
|
||||
"postcss": false,
|
||||
"preloadBackgroundData": false,
|
||||
"minified": false,
|
||||
"newFeature": true,
|
||||
"coverView": true,
|
||||
"nodeModules": false,
|
||||
"autoAudits": false,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"scopeDataCheck": false,
|
||||
"uglifyFileName": false,
|
||||
"checkInvalidKey": true,
|
||||
"checkSiteMap": true,
|
||||
"uploadWithSourceMap": true,
|
||||
"compileHotReLoad": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"useMultiFrameRuntime": true,
|
||||
"useApiHook": true,
|
||||
"useApiHostProcess": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"enableEngineNative": false,
|
||||
"useIsolateContext": true,
|
||||
"userConfirmedBundleSwitch": false,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"disableUseStrict": false,
|
||||
"minifyWXML": true,
|
||||
"showES6CompileOption": false,
|
||||
"useCompilerPlugins": false,
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"compileWorklet": false,
|
||||
"enhance": false,
|
||||
"localPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "2.25.1",
|
||||
"srcMiniprogramRoot": "src/",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {
|
||||
"tabIndent": "insertSpaces",
|
||||
"tabSize": 2
|
||||
},
|
||||
"simulatorPluginLibVersion": {}
|
||||
}
|
||||
22
miniprogram/project.private.config.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"libVersion": "3.13.1",
|
||||
"projectname": "miniprogram",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": true,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false,
|
||||
"useIsolateContext": true
|
||||
}
|
||||
}
|
||||
126
miniprogram/src/api/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { request } from '../utils/request'
|
||||
|
||||
// Configs / Products
|
||||
export const getConfigs = () => request({ url: '/configs/' })
|
||||
export const getConfigDetail = (id: number) => request({ url: `/configs/${id}/` })
|
||||
|
||||
const getInviteCode = () => Taro.getStorageSync('invite_code') || ''
|
||||
|
||||
// Orders
|
||||
export const createOrder = (data: any) => {
|
||||
const code = getInviteCode()
|
||||
if (code) data.ref_code = code
|
||||
return request({ url: '/orders/', method: 'POST', data })
|
||||
}
|
||||
export const getOrder = (id: number) => request({ url: `/orders/${id}/` })
|
||||
export const getMyOrders = () => request({ url: '/orders/' })
|
||||
export const prepayMiniprogram = (orderId: number) => request({ url: `/orders/${orderId}/prepay_miniprogram/`, method: 'POST' })
|
||||
export const queryOrderStatus = (orderId: number) => request({ url: `/orders/${orderId}/query_status/` })
|
||||
|
||||
// AI Services
|
||||
export const getServices = () => request({ url: '/services/' })
|
||||
export const getServiceDetail = (id: number) => request({ url: `/services/${id}/` })
|
||||
export const createServiceOrder = (data: any) => {
|
||||
const code = getInviteCode()
|
||||
if (code) data.ref_code = code
|
||||
return request({ url: '/service-orders/', method: 'POST', data })
|
||||
}
|
||||
|
||||
// VB Courses
|
||||
export const getVBCourses = () => request({ url: '/courses/' })
|
||||
export const getVBCourseDetail = (id: number) => request({ url: `/courses/${id}/?_t=${Date.now()}` })
|
||||
|
||||
// Distributor
|
||||
export const distributorRegister = (data: any) => {
|
||||
const code = getInviteCode()
|
||||
if (code && !data.invite_code) data.invite_code = code
|
||||
return request({ url: '/distributor/register/', method: 'POST', data })
|
||||
}
|
||||
export const distributorInfo = () => request({ url: '/distributor/info/' })
|
||||
export const distributorInvite = () => request({ url: '/distributor/invite/', method: 'POST' })
|
||||
export const distributorWithdraw = (amount: number) => request({ url: '/distributor/withdraw/', method: 'POST', data: { amount } })
|
||||
export const distributorTeam = () => request({ url: '/distributor/team/' })
|
||||
export const distributorEarnings = (params?: any) => request({ url: '/distributor/earnings/', data: params })
|
||||
export const distributorOrders = (params?: any) => request({ url: '/distributor/orders/', data: params })
|
||||
|
||||
// User
|
||||
export const updateUserInfo = (data: any) => request({ url: '/wechat/update/', method: 'POST', data })
|
||||
export const wechatLogin = (code: string) => request({ url: '/wechat/login/', method: 'POST', data: { code } })
|
||||
|
||||
// Forum / Community
|
||||
export const getTopics = (params: any) => request({ url: '/community/topics/', data: params })
|
||||
export const getTopicDetail = (id: number) => request({ url: `/community/topics/${id}/` })
|
||||
export const likeTopic = (id: number) => request({ url: `/community/topics/${id}/like/`, method: 'POST' })
|
||||
export const createTopic = (data: any) => request({ url: '/community/topics/', method: 'POST', data })
|
||||
export const updateTopic = (id: number, data: any) => request({ url: `/community/topics/${id}/`, method: 'PATCH', data })
|
||||
export const getReplies = (params: any) => request({ url: '/community/replies/', data: params })
|
||||
export const likeReply = (id: number) => request({ url: `/community/replies/${id}/like/`, method: 'POST' })
|
||||
export const createReply = (data: any) => request({ url: '/community/replies/', method: 'POST', data })
|
||||
export const updateReply = (id: number, data: any) => request({ url: `/community/replies/${id}/`, method: 'PATCH', data })
|
||||
export const deleteReply = (id: number) => request({ url: `/community/replies/${id}/`, method: 'DELETE' })
|
||||
export const deleteTopic = (id: number) => request({ url: `/community/topics/${id}/`, method: 'DELETE' })
|
||||
export const getStarUsers = () => request({ url: '/users/stars/' })
|
||||
export const getAnnouncements = () => request({ url: '/community/announcements/' })
|
||||
|
||||
// Activities
|
||||
export const getActivities = () => request({ url: '/community/activities/', data: { _t: Date.now() } })
|
||||
export const getActivityDetail = (id: number) => request({ url: `/community/activities/${id}/`, data: { _t: Date.now() } })
|
||||
export const signupActivity = (id: number, data?: any) => request({ url: `/community/activities/${id}/signup/`, method: 'POST', data })
|
||||
export const getMySignups = () => request({ url: '/community/activities/my_signups/' })
|
||||
|
||||
// Competitions
|
||||
export const getCompetitions = (params?: any) => request({ url: '/competition/competitions/', data: params })
|
||||
export const getCompetitionDetail = (id: number) => request({ url: `/competition/competitions/${id}/` })
|
||||
export const enrollCompetition = (id: number, data: any) => request({ url: `/competition/competitions/${id}/enroll/`, method: 'POST', data })
|
||||
export const getMyCompetitionEnrollment = (id: number) => request({ url: `/competition/competitions/${id}/my_enrollment/` })
|
||||
export const getMyEnrollments = () => request({ url: '/competition/competitions/my_enrollments/' })
|
||||
export const getProjects = (params?: any) => request({ url: '/competition/projects/', data: params })
|
||||
export const getProjectDetail = (id: number) => request({ url: `/competition/projects/${id}/` })
|
||||
export const createProject = (data: any) => request({ url: '/competition/projects/', method: 'POST', data })
|
||||
export const updateProject = (id: number, data: any) => request({ url: `/competition/projects/${id}/`, method: 'PATCH', data })
|
||||
export const submitProject = (id: number) => request({ url: `/competition/projects/${id}/submit/`, method: 'POST' })
|
||||
export const getComments = (params: any) => request({ url: '/competition/comments/', data: params })
|
||||
export const uploadProjectFile = (filePath: string, projectId: number, fileName?: string) => {
|
||||
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
|
||||
return Taro.uploadFile({
|
||||
url: `${BASE_URL}/competition/files/`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
project: projectId,
|
||||
name: fileName || ''
|
||||
},
|
||||
header: {
|
||||
'Authorization': `Bearer ${Taro.getStorageSync('token')}`
|
||||
}
|
||||
}).then(res => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return JSON.parse(res.data)
|
||||
}
|
||||
throw new Error('Upload failed')
|
||||
})
|
||||
}
|
||||
|
||||
// Upload Media for Forum
|
||||
export const uploadMedia = (filePath: string, type: 'image' | 'video') => {
|
||||
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
|
||||
return Taro.uploadFile({
|
||||
url: `${BASE_URL}/community/media/`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
media_type: type
|
||||
},
|
||||
header: {
|
||||
'Authorization': `Bearer ${Taro.getStorageSync('token')}`
|
||||
}
|
||||
}).then(res => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return JSON.parse(res.data)
|
||||
}
|
||||
throw new Error('Upload failed')
|
||||
})
|
||||
}
|
||||
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
92
miniprogram/src/app.config.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/services/index',
|
||||
'pages/services/detail',
|
||||
'pages/courses/index',
|
||||
'pages/courses/detail',
|
||||
'pages/forum/index',
|
||||
'pages/goods/detail',
|
||||
'pages/webview/index',
|
||||
'pages/cart/cart',
|
||||
'pages/order/checkout',
|
||||
'pages/order/payment',
|
||||
'pages/order/list',
|
||||
'pages/order/detail',
|
||||
'pages/user/index',
|
||||
'pages/competition/index',
|
||||
'pages/competition/detail',
|
||||
'pages/competition/project',
|
||||
'pages/competition/project-detail'
|
||||
],
|
||||
subPackages: [
|
||||
{
|
||||
root: 'subpackages/distributor',
|
||||
pages: [
|
||||
'index',
|
||||
'register',
|
||||
'invite',
|
||||
'withdraw',
|
||||
'team',
|
||||
'earnings',
|
||||
'orders'
|
||||
]
|
||||
},
|
||||
{
|
||||
root: 'subpackages/forum',
|
||||
pages: [
|
||||
'detail/index',
|
||||
'create/index',
|
||||
'activity/index',
|
||||
'activity/detail'
|
||||
]
|
||||
}
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#000000',
|
||||
navigationBarTitleText: 'Quant Speed Market',
|
||||
navigationBarTextStyle: 'white'
|
||||
},
|
||||
tabBar: {
|
||||
color: "#666666",
|
||||
selectedColor: "#00b96b",
|
||||
backgroundColor: "#000000",
|
||||
borderStyle: "black",
|
||||
list: [
|
||||
{
|
||||
pagePath: "pages/index/index",
|
||||
text: "首页",
|
||||
iconPath: "./assets/home.png",
|
||||
selectedIconPath: "./assets/home_active.png"
|
||||
},
|
||||
{
|
||||
pagePath: "pages/services/index",
|
||||
text: "AI服务",
|
||||
iconPath: "./assets/AI_service.png",
|
||||
selectedIconPath: "./assets/AI_service_active.png"
|
||||
},
|
||||
{
|
||||
pagePath: "pages/forum/index",
|
||||
text: "社区",
|
||||
iconPath: "./assets/VR.png",
|
||||
selectedIconPath: "./assets/VR_active.png"
|
||||
},
|
||||
{
|
||||
pagePath: "pages/cart/cart",
|
||||
text: "购物车",
|
||||
iconPath: "./assets/cart.png",
|
||||
selectedIconPath: "./assets/cart_active.png"
|
||||
},
|
||||
{
|
||||
pagePath: "pages/user/index",
|
||||
text: "我的",
|
||||
iconPath: "./assets/user.png",
|
||||
selectedIconPath: "./assets/user_active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
requiredPrivateInfos: [
|
||||
"chooseAddress"
|
||||
]
|
||||
})
|
||||
22
miniprogram/src/app.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@import 'taro-ui/dist/style/index.scss';
|
||||
|
||||
page {
|
||||
--primary-cyan: #00f0ff;
|
||||
--primary-green: #00b96b;
|
||||
--primary-purple: #bd00ff;
|
||||
--bg-dark: #050505;
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--text-main: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
|
||||
background-color: var(--bg-dark);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
|
||||
Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei',
|
||||
sans-serif;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
51
miniprogram/src/app.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import Taro, { useLaunch } from '@tarojs/taro'
|
||||
import { login } from './utils/request'
|
||||
import './app.scss'
|
||||
|
||||
function App({ children }: PropsWithChildren<any>) {
|
||||
|
||||
useLaunch((options) => {
|
||||
console.log('App launched.', options)
|
||||
|
||||
// 捕获邀请码 (场景值或直接参数)
|
||||
const { query } = options
|
||||
if (query) {
|
||||
let inviteCode = ''
|
||||
if (query.scene) {
|
||||
// 扫码进入,scene 需要解码
|
||||
inviteCode = decodeURIComponent(query.scene)
|
||||
} else if (query.invite_code) {
|
||||
// 链接分享进入
|
||||
inviteCode = query.invite_code
|
||||
}
|
||||
|
||||
if (inviteCode && inviteCode !== 'undefined') {
|
||||
console.log('Captured invite code:', inviteCode)
|
||||
Taro.setStorageSync('invite_code', inviteCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto login only if user info with phone number exists
|
||||
const userInfo = Taro.getStorageSync('userInfo')
|
||||
if (userInfo && userInfo.phone_number) {
|
||||
console.log('User has phone number, attempting auto login...')
|
||||
login().then(res => {
|
||||
console.log('Auto login success, user:', res?.nickname)
|
||||
}).catch(err => {
|
||||
console.log('Auto login failed', err)
|
||||
// If login fails (e.g. user deleted on backend), clear storage
|
||||
if (err.statusCode === 404 || err.statusCode === 401) {
|
||||
Taro.removeStorageSync('userInfo')
|
||||
Taro.removeStorageSync('token')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('No phone number found, skipping auto login')
|
||||
}
|
||||
})
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default App
|
||||
BIN
miniprogram/src/assets/AI_service.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
miniprogram/src/assets/AI_service_active.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
miniprogram/src/assets/VR.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
miniprogram/src/assets/VR_active.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
miniprogram/src/assets/cart.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
miniprogram/src/assets/cart_active.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
miniprogram/src/assets/home.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
miniprogram/src/assets/home_active.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
104
miniprogram/src/assets/logo.svg
Normal file
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="300.000000pt" height="198.000000pt" viewBox="0 0 300.000000 198.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.10, written by Peter Selinger 2001-2011
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,198.000000) scale(0.050000,-0.050000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1836 3803 c-149 -150 -196 -223 -196 -304 0 -58 24 -46 85 42 95
|
||||
137 365 284 404 220 17 -27 127 -410 120 -417 -3 -4 -43 21 -90 55 -108 78
|
||||
-289 58 -349 -38 -27 -45 9 -46 101 -2 225 105 249 -11 66 -313 -41 -68 -99
|
||||
-170 -127 -227 -47 -91 -58 -101 -102 -90 -78 19 -88 -3 -48 -95 21 -47 44
|
||||
-125 52 -172 29 -184 162 -172 247 23 25 58 98 213 161 345 63 132 121 267
|
||||
129 300 18 75 31 60 153 -178 208 -401 479 -647 755 -684 106 -14 223 32 223
|
||||
87 0 64 -209 203 -307 205 -115 1 -389 222 -556 447 -182 243 -255 595 -117
|
||||
559 49 -13 68 30 43 96 -28 71 -174 218 -217 218 -20 0 -86 18 -146 40 -60 22
|
||||
-114 40 -120 39 -5 0 -79 -70 -164 -156z"/>
|
||||
<path d="M5153 3827 c-24 -25 -13 -47 23 -47 98 0 112 -112 22 -178 -50 -37
|
||||
-78 -44 -140 -34 -100 16 -109 -43 -18 -108 79 -56 76 -68 -29 -108 -115 -45
|
||||
-191 -105 -191 -153 0 -45 18 -48 87 -17 59 27 142 -9 125 -54 -6 -15 -3 -50
|
||||
7 -75 13 -35 9 -56 -16 -82 -31 -32 -29 -34 27 -28 118 11 61 -34 -136 -108
|
||||
-121 -45 -234 -75 -288 -75 -104 0 -107 -30 -8 -87 l64 -38 177 83 c182 84
|
||||
241 99 241 60 0 -53 -93 -181 -166 -229 -100 -67 -76 -108 43 -72 126 38 174
|
||||
89 224 242 39 117 54 141 97 150 103 21 219 18 248 -6 59 -49 423 -22 430 32
|
||||
6 37 -8 52 -70 76 -42 17 -98 41 -126 53 -27 13 -59 21 -70 18 -29 -8 -357
|
||||
-61 -376 -62 -67 -1 7 94 95 123 145 48 141 75 -17 126 -35 11 -34 18 23 77
|
||||
47 48 58 72 44 96 -22 40 30 93 114 116 83 22 78 60 -21 167 -70 76 -101 95
|
||||
-155 96 -37 0 -89 13 -117 29 -53 30 -125 39 -147 17z m286 -115 c27 -33 -11
|
||||
-144 -66 -189 -43 -36 -44 -1 -5 113 16 44 24 86 17 92 -6 7 -2 12 9 12 12 0
|
||||
32 -13 45 -28z m-211 -378 c17 -27 10 -45 -33 -88 l-55 -55 -70 38 c-89 47
|
||||
-88 54 15 106 95 47 113 47 143 -1z"/>
|
||||
<path d="M1151 3392 c11 -60 8 -72 -21 -72 -19 0 -52 -22 -74 -50 -21 -27 -53
|
||||
-50 -70 -50 -25 0 -26 5 -6 30 14 17 20 36 14 43 -7 7 -23 -14 -35 -45 -41
|
||||
-103 -53 -118 -94 -118 -59 0 -75 -24 -27 -40 23 -7 43 -23 44 -36 6 -59 -8
|
||||
-127 -31 -154 -41 -50 -62 -95 -30 -65 38 35 59 31 58 -10 0 -42 -96 -117
|
||||
-118 -92 -109 123 -96 467 17 467 12 0 22 9 22 20 0 11 -12 20 -26 20 -14 0
|
||||
-61 18 -105 40 -110 57 -138 41 -99 -53 17 -41 44 -147 59 -236 16 -88 43
|
||||
-194 60 -235 43 -103 40 -114 -37 -146 -66 -28 -92 -63 -92 -125 0 -26 20 -29
|
||||
128 -19 100 9 175 1 332 -37 281 -68 620 -39 620 53 0 44 -63 76 -112 57 -28
|
||||
-10 -98 -19 -157 -19 l-106 0 -12 75 c-7 41 -13 130 -13 197 l0 123 85 31 c47
|
||||
17 108 34 135 38 28 3 50 19 50 36 0 41 -124 79 -215 66 -85 -13 -115 59 -35
|
||||
84 49 16 53 81 6 107 -19 11 -44 53 -55 94 -29 103 -79 120 -60 21z m9 -367
|
||||
c0 -93 -110 -178 -146 -113 -17 31 -22 29 -46 -14 l-27 -48 0 54 c-1 34 12 57
|
||||
35 65 21 6 63 38 94 71 75 78 90 76 90 -15z m0 -254 c0 -105 -24 -126 -91 -82
|
||||
-74 48 -59 13 35 -83 106 -108 100 -113 -121 -83 -178 23 -183 26 -183 76 0
|
||||
60 95 129 148 109 21 -8 37 2 48 31 14 38 111 116 149 120 8 0 15 -39 15 -88z"/>
|
||||
<path d="M3842 3282 c-44 -28 -6 -62 70 -62 96 0 201 -48 218 -99 22 -69 -171
|
||||
-359 -400 -601 -253 -267 -269 -338 -114 -505 71 -76 113 -69 184 30 34 47
|
||||
107 147 163 223 309 424 418 676 349 808 -75 145 -365 272 -470 206z"/>
|
||||
<path d="M5331 2779 c7 -12 75 -62 151 -110 84 -54 138 -103 138 -124 1 -68
|
||||
198 -200 226 -150 55 98 13 173 -156 281 -125 79 -392 156 -359 103z"/>
|
||||
<path d="M4240 2461 c0 -10 52 -59 116 -108 64 -49 147 -124 186 -166 170
|
||||
-188 257 -247 361 -247 206 0 55 221 -243 355 -60 27 -173 80 -250 117 -148
|
||||
73 -170 79 -170 49z"/>
|
||||
<path d="M1145 2247 c-31 -13 -277 -299 -335 -392 -20 -31 -151 -222 -327
|
||||
-475 -49 -71 -106 -148 -126 -170 -19 -23 -59 -86 -88 -140 -29 -55 -92 -151
|
||||
-140 -213 -129 -165 -137 -196 -89 -356 83 -281 158 -274 309 29 63 127 149
|
||||
268 192 315 42 47 87 109 99 137 21 49 34 53 186 65 90 7 223 18 295 25 118
|
||||
12 130 9 131 -25 0 -20 7 -71 15 -114 14 -67 10 -77 -27 -87 -85 -22 67 -396
|
||||
161 -396 l49 0 -1 240 c-1 340 -88 1041 -148 1198 -13 35 -17 104 -9 172 19
|
||||
167 -33 233 -147 187z m44 -412 c16 -104 51 -542 43 -550 -4 -4 -82 -10 -175
|
||||
-14 -92 -3 -187 -13 -212 -21 -67 -22 -57 16 43 178 48 78 94 164 102 192 19
|
||||
61 155 280 174 280 8 0 19 -29 25 -65z"/>
|
||||
<path d="M5212 1983 c-7 -11 15 -118 49 -237 161 -559 176 -476 -116 -627 -94
|
||||
-49 -189 -113 -211 -143 -22 -30 -48 -50 -57 -44 -36 22 -14 79 65 173 266
|
||||
315 280 517 46 634 -133 66 -203 42 -106 -37 65 -52 -40 -220 -319 -507 l-111
|
||||
-114 34 -71 c19 -39 34 -83 34 -98 0 -60 78 -102 145 -76 80 30 80 29 52 -94
|
||||
-15 -65 -17 -110 -6 -118 11 -8 37 -50 59 -94 82 -164 121 -122 113 120 -8
|
||||
250 11 297 131 317 42 7 147 44 235 83 87 38 162 65 167 61 10 -10 79 -260
|
||||
113 -411 14 -60 40 -145 58 -187 18 -43 33 -98 33 -122 0 -63 140 -310 201
|
||||
-355 126 -93 150 -17 146 464 -5 576 -6 600 -27 600 -11 0 -20 -27 -20 -60 0
|
||||
-71 -107 -415 -146 -468 -25 -34 -30 -29 -54 60 -14 54 -40 125 -57 158 -38
|
||||
74 -122 328 -123 372 0 17 25 44 55 59 30 16 80 46 110 67 30 22 93 44 140 51
|
||||
181 24 102 101 -155 150 -262 50 -273 59 -338 267 -54 174 -114 270 -140 227z
|
||||
m-364 -559 c-75 -99 -310 -344 -331 -344 -9 0 71 103 178 228 180 211 282 288
|
||||
153 116z m691 56 c102 -52 111 -69 52 -108 -75 -49 -103 -39 -128 45 -36 122
|
||||
-37 121 76 63z"/>
|
||||
<path d="M5560 1980 c0 -11 9 -20 19 -20 11 0 27 -29 37 -65 11 -46 36 -75 85
|
||||
-98 131 -63 148 35 26 143 -66 58 -167 82 -167 40z"/>
|
||||
<path d="M2490 1947 c-69 -16 -169 -24 -235 -18 -100 9 -115 6 -115 -24 1 -77
|
||||
77 -125 201 -125 71 0 81 -5 69 -35 -8 -19 -19 -53 -25 -75 -23 -95 -197 -554
|
||||
-265 -700 -99 -214 -106 -219 -298 -203 -170 15 -182 8 -182 -96 0 -89 28 -94
|
||||
506 -84 446 9 534 23 534 85 0 66 -265 168 -337 129 -161 -86 -171 29 -20 242
|
||||
20 29 37 71 37 93 0 44 72 216 166 394 39 75 63 150 63 200 l1 80 145 1 c165
|
||||
1 212 26 158 85 -72 80 -205 97 -403 51z"/>
|
||||
<path d="M3873 1627 c-7 -84 -13 -191 -13 -237 l0 -84 -135 -51 c-74 -28 -160
|
||||
-57 -191 -64 -32 -7 -53 -23 -49 -37 14 -42 167 -39 267 5 51 22 96 41 101 41
|
||||
4 0 7 -60 7 -132 l0 -133 -65 15 c-148 35 -275 -7 -275 -92 0 -32 5 -33 61 -4
|
||||
62 31 169 24 244 -16 37 -21 48 -149 22 -277 -20 -100 -244 -43 -288 73 -11
|
||||
28 -25 47 -30 42 -40 -41 318 -436 394 -436 65 0 77 88 77 569 l0 467 63 22
|
||||
c35 12 89 22 120 22 94 0 77 73 -23 100 -99 27 -160 90 -160 164 0 214 -109
|
||||
251 -127 43z"/>
|
||||
<path d="M3063 1349 c-22 -28 -53 -94 -69 -145 -85 -278 -83 -254 -38 -350 77
|
||||
-164 174 -184 245 -51 23 41 24 41 80 -21 95 -104 146 -23 147 238 2 292 -70
|
||||
418 -194 341 -33 -21 -43 -20 -58 7 -26 46 -67 39 -113 -19z m237 -129 c0 -59
|
||||
-8 -80 -31 -80 -17 0 -58 -14 -90 -31 -82 -42 -81 8 3 113 81 103 118 102 118
|
||||
-2z m0 -167 c0 -70 -82 -137 -122 -101 -53 48 -47 81 17 105 78 28 105 27 105
|
||||
-4z m-140 -132 c16 -32 13 -43 -20 -61 -36 -19 -40 -15 -40 39 0 69 29 80 60
|
||||
22z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
BIN
miniprogram/src/assets/user.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
miniprogram/src/assets/user_active.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
42
miniprogram/src/components/MarkdownReader/CodeBlock.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtIcon } from 'taro-ui'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ code, language }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = (e) => {
|
||||
e.stopPropagation()
|
||||
Taro.setClipboardData({
|
||||
data: code,
|
||||
success: () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='markdown-code-block'>
|
||||
<View className='code-header'>
|
||||
<Text className='language'>{language || 'text'}</Text>
|
||||
<View className='copy-btn' onClick={handleCopy}>
|
||||
<AtIcon value='copy' size='14' color='#ccc' />
|
||||
<Text className='copy-text'>{copied ? '已复制' : '复制'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView scrollX scrollY className='code-content'>
|
||||
<Text userSelect className='code-text'>{code}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeBlock
|
||||
101
miniprogram/src/components/MarkdownReader/index.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
.markdown-reader {
|
||||
.markdown-text {
|
||||
/* Inherit font styles and color from parent */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
|
||||
/* Ensure rich text images are responsive */
|
||||
image {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-video-container {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #0a0a1a;
|
||||
border: 1px solid rgba(0, 243, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 243, 255, 0.1);
|
||||
|
||||
.markdown-video {
|
||||
width: 100%;
|
||||
height: 225px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-video-caption {
|
||||
padding: 12px;
|
||||
background: rgba(10, 10, 26, 0.9);
|
||||
color: rgba(0, 243, 255, 0.9);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-code-block {
|
||||
margin: 16px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
.language {
|
||||
color: #9cdcfe;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.copy-text {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 12px;
|
||||
background-color: #1e1e1e;
|
||||
max-height: 400px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.code-text {
|
||||
color: #d4d4d4;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
146
miniprogram/src/components/MarkdownReader/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { View, RichText, Video } from '@tarojs/components'
|
||||
import { marked, Renderer } from 'marked'
|
||||
import CodeBlock from './CodeBlock'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
themeColor?: string
|
||||
}
|
||||
|
||||
const MarkdownReader: React.FC<Props> = ({ content, themeColor = '#00b96b' }) => {
|
||||
const elements = useMemo(() => {
|
||||
if (!content) return []
|
||||
|
||||
const tokens = marked.lexer(content)
|
||||
const result: React.ReactNode[] = []
|
||||
let currentTokens: any[] = []
|
||||
|
||||
// Configure renderer
|
||||
const renderer = new Renderer()
|
||||
|
||||
renderer.table = (header, body) => {
|
||||
return `<div style="overflow-x: auto; width: 100%; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; min-width: 600px; border-collapse: collapse; margin: 16px 0; font-size: 16px;">
|
||||
<thead>${header}</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
}
|
||||
|
||||
renderer.tablecell = (content, flags) => {
|
||||
const type = flags.header ? 'th' : 'td'
|
||||
const style = [
|
||||
'border: 1px solid rgba(255,255,255,0.1)',
|
||||
'padding: 10px',
|
||||
flags.header ? 'background-color: rgba(255,255,255,0.05); font-weight: 700; color: #fff;' : 'color: #ddd;',
|
||||
flags.align ? `text-align: ${flags.align}` : 'text-align: left'
|
||||
].join(';')
|
||||
return `<${type} style="${style}">${content}</${type}>`
|
||||
}
|
||||
|
||||
renderer.image = (href, title, text) => {
|
||||
return `<img src="${href}" style="max-width:100%;border-radius:8px;margin:10px 0;box-shadow: 0 4px 12px rgba(0,0,0,0.3);" title="${title || ''}" alt="${text || ''}" />`
|
||||
}
|
||||
|
||||
renderer.link = (href, title, text) => {
|
||||
return `<a href="${href}" style="color: ${themeColor}; text-decoration: none;">${text}</a>`
|
||||
}
|
||||
|
||||
// Process tokens
|
||||
tokens.forEach((token, index) => {
|
||||
if (token.type === 'code') {
|
||||
// Skip css blocks that look like the video component styles
|
||||
if ((token.lang === 'css' || !token.lang) && token.text.includes('.simple-tech-video')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Flush accumulated tokens
|
||||
if (currentTokens.length > 0) {
|
||||
// preserve links if any
|
||||
(currentTokens as any).links = (tokens as any).links
|
||||
const html = marked.parser(currentTokens as any, { renderer, breaks: true })
|
||||
result.push(<RichText key={`rt-${index}`} nodes={html} className='markdown-text' />)
|
||||
currentTokens = []
|
||||
}
|
||||
|
||||
// Add code block
|
||||
result.push(
|
||||
<View key={`cb-${index}`} className='code-block-wrapper'>
|
||||
<CodeBlock
|
||||
code={token.text}
|
||||
language={token.lang}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (token.type === 'html') {
|
||||
// Check for video tag
|
||||
const videoRegex = /<video[^>]*>[\s\S]*?<source[^>]*src=["'](.*?)["'][^>]*>[\s\S]*?<\/video>/i
|
||||
const simpleVideoRegex = /<video[^>]*src=["'](.*?)["'][^>]*>/i
|
||||
|
||||
const match = token.text.match(videoRegex) || token.text.match(simpleVideoRegex)
|
||||
|
||||
if (match) {
|
||||
// Flush accumulated tokens
|
||||
if (currentTokens.length > 0) {
|
||||
(currentTokens as any).links = (tokens as any).links
|
||||
const html = marked.parser(currentTokens as any, { renderer, breaks: true })
|
||||
result.push(<RichText key={`rt-${index}`} nodes={html} className='markdown-text' />)
|
||||
currentTokens = []
|
||||
}
|
||||
|
||||
const src = match[1]
|
||||
// Try to extract caption
|
||||
const captionRegex = /class=["']video-caption["'][^>]*>(.*?)<\/div>/i
|
||||
const captionMatch = token.text.match(captionRegex)
|
||||
const caption = captionMatch ? captionMatch[1] : null
|
||||
|
||||
result.push(
|
||||
<View key={`video-${index}`} className='markdown-video-container'>
|
||||
<Video
|
||||
src={src}
|
||||
className='markdown-video'
|
||||
controls
|
||||
autoplay={false}
|
||||
objectFit='contain'
|
||||
showFullscreenBtn
|
||||
showPlayBtn
|
||||
showCenterPlayBtn
|
||||
enablePlayGesture
|
||||
/>
|
||||
{caption && <View className='markdown-video-caption'>{caption}</View>}
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
// Filter out style tags for video component if they are parsed as HTML
|
||||
if (token.text.includes('<style>') && token.text.includes('.simple-tech-video')) {
|
||||
// If it's JUST the style tag, ignore it. If it's mixed with other content, we might need to be careful.
|
||||
// But usually marked parses block HTML separately.
|
||||
// Let's verify if we can just skip it.
|
||||
// If the token is ONLY the style block, we skip it.
|
||||
// If it contains other content, we might need to strip the style.
|
||||
// For now, let's assume it's a block HTML token.
|
||||
return
|
||||
}
|
||||
currentTokens.push(token)
|
||||
}
|
||||
} else {
|
||||
currentTokens.push(token)
|
||||
}
|
||||
})
|
||||
|
||||
// Flush remaining tokens
|
||||
if (currentTokens.length > 0) {
|
||||
(currentTokens as any).links = (tokens as any).links
|
||||
const html = marked.parser(currentTokens as any, { renderer, breaks: true })
|
||||
result.push(<RichText key={`rt-end`} nodes={html} className='markdown-text' />)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [content, themeColor])
|
||||
|
||||
return <View className='markdown-reader'>{elements}</View>
|
||||
}
|
||||
|
||||
export default MarkdownReader
|
||||
10
miniprogram/src/components/ParticleBackground/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.particle-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #000;
|
||||
}
|
||||
175
miniprogram/src/components/ParticleBackground/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Canvas, View } from '@tarojs/components'
|
||||
import Taro, { useReady, useUnload } from '@tarojs/taro'
|
||||
import { useRef } from 'react'
|
||||
import './index.scss'
|
||||
|
||||
export default function ParticleBackground() {
|
||||
const canvasRef = useRef<any>(null)
|
||||
const animationRef = useRef<any>(null)
|
||||
|
||||
useReady(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.select('#particle-canvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
if (!res[0]) return
|
||||
const canvas = res[0].node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = Taro.getSystemInfoSync().pixelRatio
|
||||
|
||||
canvas.width = res[0].width * dpr
|
||||
canvas.height = res[0].height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const width = res[0].width
|
||||
const height = res[0].height
|
||||
|
||||
// Init particles
|
||||
const particles: any[] = []
|
||||
const particleCount = 40 // Reduced for mobile performance
|
||||
const meteors: any[] = []
|
||||
const meteorCount = 4
|
||||
|
||||
class Particle {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
constructor() {
|
||||
this.x = Math.random() * width
|
||||
this.y = Math.random() * height
|
||||
this.vx = (Math.random() - 0.5) * 0.5
|
||||
this.vy = (Math.random() - 0.5) * 0.5
|
||||
this.size = Math.random() * 2
|
||||
this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '
|
||||
}
|
||||
update() {
|
||||
this.x += this.vx
|
||||
this.y += this.vy
|
||||
if (this.x < 0 || this.x > width) this.vx *= -1
|
||||
if (this.y < 0 || this.y > height) this.vy *= -1
|
||||
}
|
||||
draw() {
|
||||
ctx.beginPath()
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = this.color + (0.5 + Math.random() * 0.5) + ')'
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
class Meteor {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
len: number
|
||||
color: string
|
||||
opacity: number
|
||||
maxOpacity: number
|
||||
wait: number
|
||||
constructor() {
|
||||
this.x = 0
|
||||
this.y = 0
|
||||
this.vx = 0
|
||||
this.vy = 0
|
||||
this.len = 0
|
||||
this.color = ''
|
||||
this.opacity = 0
|
||||
this.maxOpacity = 0
|
||||
this.wait = 0
|
||||
this.reset()
|
||||
}
|
||||
reset() {
|
||||
this.x = Math.random() * width * 1.5
|
||||
this.y = Math.random() * -height
|
||||
this.vx = -(Math.random() * 3 + 3)
|
||||
this.vy = Math.random() * 3 + 3
|
||||
this.len = Math.random() * 100 + 100
|
||||
this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '
|
||||
this.opacity = 0
|
||||
this.maxOpacity = Math.random() * 0.5 + 0.2
|
||||
this.wait = Math.random() * 200
|
||||
}
|
||||
update() {
|
||||
if (this.wait > 0) {
|
||||
this.wait--
|
||||
return
|
||||
}
|
||||
this.x += this.vx
|
||||
this.y += this.vy
|
||||
if (this.opacity < this.maxOpacity) this.opacity += 0.02
|
||||
if (this.x < -this.len || this.y > height + this.len) this.reset()
|
||||
}
|
||||
draw() {
|
||||
if (this.wait > 0) return
|
||||
const tailX = this.x - this.vx * (this.len / 15)
|
||||
const tailY = this.y - this.vy * (this.len / 15)
|
||||
|
||||
const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY)
|
||||
gradient.addColorStop(0, this.color + this.opacity + ')')
|
||||
gradient.addColorStop(1, this.color + '0)')
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = gradient
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.moveTo(this.x, this.y)
|
||||
ctx.lineTo(tailX, tailY)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < particleCount; i++) particles.push(new Particle())
|
||||
for (let i = 0; i < meteorCount; i++) meteors.push(new Meteor())
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Draw Meteors
|
||||
meteors.forEach(m => {
|
||||
m.update()
|
||||
m.draw()
|
||||
})
|
||||
|
||||
// Draw Lines
|
||||
ctx.lineWidth = 0.5
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
for (let j = i; j < particleCount; j++) {
|
||||
const dx = particles[i].x - particles[j].x
|
||||
const dy = particles[i].y - particles[j].y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 80) { // Reduced distance for mobile
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = `rgba(100, 255, 218, ${1 - dist / 80})`
|
||||
ctx.moveTo(particles[i].x, particles[i].y)
|
||||
ctx.lineTo(particles[j].x, particles[j].y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Particles
|
||||
particles.forEach(p => {
|
||||
p.update()
|
||||
p.draw()
|
||||
})
|
||||
|
||||
canvas.requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
type='2d'
|
||||
id='particle-canvas'
|
||||
className='particle-canvas'
|
||||
/>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/cart/cart.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '购物车'
|
||||
})
|
||||
214
miniprogram/src/pages/cart/cart.scss
Normal file
@@ -0,0 +1,214 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding-bottom: 120px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.checkbox-area {
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
|
||||
.checkbox {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.checked {
|
||||
border-color: #00b96b;
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
color: #00b96b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
margin-right: 20px;
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.item-name {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 24px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
|
||||
.price {
|
||||
font-size: 32px;
|
||||
color: #00b96b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
|
||||
.btn-qty {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.qty-num {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 10px;
|
||||
margin-left: 10px;
|
||||
color: #ff4d4f;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
z-index: 100;
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.select-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 30px;
|
||||
|
||||
.checkbox {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #666;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.checked {
|
||||
border-color: #00b96b;
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
color: #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.label { font-size: 28px; color: #fff; }
|
||||
}
|
||||
|
||||
.total-info {
|
||||
.label { font-size: 24px; color: #888; margin-right: 10px; }
|
||||
.price { font-size: 40px; color: #00b96b; font-weight: bold; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-checkout {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000;
|
||||
border-radius: 40px;
|
||||
padding: 0 50px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active { transform: scale(0.98); }
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
miniprogram/src/pages/cart/cart.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { getCart, updateQuantity, removeItem, toggleSelect, toggleSelectAll, CartItem } from '../../utils/cart'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './cart.scss'
|
||||
|
||||
export default function Cart() {
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
refreshCart()
|
||||
})
|
||||
|
||||
const refreshCart = () => {
|
||||
setCartItems(getCart())
|
||||
}
|
||||
|
||||
const handleUpdateQuantity = (id: number, delta: number) => {
|
||||
const item = cartItems.find(i => i.id === id)
|
||||
if (!item) return
|
||||
const newQty = item.quantity + delta
|
||||
if (newQty < 1) return
|
||||
const newCart = updateQuantity(id, newQty)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该商品吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const newCart = removeItem(id)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggle = (id: number) => {
|
||||
const newCart = toggleSelect(id)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return cartItems.length > 0 && cartItems.every(i => i.selected)
|
||||
}, [cartItems])
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const newCart = toggleSelectAll(!isAllSelected)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.quantity, 0)
|
||||
}, [cartItems])
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.price * i.quantity, 0)
|
||||
}, [cartItems])
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!checkLogin()) return
|
||||
if (selectedCount === 0) {
|
||||
Taro.showToast({ title: '请选择商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: '/pages/order/checkout?from=cart'
|
||||
})
|
||||
}
|
||||
|
||||
const goShopping = () => {
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{cartItems.length === 0 ? (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-icon'>🛒</Text>
|
||||
<Text className='empty-text'>购物车空空如也</Text>
|
||||
<Button onClick={goShopping} style={{marginTop: 20, background: '#00b96b', color: '#fff'}}>去逛逛</Button>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollY className='cart-list'>
|
||||
{cartItems.map(item => (
|
||||
<View key={item.id} className='cart-item'>
|
||||
<View className='checkbox-area' onClick={() => handleToggle(item.id)}>
|
||||
<View className={`checkbox ${item.selected ? 'checked' : ''}`}>
|
||||
{item.selected && <Text>✓</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Image src={item.image} className='item-img' mode='aspectFill' />
|
||||
|
||||
<View className='item-info'>
|
||||
<View>
|
||||
<Text className='item-name'>{item.name}</Text>
|
||||
{/* <Text className='item-desc'>{item.description}</Text> */}
|
||||
</View>
|
||||
|
||||
<View className='price-row'>
|
||||
<Text className='price'>¥{item.price}</Text>
|
||||
|
||||
<View className='quantity-control'>
|
||||
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, -1)}>−</View>
|
||||
<Text className='qty-num'>{item.quantity}</Text>
|
||||
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, 1)}>+</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='btn-delete' onClick={() => handleRemove(item.id)}>×</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{cartItems.length > 0 && (
|
||||
<View className='bottom-bar'>
|
||||
<View className='left-section'>
|
||||
<View className='select-all-btn' onClick={handleToggleAll}>
|
||||
<View className={`checkbox ${isAllSelected ? 'checked' : ''}`}>
|
||||
{isAllSelected && <Text>✓</Text>}
|
||||
</View>
|
||||
<Text className='label'>全选</Text>
|
||||
</View>
|
||||
|
||||
<View className='total-info'>
|
||||
<Text className='label'>合计:</Text>
|
||||
<Text className='price'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className={`btn-checkout ${selectedCount === 0 ? 'disabled' : ''}`}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
去结算({selectedCount})
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
378
miniprogram/src/pages/competition/detail.scss
Normal file
@@ -0,0 +1,378 @@
|
||||
.comp-detail {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
background: #111;
|
||||
border-radius: 20px 20px 0 0;
|
||||
margin-top: -24px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 16px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
margin-left: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.registration { background: #07c160; color: #fff; }
|
||||
&.submission { background: #1890ff; color: #fff; }
|
||||
&.judging { background: #faad14; color: #fff; }
|
||||
&.ended { background: #ff4d4f; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
height: 4px;
|
||||
background: #00b96b;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-list {
|
||||
.project-card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
.cover {
|
||||
width: 140px;
|
||||
height: 105px;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #faad14;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
.rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #222;
|
||||
|
||||
.rank-num {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
|
||||
&.top1 { color: #ffd700; }
|
||||
&.top2 { color: #c0c0c0; }
|
||||
&.top3 { color: #cd7f32; }
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
margin-right: 16px;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.nickname {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #00b96b;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
border-left: 5px solid #00b96b;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Markdown styling borrowed from Forum */
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
color: #e0e0e0;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 { margin-top: 30px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||
h1 { font-size: 32px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||
h2 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||
h3 { font-size: 24px; }
|
||||
h4 { font-size: 20px; }
|
||||
h5 { font-size: 18px; color: #ddd; }
|
||||
|
||||
p { margin-bottom: 20px; }
|
||||
|
||||
strong { font-weight: 800; color: #fff; }
|
||||
em { font-style: italic; color: #aaa; }
|
||||
del { text-decoration: line-through; color: #666; }
|
||||
|
||||
ul, ol { margin-bottom: 20px; padding-left: 24px; }
|
||||
li { margin-bottom: 8px; list-style-position: outside; }
|
||||
ul li { list-style-type: disc; }
|
||||
ol li { list-style-type: decimal; }
|
||||
|
||||
li input[type="checkbox"] { margin-right: 12px; }
|
||||
|
||||
blockquote {
|
||||
border-left: 5px solid #00b96b;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 6px;
|
||||
color: #bbb;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
|
||||
p { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 16px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
|
||||
th, td {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
color: #ff7875;
|
||||
font-size: 16px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #161616;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
color: #a6e22e;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-action {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f1f;
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid #333;
|
||||
z-index: 100;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
border-radius: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #00b96b;
|
||||
border: none;
|
||||
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&.enrolled {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
318
miniprogram/src/pages/competition/detail.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
||||
import MarkdownReader from '../../components/MarkdownReader'
|
||||
import './detail.scss'
|
||||
|
||||
export default function CompetitionDetail() {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [enrollment, setEnrollment] = useState<any>(null)
|
||||
const [projects, setProjects] = useState<any[]>([])
|
||||
const [myProject, setMyProject] = useState<any>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad((options) => {
|
||||
const { id } = options
|
||||
if (id) {
|
||||
fetchDetail(id)
|
||||
fetchEnrollment(id)
|
||||
fetchProjects(id)
|
||||
}
|
||||
})
|
||||
|
||||
useDidShow(() => {
|
||||
// 每次显示页面时刷新一下我的项目信息(比如从编辑页返回)
|
||||
if (detail?.id) {
|
||||
fetchMyProject(detail.id)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: detail?.title || '赛事详情',
|
||||
path: `/pages/competition/detail?id=${detail?.id || ''}`,
|
||||
imageUrl: detail?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: detail?.title || '赛事详情',
|
||||
query: `id=${detail?.id || ''}`,
|
||||
imageUrl: detail?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCompetitionDetail(id)
|
||||
setDetail(res)
|
||||
fetchMyProject(id)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载详情失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyProject = async (competitionId) => {
|
||||
try {
|
||||
const userInfo = Taro.getStorageSync('userInfo')
|
||||
if (!userInfo) return
|
||||
|
||||
const res = await getProjects({ competition: competitionId })
|
||||
const list = res.results || res
|
||||
// 尝试通过 enrollment.id 匹配,或者通过 user nickname 匹配(不够严谨但暂时可用)
|
||||
|
||||
if (enrollment) {
|
||||
const mine = list.find((p: any) => p.contestant === enrollment.id)
|
||||
if (mine) {
|
||||
setMyProject(mine)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use nickname match if enrollment not ready or failed
|
||||
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname)
|
||||
if (myProj) setMyProject(myProj)
|
||||
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEnrollment = async (id) => {
|
||||
try {
|
||||
const res = await getMyCompetitionEnrollment(id)
|
||||
setEnrollment(res)
|
||||
// 获取到 enrollment 后,去匹配 myProject
|
||||
if (projects.length > 0) {
|
||||
const mine = projects.find((p: any) => p.contestant === res.id)
|
||||
setMyProject(mine)
|
||||
}
|
||||
} catch (e) {
|
||||
// 没报名则无数据,忽略
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProjects = async (id) => {
|
||||
try {
|
||||
// 注意:这里我们去掉了 status='submitted',因为我们要找自己的 draft
|
||||
const res = await getProjects({ competition: id })
|
||||
const list = res.results || res
|
||||
const allProjects = Array.isArray(list) ? list : []
|
||||
|
||||
// 过滤出 submitted 的给列表显示
|
||||
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
|
||||
setProjects(submittedProjects)
|
||||
} catch (e) {
|
||||
console.error('Fetch projects failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化设置 myProject
|
||||
useEffect(() => {
|
||||
if (enrollment && projects.length >= 0) { // projects could be empty
|
||||
fetchMySpecificProject(detail?.id, enrollment.id)
|
||||
}
|
||||
}, [enrollment])
|
||||
|
||||
const fetchMySpecificProject = async (compId, enrollId) => {
|
||||
if (!compId || !enrollId) return
|
||||
try {
|
||||
const res = await getProjects({ competition: compId })
|
||||
const list = res.results || res
|
||||
const mine = list.find((p: any) => p.contestant === enrollId)
|
||||
setMyProject(mine)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!detail) return
|
||||
try {
|
||||
await enrollCompetition(detail.id, { role: 'contestant' })
|
||||
Taro.showToast({ title: '报名成功', icon: 'success' })
|
||||
fetchEnrollment(detail.id)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '报名失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
'registration': '报名中',
|
||||
'submission': '作品提交中',
|
||||
'judging': '评审中',
|
||||
'ended': '已结束',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getEmptyMessage = (visibility, enrollment) => {
|
||||
const role = enrollment?.status === 'approved' ? enrollment.role : null;
|
||||
|
||||
if (visibility === 'judge') {
|
||||
if (role === 'judge') return '暂无参赛项目';
|
||||
return '该比赛项目仅评委可见';
|
||||
}
|
||||
if (visibility === 'guest') {
|
||||
if (role === 'judge' || role === 'guest') return '暂无参赛项目';
|
||||
return '该比赛项目仅嘉宾/评委可见';
|
||||
}
|
||||
if (visibility === 'contestant') {
|
||||
if (role) return '暂无参赛项目';
|
||||
return '该比赛项目仅参赛选手可见,请先报名';
|
||||
}
|
||||
return '暂无参赛项目';
|
||||
}
|
||||
|
||||
if (loading || !detail) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='comp-detail'>
|
||||
<Image
|
||||
className='banner'
|
||||
mode='aspectFill'
|
||||
src={detail.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
|
||||
<View className='content'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{detail.title}</Text>
|
||||
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
|
||||
</View>
|
||||
|
||||
<View className='tabs'>
|
||||
{['详情', '参赛项目', '排行榜'].map((tab, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`tab-item ${activeTab === index ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(index)}
|
||||
>
|
||||
{tab}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activeTab === 0 && (
|
||||
<>
|
||||
<View className='section'>
|
||||
<Text className='section-title'>简介</Text>
|
||||
<MarkdownReader content={detail.description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>规则</Text>
|
||||
<MarkdownReader content={detail.rule_description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参赛条件</Text>
|
||||
<MarkdownReader content={detail.condition_description} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<View className='project-list'>
|
||||
{projects.map(project => (
|
||||
<View className='project-card' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={project.display_cover_image || 'https://via.placeholder.com/120x90'}
|
||||
/>
|
||||
<View className='info'>
|
||||
<Text className='title'>{project.title}</Text>
|
||||
<View className='author'>
|
||||
<View className='user'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<Text>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
</View>
|
||||
{project.final_score > 0 && <Text className='score'>{project.final_score}分</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<View className='ranking-list'>
|
||||
{projects
|
||||
.filter(p => p.final_score > 0)
|
||||
.sort((a, b) => b.final_score - a.final_score)
|
||||
.map((project, index) => (
|
||||
<View className='rank-item' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
|
||||
<Text className={`rank-num top${index + 1}`}>{index + 1}</Text>
|
||||
<View className='info'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<View className='detail'>
|
||||
<Text className='nickname'>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
<Text className='project-title'>{project.title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='score'>{project.final_score}</Text>
|
||||
</View>
|
||||
))}
|
||||
{projects.filter(p => p.final_score > 0).length === 0 && <View className='empty'>暂无排名数据</View>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='footer-action'>
|
||||
{enrollment ? (
|
||||
myProject ? (
|
||||
<View style={{ display: 'flex', width: '100%', gap: '10px' }}>
|
||||
<Button
|
||||
className='btn enrolled'
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
|
||||
>
|
||||
我的作品 ({myProject.status === 'submitted' ? '已提交' : '草稿'})
|
||||
</Button>
|
||||
<Button
|
||||
className='btn'
|
||||
style={{ width: '80px', background: '#fff', color: '#333', border: '1px solid #ddd', fontSize: '12px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${myProject.id}` })}
|
||||
>
|
||||
评语
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
enrollment.status === 'approved' ? (
|
||||
<Button
|
||||
className='btn enrolled'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?competitionId=${detail.id}` })}
|
||||
>
|
||||
立即提交作品
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled className='btn enrolled'>
|
||||
报名审核中
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
className='btn enroll'
|
||||
onClick={handleEnroll}
|
||||
disabled={detail.status !== 'registration'}
|
||||
>
|
||||
{detail.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
85
miniprogram/src/pages/competition/index.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
.competition-page {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
|
||||
.comp-list {
|
||||
.comp-card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
|
||||
&.registration { background: #07c160; color: #fff; }
|
||||
&.submission { background: #1890ff; color: #fff; }
|
||||
&.judging { background: #faad14; color: #fff; }
|
||||
&.ended { background: #ff4d4f; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
110
miniprogram/src/pages/competition/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getCompetitions } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function CompetitionList() {
|
||||
const [competitions, setCompetitions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [debugMsg, setDebugMsg] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchCompetitions()
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: '赛事中心',
|
||||
path: '/pages/competition/index'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: '赛事中心',
|
||||
query: ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchCompetitions = async () => {
|
||||
setLoading(true)
|
||||
setDebugMsg('开始加载...')
|
||||
try {
|
||||
console.log('Fetching competitions...')
|
||||
const res = await getCompetitions()
|
||||
console.log('Competitions res:', res)
|
||||
setDebugMsg(`请求成功: 数量 ${res?.results?.length}`)
|
||||
|
||||
if (res && res.results) {
|
||||
setCompetitions(res.results)
|
||||
} else {
|
||||
setDebugMsg(`数据格式异常: ${JSON.stringify(res)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch failed:', e)
|
||||
setDebugMsg(`请求失败: ${e.errMsg || JSON.stringify(e)}`)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
Taro.navigateTo({ url: `/pages/competition/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
'published': '即将开始',
|
||||
'registration': '报名中',
|
||||
'submission': '作品提交中',
|
||||
'judging': '评审中',
|
||||
'ended': '已结束',
|
||||
'draft': '草稿'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='competition-page'>
|
||||
<ScrollView scrollY className='comp-list'>
|
||||
{competitions.map(item => (
|
||||
<View key={item.id} className='comp-card' onClick={() => goDetail(item.id)}>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={item.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
<View className='info'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{item.title}</Text>
|
||||
<Text className={`status ${item.status}`}>{getStatusText(item.status)}</Text>
|
||||
</View>
|
||||
<Text className='desc'>{item.description}</Text>
|
||||
<View className='footer'>
|
||||
<Text className='time'>
|
||||
{item.start_time?.split('T')[0]} ~ {item.end_time?.split('T')[0]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{!loading && competitions.length === 0 && (
|
||||
<View className='empty'>
|
||||
<Text>暂无比赛</Text>
|
||||
<View style={{ marginTop: 20, color: '#666', fontSize: 12, wordBreak: 'break-all', padding: 20 }}>
|
||||
调试信息: {debugMsg}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '项目详情'
|
||||
})
|
||||
288
miniprogram/src/pages/competition/project-detail.scss
Normal file
@@ -0,0 +1,288 @@
|
||||
.project-detail {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 60px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
background: #111;
|
||||
border-radius: 24px 24px 0 0;
|
||||
margin-top: -30px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 60vh;
|
||||
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
.title {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 12px 20px;
|
||||
border-radius: 30px;
|
||||
display: inline-flex;
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
background: #333;
|
||||
}
|
||||
.name {
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 50px;
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
border-left: 6px solid #00b96b;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
line-height: 1.8;
|
||||
background: #1f1f1f;
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
|
||||
/* Markdown Styles */
|
||||
h1, h2, h3, h4, h5, h6 { margin-top: 40px; margin-bottom: 24px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||
h1 { font-size: 34px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||
h2 { font-size: 30px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||
h3 { font-size: 26px; }
|
||||
h4 { font-size: 24px; }
|
||||
h5 { font-size: 22px; color: #ddd; }
|
||||
|
||||
p { margin-bottom: 24px; }
|
||||
|
||||
strong { font-weight: 800; color: #fff; }
|
||||
em { font-style: italic; color: #aaa; }
|
||||
del { text-decoration: line-through; color: #666; }
|
||||
|
||||
ul, ol { margin-bottom: 24px; padding-left: 28px; }
|
||||
li { margin-bottom: 10px; list-style-position: outside; }
|
||||
ul li { list-style-type: disc; }
|
||||
ol li { list-style-type: decimal; }
|
||||
|
||||
li input[type="checkbox"] { margin-right: 12px; transform: scale(1.2); }
|
||||
|
||||
blockquote {
|
||||
border-left: 6px solid #00b96b;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px 24px;
|
||||
margin: 24px 0;
|
||||
border-radius: 8px;
|
||||
color: #bbb;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
p { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
font-size: 18px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
|
||||
th, td {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
color: #ff7875;
|
||||
font-size: 18px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #161616;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
color: #a6e22e;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
margin: 24px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding: 40px 0;
|
||||
background: #1f1f1f;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: #1f1f1f;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-action {
|
||||
font-size: 16px;
|
||||
color: #00b96b;
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #00b96b;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
.comment-item {
|
||||
background: #1f1f1f;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.judge-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.judge-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #00b96b;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.judge-score-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.score-num {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
.comment-content {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
miniprogram/src/pages/competition/project-detail.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getProjectDetail, getComments } from '../../api'
|
||||
import MarkdownReader from '../../components/MarkdownReader'
|
||||
import './project-detail.scss'
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const [project, setProject] = useState<any>(null)
|
||||
const [comments, setComments] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useLoad((options) => {
|
||||
const { id } = options
|
||||
if (id) {
|
||||
fetchProject(id)
|
||||
fetchComments(id)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
return {
|
||||
title: project?.title || '项目详情',
|
||||
path: `/pages/competition/project-detail?id=${id}`,
|
||||
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
return {
|
||||
title: project?.title || '项目详情',
|
||||
query: `id=${id}`,
|
||||
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
* @param id 项目ID
|
||||
*/
|
||||
const fetchProject = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getProjectDetail(id)
|
||||
setProject(res)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载项目详情失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目评语
|
||||
* @param id 项目ID
|
||||
*/
|
||||
const fetchComments = async (id) => {
|
||||
try {
|
||||
const res = await getComments({ project: id })
|
||||
const list = res.results || res.data || res || []
|
||||
setComments(Array.isArray(list) ? list : [])
|
||||
} catch (e) {
|
||||
console.error('获取评语失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开/下载附件
|
||||
* @param file 文件对象
|
||||
*/
|
||||
const handleOpenFile = (file) => {
|
||||
if (!file.file) return
|
||||
|
||||
// 如果是图片,预览
|
||||
if (file.file.match(/\.(jpg|jpeg|png|gif)$/i)) {
|
||||
Taro.previewImage({ urls: [file.file] })
|
||||
return
|
||||
}
|
||||
|
||||
// 其他文件尝试下载打开
|
||||
Taro.showLoading({ title: '下载中...' })
|
||||
Taro.downloadFile({
|
||||
url: file.file,
|
||||
success: (res) => {
|
||||
const filePath = res.tempFilePath
|
||||
Taro.openDocument({
|
||||
filePath,
|
||||
success: () => console.log('打开文档成功'),
|
||||
fail: (err) => {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '打开文件失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
Taro.showToast({ title: '下载文件失败', icon: 'none' })
|
||||
},
|
||||
complete: () => {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (loading || !project) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='project-detail'>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={project.display_cover_image || project.cover_image_url || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
|
||||
<View className='content'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{project.title}</Text>
|
||||
<View className='author'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<Text className='name'>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>项目介绍</Text>
|
||||
<View className='text-content'>
|
||||
{project.description ? <MarkdownReader content={project.description} /> : <Text className='empty'>暂无介绍</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>团队介绍</Text>
|
||||
<View className='text-content'>
|
||||
<Text>{project.team_info || '暂无团队信息'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>项目附件</Text>
|
||||
{project.files && project.files.length > 0 ? (
|
||||
<View className='file-list'>
|
||||
{project.files.map((file, index) => (
|
||||
<View key={index} className='file-item' onClick={() => handleOpenFile(file)}>
|
||||
<Text className='file-name'>{file.name || '附件 ' + (index + 1)}</Text>
|
||||
<Text className='file-action'>查看</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className='empty'>暂无附件</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='section comments-section'>
|
||||
<Text className='section-title'>评委评语</Text>
|
||||
{comments.length > 0 ? (
|
||||
<View className='comment-list'>
|
||||
{comments.map((c) => (
|
||||
<View key={c.id} className='comment-item'>
|
||||
<View className='comment-header'>
|
||||
<View className='judge-info'>
|
||||
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
|
||||
{c.score && (
|
||||
<View className='judge-score-box'>
|
||||
<Text className='score-num'>{c.score}</Text>
|
||||
<Text className='score-unit'>分</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className='comment-time'>{c.created_at?.substring(0, 16)}</Text>
|
||||
</View>
|
||||
<Text className='comment-content'>{c.content}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className='empty'>暂无评语</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/competition/project.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '参赛作品'
|
||||
})
|
||||
93
miniprogram/src/pages/competition/project.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.project-edit {
|
||||
padding: 24px;
|
||||
background: #000;
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
padding-bottom: 100px;
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.input, .textarea, .picker {
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: 200px;
|
||||
|
||||
&.small {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px dashed #333;
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btns {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 24px;
|
||||
background: #1f1f1f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
border-top: 1px solid #333;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
margin: 0 8px;
|
||||
|
||||
&.save {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.submit {
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
279
miniprogram/src/pages/competition/project.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api'
|
||||
import './project.scss'
|
||||
|
||||
export default function ProjectEdit() {
|
||||
const [project, setProject] = useState<any>({
|
||||
title: '',
|
||||
description: '',
|
||||
team_info: '',
|
||||
files: []
|
||||
})
|
||||
const [competitionId, setCompetitionId] = useState<string>('')
|
||||
const [competitions, setCompetitions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useLoad((options) => {
|
||||
fetchCompetitions()
|
||||
const { id, competitionId } = options
|
||||
if (id) {
|
||||
setIsEdit(true)
|
||||
fetchProject(id)
|
||||
} else if (competitionId) {
|
||||
setCompetitionId(competitionId)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
const compId = competitionId || router.params.competitionId || ''
|
||||
return {
|
||||
title: project?.title || '提交作品',
|
||||
path: `/pages/competition/project?id=${id}&competitionId=${compId}`,
|
||||
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
const compId = competitionId || router.params.competitionId || ''
|
||||
return {
|
||||
title: project?.title || '提交作品',
|
||||
query: `id=${id}&competitionId=${compId}`,
|
||||
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchCompetitions = async () => {
|
||||
try {
|
||||
const res = await getCompetitions()
|
||||
if (res && res.results) {
|
||||
setCompetitions(res.results)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取比赛列表失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProject = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getProjectDetail(id)
|
||||
setProject(res)
|
||||
setCompetitionId(res.competition)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载项目失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (key, value) => {
|
||||
setProject(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleUploadCover = async () => {
|
||||
try {
|
||||
const { tempFilePaths } = await Taro.chooseImage({ count: 1 })
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
|
||||
const res = await uploadMedia(tempFilePaths[0], 'image')
|
||||
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
|
||||
|
||||
Taro.hideLoading()
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadFile = async () => {
|
||||
if (!project.id) {
|
||||
Taro.showToast({ title: '请先保存草稿再上传附件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
|
||||
const tempFiles = res.tempFiles
|
||||
if (!tempFiles.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
const file = tempFiles[0]
|
||||
|
||||
// @ts-ignore
|
||||
const result = await uploadProjectFile(file.path, project.id, file.name)
|
||||
|
||||
// Update file list
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
files: [...(prev.files || []), result]
|
||||
}))
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '上传成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
console.error(e)
|
||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFile = (fileId) => {
|
||||
// API call to delete file not implemented yet? Or just remove from list?
|
||||
// Usually we should call delete API. For now just remove from UI.
|
||||
// Ideally we should have deleteProjectFile API.
|
||||
// But user only asked to "optimize upload".
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
files: prev.files.filter(f => f.id !== fileId)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (submit = false) => {
|
||||
if (!project.title) {
|
||||
Taro.showToast({ title: '请输入项目标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = {
|
||||
competition: competitionId,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
team_info: project.team_info,
|
||||
cover_image_url: project.cover_image_url
|
||||
}
|
||||
|
||||
let res
|
||||
if (isEdit) {
|
||||
res = await updateProject(project.id, data)
|
||||
} else {
|
||||
res = await createProject(data)
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
await submitProject(res.id)
|
||||
Taro.showToast({ title: '提交成功', icon: 'success' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
} else {
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
if (!isEdit) {
|
||||
// 创建变编辑
|
||||
setIsEdit(true)
|
||||
setProject(res)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !project.id && isEdit) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<View className='project-edit'>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>所属比赛</Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={competitions}
|
||||
rangeKey='title'
|
||||
onChange={e => {
|
||||
const idx = Number(e.detail.value)
|
||||
const selected = competitions[idx]
|
||||
if (selected) {
|
||||
setCompetitionId(String(selected.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className='picker'>
|
||||
{competitions.find(c => String(c.id) === String(competitionId))?.title || '请选择比赛'}
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>项目标题</Text>
|
||||
<Input
|
||||
className='input'
|
||||
placeholder='请输入项目标题'
|
||||
value={project.title}
|
||||
onInput={e => handleInput('title', e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>封面图</Text>
|
||||
<View className='upload-box' onClick={handleUploadCover}>
|
||||
{project.cover_image_url || project.display_cover_image ? (
|
||||
<Image
|
||||
className='preview'
|
||||
mode='aspectFill'
|
||||
src={project.cover_image_url || project.display_cover_image}
|
||||
/>
|
||||
) : (
|
||||
<Text className='placeholder'>点击上传封面</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>项目介绍</Text>
|
||||
<Textarea
|
||||
className='textarea'
|
||||
placeholder='请输入项目详细介绍'
|
||||
value={project.description}
|
||||
onInput={e => handleInput('description', e.detail.value)}
|
||||
maxlength={2000}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>团队介绍</Text>
|
||||
<Textarea
|
||||
className='textarea small'
|
||||
placeholder='请输入团队成员信息'
|
||||
value={project.team_info}
|
||||
onInput={e => handleInput('team_info', e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<View className='label-row' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<Text className='label' style={{ marginBottom: 0 }}>项目附件</Text>
|
||||
<Button size='mini' style={{ margin: 0, fontSize: '12px' }} onClick={handleUploadFile}>上传附件</Button>
|
||||
</View>
|
||||
<View className='file-list'>
|
||||
{project.files && project.files.map((file, index) => (
|
||||
<View key={index} className='file-item' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}>
|
||||
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name || '未知文件'}</Text>
|
||||
{/* <Text className='delete' style={{ color: 'red', marginLeft: '10px' }} onClick={() => handleDeleteFile(file.id)}>删除</Text> */}
|
||||
</View>
|
||||
))}
|
||||
{(!project.files || project.files.length === 0) && <Text style={{ color: '#999', fontSize: '12px' }}>暂无附件 (PDF/PPT/视频)</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='footer-btns'>
|
||||
<Button className='btn save' onClick={() => handleSave(false)}>保存草稿</Button>
|
||||
<Button className='btn submit' onClick={() => handleSave(true)}>提交作品</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/courses/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '体验详情'
|
||||
})
|
||||
365
miniprogram/src/pages/courses/detail.scss
Normal file
@@ -0,0 +1,365 @@
|
||||
.page-container {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 100px); /* 留出底部栏高度 */
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 30px;
|
||||
background: #000;
|
||||
border-radius: 30px 30px 0 0;
|
||||
margin-top: -30px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 30px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #aaa;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
|
||||
&.highlight {
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
color: #00f0ff;
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 48px;
|
||||
color: #00f0ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.video-section {
|
||||
.course-video {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border-radius: 16px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.video-locked {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
background-color: #111;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.locked-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
opacity: 0.5;
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.locked-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.lock-text {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 30px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-unlock {
|
||||
background: linear-gradient(90deg, #00f0ff, #0099ff);
|
||||
color: #000;
|
||||
font-size: 28px;
|
||||
padding: 0 40px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
border-radius: 40px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 50px;
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 6px;
|
||||
background: #00f0ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructor-section {
|
||||
.instructor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
text {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
margin-right: 20px;
|
||||
border: 2px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instructor-info {
|
||||
flex: 1;
|
||||
.name {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title-tag {
|
||||
font-size: 20px;
|
||||
color: #000;
|
||||
background: #00f0ff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
margin-left: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
.desc {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
|
||||
.grid-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-section {
|
||||
.schedule-box {
|
||||
background: #111;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
font-size: 28px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #00f0ff;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
color: #aaa;
|
||||
font-size: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-images {
|
||||
.detail-long-image {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: #111;
|
||||
border: 2px dashed #333;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
height: 120px;
|
||||
background: #111;
|
||||
border-top: 1px solid #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30px;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
.price-container {
|
||||
flex: 1;
|
||||
|
||||
.label {
|
||||
color: #aaa;
|
||||
font-size: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #00f0ff;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-buy {
|
||||
width: 240px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
background: linear-gradient(90deg, #00f0ff, #0099ff);
|
||||
color: #000;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
border-radius: 40px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
258
miniprogram/src/pages/courses/detail.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { View, Text, Button, Image, ScrollView, Video } from '@tarojs/components'
|
||||
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useRef } from 'react'
|
||||
import { getVBCourseDetail } from '../../api'
|
||||
import { login } from '../../utils/request'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function CourseDetail() {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const courseIdRef = useRef<string>('')
|
||||
|
||||
useLoad((options) => {
|
||||
if (options.id) {
|
||||
courseIdRef.current = options.id
|
||||
}
|
||||
})
|
||||
|
||||
useDidShow(async () => {
|
||||
if (!courseIdRef.current) return
|
||||
|
||||
// 检查并确保有 Token,以便获取最新的购买状态
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) {
|
||||
try {
|
||||
await login()
|
||||
} catch (e) {
|
||||
console.error('Silent login failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail(courseIdRef.current)
|
||||
})
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
software: '软件课程',
|
||||
hardware: '硬件课程',
|
||||
incubation: '产品商业孵化'
|
||||
}
|
||||
|
||||
const fetchDetail = async (id: string) => {
|
||||
try {
|
||||
// Add timestamp to prevent caching
|
||||
const res: any = await getVBCourseDetail(Number(id))
|
||||
console.log('Course detail:', res)
|
||||
setDetail(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLaunch = () => {
|
||||
if (!checkLogin()) return
|
||||
if (!detail) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/order/checkout?id=${detail.id}&type=course`
|
||||
})
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: detail?.title || 'VC 课程详情',
|
||||
path: `/pages/courses/detail?id=${detail?.id}`,
|
||||
imageUrl: detail?.cover_image_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: detail?.title || 'VC 课程详情',
|
||||
query: `id=${detail?.id}`,
|
||||
imageUrl: detail?.cover_image_url
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
const extractIframeSrc = (html: string) => {
|
||||
if (!html) return null
|
||||
const match = html.match(/src=["'](.*?)["']/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const handleOpenWebview = (url: string) => {
|
||||
if (!url) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/webview/index?url=${encodeURIComponent(url)}`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='scroll-content'>
|
||||
{/* 封面图 */}
|
||||
{detail.cover_image_url && (
|
||||
<Image src={detail.cover_image_url} className='cover-image' mode='aspectFill' />
|
||||
)}
|
||||
|
||||
<View className='content-wrapper'>
|
||||
{/* 标题区 */}
|
||||
<View className='header-section'>
|
||||
<Text className='title'>{detail.title}</Text>
|
||||
<View className='tags-row'>
|
||||
<Text className='tag'>{typeMap[detail.course_type] || 'VC课程'}</Text>
|
||||
{detail.tag && <Text className='tag highlight'>{detail.tag}</Text>}
|
||||
</View>
|
||||
<Text className='price'>¥{detail.price}</Text>
|
||||
</View>
|
||||
|
||||
{/* 视频播放区域 */}
|
||||
{detail.is_video_course && (
|
||||
<View className='section video-section'>
|
||||
<Text className='section-title'>课程视频</Text>
|
||||
{detail.video_url ? (
|
||||
<Video
|
||||
src={detail.video_url}
|
||||
className='course-video'
|
||||
poster={detail.cover_image_url}
|
||||
controls
|
||||
autoplay={false}
|
||||
/>
|
||||
) : detail.video_embed_code ? (
|
||||
<View className='video-locked' onClick={() => {
|
||||
const src = extractIframeSrc(detail.video_embed_code)
|
||||
if (src) handleOpenWebview(src)
|
||||
else Taro.showToast({ title: '无法解析视频地址', icon: 'none' })
|
||||
}}>
|
||||
<Image src={detail.cover_image_url} className='locked-bg' mode='aspectFill' />
|
||||
<View className='locked-overlay'>
|
||||
<View className='lock-icon' style={{fontSize: '40px'}}>▶</View>
|
||||
<Text className='lock-text'>点击观看视频</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='video-locked' onClick={handleLaunch}>
|
||||
<Image src={detail.cover_image_url} className='locked-bg' mode='aspectFill' />
|
||||
<View className='locked-overlay'>
|
||||
<View className='lock-icon'>🔒</View>
|
||||
<Text className='lock-text'>购买课程后解锁视频</Text>
|
||||
<Button className='btn-unlock'>立即解锁</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 讲师信息 */}
|
||||
<View className='section instructor-section'>
|
||||
<Text className='section-title'>讲师介绍</Text>
|
||||
<View className='instructor-row'>
|
||||
{detail.instructor_avatar_url ? (
|
||||
<Image src={detail.instructor_avatar_url} className='avatar' mode='aspectFill' />
|
||||
) : (
|
||||
<View className='avatar-placeholder'>
|
||||
<Text>讲师</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='instructor-info'>
|
||||
<Text className='name'>{detail.instructor} <Text className='title-tag'>{detail.instructor_title}</Text></Text>
|
||||
<Text className='desc'>{detail.instructor_desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 课程信息 */}
|
||||
<View className='section info-grid'>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>时长</Text>
|
||||
<Text className='value'>{detail.duration}</Text>
|
||||
</View>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>课时</Text>
|
||||
<Text className='value'>{detail.lesson_count}节</Text>
|
||||
</View>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>难度</Text>
|
||||
<Text className='value'>中级</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开课时间 */}
|
||||
{detail.is_fixed_schedule && (detail.start_time || detail.end_time) && (
|
||||
<View className='section schedule-section'>
|
||||
<Text className='section-title'>开课时间</Text>
|
||||
<View className='schedule-box'>
|
||||
{detail.start_time && (
|
||||
<View className='time-row'>
|
||||
<Text className='label'>开始时间:</Text>
|
||||
<Text className='value'>{formatDateTime(detail.start_time)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{detail.end_time && (
|
||||
<View className='time-row'>
|
||||
<Text className='label'>结束时间:</Text>
|
||||
<Text className='value'>{formatDateTime(detail.end_time)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 课程简介 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>课程简介</Text>
|
||||
<Text className='desc-text'>{detail.description}</Text>
|
||||
</View>
|
||||
|
||||
{/* 详情长图 */}
|
||||
<View className='section detail-images'>
|
||||
<Text className='section-title'>课程详情</Text>
|
||||
{detail.display_detail_image || detail.detail_image_url ? (
|
||||
<Image
|
||||
src={detail.detail_image_url || detail.display_detail_image}
|
||||
className='detail-long-image'
|
||||
mode='widthFix'
|
||||
/>
|
||||
) : (
|
||||
<View className='placeholder-box'>
|
||||
<Text>暂无详情长图</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部栏 */}
|
||||
<View className='bottom-bar'>
|
||||
<View className='price-container'>
|
||||
<Text className='label'>总价:</Text>
|
||||
<Text className='amount'>¥{detail.price}</Text>
|
||||
</View>
|
||||
<Button
|
||||
className={`btn-buy ${detail.is_purchased ? 'disabled' : ''}`}
|
||||
onClick={() => !detail.is_purchased && handleLaunch()}
|
||||
disabled={detail.is_purchased}
|
||||
>
|
||||
{detail.is_purchased ? '已购买' : (detail.is_video_course ? '立即购买' : '立即报名')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/courses/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AR 体验馆'
|
||||
})
|
||||
148
miniprogram/src/pages/courses/index.scss
Normal file
@@ -0,0 +1,148 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
display: block;
|
||||
|
||||
.highlight {
|
||||
color: #00f0ff;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #aaa;
|
||||
font-size: 28px;
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ar-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ar-card {
|
||||
width: 100%; // Single column on small screens
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.cover-box {
|
||||
height: 400px;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: #333;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tag-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
border: 1px solid #00f0ff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
.type-text {
|
||||
color: #00f0ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&.special {
|
||||
background: rgba(255, 87, 34, 0.2);
|
||||
border: 1px solid #ff5722;
|
||||
.type-text {
|
||||
color: #ff5722;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
|
||||
.item-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
.info-text {
|
||||
color: #aaa;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
color: #888;
|
||||
font-size: 26px;
|
||||
margin-bottom: 30px;
|
||||
min-height: 80px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: transparent;
|
||||
border: 1px solid #00f0ff;
|
||||
color: #00f0ff;
|
||||
font-size: 28px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.05) 0%, transparent 50%);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
81
miniprogram/src/pages/courses/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getVBCourses } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function CourseIndex() {
|
||||
const [courseList, setCourseList] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchCourses()
|
||||
})
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const res: any = await getVBCourses()
|
||||
setCourseList(res.results || res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'VC COURSES - 探索 VC 编程课程',
|
||||
path: '/pages/courses/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'VC COURSES - 探索 VC 编程课程'
|
||||
}
|
||||
})
|
||||
|
||||
const goDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/courses/detail?id=${id}` })
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='bg-decoration' />
|
||||
|
||||
<View className='header'>
|
||||
<Text className='title'>VC <Text className='highlight'>COURSES</Text></Text>
|
||||
<Text className='desc'>探索 VC 编程课程</Text>
|
||||
</View>
|
||||
|
||||
<View className='ar-grid'>
|
||||
{courseList.length === 0 ? (
|
||||
<View style={{ width: '100%', textAlign: 'center', color: '#666', marginTop: 50 }}>
|
||||
<Text>暂无 VC 课程内容</Text>
|
||||
</View>
|
||||
) : (
|
||||
courseList.map((item) => (
|
||||
<View key={item.id} className='ar-card' onClick={() => goDetail(item.id)}>
|
||||
<View className='cover-box'>
|
||||
{item.cover_image_url ? (
|
||||
<Image src={item.cover_image_url} className='cover-img' mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='placeholder-icon'>VC</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='content'>
|
||||
<Text className='item-title'>{item.title}</Text>
|
||||
<Text className='item-desc'>{item.description}</Text>
|
||||
<Button className='btn-start' onClick={(e) => { e.stopPropagation(); goDetail(item.id) }}>报名课程</Button>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
7
miniprogram/src/pages/forum/index.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '开发者社区',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#000000',
|
||||
navigationBarBackgroundColor: '#000000',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
683
miniprogram/src/pages/forum/index.scss
Normal file
@@ -0,0 +1,683 @@
|
||||
.forum-page {
|
||||
min-height: 100vh;
|
||||
background-color: #121212; /* Darker background for modern feel */
|
||||
padding-bottom: 80px;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
/* Global Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 185, 107, 0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 8px 24px rgba(0, 185, 107, 0.6); }
|
||||
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 185, 107, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 60px 20px 30px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.8) 0%, rgba(0,185,107,0.15) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Subtle pattern overlay if desired */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-image: radial-gradient(#333 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.1;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
font-size: 42px; /* Increased from 36px */
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
||||
z-index: 1;
|
||||
|
||||
.highlight {
|
||||
color: #00b96b;
|
||||
background: linear-gradient(45deg, #00b96b, #00ff9d);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
position: relative;
|
||||
color: #aaa;
|
||||
font-size: 19px; /* Increased from 17px */
|
||||
margin-bottom: 36px;
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
z-index: 2;
|
||||
|
||||
.at-search-bar {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
|
||||
&::after { border-bottom: none; }
|
||||
|
||||
.at-search-bar__input-cnt {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 30px; /* More rounded */
|
||||
transition: all 0.3s ease;
|
||||
height: 56px; /* Taller touch target (from 48px) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
border-color: #00b96b;
|
||||
box-shadow: 0 0 0 2px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.at-search-bar__input {
|
||||
color: #fff;
|
||||
font-size: 18px; /* Larger input text (from 17px) */
|
||||
}
|
||||
|
||||
.at-search-bar__placeholder {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #009456 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
padding: 0 28px;
|
||||
font-size: 18px; /* Larger button text */
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-container {
|
||||
margin: 0 16px 24px;
|
||||
background: #1e1e1e; /* Card background */
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
.section-title {
|
||||
font-size: 20px; /* Increased from 16px */
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-swiper {
|
||||
height: 48px;
|
||||
|
||||
.announcement-item {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #ff4d4f;
|
||||
|
||||
.item-text {
|
||||
font-size: 16px; /* Increased from 14px */
|
||||
color: #ddd;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.star-users-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
padding-bottom: 8px; /* Scrollbar space if visible */
|
||||
|
||||
.star-user-card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 18px;
|
||||
width: 90px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 16px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px; /* Increased from 48px */
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffd700;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px; /* Increased from 12px */
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-title {
|
||||
font-size: 12px; /* Increased from 10px */
|
||||
color: #888;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: #ffd700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-wrapper {
|
||||
background-color: transparent; /* Changed from black */
|
||||
margin-bottom: 16px;
|
||||
padding: 0 12px;
|
||||
|
||||
/* Override Taro UI default white background */
|
||||
.at-tabs {
|
||||
background-color: transparent;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.at-tabs__header {
|
||||
background-color: transparent;
|
||||
border-bottom: none; /* Removed border */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.at-tabs__item {
|
||||
color: #888;
|
||||
font-size: 18px; /* Increased from 17px */
|
||||
padding: 16px 24px; /* Larger touch target */
|
||||
transition: all 0.3s;
|
||||
|
||||
&--active {
|
||||
color: #fff; /* White active text */
|
||||
font-weight: 700;
|
||||
font-size: 22px; /* Increased from 20px */
|
||||
text-shadow: 0 0 10px rgba(0, 185, 107, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.at-tabs__item-underline {
|
||||
background-color: #00b96b;
|
||||
height: 5px; /* Slightly thicker */
|
||||
border-radius: 3px;
|
||||
bottom: 6px;
|
||||
width: 32px !important; /* Short underline style */
|
||||
margin-left: calc(50% - 16px); /* Center specific width underline */
|
||||
}
|
||||
}
|
||||
|
||||
.topic-list {
|
||||
padding: 12px 18px;
|
||||
|
||||
.topic-card {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 20px;
|
||||
padding: 28px; /* Increased from 24px */
|
||||
margin-bottom: 28px; /* Increased spacing */
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
animation: fadeInUp 0.5s ease-out backwards; /* Apply animation */
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
border-color: rgba(0, 185, 107, 0.3);
|
||||
background: linear-gradient(180deg, rgba(0, 185, 107, 0.05) 0%, #1e1e1e 100%);
|
||||
box-shadow: 0 8px 20px rgba(0, 185, 107, 0.1);
|
||||
}
|
||||
|
||||
/* Animation delay for staggered effect - simplistic approach (nth-child logic is better in CSS-in-JS or fixed list) */
|
||||
&:nth-child(1) { animation-delay: 0.1s; }
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
&:nth-child(4) { animation-delay: 0.4s; }
|
||||
&:nth-child(5) { animation-delay: 0.5s; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
.tag {
|
||||
font-size: 14px; /* Slightly larger */
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #aaa;
|
||||
|
||||
&.pinned-tag {
|
||||
background: rgba(255, 77, 79, 0.15);
|
||||
color: #ff4d4f;
|
||||
border: 1px solid rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
&.verified-tag {
|
||||
background: rgba(0, 185, 107, 0.15);
|
||||
color: #00b96b;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 26px; /* Increased from 22px */
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 19px; /* Increased from 17px */
|
||||
color: #ccc; /* Slightly brighter for better contrast */
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.8;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3; /* Show 3 lines */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
max-height: 240px; /* Taller image preview */
|
||||
object-fit: cover;
|
||||
display: block; /* Remove inline spacing */
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 15px; /* Increased from 14px */
|
||||
color: #888;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.avatar {
|
||||
width: 48px; /* Larger avatar */
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.nickname {
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
|
||||
&.star {
|
||||
color: #ffd700;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.at-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
|
||||
&::before {
|
||||
content: '📭'; /* Simple icon */
|
||||
display: block;
|
||||
font-size: 50px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
bottom: 60px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #00b96b 0%, #009456 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 185, 107, 0.5);
|
||||
z-index: 100;
|
||||
animation: pulse 3s infinite;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.at-icon {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expert Modal Styles - Tech & Dark Theme */
|
||||
.at-float-layout {
|
||||
.at-float-layout__overlay {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.at-float-layout__container {
|
||||
background-color: #0f1216 !important; /* Deep dark tech background */
|
||||
border-top: 1px solid rgba(0, 185, 107, 0.3); /* Tech green border */
|
||||
box-shadow: 0 -10px 40px rgba(0, 185, 107, 0.15);
|
||||
border-radius: 24px 24px 0 0; /* More rounded top */
|
||||
|
||||
.layout-header {
|
||||
background-color: #15191f;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 20px 28px;
|
||||
|
||||
.layout-header__title {
|
||||
color: #00b96b; /* Tech green */
|
||||
font-size: 20px; /* Increased from 18px */
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 0 10px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
|
||||
.layout-header__btn-close {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-body {
|
||||
background-color: #0f1216;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-modal-content {
|
||||
padding: 36px 28px 70px;
|
||||
color: #fff;
|
||||
background: radial-gradient(circle at 50% 10%, rgba(0, 185, 107, 0.08), transparent 60%);
|
||||
|
||||
.expert-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-bottom: 28px;
|
||||
|
||||
.expert-avatar {
|
||||
width: 120px; /* Increased from 100px */
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffd700; /* Gold for expert */
|
||||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.25), inset 0 0 10px rgba(255, 215, 0, 0.2);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
position: absolute;
|
||||
top: -14px; left: -14px; right: -14px; bottom: -14px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(255, 215, 0, 0.5);
|
||||
animation: spin 12s linear infinite;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px; left: -8px; right: -8px; bottom: -8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3); /* Outer green ring */
|
||||
animation: spin 8s reverse linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-info {
|
||||
text-align: center;
|
||||
|
||||
.expert-name {
|
||||
font-size: 30px; /* Increased from 26px */
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin-bottom: 14px;
|
||||
text-shadow: 0 0 15px rgba(0, 185, 107, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.expert-title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05));
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.15);
|
||||
|
||||
.at-icon {
|
||||
text-shadow: 0 0 5px #ffd700;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 16px; /* Increased from 14px */
|
||||
color: #ffd700;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-skills-section {
|
||||
margin-bottom: 36px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Tech corner accent */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 12px; height: 12px;
|
||||
border-top: 3px solid #00b96b;
|
||||
border-left: 3px solid #00b96b;
|
||||
border-radius: 4px 0 0 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; right: 0;
|
||||
width: 12px; height: 12px;
|
||||
border-bottom: 3px solid #00b96b;
|
||||
border-right: 3px solid #00b96b;
|
||||
border-radius: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.label-text {
|
||||
font-size: 17px; /* Increased from 15px */
|
||||
font-weight: 700;
|
||||
color: #00b96b;
|
||||
margin-right: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(0, 185, 107, 0.5), transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
|
||||
.skill-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 185, 107, 0.08);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 185, 107, 0.25);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Left accent bar */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 4px; height: 100%;
|
||||
background: #00b96b;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
|
||||
.skill-icon {
|
||||
width: 24px; /* Increased from 20px */
|
||||
height: 24px;
|
||||
margin-right: 10px;
|
||||
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
.skill-text {
|
||||
font-size: 15px; /* Increased from 13px */
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
374
miniprogram/src/pages/forum/index.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import Taro, { usePullDownRefresh, useReachBottom, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { View, Text, Image, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
|
||||
import { AtSearchBar, AtTabs, AtIcon, AtActivityIndicator, AtFloatLayout } from 'taro-ui'
|
||||
import { getTopics, getAnnouncements, getStarUsers } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
const ForumList = () => {
|
||||
const [topics, setTopics] = useState<any[]>([])
|
||||
const [announcements, setAnnouncements] = useState<any[]>([])
|
||||
const [starUsers, setStarUsers] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [currentTab, setCurrentTab] = useState(0)
|
||||
const isMounted = useRef(false)
|
||||
|
||||
// Expert Detail
|
||||
const [showExpert, setShowExpert] = useState(false)
|
||||
const [selectedExpert, setSelectedExpert] = useState<any>(null)
|
||||
|
||||
const categories = [
|
||||
{ title: '全部话题', key: 'all' },
|
||||
{ title: '技术讨论', key: 'discussion' },
|
||||
{ title: '求助问答', key: 'help' },
|
||||
{ title: '经验分享', key: 'share' },
|
||||
{ title: '官方公告', key: 'notice' },
|
||||
]
|
||||
|
||||
const fetchExtraData = async () => {
|
||||
try {
|
||||
const [announceRes, starRes] = await Promise.all([
|
||||
getAnnouncements(),
|
||||
getStarUsers()
|
||||
])
|
||||
setAnnouncements(Array.isArray(announceRes) ? announceRes : (announceRes.results || announceRes.data || []))
|
||||
setStarUsers(Array.isArray(starRes) ? starRes : (starRes.data || []))
|
||||
} catch (err) {
|
||||
console.error('Fetch extra data failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async (reset = false) => {
|
||||
if (loading) return
|
||||
if (!reset && !hasMore) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = reset ? 1 : page
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
search: searchText
|
||||
}
|
||||
|
||||
if (categories[currentTab].key !== 'all') {
|
||||
params.category = categories[currentTab].key
|
||||
}
|
||||
|
||||
const res = await getTopics(params)
|
||||
let newTopics: any[] = []
|
||||
let hasNextPage = false
|
||||
|
||||
if (Array.isArray(res)) {
|
||||
newTopics = res
|
||||
hasNextPage = false
|
||||
} else {
|
||||
newTopics = res.results || res.data || []
|
||||
hasNextPage = !!res.next
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
setTopics(newTopics)
|
||||
} else {
|
||||
setTopics(prev => [...prev, ...newTopics])
|
||||
}
|
||||
|
||||
setHasMore(hasNextPage)
|
||||
if (hasNextPage) {
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
Taro.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
fetchList(true)
|
||||
fetchExtraData()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true
|
||||
return
|
||||
}
|
||||
fetchList(true)
|
||||
// fetchExtraData is covered by useDidShow usually, but if tab change needs it? likely not.
|
||||
}, [currentTab])
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchList(true)
|
||||
fetchExtraData()
|
||||
})
|
||||
|
||||
useReachBottom(() => {
|
||||
fetchList(false)
|
||||
})
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'Quant Speed Developer Community',
|
||||
path: '/pages/forum/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'Quant Speed Developer Community'
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = (value) => {
|
||||
setSearchText(value)
|
||||
}
|
||||
|
||||
const onSearchConfirm = () => {
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const handleTabClick = (value) => {
|
||||
setCurrentTab(value)
|
||||
// useEffect will trigger fetch
|
||||
}
|
||||
|
||||
const navigateToDetail = (id) => {
|
||||
Taro.navigateTo({
|
||||
url: `/subpackages/forum/detail/index?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCreate = () => {
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
// Optional: Trigger login flow
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: '/subpackages/forum/create/index'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToActivity = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/subpackages/forum/activity/index'
|
||||
})
|
||||
}
|
||||
|
||||
const getCategoryLabel = (cat) => {
|
||||
const map = {
|
||||
'help': '求助',
|
||||
'share': '分享',
|
||||
'notice': '公告',
|
||||
'discussion': '讨论'
|
||||
}
|
||||
return map[cat] || '讨论'
|
||||
}
|
||||
|
||||
// Helper to extract first image from markdown
|
||||
const getCoverImage = (content) => {
|
||||
const match = content.match(/!\[.*?\]\((.*?)\)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const stripMarkdown = (content) => {
|
||||
return content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')
|
||||
}
|
||||
|
||||
const showUserTitle = (e, user) => {
|
||||
e.stopPropagation()
|
||||
if (user.is_star || user.title) {
|
||||
setSelectedExpert(user)
|
||||
setShowExpert(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='forum-page'>
|
||||
<View className='hero-section'>
|
||||
<View className='title'>
|
||||
<Text className='highlight'>Quant Speed</Text> Developer Community
|
||||
</View>
|
||||
<View className='subtitle'>技术交流 · 硬件开发 · 官方支持 · 量迹生态</View>
|
||||
|
||||
<View className='search-box'>
|
||||
<AtSearchBar
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
onActionClick={onSearchConfirm}
|
||||
onConfirm={onSearchConfirm}
|
||||
placeholder='搜索感兴趣的话题...'
|
||||
/>
|
||||
<View className='create-btn' onClick={navigateToCreate}>
|
||||
<AtIcon value='add' size='20' color='#fff' />
|
||||
<Text style={{marginLeft: '6px'}}>发布</Text>
|
||||
</View>
|
||||
<View className='create-btn' onClick={navigateToActivity} style={{marginLeft: '10px', background: 'linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 100%)', backdropFilter: 'blur(5px)'}}>
|
||||
<AtIcon value='calendar' size='20' color='#fff' />
|
||||
<Text style={{marginLeft: '6px'}}>活动</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Announcements Section */}
|
||||
{announcements.length > 0 && (
|
||||
<View className='section-container'>
|
||||
<View className='section-header'>
|
||||
<AtIcon value='volume-plus' size='16' color='#ff4d4f' />
|
||||
<Text className='section-title'>社区公告</Text>
|
||||
</View>
|
||||
<Swiper
|
||||
className='announcement-swiper'
|
||||
vertical
|
||||
autoplay
|
||||
circular
|
||||
interval={3000}
|
||||
>
|
||||
{announcements.map(item => (
|
||||
<SwiperItem key={item.id}>
|
||||
<View className='announcement-item'>
|
||||
<Text className='item-text'>{item.title}</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Star Users Section */}
|
||||
{starUsers.length > 0 && (
|
||||
<View className='section-container'>
|
||||
<View className='section-header'>
|
||||
<AtIcon value='star' size='16' color='#ffd700' />
|
||||
<Text className='section-title'>技术专家榜</Text>
|
||||
</View>
|
||||
<ScrollView scrollX className='star-users-scroll'>
|
||||
{starUsers.map(user => (
|
||||
<View key={user.id} className='star-user-card' onClick={(e) => showUserTitle(e, user)}>
|
||||
<Image src={user.avatar_url} className='user-avatar' />
|
||||
<Text className='user-name'>{user.nickname}</Text>
|
||||
<Text className='user-title'>{user.title || '专家'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs-wrapper'>
|
||||
<AtTabs
|
||||
current={currentTab}
|
||||
tabList={categories}
|
||||
onClick={handleTabClick}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='topic-list'>
|
||||
{topics.map(item => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`topic-card ${item.is_pinned ? 'pinned' : ''}`}
|
||||
onClick={() => navigateToDetail(item.id)}
|
||||
>
|
||||
<View className='card-header'>
|
||||
{item.is_pinned && <Text className='tag pinned-tag'>置顶</Text>}
|
||||
<Text className='tag'>{getCategoryLabel(item.category)}</Text>
|
||||
{item.is_verified_owner && <Text className='tag verified-tag'>认证</Text>}
|
||||
<Text className='card-title'>{item.title}</Text>
|
||||
</View>
|
||||
|
||||
<View className='card-content'>
|
||||
{stripMarkdown(item.content)}
|
||||
</View>
|
||||
|
||||
{getCoverImage(item.content) && (
|
||||
<View className='card-image'>
|
||||
<Image src={getCoverImage(item.content)} mode='aspectFill' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='card-footer'>
|
||||
<View className='author-info'>
|
||||
<Image
|
||||
className='avatar'
|
||||
src={item.author_info?.avatar_url || 'https://via.placeholder.com/30'}
|
||||
onClick={(e) => showUserTitle(e, item.author_info)}
|
||||
/>
|
||||
<Text className={`nickname ${item.author_info?.is_star ? 'star' : ''}`}>
|
||||
{item.author_info?.nickname || '匿名'}
|
||||
</Text>
|
||||
<Text style={{color: '#555', fontSize: '10px'}}>•</Text>
|
||||
<Text style={{color: '#666', fontSize: '11px'}}>{new Date(item.created_at).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className='stats'>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='eye' size='14' color='#777' />
|
||||
<Text>{item.view_count || 0}</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='heart' size='14' color='#777' />
|
||||
<Text>{item.like_count || 0}</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='message' size='14' color='#777' />
|
||||
<Text>{item.replies?.length || 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{loading && <View style={{textAlign: 'center', padding: 10}}><AtActivityIndicator color='#00b96b' /></View>}
|
||||
{!loading && topics.length === 0 && <View className='empty-state'>暂无内容</View>}
|
||||
</View>
|
||||
|
||||
<View className='fab' onClick={navigateToCreate}>
|
||||
<AtIcon value='add' size='24' color='#fff' />
|
||||
</View>
|
||||
|
||||
<AtFloatLayout isOpened={showExpert} title="技术专家信息" onClose={() => setShowExpert(false)}>
|
||||
{selectedExpert && (
|
||||
<View className='expert-modal-content'>
|
||||
<View className='expert-header'>
|
||||
<View className='avatar-container'>
|
||||
<Image src={selectedExpert.avatar_url} className='expert-avatar' />
|
||||
<View className='avatar-ring'></View>
|
||||
</View>
|
||||
<View className='expert-info'>
|
||||
<View className='expert-name'>{selectedExpert.nickname}</View>
|
||||
<View className='expert-title-badge'>
|
||||
<AtIcon value='sketch' size='14' color='#ffd700' />
|
||||
<Text>{selectedExpert.title || '技术专家'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
|
||||
<View className='expert-skills-section'>
|
||||
<View className='section-label'>
|
||||
<Text className='label-text'>擅长技能</Text>
|
||||
<View className='label-line'></View>
|
||||
</View>
|
||||
<View className='skills-grid'>
|
||||
{selectedExpert.skills.map((skill, idx) => (
|
||||
<View key={idx} className='skill-tag'>
|
||||
{typeof skill === 'object' && skill.icon && <Image src={skill.icon} className='skill-icon' />}
|
||||
<Text className='skill-text'>{typeof skill === 'object' ? skill.text : skill}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</AtFloatLayout>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForumList
|
||||
3
miniprogram/src/pages/goods/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '商品详情'
|
||||
})
|
||||
365
miniprogram/src/pages/goods/detail.scss
Normal file
@@ -0,0 +1,365 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.loading-screen, .error-screen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00f0ff;
|
||||
background: #000;
|
||||
font-size: 28px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-bottom: 200px; // Ensure scroll space for bottom bar
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(40px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
100% { transform: translateY(0px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
|
||||
50% { box-shadow: 0 0 25px rgba(0, 185, 107, 0.8), 0 0 10px rgba(0, 240, 255, 0.4); }
|
||||
100% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% { top: -10%; opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { top: 110%; opacity: 0; }
|
||||
}
|
||||
|
||||
// Hero Section
|
||||
.hero-section {
|
||||
position: relative;
|
||||
margin-bottom: 40px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
min-height: 600px; // Slightly reduced to fit better
|
||||
background: radial-gradient(circle at center, rgba(0, 240, 255, 0.05) 0%, transparent 70%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
// Scanline effect
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, rgba(0, 240, 255, 0.5), transparent);
|
||||
animation: scanline 3s linear infinite;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
width: 75%;
|
||||
height: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 240, 255, 0.2));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
.icon-bolt { font-size: 150px; color: #00b96b; text-shadow: 0 0 30px rgba(0, 185, 107, 0.5); }
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
padding: 0 40px;
|
||||
margin-top: -40px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.hero-title {
|
||||
font-size: 60px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.1;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.tag {
|
||||
padding: 10px 28px;
|
||||
border-radius: 4px; // Techy sharp corners
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Tech border effect
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 4px; height: 100%;
|
||||
}
|
||||
|
||||
&.cyan {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
&::before { background: #00f0ff; }
|
||||
}
|
||||
&.blue {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
&::before { background: #3b82f6; }
|
||||
}
|
||||
&.purple {
|
||||
color: #a855f7;
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
&::before { background: #a855f7; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Card (HUD Style)
|
||||
.stats-card {
|
||||
margin: 40px 40px 60px;
|
||||
padding: 30px !important;
|
||||
background: rgba(20, 20, 20, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
|
||||
// Corner accents
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px; left: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-top: 2px solid #00b96b;
|
||||
border-left: 2px solid #00b96b;
|
||||
border-top-left-radius: 12px;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-bottom: 2px solid #00b96b;
|
||||
border-right: 2px solid #00b96b;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
.label { font-size: 24px; color: #666; flex: 1; text-transform: uppercase; letter-spacing: 1px; }
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
|
||||
.price-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
.symbol { font-size: 32px; color: #00b96b; font-weight: bold; margin-right: 4px; }
|
||||
.price {
|
||||
font-size: 72px;
|
||||
color: #00b96b;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 25px rgba(0, 185, 107, 0.4);
|
||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; // Ensure clean number font
|
||||
}
|
||||
}
|
||||
|
||||
.stock-box {
|
||||
.stock { font-size: 36px; color: #fff; font-weight: bold; }
|
||||
.unit { font-size: 24px; color: #666; margin-left: 6px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Features Section
|
||||
.features-section {
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
margin-bottom: 60px;
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: row; // Change to row for better list layout
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
// Stagger animations manually or via JS (here simplified)
|
||||
|
||||
.feature-icon-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-right: 30px;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.f-icon { font-size: 50px; color: #00b96b; }
|
||||
.f-icon-img { width: 60px; height: 60px; object-fit: contain; }
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
flex: 1;
|
||||
.f-title { font-size: 32px; font-weight: bold; color: #fff; margin-bottom: 10px; display: block; }
|
||||
.f-desc { font-size: 24px; color: #888; line-height: 1.5; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-image-section {
|
||||
width: 100%;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
// Decorative line top
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
margin: 0 auto 40px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.long-detail-img { width: 100%; height: auto; display: block; }
|
||||
}
|
||||
|
||||
.footer-spacer { height: 200px; }
|
||||
|
||||
// Bottom Bar
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 30px;
|
||||
right: 30px;
|
||||
height: 110px;
|
||||
z-index: 100;
|
||||
border-radius: 55px; // Fully rounded capsule
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.action-row {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn-add-cart {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-radius: 45px 0 0 45px;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { background: rgba(255, 255, 255, 0.2); }
|
||||
}
|
||||
|
||||
.btn-buy-now {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-radius: 0 45px 45px 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000; // Black text for high contrast on neon
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: pulse-glow 3s infinite;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
font-size: 36px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
192
miniprogram/src/pages/goods/detail.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getConfigDetail } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import ParticleBackground from '../../components/ParticleBackground'
|
||||
import { addToCart } from '../../utils/cart'
|
||||
import './detail.scss'
|
||||
//
|
||||
export default function Detail() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [product, setProduct] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
if (id) fetchDetail(id)
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
try {
|
||||
const res = await getConfigDetail(id)
|
||||
setProduct(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: product?.name || '商品详情',
|
||||
path: `/pages/goods/detail?id=${product?.id}`,
|
||||
imageUrl: product?.static_image_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: product?.name || '商品详情',
|
||||
query: `id=${product?.id}`,
|
||||
imageUrl: product?.static_image_url
|
||||
}
|
||||
})
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return
|
||||
addToCart(product)
|
||||
}
|
||||
|
||||
const buyNow = () => {
|
||||
if (!checkLogin()) return
|
||||
if (!product) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/order/checkout?id=${product.id}&quantity=1`
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <View className='loading-screen'><Text>Loading...</Text></View>
|
||||
if (!product) return <View className='error-screen'><Text>Product Not Found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ParticleBackground />
|
||||
<ScrollView scrollY className='content'>
|
||||
{/* Hero Section */}
|
||||
<View className='hero-section'>
|
||||
<View className='image-container'>
|
||||
{product.static_image_url ? (
|
||||
<Image src={product.static_image_url} mode='widthFix' className='hero-img' />
|
||||
) : (
|
||||
<View className='placeholder-box'>
|
||||
<Text className='icon-bolt'>⚡</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='hero-overlay' />
|
||||
</View>
|
||||
|
||||
<View className='hero-content'>
|
||||
<Text className='hero-title'>{product.name}</Text>
|
||||
<Text className='hero-desc'>{product.description}</Text>
|
||||
|
||||
<View className='tags-row'>
|
||||
<View className='tag cyan'><Text>{product.chip_type}</Text></View>
|
||||
{product.has_camera && <View className='tag blue'><Text>高清摄像头</Text></View>}
|
||||
{product.has_microphone && <View className='tag purple'><Text>阵列麦克风</Text></View>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Section */}
|
||||
<View className='stats-card'>
|
||||
<View className='label-row'>
|
||||
<Text className='label'>售价</Text>
|
||||
<Text className='label' style={{textAlign: 'right'}}>库存</Text>
|
||||
</View>
|
||||
<View className='value-row'>
|
||||
<View className='price-box'>
|
||||
<Text className='symbol'>¥</Text>
|
||||
<Text className='price'>{product.price}</Text>
|
||||
</View>
|
||||
<View className='stock-box'>
|
||||
<Text className='stock'>{product.stock}</Text>
|
||||
<Text className='unit'>件</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Features Section */}
|
||||
<View className='features-section'>
|
||||
{product.features && product.features.length > 0 ? (
|
||||
product.features.map((f, idx) => {
|
||||
let iconContent
|
||||
if (f.display_icon) {
|
||||
iconContent = <Image src={f.display_icon} className='f-icon-img' />
|
||||
} else if (f.icon_url) {
|
||||
iconContent = <Image src={f.icon_url} className='f-icon-img' />
|
||||
} else {
|
||||
let iconChar = '⭐'
|
||||
let iconColor = '#00b96b'
|
||||
switch(f.icon_name) {
|
||||
case 'SafetyCertificate': iconChar = '🛡'; break;
|
||||
case 'Eye': iconChar = '👁'; iconColor = '#3b82f6'; break;
|
||||
case 'Thunderbolt': iconChar = '⚡'; iconColor = '#faad14'; break;
|
||||
default: break;
|
||||
}
|
||||
iconContent = <Text className='f-icon' style={{color: iconColor}}>{iconChar}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={idx} className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
{iconContent}
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title'>{f.title}</Text>
|
||||
<Text className='f-desc'>{f.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<View className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
<Text className='f-icon'>🛡</Text>
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title'>工业级安全标准</Text>
|
||||
<Text className='f-desc'>采用军工级加密芯片,保障您的数据隐私安全。无论是边缘计算还是云端同步,全程加密传输。</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
<Text className='f-icon' style={{color: '#3b82f6'}}>👁</Text>
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title' style={{color: '#3b82f6'}}>超清视觉感知</Text>
|
||||
<Text className='f-desc'>搭载 4K 高清摄像头与 AI 视觉算法,实时捕捉每一个细节。支持人脸识别、物体检测等。</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Detail Image */}
|
||||
{(product.display_detail_image || product.detail_image_url) && (
|
||||
<View className='detail-image-section'>
|
||||
<Image src={product.display_detail_image || product.detail_image_url} mode='widthFix' className='long-detail-img' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='footer-spacer' />
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<View className='bottom-bar'>
|
||||
<View className='action-row'>
|
||||
<Button className='btn-add-cart' onClick={handleAddToCart}>
|
||||
<Text>加入购物车</Text>
|
||||
</Button>
|
||||
<Button className='btn-buy-now' onClick={buyNow}>
|
||||
<Text className='cart-icon'>🛒</Text>
|
||||
<Text>立即购买</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'Quant Speed Market'
|
||||
})
|
||||
570
miniprogram/src/pages/index/index.scss
Normal file
@@ -0,0 +1,570 @@
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
// Ambient Light 1 (Cyan)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
width: 60%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(0, 240, 255, 0.15) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Ambient Light 2 (Green/Purple mix)
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(189, 0, 255, 0.1) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scroll-inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 80px 24px 60px; // 增加头部留白
|
||||
position: relative;
|
||||
|
||||
.logo-box {
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.logo-img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 25px rgba(0, 240, 255, 0.5));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 8px;
|
||||
text-shadow: 0 0 30px rgba(0, 240, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.title-container {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-cyan);
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
margin-left: 8px;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 28px;
|
||||
line-height: 1.8; // 增加行高
|
||||
display: block;
|
||||
padding: 0 40px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
// News Ticker Styles
|
||||
.news-section {
|
||||
padding: 0 32px 40px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.news-inner {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.news-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
|
||||
.news-icon {
|
||||
font-size: 32px;
|
||||
animation: pulse-icon 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.news-swiper {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-item {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #000;
|
||||
background: var(--primary-cyan);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-icon {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
// Activity Banner Styles
|
||||
.activity-section {
|
||||
padding-bottom: 60px;
|
||||
|
||||
.section-header {
|
||||
padding: 0 32px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.arrow {
|
||||
margin-left: 8px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:active .arrow {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-swiper {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.activity-swiper-item {
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #111;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
|
||||
.activity-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.activity-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
background: var(--primary-purple);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 0 10px rgba(189, 0, 255, 0.4);
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 24px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px; // 增加卡片间距
|
||||
}
|
||||
|
||||
// 玻璃态卡片升级版
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.4),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05); // 内描边增强质感
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
|
||||
// 高光反射效果
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
box-shadow:
|
||||
0 10px 20px rgba(0, 0, 0, 0.4),
|
||||
0 0 30px rgba(0, 240, 255, 0.1); // 按压发光
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
&-cover {
|
||||
height: 400px; // 加大图片区域
|
||||
background: #111;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.placeholder-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at center, #1a1a1a, #050505);
|
||||
|
||||
.radar-scan {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 2px solid rgba(0, 240, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--primary-cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--primary-cyan);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, transparent 0%, transparent 60%, rgba(0, 240, 255, 0.4) 100%);
|
||||
animation: radar-spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-title {
|
||||
font-size: 40px; // 加大标题
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 36px;
|
||||
color: var(--primary-cyan); // 统一用青色或根据产品类型变化
|
||||
font-weight: 800;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.tag {
|
||||
padding: 10px 24px;
|
||||
border-radius: 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.cyan {
|
||||
color: var(--primary-cyan);
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
&.blue {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
&.purple {
|
||||
color: var(--primary-purple);
|
||||
background: rgba(189, 0, 255, 0.08);
|
||||
border: 1px solid rgba(189, 0, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
.btn-buy {
|
||||
background: linear-gradient(90deg, var(--primary-green), var(--primary-cyan));
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
border-radius: 60px; // 更圆润
|
||||
border: none;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
box-shadow: 0 10px 30px rgba(0, 185, 107, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 流光效果
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 5px 15px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
20% { left: 100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes radar-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
// 骨架屏样式
|
||||
.skeleton-wrapper {
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 700px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
// 列表入场动画
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
258
miniprogram/src/pages/index/index.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { View, Text, Image, ScrollView, Button, Swiper, SwiperItem } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getConfigs, getAnnouncements, getActivities } from '../../api'
|
||||
import ParticleBackground from '../../components/ParticleBackground'
|
||||
import './index.scss'
|
||||
|
||||
export default function Index() {
|
||||
const [products, setProducts] = useState<any[]>([])
|
||||
const [announcements, setAnnouncements] = useState<any[]>([])
|
||||
const [activities, setActivities] = useState<any[]>([])
|
||||
const [typedText, setTypedText] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const fullText = "未来已来 AI 核心驱动"
|
||||
|
||||
useLoad(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
i++
|
||||
setTypedText(fullText.slice(0, i))
|
||||
if (i >= fullText.length) clearInterval(interval)
|
||||
}, 150)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Parallel fetch for better performance
|
||||
const [productsRes, announcementsRes, activitiesRes] = await Promise.all([
|
||||
getConfigs().catch(() => ({ results: [] })),
|
||||
getAnnouncements().catch(() => ({ results: [] })),
|
||||
getActivities().catch(() => ({ results: [] }))
|
||||
])
|
||||
|
||||
const productList = Array.isArray(productsRes) ? productsRes : (productsRes?.results || productsRes?.data || [])
|
||||
setProducts(productList)
|
||||
|
||||
const announcementList = Array.isArray(announcementsRes) ? announcementsRes : (announcementsRes?.results || announcementsRes?.data || [])
|
||||
// Mock data for demo if empty
|
||||
if (announcementList.length === 0) {
|
||||
announcementList.push(
|
||||
{ id: 101, title: 'Quant Speed AI 开发者大会即将开启报名' },
|
||||
{ id: 102, title: '新品发布:AI小智 V2 性能提升300%' },
|
||||
{ id: 103, title: '社区活动:分享你的边缘计算项目赢大奖' }
|
||||
)
|
||||
}
|
||||
setAnnouncements(announcementList)
|
||||
|
||||
const activityList = Array.isArray(activitiesRes) ? activitiesRes : (activitiesRes?.results || activitiesRes?.data || [])
|
||||
// Mock data for demo if empty
|
||||
if (activityList.length === 0) {
|
||||
activityList.push({
|
||||
id: 201,
|
||||
title: '2025 AI 硬件黑客马拉松',
|
||||
start_time: '2025-05-20T10:00:00',
|
||||
cover_image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop'
|
||||
}, {
|
||||
id: 202,
|
||||
title: '边缘计算实战训练营',
|
||||
start_time: '2025-06-15T09:00:00',
|
||||
cover_image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=2070&auto=format&fit=crop'
|
||||
})
|
||||
}
|
||||
setActivities(activityList)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err.errMsg || '加载失败,请检查网络')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'Quant Speed - AI 核心驱动',
|
||||
path: '/pages/index/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'Quant Speed - AI 核心驱动'
|
||||
}
|
||||
})
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/goods/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToAnnouncement = (id: number) => {
|
||||
// Assuming generic topic detail or specific announcement page
|
||||
Taro.navigateTo({ url: `/subpackages/forum/detail/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToActivity = (id: number) => {
|
||||
Taro.navigateTo({ url: `/subpackages/forum/activity/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToActivityList = () => {
|
||||
Taro.navigateTo({ url: `/subpackages/forum/activity/index` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ParticleBackground />
|
||||
|
||||
<ScrollView scrollY className='content-scroll'>
|
||||
<View className='scroll-inner'>
|
||||
<View className='header'>
|
||||
<View className='logo-box'>
|
||||
<Image src='../../assets/logo.svg' className='logo-img' mode='widthFix' />
|
||||
<Text className='logo-text'>QUANT SPEED</Text>
|
||||
</View>
|
||||
|
||||
<View className='title-container'>
|
||||
<Text className='title-text'>{typedText}</Text>
|
||||
<Text className='cursor'>|</Text>
|
||||
</View>
|
||||
<Text className='subtitle'>量迹 AI 硬件为您提供最强大的边缘计算能力</Text>
|
||||
</View>
|
||||
|
||||
{/* News Ticker */}
|
||||
{!loading && announcements.length > 0 && (
|
||||
<View className='news-section fade-in-up'>
|
||||
<View className='news-inner'>
|
||||
<View className='news-icon-box'>
|
||||
<Text className='news-icon'>⚡</Text>
|
||||
</View>
|
||||
<Swiper
|
||||
className='news-swiper'
|
||||
vertical
|
||||
circular
|
||||
autoplay
|
||||
interval={4000}
|
||||
duration={600}
|
||||
>
|
||||
{announcements.map(item => (
|
||||
<SwiperItem key={item.id} onClick={() => goToAnnouncement(item.id)}>
|
||||
<View className='news-item'>
|
||||
<Text className='news-tag'>NEWS</Text>
|
||||
<Text className='news-title'>{item.title}</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Activity Banner */}
|
||||
{!loading && activities.length > 0 && (
|
||||
<View className='activity-section fade-in-up' style={{ animationDelay: '0.1s' }}>
|
||||
<View className='section-header'>
|
||||
<Text className='section-title'>近期活动 / EVENTS</Text>
|
||||
<View className='more-btn' onClick={goToActivityList}>
|
||||
<Text>MORE</Text>
|
||||
<Text className='arrow'>→</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Swiper
|
||||
className='activity-swiper'
|
||||
circular
|
||||
autoplay
|
||||
interval={5000}
|
||||
duration={600}
|
||||
previousMargin='30px'
|
||||
nextMargin='30px'
|
||||
>
|
||||
{activities.map(item => (
|
||||
<SwiperItem key={item.id} className='activity-swiper-item' onClick={() => goToActivity(item.id)}>
|
||||
<View className='activity-card'>
|
||||
<Image
|
||||
src={item.display_banner_url || item.banner_url || item.cover_image || 'https://via.placeholder.com/600x300'}
|
||||
mode='aspectFill'
|
||||
className='activity-img'
|
||||
/>
|
||||
<View className='activity-overlay'>
|
||||
<View className='activity-info'>
|
||||
<Text className='activity-status'>报名中</Text>
|
||||
<Text className='activity-title'>{item.title}</Text>
|
||||
<Text className='activity-time'>{item.start_time ? item.start_time.split('T')[0] : 'TBD'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<View className='skeleton-wrapper'>
|
||||
{[1, 2, 3].map(i => (
|
||||
<View key={i} className='skeleton-card' />
|
||||
))}
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='status-box'>
|
||||
<Text className='error-text'>{error}</Text>
|
||||
<Button className='btn-retry' onClick={fetchData}>重试</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item, index) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='card fade-in-up'
|
||||
style={{ animationDelay: `${0.2 + index * 0.1}s` }}
|
||||
onClick={() => goToDetail(item.id)}
|
||||
>
|
||||
<View className='card-cover'>
|
||||
{item.static_image_url ? (
|
||||
<Image src={item.static_image_url} mode='aspectFill' className='card-img' />
|
||||
) : (
|
||||
<View className='placeholder-img'>
|
||||
<View className='radar-scan'></View>
|
||||
</View>
|
||||
)}
|
||||
<View className='card-overlay' />
|
||||
</View>
|
||||
|
||||
<View className='card-body'>
|
||||
<View className='card-header'>
|
||||
<Text className='card-title'>{item.name}</Text>
|
||||
<Text className='price'>¥{item.price}</Text>
|
||||
</View>
|
||||
|
||||
<Text className='card-desc'>{item.description}</Text>
|
||||
|
||||
<View className='tags'>
|
||||
<View className='tag cyan'><Text>{item.chip_type}</Text></View>
|
||||
{item.has_camera && <View className='tag blue'><Text>Camera</Text></View>}
|
||||
{item.has_microphone && <View className='tag purple'><Text>Mic</Text></View>}
|
||||
</View>
|
||||
|
||||
<View className='card-footer'>
|
||||
<Button className='btn-buy'>立即购买</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='footer-spacer' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/checkout.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认订单'
|
||||
})
|
||||
191
miniprogram/src/pages/order/checkout.scss
Normal file
@@ -0,0 +1,191 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding-bottom: 120px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
background: #00b96b;
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-type-section {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
|
||||
.type-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.address-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.address-info {
|
||||
flex: 1;
|
||||
.user-info {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
.phone { margin-left: 20px; color: #888; font-weight: normal; font-size: 26px; }
|
||||
}
|
||||
.address-text {
|
||||
font-size: 26px;
|
||||
color: #aaa;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.placeholder {
|
||||
color: #00b96b;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 30px;
|
||||
color: #666;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-section {
|
||||
padding: 0; // Remove padding for list
|
||||
overflow: hidden;
|
||||
|
||||
.section-title { margin: 24px 24px 10px; }
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.p-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
margin-right: 20px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.p-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.p-name { font-size: 28px; color: #fff; font-weight: bold; }
|
||||
.p-desc { font-size: 24px; color: #888; }
|
||||
|
||||
.p-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.p-price { font-size: 30px; color: #00b96b; font-weight: bold; }
|
||||
.p-qty { font-size: 26px; color: #888; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
|
||||
&.total {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
.price { color: #00b96b; font-size: 40px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 30px;
|
||||
z-index: 100;
|
||||
|
||||
.total-label { font-size: 28px; color: #fff; margin-right: 20px; }
|
||||
.total-price { font-size: 40px; color: #00b96b; font-weight: bold; margin-right: 30px; }
|
||||
|
||||
.btn-submit {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000;
|
||||
border-radius: 40px;
|
||||
padding: 0 60px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active { transform: scale(0.98); }
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
265
miniprogram/src/pages/order/checkout.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { getConfigDetail, createOrder, getVBCourseDetail } from '../../api'
|
||||
import { getSelectedItems, removeItem } from '../../utils/cart'
|
||||
import './checkout.scss'
|
||||
|
||||
export default function Checkout() {
|
||||
const router = useRouter()
|
||||
const params = router.params
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [address, setAddress] = useState<any>(null)
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
|
||||
const [userAddress, setUserAddress] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const PICKUP_ADDRESS = {
|
||||
userName: '云南量迹科技有限公司',
|
||||
telNumber: '18585164448',
|
||||
provinceName: '云南省',
|
||||
cityName: '昆明市',
|
||||
countyName: '西山区',
|
||||
detailInfo: '永昌街道办事处云纺国际商厦 B 座 1406 号'
|
||||
}
|
||||
|
||||
useLoad(async () => {
|
||||
if (params.from === 'cart') {
|
||||
const cartItems = getSelectedItems()
|
||||
if (cartItems.length === 0) {
|
||||
Taro.navigateBack()
|
||||
return
|
||||
}
|
||||
setItems(cartItems)
|
||||
setLoading(false)
|
||||
} else if (params.id) {
|
||||
try {
|
||||
let res: any = null
|
||||
if (params.type === 'course') {
|
||||
res = await getVBCourseDetail(Number(params.id))
|
||||
setItems([{
|
||||
id: res.id,
|
||||
name: res.title,
|
||||
price: res.price,
|
||||
image: res.cover_image_url || res.detail_image_url,
|
||||
quantity: 1,
|
||||
description: res.description
|
||||
}])
|
||||
} else {
|
||||
res = await getConfigDetail(params.id)
|
||||
setItems([{
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
price: res.price,
|
||||
image: res.static_image_url || res.detail_image_url,
|
||||
quantity: Number(params.quantity) || 1,
|
||||
description: res.description
|
||||
}])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '商品加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const chooseAddress = async () => {
|
||||
if (deliveryType === 'pickup') return
|
||||
try {
|
||||
const res = await Taro.chooseAddress()
|
||||
setAddress(res)
|
||||
setUserAddress(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
// User cancelled or auth denied
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeChange = (type: 'delivery' | 'pickup') => {
|
||||
if (type === deliveryType) return
|
||||
setDeliveryType(type)
|
||||
if (type === 'pickup') {
|
||||
setAddress(PICKUP_ADDRESS)
|
||||
} else {
|
||||
setAddress(userAddress)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
}, [items])
|
||||
|
||||
const submitOrder = async () => {
|
||||
// 免费课程不需要地址
|
||||
const isFreeCourse = params.type === 'course' && items.length > 0 && Number(items[0].price) === 0
|
||||
|
||||
if (!address && !isFreeCourse) {
|
||||
// 尝试调用 chooseAddress
|
||||
try {
|
||||
await chooseAddress()
|
||||
if (!address) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是免费课程且没有地址,使用默认值
|
||||
const orderAddress = address || {
|
||||
userName: '免费课程学员',
|
||||
telNumber: '13800000000',
|
||||
provinceName: '',
|
||||
cityName: '',
|
||||
countyName: '',
|
||||
detailInfo: '线上课程'
|
||||
}
|
||||
|
||||
Taro.showLoading({ title: '提交中...' })
|
||||
|
||||
try {
|
||||
const orderPromises = items.map(item => {
|
||||
const type = params.type || 'config'
|
||||
|
||||
// 构造订单数据
|
||||
const orderData: any = {
|
||||
quantity: item.quantity,
|
||||
customer_name: orderAddress.userName,
|
||||
phone_number: orderAddress.telNumber,
|
||||
shipping_address: `${orderAddress.provinceName}${orderAddress.cityName}${orderAddress.countyName}${orderAddress.detailInfo}`,
|
||||
ref_code: Taro.getStorageSync('ref_code') || ''
|
||||
}
|
||||
|
||||
if (type === 'course') {
|
||||
orderData.course = item.id
|
||||
} else {
|
||||
orderData.config = item.id
|
||||
}
|
||||
|
||||
return createOrder(orderData)
|
||||
})
|
||||
|
||||
const results = await Promise.all(orderPromises)
|
||||
|
||||
// If from cart, remove bought items
|
||||
if (params.from === 'cart') {
|
||||
items.forEach(item => removeItem(item.id))
|
||||
}
|
||||
|
||||
Taro.hideLoading()
|
||||
|
||||
if (results.length === 1) {
|
||||
// Single order, go to payment
|
||||
const orderId = results[0].id
|
||||
Taro.redirectTo({
|
||||
url: `/pages/order/payment?id=${orderId}`
|
||||
})
|
||||
} else {
|
||||
// Multiple orders
|
||||
Taro.showModal({
|
||||
title: '下单成功',
|
||||
content: `成功创建 ${results.length} 个订单,请前往订单列表支付`,
|
||||
showCancel: false,
|
||||
confirmText: '去支付',
|
||||
success: () => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Taro.hideLoading()
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '下单失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><View className='section'><Text>Loading...</Text></View></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY style={{height: 'calc(100vh - 120px)'}}>
|
||||
{/* Delivery Type Section */}
|
||||
<View className='section delivery-type-section'>
|
||||
<View
|
||||
className={`type-item ${deliveryType === 'delivery' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('delivery')}
|
||||
>
|
||||
快递配送
|
||||
</View>
|
||||
<View
|
||||
className={`type-item ${deliveryType === 'pickup' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('pickup')}
|
||||
>
|
||||
门店自提
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Address Section */}
|
||||
<View className='section address-section' onClick={chooseAddress}>
|
||||
{address ? (
|
||||
<View className='address-info'>
|
||||
<View className='user-info'>
|
||||
<Text>{address.userName}</Text>
|
||||
<Text className='phone'>{address.telNumber}</Text>
|
||||
</View>
|
||||
<View className='address-text'>
|
||||
{address.provinceName}{address.cityName}{address.countyName}{address.detailInfo}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='address-info'>
|
||||
<Text className='placeholder'>+ 添加收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
{deliveryType === 'delivery' && <Text className='arrow'>›</Text>}
|
||||
</View>
|
||||
|
||||
{/* Products Section */}
|
||||
<View className='section product-section'>
|
||||
<Text className='section-title'>商品信息</Text>
|
||||
{items.map((item, idx) => (
|
||||
<View key={idx} className='product-item'>
|
||||
<Image src={item.image} className='p-img' mode='aspectFill' />
|
||||
<View className='p-info'>
|
||||
<Text className='p-name'>{item.name}</Text>
|
||||
<Text className='p-desc'>{item.description}</Text>
|
||||
<View className='p-meta'>
|
||||
<Text className='p-price'>¥{item.price}</Text>
|
||||
<Text className='p-qty'>x{item.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Summary Section */}
|
||||
<View className='section summary-section'>
|
||||
<View className='row'>
|
||||
<Text>商品总价</Text>
|
||||
<Text>¥{totalPrice}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text>运费</Text>
|
||||
<Text>¥0</Text>
|
||||
</View>
|
||||
<View className='row total'>
|
||||
<Text>合计</Text>
|
||||
<Text className='price'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<View className='bottom-bar'>
|
||||
<Text className='total-label'>共{items.length}件</Text>
|
||||
<Text className='total-price'>¥{totalPrice}</Text>
|
||||
<Button className='btn-submit' onClick={submitOrder}>提交订单</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单详情'
|
||||
})
|
||||
86
miniprogram/src/pages/order/detail.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
|
||||
.status-text {
|
||||
font-size: 28px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--primary-green);
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
|
||||
&.pending { color: var(--primary-cyan); }
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 48px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
font-size: 18px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px; /* 放大 */
|
||||
align-items: flex-start;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
width: 90px; /* 加宽 label */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
color: var(--text-main);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-area {
|
||||
margin-top: 40px;
|
||||
|
||||
.btn {
|
||||
border-radius: 30px;
|
||||
font-size: 18px; /* 放大 */
|
||||
font-weight: 500;
|
||||
height: 56px; /* 加高 */
|
||||
line-height: 56px;
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary-green);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
miniprogram/src/pages/order/detail.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { View, Text, Button, Image } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getOrder, prepayMiniprogram } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function OrderDetail() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [order, setOrder] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad(async () => {
|
||||
if (id) {
|
||||
fetchOrder(Number(id))
|
||||
}
|
||||
})
|
||||
|
||||
const fetchOrder = async (orderId: number) => {
|
||||
try {
|
||||
const res = await getOrder(orderId)
|
||||
setOrder(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
Taro.showToast({ title: '获取订单失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!checkLogin()) return
|
||||
if (!order) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = await prepayMiniprogram(order.id)
|
||||
|
||||
await Taro.requestPayment({
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign
|
||||
})
|
||||
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
fetchOrder(order.id) // Refresh order status
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) {
|
||||
Taro.showToast({ title: '取消支付', icon: 'none' })
|
||||
} else {
|
||||
Taro.showToast({ title: '支付失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <View className='page-container'><Text>Loading...</Text></View>
|
||||
|
||||
const isPending = order.status === 'pending'
|
||||
const isPaid = order.status === 'paid'
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='status-header'>
|
||||
<Text className={`status-text ${order.status}`}>
|
||||
{isPending ? '待支付' : isPaid ? '已支付' : order.status}
|
||||
</Text>
|
||||
<Text className='amount'>¥{order.total_price}</Text>
|
||||
</View>
|
||||
|
||||
{/* Product Info */}
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>商品信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>商品名称</Text>
|
||||
<Text className='value'>{order.config_name || order.course_title || (order.activity_title ? `报名活动:${order.activity_title}` : '未知商品')}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>数量</Text>
|
||||
<Text className='value'>x {order.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Shipping Info - Only show if available */}
|
||||
{(order.customer_name || order.shipping_address) && (
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>收货信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>收货人</Text>
|
||||
<Text className='value'>{order.customer_name}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>联系电话</Text>
|
||||
<Text className='value'>{order.phone_number}</Text>
|
||||
</View>
|
||||
{order.shipping_address && (
|
||||
<View className='info-row'>
|
||||
<Text className='label'>收货地址</Text>
|
||||
<Text className='value'>{order.shipping_address}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Logistics Info - Only show if shipped */}
|
||||
{(order.courier_name || order.tracking_number) && (
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>物流信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>快递公司</Text>
|
||||
<Text className='value'>{order.courier_name || '-'}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>运单号</Text>
|
||||
<Text className='value'>{order.tracking_number || '-'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Order Info */}
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>订单信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>订单编号</Text>
|
||||
<Text className='value'>{order.wechat_trade_no || order.id}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>下单时间</Text>
|
||||
<Text className='value'>{order.created_at?.replace('T', ' ').substring(0, 19)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isPending && (
|
||||
<View className='btn-area safe-area-bottom'>
|
||||
<Button className='btn btn-primary' onClick={handlePay} loading={loading}>立即支付</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的订单'
|
||||
})
|
||||
98
miniprogram/src/pages/order/list.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 20px; /* 加大页面边距 */
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px; /* 更圆润 */
|
||||
padding: 24px; /* 加大卡片内边距 */
|
||||
margin-bottom: 24px; /* 加大卡片间距 */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px; /* 基础字体放大 */
|
||||
color: var(--text-secondary);
|
||||
|
||||
.status {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
&.pending { color: var(--primary-cyan); }
|
||||
&.paid { color: var(--primary-green); }
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: flex-start; /* 对齐方式微调 */
|
||||
|
||||
.img {
|
||||
width: 110px; /* 图片加大 */
|
||||
height: 110px;
|
||||
border-radius: 12px;
|
||||
background: #333;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 110px;
|
||||
|
||||
.name {
|
||||
font-size: 20px; /* 名称显著放大 */
|
||||
color: var(--text-main);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.qty {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 24px; /* 价格加大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
align-self: flex-end; /* 价格靠下对齐或根据设计调整,这里保持原位或微调 */
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px; /* 增加一点间隔 */
|
||||
|
||||
.btn-pay {
|
||||
border: 1px solid var(--primary-green);
|
||||
color: var(--primary-green);
|
||||
padding: 10px 24px; /* 按钮加大 */
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding-top: 150px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
}
|
||||
63
miniprogram/src/pages/order/list.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getMyOrders } from '../../api'
|
||||
import './list.scss'
|
||||
|
||||
export default function OrderList() {
|
||||
const [orders, setOrders] = useState<any[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await getMyOrders()
|
||||
setOrders(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id) => Taro.navigateTo({ url: `/pages/order/detail?id=${id}` })
|
||||
const goPay = (e, id) => {
|
||||
e.stopPropagation()
|
||||
Taro.navigateTo({ url: `/pages/order/payment?id=${id}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='list'>
|
||||
{orders.map(order => (
|
||||
<View key={order.id} className='card' onClick={() => goDetail(order.id)}>
|
||||
<View className='header'>
|
||||
<Text className='time'>{order.created_at?.substring(0, 10)}</Text>
|
||||
<Text className={`status ${order.status}`}>
|
||||
{order.status === 'pending' ? '待支付' : order.status === 'paid' ? '已支付' : order.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='body'>
|
||||
<Image src={order.config_image || 'https://via.placeholder.com/80'} className='img' mode='aspectFill' />
|
||||
<View className='info'>
|
||||
<Text className='name'>
|
||||
{order.config_name || order.course_title || (order.activity_title ? `报名活动:${order.activity_title}` : '未知商品')}
|
||||
</Text>
|
||||
<Text className='qty'>x {order.quantity}</Text>
|
||||
</View>
|
||||
<View className='price'>
|
||||
<Text>¥{order.total_price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='footer'>
|
||||
{order.status === 'pending' && (
|
||||
<View className='btn-pay' onClick={(e) => goPay(e, order.id)}>去支付</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{orders.length === 0 && <View className='empty'>暂无订单</View>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/payment.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单支付'
|
||||
})
|
||||
70
miniprogram/src/pages/order/payment.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
|
||||
.amount {
|
||||
font-size: 64px; /* 超大金额 */
|
||||
font-weight: bold;
|
||||
color: var(--primary-green);
|
||||
display: block;
|
||||
text-shadow: 0 0 25px rgba(0, 185, 107, 0.4);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
align-items: center;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.label { color: var(--text-secondary); font-size: 18px; }
|
||||
.value { color: var(--text-main); font-size: 18px; font-weight: 500; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-area {
|
||||
.btn-pay {
|
||||
background: var(--primary-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
height: 64px; /* 更高的按钮 */
|
||||
line-height: 64px;
|
||||
box-shadow: 0 8px 30px rgba(0, 185, 107, 0.5);
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
102
miniprogram/src/pages/order/payment.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getOrder, prepayMiniprogram, queryOrderStatus } from '../../api'
|
||||
import './payment.scss'
|
||||
|
||||
export default function Payment() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [order, setOrder] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad(async () => {
|
||||
if (id) {
|
||||
try {
|
||||
const res = await getOrder(Number(id))
|
||||
setOrder(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!order) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
// 如果是免费订单,直接显示成功并跳转
|
||||
if (parseFloat(order.total_price) <= 0) {
|
||||
Taro.showToast({ title: '报名成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params = await prepayMiniprogram(order.id)
|
||||
|
||||
await Taro.requestPayment({
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign
|
||||
})
|
||||
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
|
||||
// 主动查询订单状态,确保后台已更新
|
||||
try {
|
||||
await queryOrderStatus(order.id)
|
||||
} catch (e) {
|
||||
console.error('Query status failed', e)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) {
|
||||
Taro.showToast({ title: '取消支付', icon: 'none' })
|
||||
} else {
|
||||
Taro.showToast({ title: '支付失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <View>Loading...</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='status-header'>
|
||||
<Text className='amount'>¥{order.total_price}</Text>
|
||||
<Text className='desc'>订单待支付</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='row'>
|
||||
<Text className='label'>订单编号</Text>
|
||||
<Text className='value'>{order.out_trade_no || order.id}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text className='label'>商品名称</Text>
|
||||
<Text className='value'>{order.config_name} x {order.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='btn-area safe-area-bottom'>
|
||||
<Button className='btn-pay' onClick={handlePay} loading={loading}>
|
||||
{parseFloat(order.total_price) <= 0 ? '确认报名' : '微信支付'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '服务详情'
|
||||
})
|
||||
245
miniprogram/src/pages/services/detail.scss
Normal file
@@ -0,0 +1,245 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 120px; // Space for bottom bar
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #888;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 40px;
|
||||
|
||||
.card-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.bar {
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.value {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-image-box {
|
||||
width: 100%;
|
||||
background: #111;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.price-card {
|
||||
background: #1f1f1f;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.price-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.price-val {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.price-unit {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f1f;
|
||||
padding: 20px 30px;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
|
||||
.btn-buy {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
border-radius: 45px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Styles
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2c2c2c;
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
padding: 40px;
|
||||
padding-bottom: calc(40px + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc(40px + env(safe-area-inset-bottom));
|
||||
z-index: 1000;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
|
||||
&.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
color: #999;
|
||||
font-size: 26px;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 25px;
|
||||
|
||||
.label {
|
||||
color: #ccc;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #444;
|
||||
border-radius: 12px;
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #444;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
flex: 2;
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
miniprogram/src/pages/services/detail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { View, Text, Image, Button, Input, Textarea } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getServiceDetail, createServiceOrder } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function ServiceDetail() {
|
||||
const [service, setService] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
customer_name: '',
|
||||
company_name: '',
|
||||
phone_number: '',
|
||||
email: '',
|
||||
requirements: ''
|
||||
})
|
||||
|
||||
useLoad((options) => {
|
||||
if (options.id) {
|
||||
fetchDetail(options.id)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDetail = async (id: string) => {
|
||||
try {
|
||||
const res: any = await getServiceDetail(Number(id))
|
||||
setService(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: service?.title || '服务详情',
|
||||
path: `/pages/services/detail?id=${service?.id}`,
|
||||
imageUrl: service?.cover_image_url || service?.icon_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: service?.title || '服务详情',
|
||||
query: `id=${service?.id}`,
|
||||
imageUrl: service?.cover_image_url || service?.icon_url
|
||||
}
|
||||
})
|
||||
|
||||
const handleInput = (key: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.customer_name || !formData.phone_number) {
|
||||
Taro.showToast({ title: '请填写姓名和电话', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '提交中...' })
|
||||
await createServiceOrder({
|
||||
service: service.id,
|
||||
...formData,
|
||||
ref_code: Taro.getStorageSync('ref_code') || ''
|
||||
})
|
||||
Taro.hideLoading()
|
||||
setModalVisible(false)
|
||||
Taro.showModal({
|
||||
title: '提交成功',
|
||||
content: '需求已提交,我们的销售顾问将尽快与您联系!',
|
||||
showCancel: false
|
||||
})
|
||||
} catch (err) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '提交失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!service) return <View className='page-container'><Text style={{color:'#fff'}}>Service not found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='detail-header'>
|
||||
<Text className='title'>{service.title}</Text>
|
||||
<Text className='desc'>{service.description}</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='card-title'>
|
||||
<View className='bar' style={{ background: service.color }} />
|
||||
<Text>服务详情</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付周期</Text>
|
||||
<Text className='value'>{service.delivery_time || '待沟通'}</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付内容</Text>
|
||||
<Text className='value'>{service.delivery_content || '根据需求定制'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{service.detail_image_url && (
|
||||
<View className='detail-image-box' style={{ boxShadow: `0 10px 40px ${service.color}22` }}>
|
||||
<Image src={service.detail_image_url} className='detail-img' mode='widthFix' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='price-card'>
|
||||
<Text className='price-title'>服务报价</Text>
|
||||
<View className='price-row'>
|
||||
<Text className='price-val' style={{ color: service.color }}>¥{service.price}</Text>
|
||||
<Text className='price-unit'>/ {service.unit} 起</Text>
|
||||
</View>
|
||||
<View className='tags'>
|
||||
{service.features && service.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='tag' style={{
|
||||
background: `${service.color}11`,
|
||||
color: service.color,
|
||||
border: `1px solid ${service.color}66`
|
||||
}}>
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='bottom-bar'>
|
||||
<Button
|
||||
className='btn-buy'
|
||||
style={{ background: service.color }}
|
||||
onClick={() => {
|
||||
if (checkLogin()) {
|
||||
setModalVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
立即咨询 / 购买
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Modal Layer */}
|
||||
{modalVisible && (
|
||||
<View className='modal-mask' onClick={() => setModalVisible(false)} />
|
||||
)}
|
||||
<View className={`modal-content ${modalVisible ? 'visible' : ''}`}>
|
||||
<Text className='modal-title'>咨询/购买</Text>
|
||||
<Text className='modal-desc'>请填写您的联系方式,我们将尽快联系您</Text>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>您的姓名 *</Text>
|
||||
<Input className='input' placeholder='请输入姓名' value={formData.customer_name} onInput={(e) => handleInput('customer_name', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>联系电话 *</Text>
|
||||
<Input className='input' type='number' placeholder='请输入电话' value={formData.phone_number} onInput={(e) => handleInput('phone_number', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>需求描述</Text>
|
||||
<Textarea className='textarea' placeholder='请简单描述您的需求...' value={formData.requirements} onInput={(e) => handleInput('requirements', e.detail.value)} />
|
||||
</View>
|
||||
|
||||
<View className='modal-actions'>
|
||||
<Button className='btn-cancel' onClick={() => setModalVisible(false)}>取消</Button>
|
||||
<Button className='btn-submit' onClick={handleSubmit}>提交需求</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AI 全栈解决方案'
|
||||
})
|
||||
414
miniprogram/src/pages/services/index.scss
Normal file
@@ -0,0 +1,414 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
|
||||
.highlight {
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 10px rgba(0,240,255,0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vc-promo-container {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.vc-info-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
|
||||
&::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 20px;
|
||||
filter: drop-shadow(0 0 10px rgba(255, 255, 0, 0.3));
|
||||
}
|
||||
|
||||
.info-content {
|
||||
text-align: left;
|
||||
|
||||
.info-title {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
color: #ccc;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: linear-gradient(90deg, #00b96b, #00f0ff);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
font-size: 28px;
|
||||
padding: 0 50px;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 32px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.hud-corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&.tl { top: 0; left: 0; border-top: 2px solid; border-left: 2px solid; }
|
||||
&.br { bottom: 0; right: 0; border-bottom: 2px solid; border-right: 2px solid; }
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.icon-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
|
||||
.icon-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #ccc;
|
||||
font-size: 26px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-more {
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
|
||||
&:after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.process-section {
|
||||
margin-top: 60px;
|
||||
padding: 40px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Background Tech Grid
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 185, 107, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 185, 107, 0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 60px;
|
||||
display: block;
|
||||
text-shadow: 0 0 15px rgba(0, 185, 107, 0.8);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #00b96b;
|
||||
margin: 15px auto 0;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.process-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 20px;
|
||||
|
||||
// Vertical connecting line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
left: 60px; // Center of the icon (40px + padding)
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// Moving signal on the line
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 60px;
|
||||
width: 2px;
|
||||
height: 100px;
|
||||
background: linear-gradient(to bottom, transparent, #00b96b, transparent);
|
||||
animation: signalFlow 3s infinite linear;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.step-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(0, 185, 107, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00b96b;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-right: 30px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 15px rgba(0, 185, 107, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// Pulse effect for icon
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px; bottom: -5px; left: -5px; right: -5px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
animation: pulseBorder 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Content Card
|
||||
.step-content-wrapper {
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-left: 4px solid #00b96b;
|
||||
padding: 20px 24px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
backdrop-filter: blur(5px);
|
||||
transform: translateX(0);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
text-shadow: 0 0 5px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes signalFlow {
|
||||
0% { top: 0; opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulseBorder {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
100% { transform: scale(1.15); opacity: 0; }
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
padding: 8px 20px;
|
||||
border-radius: 30px;
|
||||
margin: 15px auto;
|
||||
display: inline-block;
|
||||
backdrop-filter: blur(5px);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.1);
|
||||
|
||||
text {
|
||||
color: #00b96b;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 0 5px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.compliance-footer {
|
||||
text-align: center;
|
||||
padding: 30px 20px 50px;
|
||||
margin-top: 40px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.5));
|
||||
|
||||
.compliance-text {
|
||||
color: #444;
|
||||
font-size: 22px;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
143
miniprogram/src/pages/services/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getServices } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function ServicesIndex() {
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchServices()
|
||||
})
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res: any = await getServices()
|
||||
// Adapt API response if needed (res.data vs res)
|
||||
setServices(res.results || res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'AI 全栈解决方案',
|
||||
path: '/pages/services/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'AI 全栈解决方案'
|
||||
}
|
||||
})
|
||||
|
||||
const goDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/services/detail?id=${id}` })
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header'>
|
||||
<Text className='title'>AI 全栈<Text className='highlight'>解决方案</Text></Text>
|
||||
<View className='ai-badge'>
|
||||
<Text>生成式AI生成内容</Text>
|
||||
</View>
|
||||
|
||||
<Text className='subtitle'>从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。</Text>
|
||||
|
||||
<View className='vc-promo-container'>
|
||||
<View className='vc-info-card' onClick={() => Taro.navigateTo({ url: '/pages/courses/index' })}>
|
||||
<View className='info-icon'>💡</View>
|
||||
<View className='info-content'>
|
||||
<Text className='info-title'>AI + VC 课程</Text>
|
||||
<Text className='info-desc'>深度解析 AI 如何赋能创投,掌握技术变现的核心逻辑</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='nav-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/courses/index' })}
|
||||
>
|
||||
探索 VC 课程
|
||||
<Text className='arrow'>→</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='service-grid'>
|
||||
{services.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='service-card'
|
||||
style={{
|
||||
border: `1px solid ${item.color}33`,
|
||||
boxShadow: `0 0 20px ${item.color}11`
|
||||
}}
|
||||
onClick={() => goDetail(item.id)}
|
||||
>
|
||||
<View className='hud-corner tl' style={{ borderColor: item.color }} />
|
||||
<View className='hud-corner br' style={{ borderColor: item.color }} />
|
||||
|
||||
<View className='card-header'>
|
||||
<View className='icon-box' style={{ background: `${item.color}22` }}>
|
||||
{item.icon_url ? (
|
||||
<Image src={item.icon_url} className='icon-img' mode='aspectFit' />
|
||||
) : (
|
||||
<View className='icon-placeholder' style={{ background: item.color }} />
|
||||
)}
|
||||
</View>
|
||||
<Text className='title'>{item.title}</Text>
|
||||
</View>
|
||||
|
||||
<Text className='description'>{item.description}</Text>
|
||||
|
||||
<View className='features'>
|
||||
{item.features && item.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='feature-item' style={{ color: item.color }}>
|
||||
<View className='dot' style={{ background: item.color }} />
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button className='btn-more'>了解更多 {'>'}</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='process-section'>
|
||||
<Text className='section-title'>服务流程</Text>
|
||||
<View className='process-steps'>
|
||||
{[
|
||||
{ title: '需求分析', desc: '深度沟通需求', id: 1 },
|
||||
{ title: '数据准备', desc: '高效数据处理', id: 2 },
|
||||
{ title: '模型训练', desc: '高性能算力', id: 3 },
|
||||
{ title: '测试验证', desc: '多维精度测试', id: 4 },
|
||||
{ title: '私有化部署', desc: '全栈落地部署', id: 5 }
|
||||
].map((step) => (
|
||||
<View key={step.id} className='step-item'>
|
||||
<View className='step-icon'><Text>{step.id}</Text></View>
|
||||
<View className='step-content-wrapper'>
|
||||
<Text className='step-title'>{step.title}</Text>
|
||||
<Text className='step-desc'>{step.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='compliance-footer'>
|
||||
<Text className='compliance-text'>深度合成-AI问答类目</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
5
miniprogram/src/pages/user/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '个人中心',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark'
|
||||
})
|
||||
443
miniprogram/src/pages/user/index.scss
Normal file
@@ -0,0 +1,443 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding: 30px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(0, 185, 107, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.card-bg-effect {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(0, 185, 107, 0.2) 0%, transparent 70%);
|
||||
filter: blur(40px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-right: 30px;
|
||||
z-index: 1;
|
||||
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
border: 2px solid rgba(0, 185, 107, 0.5);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #00b96b;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #111;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.info-col {
|
||||
flex: 1;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nickname {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.badges-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.badge-icon { margin-right: 6px; font-size: 22px; }
|
||||
|
||||
&.star {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 1px solid rgba(255, 215, 0, 0.6);
|
||||
color: #ffd700;
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.1);
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: rgba(255, 71, 87, 0.15);
|
||||
border: 1px solid rgba(255, 71, 87, 0.6);
|
||||
color: #ff4757;
|
||||
box-shadow: 0 0 15px rgba(255, 71, 87, 0.1);
|
||||
}
|
||||
|
||||
&.web {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.active {
|
||||
background: rgba(30, 144, 255, 0.15);
|
||||
border: 1px solid rgba(30, 144, 255, 0.6);
|
||||
color: #1e90ff;
|
||||
box-shadow: 0 0 15px rgba(30, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #666;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uid {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
border: 1px solid #00b96b;
|
||||
color: #00b96b;
|
||||
font-size: 24px;
|
||||
border-radius: 30px;
|
||||
padding: 0 30px;
|
||||
height: 60px;
|
||||
line-height: 58px;
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
|
||||
&:active { background: rgba(0, 185, 107, 0.3); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
padding: 0 10px;
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.stat-val { font-size: 36px; font-weight: bold; color: #fff; margin-bottom: 5px; }
|
||||
.stat-lbl { font-size: 24px; color: #666; }
|
||||
}
|
||||
}
|
||||
|
||||
.service-container {
|
||||
padding-bottom: 40px;
|
||||
|
||||
.service-group {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.group-title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #00b96b;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
|
||||
.grid-item {
|
||||
width: calc(33.33% - 14px); // 3 items per row, accounting for gap
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
padding: 30px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.icon { font-size: 40px; }
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 26px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-info {
|
||||
margin-top: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
text {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login Modal Styles */
|
||||
.login-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-modal-content {
|
||||
width: 600px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 30px;
|
||||
padding: 50px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 60px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
.modal-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: -10px;
|
||||
font-size: 48px;
|
||||
color: #666;
|
||||
padding: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
width: 100%;
|
||||
|
||||
.btn-modal-login {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
background: #333;
|
||||
color: #888;
|
||||
border: none;
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(90deg, #00b96b 0%, #009959 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-modal-cancel {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
font-size: 30px;
|
||||
margin-bottom: 30px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.agreement-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
|
||||
.agreement-checkbox {
|
||||
transform: scale(0.7);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 24px;
|
||||
color: #888;
|
||||
line-height: 1.4;
|
||||
|
||||
.link {
|
||||
color: #00b96b;
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Agreement Detail Modal */
|
||||
.agreement-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
width: 600px;
|
||||
height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.agreement-title {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.agreement-scroll {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
overflow-y: auto;
|
||||
|
||||
.p {
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
height: 100px;
|
||||
line-height: 100px;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
color: #00b96b;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border-radius: 0;
|
||||
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
517
miniprogram/src/pages/user/index.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { View, Text, Image, Button, Checkbox, CheckboxGroup, RichText } from '@tarojs/components'
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { login as silentLogin } from '../../utils/request'
|
||||
import { getMyEnrollments, getProjects } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function UserIndex() {
|
||||
const [userInfo, setUserInfo] = useState<any>(null)
|
||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||
const [isAgreed, setIsAgreed] = useState(false)
|
||||
const [showAgreement, setShowAgreement] = useState(false) // For showing agreement content
|
||||
const [myEnrollments, setMyEnrollments] = useState<any[]>([])
|
||||
const [myProjects, setMyProjects] = useState<any[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
const info = Taro.getStorageSync('userInfo')
|
||||
if (info) {
|
||||
setUserInfo(info)
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
|
||||
usePullDownRefresh(async () => {
|
||||
try {
|
||||
const res = await silentLogin()
|
||||
if (res) {
|
||||
setUserInfo(res)
|
||||
fetchData()
|
||||
}
|
||||
Taro.stopPullDownRefresh()
|
||||
} catch (e) {
|
||||
Taro.stopPullDownRefresh()
|
||||
Taro.showToast({ title: '刷新失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [enrollRes, projectRes] = await Promise.all([
|
||||
getMyEnrollments(),
|
||||
getProjects()
|
||||
])
|
||||
|
||||
let enrollments: any[] = []
|
||||
if (Array.isArray(enrollRes)) {
|
||||
enrollments = enrollRes
|
||||
setMyEnrollments(enrollRes)
|
||||
}
|
||||
|
||||
const allProjects = (projectRes.results || projectRes) as any[]
|
||||
if (Array.isArray(allProjects) && enrollments.length > 0) {
|
||||
// 筛选出属于我的项目 (通过 enrollment id 匹配)
|
||||
const myEnrollmentIds = enrollments.map(e => e.id)
|
||||
const mine = allProjects.filter(p => myEnrollmentIds.includes(p.contestant))
|
||||
setMyProjects(mine)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch data failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
|
||||
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
|
||||
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
|
||||
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
|
||||
|
||||
const goActivityList = (tab = 'all') => Taro.navigateTo({ url: `/subpackages/forum/activity/index?tab=${tab}` })
|
||||
const goCompetitionList = () => Taro.navigateTo({ url: '/pages/competition/index' })
|
||||
|
||||
const goUploadProject = () => {
|
||||
// 找到所有有效的选手报名
|
||||
const contestantEnrollments = myEnrollments.filter(e => e.role === 'contestant')
|
||||
|
||||
if (contestantEnrollments.length === 1) {
|
||||
const enrollment = contestantEnrollments[0]
|
||||
// 查找该报名对应的项目
|
||||
const project = myProjects.find(p => p.contestant === enrollment.id)
|
||||
|
||||
if (project) {
|
||||
// 已有项目,去编辑
|
||||
Taro.navigateTo({ url: `/pages/competition/project?id=${project.id}` })
|
||||
} else {
|
||||
// 无项目,去新建
|
||||
Taro.navigateTo({ url: `/pages/competition/project?competitionId=${enrollment.competition}` })
|
||||
}
|
||||
} else {
|
||||
// 多个比赛或无比赛,去列表页让用户选
|
||||
Taro.navigateTo({ url: '/pages/competition/index' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddress = async () => {
|
||||
try {
|
||||
const res = await Taro.chooseAddress()
|
||||
// 同步地址信息到后端
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (token) {
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
province: res.provinceName,
|
||||
city: res.cityName,
|
||||
country: '中国' // 默认中国,chooseAddress通常返回国内地址
|
||||
}
|
||||
})
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, province: res.provinceName, city: res.cityName, country: '中国' }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
Taro.showToast({ title: '地址信息已同步', icon: 'success' })
|
||||
}
|
||||
} catch(e) {
|
||||
// 用户取消或其他错误,忽略
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarClick = async () => {
|
||||
if (!userInfo) return
|
||||
|
||||
try {
|
||||
const { tempFilePaths } = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
|
||||
const token = Taro.getStorageSync('token')
|
||||
const uploadRes = await Taro.uploadFile({
|
||||
url: 'https://market.quant-speed.com/api/upload/image/',
|
||||
filePath: tempFilePaths[0],
|
||||
name: 'file',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (uploadRes.statusCode !== 200) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
|
||||
const data = JSON.parse(uploadRes.data)
|
||||
const newAvatarUrl = data.url
|
||||
|
||||
// 更新后端用户信息
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
avatar_url: newAvatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, avatar_url: newAvatarUrl }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '头像更新失败', icon: 'none' })
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNicknameClick = () => {
|
||||
if (!userInfo) return
|
||||
|
||||
Taro.showModal({
|
||||
title: '修改昵称',
|
||||
content: userInfo.nickname || '',
|
||||
// @ts-ignore
|
||||
editable: true,
|
||||
placeholderText: '请输入新昵称',
|
||||
success: async function (res) {
|
||||
if (res.confirm && (res as any).content) {
|
||||
const newNickname = (res as any).content
|
||||
if (newNickname === userInfo.nickname) return
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '更新中...' })
|
||||
const token = Taro.getStorageSync('token')
|
||||
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
nickname: newNickname
|
||||
}
|
||||
})
|
||||
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, nickname: newNickname }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '昵称已更新', icon: 'success' })
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '更新失败', icon: 'none' })
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
Taro.removeStorageSync('token')
|
||||
Taro.removeStorageSync('userInfo')
|
||||
setUserInfo(null)
|
||||
Taro.showToast({ title: '已退出登录', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
// 1. 获取微信登录 Code
|
||||
const { code } = await Taro.login()
|
||||
if (!code) throw new Error('登录失败:无法获取 Code')
|
||||
|
||||
// 2. 调用后端登录 (仅 Code)
|
||||
const res = await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/login/',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
})
|
||||
|
||||
console.log('code:', code)
|
||||
|
||||
if (res.statusCode === 200 && res.data.token) {
|
||||
console.log('登录成功,后端返回用户信息:', res.data)
|
||||
Taro.setStorageSync('token', res.data.token)
|
||||
Taro.setStorageSync('userInfo', res.data)
|
||||
setUserInfo(res.data)
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(res.data.error || '登录请求失败')
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const getPhoneNumber = async (e) => {
|
||||
const { code: phoneCode, errMsg } = e.detail
|
||||
if (errMsg !== "getPhoneNumber:ok") {
|
||||
Taro.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '登录中...' })
|
||||
// 1. 获取登录 Code
|
||||
const { code: loginCode } = await Taro.login()
|
||||
|
||||
// 2. 调用后端登录 (Code + PhoneCode)
|
||||
console.log('loginCode:', loginCode)
|
||||
console.log('phoneCode:', phoneCode)
|
||||
const res = await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/login/',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: loginCode,
|
||||
phone_code: phoneCode
|
||||
}
|
||||
})
|
||||
|
||||
Taro.hideLoading()
|
||||
|
||||
if (res.statusCode === 200 && res.data.token) {
|
||||
console.log('手机号登录成功,后端返回用户信息:', res.data)
|
||||
Taro.setStorageSync('token', res.data.token)
|
||||
Taro.setStorageSync('userInfo', res.data)
|
||||
setUserInfo(res.data)
|
||||
setShowLoginModal(false) // Close modal on success
|
||||
Taro.showToast({ title: '授权登录成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(res.data.error || '登录失败')
|
||||
}
|
||||
} catch(err) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: err.message || '系统异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const isContestant = myEnrollments.some(e => e.role === 'contestant')
|
||||
|
||||
const serviceGroups = [
|
||||
{
|
||||
title: '基础服务',
|
||||
items: [
|
||||
{ title: '我的订单', icon: '📦', action: goOrders },
|
||||
{ title: '地址管理', icon: '📝', action: handleAddress },
|
||||
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '比赛服务',
|
||||
items: [
|
||||
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
|
||||
...(isContestant ? [{ title: '上传比赛资料', icon: '📤', action: goUploadProject }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '分销中心',
|
||||
items: [
|
||||
{ title: '分销首页', icon: '⚡', action: goDistributor },
|
||||
{ title: '推广邀请', icon: '🤝', action: goInvite },
|
||||
{ title: '佣金提现', icon: '💰', action: goWithdraw },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '其他',
|
||||
items: [
|
||||
{ title: '联系客服', icon: '🎧', isContact: true },
|
||||
...(userInfo ? [{ title: '退出登录', icon: '🚪', action: handleLogout }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ label: '余额', value: '0.00' },
|
||||
{ label: '积分', value: '0' },
|
||||
{ label: '优惠券', value: '0' }
|
||||
]
|
||||
|
||||
const handleAgreementCheck = (e) => {
|
||||
setIsAgreed(!!e.detail.value.length)
|
||||
}
|
||||
|
||||
const handleShowAgreement = (e) => {
|
||||
e.stopPropagation()
|
||||
setShowAgreement(true)
|
||||
}
|
||||
|
||||
const handleLoginBtnClick = () => {
|
||||
if (!isAgreed) {
|
||||
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// If agreed, the button openType='getPhoneNumber' handles it.
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{/* Profile Card */}
|
||||
<View className='profile-card'>
|
||||
<View className='avatar-container' onClick={handleAvatarClick}>
|
||||
<Image
|
||||
src={userInfo?.avatar_url || `https://api.dicebear.com/7.x/miniavs/svg?seed=${userInfo?.id || 'guest'}`}
|
||||
className='avatar'
|
||||
/>
|
||||
{userInfo && <View className='online-dot' />}
|
||||
</View>
|
||||
<View className='info-col'>
|
||||
<Text className='nickname' onClick={handleNicknameClick}>{userInfo?.nickname || '未登录用户'}</Text>
|
||||
|
||||
{userInfo && (
|
||||
<View className='badges-row'>
|
||||
{/* 管理员 */}
|
||||
{userInfo.is_admin && (
|
||||
<View className='badge admin'>
|
||||
<Text className='badge-icon'>🛡️</Text>
|
||||
<Text className='badge-text'>管理员</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 明星技术用户/专家 */}
|
||||
{userInfo.is_star && (
|
||||
<View className='badge star'>
|
||||
<Text className='badge-icon'>🌟</Text>
|
||||
<Text className='badge-text'>{userInfo.title || '技术专家'}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 网页用户徽章 */}
|
||||
{(userInfo.has_web_badge || userInfo.has_web_account) && (
|
||||
<View className='badge web active'>
|
||||
<Text className='badge-icon'>🌐</Text>
|
||||
<Text className='badge-text'>网页用户</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className='uid'>ID: {userInfo?.phone_number || '未绑定手机号'}</Text>
|
||||
{!userInfo?.phone_number && (
|
||||
<View className='login-btns'>
|
||||
<Button
|
||||
className='btn-login primary'
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
>
|
||||
{userInfo ? '绑定手机号' : '立即登录'}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='card-bg-effect' />
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View className='stats-row'>
|
||||
{stats.map((item, idx) => (
|
||||
<View key={idx} className='stat-item'>
|
||||
<Text className='stat-val'>{item.value}</Text>
|
||||
<Text className='stat-lbl'>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Service Groups */}
|
||||
<View className='service-container'>
|
||||
{serviceGroups.map((group, gIdx) => (
|
||||
<View key={gIdx} className='service-group'>
|
||||
<Text className='group-title'>{group.title}</Text>
|
||||
<View className='grid-layout'>
|
||||
{group.items.map((item, idx) => (
|
||||
<View key={idx} className='grid-item' onClick={item.action}>
|
||||
<View className='icon-box'>
|
||||
<Text className='icon'>{item.icon}</Text>
|
||||
</View>
|
||||
<Text className='item-title'>{item.title}</Text>
|
||||
{item.isContact && <Button openType='contact' className='contact-overlay' />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='version-info'>
|
||||
<Text>Quant Speed Market v1.0.0</Text>
|
||||
<Text>Powered by Taro & React</Text>
|
||||
</View>
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<View className='login-modal-mask' onClick={() => setShowLoginModal(false)}>
|
||||
<View className='login-modal-content' onClick={e => e.stopPropagation()}>
|
||||
<View className='modal-header'>
|
||||
<Text className='modal-title'>欢迎登录 Quant Speed</Text>
|
||||
<Text className='modal-subtitle'>登录后享受更多权益</Text>
|
||||
<View className='close-icon' onClick={() => setShowLoginModal(false)}>×</View>
|
||||
</View>
|
||||
|
||||
<View className='modal-body'>
|
||||
<Button
|
||||
className={`btn-modal-login ${isAgreed ? 'primary' : 'disabled'}`}
|
||||
openType={isAgreed ? 'getPhoneNumber' : undefined}
|
||||
onGetPhoneNumber={getPhoneNumber}
|
||||
onClick={handleLoginBtnClick}
|
||||
>
|
||||
手机号快捷登录
|
||||
</Button>
|
||||
|
||||
<Button className='btn-modal-cancel' onClick={() => setShowLoginModal(false)}>
|
||||
暂不登录
|
||||
</Button>
|
||||
|
||||
<View className='agreement-box'>
|
||||
<CheckboxGroup onChange={handleAgreementCheck}>
|
||||
<Checkbox value='agree' checked={isAgreed} color='#00b96b' className='agreement-checkbox' />
|
||||
</CheckboxGroup>
|
||||
<Text className='agreement-text'>
|
||||
我已阅读并同意 <Text className='link' onClick={handleShowAgreement}>《用户协议》</Text> 与 <Text className='link' onClick={handleShowAgreement}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Agreement Detail Modal */}
|
||||
{showAgreement && (
|
||||
<View className='agreement-modal-mask'>
|
||||
<View className='agreement-content'>
|
||||
<Text className='agreement-title'>用户协议与隐私政策</Text>
|
||||
<View className='agreement-scroll'>
|
||||
<View className='p'><Text>1. 特别提示</Text></View>
|
||||
<View className='p'><Text>在此特别提醒您(用户)在注册成为用户之前,请认真阅读本《用户协议》(以下简称“协议”),确保您充分理解本协议中各条款。请您审慎阅读并选择接受或不接受本协议。除非您接受本协议所有条款,否则您无权注册、登录或使用本协议所涉服务。您的注册、登录、使用等行为将视为对本协议的接受,并同意接受本协议各项条款的约束。</Text></View>
|
||||
<View className='p'><Text>2. 账号注册</Text></View>
|
||||
<View className='p'><Text>2.1 鉴于“Quant Speed”账号的绑定注册方式,您同意在注册时将您的手机号码及微信账号信息提供给“Quant Speed”用于注册。</Text></View>
|
||||
<View className='p'><Text>3. 隐私保护</Text></View>
|
||||
<View className='p'><Text>3.1 本小程序将严格保护您的个人信息安全。我们使用各种安全技术和程序来保护您的个人信息不被未经授权的访问、使用或泄漏。</Text></View>
|
||||
</View>
|
||||
<Button className='btn-close' onClick={() => setShowAgreement(false)}>我知道了</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/webview/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '加载中...'
|
||||
}
|
||||
14
miniprogram/src/pages/webview/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { WebView } from '@tarojs/components'
|
||||
import { useRouter } from '@tarojs/taro'
|
||||
|
||||
export default function WebViewPage() {
|
||||
const router = useRouter()
|
||||
const { url } = router.params
|
||||
|
||||
if (!url) return null
|
||||
|
||||
// Ensure url has protocol if missing (e.g. starts with //)
|
||||
const fullUrl = url.startsWith('//') ? `https:${url}` : url
|
||||
|
||||
return <WebView src={fullUrl} />
|
||||
}
|
||||
72
miniprogram/src/subpackages/distributor/_shared.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
// Tech/Cyberpunk Theme Variables & Mixins
|
||||
|
||||
// Colors
|
||||
$bg-dark: #050505;
|
||||
$primary-cyan: #00f0ff;
|
||||
$primary-green: #00b96b;
|
||||
$primary-purple: #bd00ff;
|
||||
$text-main: #ffffff;
|
||||
$text-secondary: rgba(255, 255, 255, 0.7);
|
||||
$text-muted: rgba(255, 255, 255, 0.4);
|
||||
|
||||
// Mixins
|
||||
@mixin page-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-dark;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgba(0, 240, 255, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 90%, rgba(189, 0, 255, 0.1) 0%, transparent 40%);
|
||||
color: $text-main;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@mixin glass-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@mixin neon-text($color: $primary-cyan) {
|
||||
color: $color;
|
||||
text-shadow: 0 0 10px rgba($color, 0.5), 0 0 20px rgba($color, 0.3);
|
||||
}
|
||||
|
||||
@mixin neon-button($color: $primary-cyan) {
|
||||
background: rgba($color, 0.1);
|
||||
border: 1px solid rgba($color, 0.5);
|
||||
color: $color;
|
||||
box-shadow: 0 0 15px rgba($color, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba($color, 0.2);
|
||||
box-shadow: 0 0 25px rgba($color, 0.4);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin tech-border {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px; left: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-top: 2px solid $primary-cyan;
|
||||
border-left: 2px solid $primary-cyan;
|
||||
border-radius: 4px 0 0 0;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-bottom: 2px solid $primary-cyan;
|
||||
border-right: 2px solid $primary-cyan;
|
||||
border-radius: 0 0 4px 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '收益明细'
|
||||
})
|
||||
56
miniprogram/src/subpackages/distributor/earnings.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
}
|
||||
|
||||
.item {
|
||||
@include glass-card;
|
||||
padding: 30px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
|
||||
.type {
|
||||
font-size: 28px;
|
||||
color: $text-main;
|
||||
font-weight: bold;
|
||||
}
|
||||
.amount {
|
||||
font-size: 32px;
|
||||
@include neon-text($primary-green);
|
||||
font-weight: bold;
|
||||
font-family: 'DIN Alternate', sans-serif;
|
||||
}
|
||||
.source {
|
||||
font-size: 24px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
.status {
|
||||
font-size: 24px;
|
||||
color: $primary-cyan;
|
||||
background: rgba(0, 240, 255, 0.1);
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
.time {
|
||||
font-size: 22px;
|
||||
color: $text-muted;
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 100px 0;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
font-size: 28px;
|
||||
}
|
||||
55
miniprogram/src/subpackages/distributor/earnings.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorEarnings } from '../../api'
|
||||
import './earnings.scss'
|
||||
|
||||
export default function Earnings() {
|
||||
const [list, setList] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res: any = await distributorEarnings()
|
||||
// Pagination support check? The backend returns { count, next, previous, results } or just list if no pagination
|
||||
if (res.results) {
|
||||
setList(res.results)
|
||||
} else if (Array.isArray(res)) {
|
||||
setList(res)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{list.length > 0 ? (
|
||||
list.map((item: any) => (
|
||||
<View className='item' key={item.id}>
|
||||
<View className='row'>
|
||||
<Text className='type'>{item.level === 1 ? '直接推广' : '团队奖励'}</Text>
|
||||
<Text className='amount'>+{item.amount}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text className='source'>
|
||||
{item.order_info?.customer_name} - 订单金额 ¥{item.order_info?.total_price}
|
||||
</Text>
|
||||
<Text className='status'>{item.status === 'settled' ? '已结算' : '待结算'}</Text>
|
||||
</View>
|
||||
<Text className='time'>{item.created_at?.replace('T', ' ').substring(0, 19)}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className='empty'>暂无收益记录</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '分销中心'
|
||||
})
|
||||
112
miniprogram/src/subpackages/distributor/index.scss
Normal file
@@ -0,0 +1,112 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
@include glass-card;
|
||||
@include tech-border;
|
||||
padding: 40px 30px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Background accent
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.05), rgba(0, 185, 107, 0.05));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 56px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 30px;
|
||||
@include neon-text($primary-cyan);
|
||||
font-family: 'DIN Alternate', sans-serif; // Use a tech-looking font if available
|
||||
}
|
||||
|
||||
.btn-withdraw {
|
||||
@include neon-button($primary-green);
|
||||
border-radius: 30px;
|
||||
font-size: 18px;
|
||||
padding: 0 40px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
@include glass-card;
|
||||
padding: 30px 0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
|
||||
.val {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: $text-main;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.lbl {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
@include glass-card;
|
||||
padding: 0 20px;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 70px; // Larger touch target
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 18px;
|
||||
color: $text-main;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.arrow {
|
||||
color: $primary-cyan;
|
||||
opacity: 0.7;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
miniprogram/src/subpackages/distributor/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorInfo } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function DistributorIndex() {
|
||||
const [info, setInfo] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useDidShow(() => {
|
||||
fetchInfo()
|
||||
})
|
||||
|
||||
const fetchInfo = async () => {
|
||||
try {
|
||||
const res = await distributorInfo()
|
||||
setInfo(res)
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404) {
|
||||
// Not registered
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/register' })
|
||||
} else {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
|
||||
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
|
||||
const goTeam = () => Taro.navigateTo({ url: '/subpackages/distributor/team' })
|
||||
const goEarnings = () => Taro.navigateTo({ url: '/subpackages/distributor/earnings' })
|
||||
const goOrders = () => Taro.navigateTo({ url: '/subpackages/distributor/orders' })
|
||||
|
||||
if (loading) return <View>Loading...</View>
|
||||
if (!info) return <View>Error</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header-card'>
|
||||
<Text className='label'>可提现余额</Text>
|
||||
<Text className='amount'>¥{info.withdrawable_balance}</Text>
|
||||
<Button className='btn-withdraw' onClick={goWithdraw}>提现</Button>
|
||||
</View>
|
||||
|
||||
<View className='stats-grid'>
|
||||
<View className='item'>
|
||||
<Text className='val'>¥{info.total_earnings}</Text>
|
||||
<Text className='lbl'>累计收益</Text>
|
||||
</View>
|
||||
<View className='item'>
|
||||
<Text className='val'>Lv.{info.level}</Text>
|
||||
<Text className='lbl'>当前等级</Text>
|
||||
</View>
|
||||
<View className='item'>
|
||||
<Text className='val'>{(Number(info.commission_rate) * 100).toFixed(1)}%</Text>
|
||||
<Text className='lbl'>分佣比例</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='menu-list'>
|
||||
<View className='menu-item' onClick={goInvite}>
|
||||
<Text>推广二维码</Text>
|
||||
<Text className='arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='menu-item' onClick={goTeam}>
|
||||
<Text>我的团队</Text>
|
||||
<Text className='arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='menu-item' onClick={goEarnings}>
|
||||
<Text>收益明细</Text>
|
||||
<Text className='arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='menu-item' onClick={goOrders}>
|
||||
<Text>分销订单</Text>
|
||||
<Text className='arrow'>{'>'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/invite.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '推广邀请'
|
||||
})
|
||||
49
miniprogram/src/subpackages/distributor/invite.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
@include glass-card;
|
||||
@include tech-border;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
|
||||
.qr-img {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 40px;
|
||||
border: 1px solid $primary-cyan;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: $text-main;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
margin-top: 60px;
|
||||
width: 100%;
|
||||
@include neon-button($primary-cyan);
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border-radius: 45px;
|
||||
}
|
||||
57
miniprogram/src/subpackages/distributor/invite.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorInvite } from '../../api'
|
||||
import './invite.scss'
|
||||
|
||||
export default function Invite() {
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchQr()
|
||||
})
|
||||
|
||||
const fetchQr = async () => {
|
||||
try {
|
||||
const res: any = await distributorInvite()
|
||||
setQrCode(res.qr_code_url)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '获取二维码失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = () => {
|
||||
if (!qrCode) return
|
||||
Taro.downloadFile({
|
||||
url: qrCode,
|
||||
success: (res) => {
|
||||
Taro.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => Taro.showToast({ title: '已保存', icon: 'success' }),
|
||||
fail: () => Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='qr-card'>
|
||||
{loading ? (
|
||||
<View className='qr-img' style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Image src={qrCode} className='qr-img' mode='aspectFit' />
|
||||
)}
|
||||
<Text className='tip'>扫码加入我的团队{'\n'}一起推广赚佣金</Text>
|
||||
</View>
|
||||
|
||||
<Button className='btn-save' onClick={saveImage} disabled={!qrCode}>保存二维码</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/orders.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '分销订单'
|
||||
})
|
||||
72
miniprogram/src/subpackages/distributor/orders.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
}
|
||||
|
||||
.item {
|
||||
@include glass-card;
|
||||
padding: 30px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.order-no {
|
||||
font-size: 24px;
|
||||
color: $text-secondary;
|
||||
font-family: monospace;
|
||||
}
|
||||
.status {
|
||||
font-size: 24px;
|
||||
color: $primary-green;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin-right: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
color: $text-main;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.price {
|
||||
font-size: 32px;
|
||||
color: $primary-cyan;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 24px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 100px 0;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
font-size: 28px;
|
||||
}
|
||||
58
miniprogram/src/subpackages/distributor/orders.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorOrders } from '../../api'
|
||||
import './orders.scss'
|
||||
|
||||
export default function Orders() {
|
||||
const [list, setList] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res: any = await distributorOrders()
|
||||
if (res.results) {
|
||||
setList(res.results)
|
||||
} else if (Array.isArray(res)) {
|
||||
setList(res)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{list.length > 0 ? (
|
||||
list.map((item: any) => (
|
||||
<View className='item' key={item.id}>
|
||||
<View className='row'>
|
||||
<Text className='order-no'>订单号: {item.wechat_trade_no || item.id}</Text>
|
||||
<Text className='status'>{item.status === 'paid' ? '已支付' : item.status}</Text>
|
||||
</View>
|
||||
<View className='content'>
|
||||
<Image className='img' src={item.config_image || ''} mode='aspectFill' />
|
||||
<View className='info'>
|
||||
<Text className='title'>{item.config_name || item.course_title || '商品'}</Text>
|
||||
<Text className='price'>¥{item.total_price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='footer'>
|
||||
<Text className='customer'>买家: {item.customer_name}</Text>
|
||||
<Text className='time'>{item.created_at?.split('T')[0]}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className='empty'>暂无分销订单</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '申请分销员'
|
||||
})
|
||||
46
miniprogram/src/subpackages/distributor/register.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include glass-card;
|
||||
@include tech-border;
|
||||
padding: 50px 30px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
@include neon-text($primary-purple);
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
display: block;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.btn-register {
|
||||
@include neon-button($primary-green);
|
||||
background: linear-gradient(90deg, rgba(0, 185, 107, 0.2), rgba(0, 240, 255, 0.2));
|
||||
border: 1px solid $primary-green;
|
||||
border-radius: 30px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
32
miniprogram/src/subpackages/distributor/register.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { distributorRegister } from '../../api'
|
||||
import './register.scss'
|
||||
|
||||
export default function Register() {
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
await distributorRegister({})
|
||||
Taro.showToast({ title: '申请已提交', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/index' })
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
if (err.data?.error === 'Already registered') {
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/index' })
|
||||
} else {
|
||||
Taro.showToast({ title: '申请失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='card'>
|
||||
<Text className='title'>加入我们</Text>
|
||||
<Text className='desc'>成为分销员,分享赚取佣金</Text>
|
||||
<Button className='btn-register' onClick={handleRegister}>立即申请</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/team.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的团队'
|
||||
})
|
||||
85
miniprogram/src/subpackages/distributor/team.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
@import './_shared.scss';
|
||||
|
||||
.page-container {
|
||||
@include page-container;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@include glass-card;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.val {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
@include neon-text($primary-cyan);
|
||||
}
|
||||
.lbl {
|
||||
font-size: 24px;
|
||||
color: $text-secondary;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
@include glass-card;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.list-header {
|
||||
padding: 24px 30px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $text-main;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 30px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
background: #333;
|
||||
border: 2px solid $primary-purple;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.name {
|
||||
font-size: 28px;
|
||||
color: $text-main;
|
||||
}
|
||||
.time {
|
||||
font-size: 22px;
|
||||
color: $text-muted;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
.level {
|
||||
font-size: 24px;
|
||||
color: $primary-purple;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||