创赢未来评分系统 - 初始化提交(移除大文件)

This commit is contained in:
爽哒哒
2026-03-18 22:28:45 +08:00
commit f26d35da66
315 changed files with 36043 additions and 0 deletions

37
miniprogram/API.md Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1,9 @@
// babel.config.js
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true
}]
]
}

View File

@@ -0,0 +1,9 @@
module.exports = {
env: {
NODE_ENV: '"development"'
},
defineConstants: {
},
mini: {},
h5: {}
}

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

View 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, [])
* }
*/
}
}

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

View 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": {}
}

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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

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

View 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

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

View 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'
/>
)
}

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '项目详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '参赛作品'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '体验详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AR 体验馆'
})

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

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

View File

@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '开发者社区',
enablePullDownRefresh: true,
backgroundColor: '#000000',
navigationBarBackgroundColor: '#000000',
navigationBarTextStyle: 'white'
})

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

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商品详情'
})

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

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

View File

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

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '确认订单'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的订单'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单支付'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '服务详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AI 全栈解决方案'
})

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

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

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '个人中心',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark'
})

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

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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '加载中...'
}

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '收益明细'
})

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

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

View File

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

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '推广邀请'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销订单'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '申请分销员'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的团队'
})

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

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