更新wz-app项目,调整文档以明确玩值移动端(new/wz-app)的称谓,禁止使用「微信小程序」的表述,同时新增用户可见文案规范,确保用户界面不暴露技术细节,提升用户体验与合规性。此外,更新了相关API接口和功能,增强了系统的稳定性与可维护性。

This commit is contained in:
乘风
2026-04-30 16:53:59 +08:00
parent e14d391676
commit f50090132d
85 changed files with 4151 additions and 1672 deletions

View File

@@ -0,0 +1,3 @@
# 团队共享经验 - 2026-04-30
Soul 三端契约与文档落点:**miniprogram 仅用 `/api/miniprogram/*`,管理端仅用 `/api/admin/*``/api/db/*`**;文档统一放在 `开发文档/Soul/`。玩值 **`/api/app/*`** 与前缀分离,禁止混用。未检出 Soul 源码时先做环境与 smoke代码级对接在检出后执行。

View File

@@ -0,0 +1,17 @@
# 开发助理 - 会议与文档归档 - 2026-04-30
- 执笔 Soul 专题会议纪要;新建 `开发文档/Soul/` 联调文档;同步 meeting README、sync-log、多角色项目索引。**说明**:本会话工作区不含 `miniprogram`/`soul-admin`/`soul-api` 目录Soul 接口对接以检出后按文档执行为准;玩值三端仍以 `开发文档/玩值/``new/wz-*` 为交付真源。
---
## 同日补充(术语)
- **玩值 `new/wz-app`** 条线统一称 **移动端 / H5+C 端****不称「小程序智能体」「微信小程序」**;「小程序」保留给 Soul `miniprogram/`。已改 soul/wanzhi boundary、wz-app Skill、paths 注释、`玩值移动端工程师/README`、change-checklist 引言等。
---
## 同日补充wz-app 去 mock + C 端文案)
- **目录数据**:多块页面改接 `catalog.ts`与既有 `mall`/`games`/`streamers` 等,减少硬编码列表;无后端能力时用用户向空态,不写「无 C 口」「GET /api/…」。
- **界面文案红线**:用户在 App 内**不得**看到接口路径、`Mongo``wz-api``ext_id``埋点` 等开发与库表术语;详见 **`玩值移动端工程师/evolution/2026-04-30-wz-app目录对接与用户可见文案.md`** 与 **`wz-app-dev` Skill**(用户可见文案专节)。
- **可选后续**:请求层对用户可见 **`Error.message`** 做温和映射,避免遗留技术句。

View File

@@ -22,6 +22,7 @@
| 日期 | 角色 | 类型 | 升级 Skill | 摘要 |
|------|------|------|------------|------|
| 2026-04-30 | 玩值移动端、开发助理 | 规范/迁移 | **wz-app-dev §7**、**wanzhi boundary** | wz-app 目录页去 mock 接 `/api/app`;用户界面禁止暴露接口路径/Mongo/wz-api 等技术话术;详见玩值移动端 evolution 2026-04-30 |
| 2026-04-24 | 玩值三端、开发助理 | 收官实施 | wz-api-dev 可参考 | 纪要好四问build-info gitCommit点卡订单管理页与真源表同步 |
| 2026-04-24 | 玩值三端、产品、测试、团队、开发助理 | 团队会议 | — | 玩值全项目进度与三端缺口P0/P1/P2、纪要、看板/三端注记、evolution 与项目索引同步 |
| 2026-04-24 | 玩值移动端、开发助理 | 迁移/联调/文档 | wz-app-dev SKILL §5 | wz-app 占位清零:大屏只读、主播/观众 API、公会旅程埋点、动态评论本地态、删 Stub对照表/看板/MIGRATION 同步 |
@@ -69,4 +70,4 @@
---
**最后更新**2026-04-22
**最后更新**2026-04-30

View File

@@ -34,9 +34,10 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-04-13 | 会议:按功能同步开发文档;功能完成=实现+文档/索引可追溯;待办见纪要问题区 | 进行中 |
| 2026-04-14 | 吸收沉淀:新建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md`;需求清单 SYNC/LOOP/MAINT/BACKEND 行 | 进行中 |
| 2026-04-24 | 玩值全项目会议三端「打通」验收定义、P0 域锁定、评论/次线抬主线等待问题区作答;纪见 `.cursor/meeting/2026-04-24_玩值全项目进度与三端缺口.md` | 进行中 |
| 2026-04-30 | 会议Soul 三端联调文档与小程序→管理端逆推核对;纪元 `.cursor/meeting/2026-04-30_Soul三端文档与接口对齐.md` | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-24
**最后更新**2026-04-30

View File

@@ -46,9 +46,10 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-04-13 | 会议:按功能同步开发文档;接口/迁移变更须标路由组与 P0/P1文档待补齐 | 进行中 |
| 2026-04-14 | 吸收沉淀:`需求汇总.md` 增加 BACKEND-P0 行;接口说明与迁移落文档 | 进行中 |
| 2026-04-15 | 业务补齐:`mentor_consultation` 支付计价/回调/权益;`CKBJoin` investor 显式失败;`getStandardPrice` 增加 buyerUserID | 已完成 |
| 2026-04-30 | 会议Soul 三端路由组隔离与玩值 `/api/app` 区分写入 `开发文档/Soul/`soul-api 检出后补充路由矩阵与 smoke | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-15
**最后更新**2026-04-30

View File

@@ -52,9 +52,10 @@
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolutionLOOP 需求行 | 进行中 |
| 2026-04-15 | 业务补齐:`app.js``err.response`;导师详情微信支付;找伙伴 join 失败展示后端文案 | 已完成 |
| 2026-04-30 | 会议Soul 三端文档与路由契约baseUrl/`/api/miniprogram/*`/合法域名对齐见 `开发文档/Soul/三端路由与联调说明.md`Soul 源码需单独检出后方可改接口 | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-15
**最后更新**2026-04-30

View File

@@ -40,7 +40,8 @@
| 2026-04-14 | 吸收沉淀LOOP 需求与逆推清单纳入回归裁剪依据 | 进行中 |
| 2026-04-24 | 玩值全项目会议P0 联调+回归(点卡/订单/直播/认证、环境检查wz-api 端口与新构建);纪见 `.cursor/meeting/2026-04-24_玩值全项目进度与三端缺口.md` | 进行中 |
| 2026-04-24 | 玩值 P0 可重复步骤:`scripts/test/wz-playhub-p0.md`(点卡/直播/管理端核对 + `go build ./cmd/server` | 进行中 |
| 2026-04-30 | 会议Soul 三端 smoke路径前缀负例、`/api/miniprogram` 与 admin/db 分离);文档 `开发文档/Soul/三端路由与联调说明.md` | 进行中 |
---
**最后更新**2026-04-24
**最后更新**2026-04-30

View File

@@ -22,5 +22,6 @@
| 2026-04-27 | 三端迁移完成度会议 | 纪要 `meeting/2026-04-27_三端迁移完成度与文档同步.md`;结论「主迁移完成、全量对齐 ongoing」看板/三端清单/日志/团队与玩值 evolution 已同步 |
| 2026-04-28 | wz-admin 大屏 API 补齐 | `ScreenViewPage``GET /api/screen/data/dashboard`(失败回退);`wanzhiAdmin.ts``fetchScreenDashboardStats`wz-admin build 通过;看板与 2026-04-28 日志同步 |
| 2026-04-28 | wz-admin 实时区补齐 | `ScreenViewPage``GET /api/screen/streamers`;新增营收 TOP5 与城市分布 Top5`wanzhiAdmin.ts``fetchScreenStreamers`build 通过并同步看板 |
| 2026-04-30 | **new/wz-appC 端)** | 多块页面去 mock、接 catalog/真实 `/api/app/*`**用户可见文案**去除接口路径/Mongo/wz-api 等Skill `wz-app-dev` §7、`wanzhi-project-boundary` 补充;经验 `玩值移动端工程师/evolution/2026-04-30-wz-app目录对接与用户可见文案.md` |
最后更新2026-04-28
最后更新2026-04-30

View File

@@ -50,11 +50,12 @@
| 2026-04-02 | 会议工作进度与需求同步UsersPage 待接口字段就绪后联调deploy 文档基址与后端对齐 | 进行中 |
| 2026-04-13 | 会议:按功能同步开发文档;页面粒度记 admin/db 依赖;类型债排期入文档 | 进行中 |
| 2026-04-14 | 吸收沉淀MAINT-ADMIN-TS 入需求表;配置入口与运营与变更联动 | 进行中 |
| 2026-04-30 | 会议Soul 文档《逆推表》核对小程序模块与管理端入口;`VITE_API_BASE_URL``/api/admin` `/api/db` 约定见 `开发文档/Soul/三端路由与联调说明.md` | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-14
**最后更新**2026-04-30
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -20,7 +20,7 @@
- **玩值 API**`skills/wz-api-dev/SKILL.md``new/wz-api`、玩值后端
- **玩值管理端**`skills/wz-admin-dev/SKILL.md``new/wz-admin`
- **玩值 C 端 / 移动Vite+React in `new/wz-app`**`skills/wz-app-dev/SKILL.md`**`new/wz-app` 为交付主线**`old/app` 仅对照
- **玩值移动端 / C Vite+React in `new/wz-app`**`skills/wz-app-dev/SKILL.md`**`new/wz-app` 为交付主线****不叫「微信小程序」**`old/app` 仅对照
- Soul 小程序(归档期仍维护 `miniprogram/` 时):`skills/miniprogram-dev/SKILL.md`
- Soul 管理端(`soul-admin/``skills/admin-dev/SKILL.md`
- Soul 后端(`soul-api/``skills/api-dev/SKILL.md`

View File

@@ -1,7 +1,9 @@
# 玩值移动端工程师(玩值 C 端React / Next
# 玩值移动端工程师(`new/wz-app`Vite + React
- **绑定目录(主线)**`old/app/``old/components/` 等玩值 **C 端**代码
- **称谓**:本角色负责 **玩值用户侧移动端 / H5 C 端****不叫「微信小程序」或「Soul 小程序智能体」**;后者指归档内的 **Soul `miniprogram/`**
- **绑定目录(主线)****`new/wz-app/`**(唯一新功能交付)
- **对照/考古**`old/app/``old/components/` — 仅核对漏页、漏接口,非真源
- **主 Skill**`.cursor/skills/wz-app-dev/SKILL.md`
- **API 前缀**`/api/app/*`(见 `开发文档/玩值/迁移-API前缀决议.md`
负责 Next+React 页面、与 wz-api 的 C 端契约;不维护微信原生 `miniprogram/`**`new/wz-app`uni已归档勿作为新功能主落点**(见 `new/wz-app/README.md`)。
不维护 Soul 微信原生 `miniprogram/`(另有「小程序开发工程师」归档角色)。

View File

@@ -0,0 +1,48 @@
# 2026-04-30 — wz-app 目录接口去 mock 与用户可见文案规范
## 场景
玩值 C 端 `new/wz-app` 从硬编码/mock 清单迁到 **`/api/app/*`** 真实数据(与 `wz-api` Mongo 目录只读口径一致);随后发现 **空态/副标题/Tost** 中写了 `GET /api/...`、Mongo 集合名、`wz-api` 等,**会直接出现在用户界面**,体验与合规不达标。
## 结论 / 做法
### 1. 数据来源与封装
- **目录只读**:统一走 **`src/api/catalog.ts`**`skipAuth: true` 的列表/详情:`guilds``venues``products``content/items``stars``game-accounts``leveling/products``party/rooms` 等),与后端 `playhub_*_wz_app_*` 注册路由对齐。
- **已有专项封装**:主播列表 `streamers.ts`;游戏分区 `games.ts`;点卡 `mall.ts`。**新 C 口子先查对照表再在 `catalog` 或独立模块补齐**,避免页面内手写 `fetch`
### 2. 移除 mock 的边界
- **能接库的页**guild/venue/mall/companion/coach/course/star部分/planet 派对 Tab/派对详情/专区/星球匹配等,优先 **加载态 + 空态**,不要保留假卡片当「演示」。
- **尚无 C 口的域**:车队列表、部分社交流等用 **简短用户向说明**(如「暂未开放」「敬请期待」),**不写**「无 C 端列表接口」一类开发话术。
- **主播工作台 / 大屏 `/screen`**:仍可能偏运营,但也要避免把 **路径字符串**、**栈名** 摆在标题或正文里。
### 3. 用户可见文案(强制)
以下 **禁止** 出现在用户在 `new/wz-app` 能看到的任何 UI含 Toast `description`、页面脚注、大屏说明)——接口说明应写在 **`开发文档/玩值/迁移-API对照表.md`** 或代码注释:
- HTTP 方法与路径字面量:`GET /api/app/...``/api/admin/...`、反引号路径
- 内部实现词:`Mongo``collection``wz-api``contentItems``ext_id``埋点``streamerId`(除类型安全的代码字段名外)、`Vite 代理``SKU`/`福禄` 等供应链内部词
- 空态:**用产品语言**,例如「暂时没有可展示的点卡,请稍后再来」,**不要** 「暂无数据 · 接口 xxx」
**允许**`npm run build`、PR 描述、`.md` 文档、**仅开发者可见**的注释。
### 4. 全局报错
- `request.ts` 等在 **502/非 JSON** 时抛给用户看的 Error 文案仍可能偏技术;后续若产品上要求「一律温和提示」,再在请求层统一映射(本次未改层,规则先记在 Skill
### 5. Gotchas
- **TS**:空态重置 `messages` 等状态时,若有 `system`/`user` 联合类型,`useState` 需显式 **`ChatMsg[]`**,否则会收窄类型报错。
- **地图/列表**:传给弹层的参数用 **VenueRow**`{ name, lat, lng }` 分清,不要将「城市默认定位」伪造成 `VenueRow`
- **索引号**`guild.id` 等对用户可称「公会编号」,避免写 **Mongo ext_id**
## 升级 Skill / 规则
- `.cursor/skills/wz-app-dev/SKILL.md`:新增 **「用户可见文案」**专节。
- `.cursor/rules/wanzhi-project-boundary.mdc`**玩值 C 端**界面禁止暴露接口路径与技术栈内幕(与 Skill 呼应)。
## 待续
- 剩余页面若有 mock装扮、路演、活动等按需接 API 或统一空壳。
- 可选:`request.ts` 对用户可见错误做一次 **文案脱敏映射**

View File

@@ -2,6 +2,7 @@
| 日期 | 主题 |
|------|------|
| 2026-04-30 | [wz-app 目录对接与用户可见文案规范](2026-04-30-wz-app目录对接与用户可见文案.md) |
| 2026-04-27 | [三端迁移完成度会议](2026-04-27-三端迁移完成度会议.md) |
| 2026-04-24 | [全项目会议与主次线](2026-04-24-全项目会议与主次线.md) |
| 2026-04-24 | [wz-app 占位补齐与 wz-api 对接](2026-04-24-wz-app占位补齐与wz-api对接.md) |

View File

@@ -66,6 +66,8 @@ LOG_EVOLUTION = SCRIPTS / "进化日志.md"
TEMPLATE_EXPERIENCE = SCRIPTS / "经验模板.md"
# ========== 角色 → agent 目录名映射 ==========
# 玩值用户侧:**玩值移动端工程师** + `new/wz-app`。勿把该角色称为「微信小程序」;
# 「小程序」「小程序开发工程师」映射指 Soul `miniprogram/`(归档目录)。
ROLE_TO_AGENT = {
# 管理层
"老板分身": "老板分身",

View File

@@ -0,0 +1,107 @@
# 会议纪要 - 2026-04-30 | Soul 三端文档同步与小程序—管理端—API 对齐
> 本文件由**助理橙子**在会议结束后整理。
---
## 基本信息
- **时间**2026-04-30 12:00
- **议题**:根据现有功能更新开发文档;按小程序能力推测管理端是否齐全合理;小程序、管理端与 API 对接
- **触发方式**:开个会 / 团队会议
- **参与角色**:乘风(主持)、产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员、助理橙子(书记)
---
## 各角色发言
### 【产品经理】
验收仍以 **C 端真实路径 + 数据来源可配置** 为准。从小程序已有的登录、阅读、VIP、分销、提现、代付、找伙伴/留资等板块看,运营侧应对应 **用户可查、订单可核、内容与 VIP 可调、提现可审、链接与推广可配**;若仅有 C 端接口无后台入口,须在需求表标「不可运营」并降级优先级。
### 【后端开发】
Soul 必须坚持 **miniprogram / admin / db** 三组分离;接口变更要落文档(路径、方法、关键字段、错误码)。当前 **部分工作区未检出 soul-api**,无法在本会话中提交路由级补丁;检出后应以 `router` 注册表与现有 handler 为准做一次 **路径清单与调用方矩阵**
### 【管理端开发工程师】
管理端功能在历史稳定版中对 **用户、订单、提现、VIP、章节内容、分销、代付、推广设置、链接/CKB** 等覆盖面较完整。风险提示:**小程序新加的只读展示**若依赖新字段,管理端列表/表单未同步则运营不可见——属于「不全」而非「不合理」。需在内容页、用户详情、订单/分销页保持字段与后端一致。
### 【小程序开发工程师】
所有请求前缀 **`/api/miniprogram/*`**`app.request` 的 baseUrl 必须与微信合法域名、soul-api 部署一致。**分享、scene、代付跳转、singlePage** 等需在联调文档或可验收清单中写明,避免仅接接口未报验。
### 【测试人员】
三端联调以 **文档 + 网络面板** 为真源:小程序网络里不得出现 `/api/admin`;管理端不得出现 `/api/miniprogram`。部署变更后优先跑 **登录—列表—一条写—后台核对** 的 smoke融合与回归范围以项目索引与 `开发文档/Soul/` 为准。
---
## 讨论过程
- **【管理端开发工程师】回复【产品经理】**:「齐全」定义为 **每个 C 端资金/权益相关动作** 在后台可追溯或可配置;纯展示型页面可只需只读接口,不强制加管理页。
- **【后端开发】回复【小程序开发工程师】**:预览内容、提现错误码、代付受益人等规则以后端为准,文档中注明 **errorCode / needBindWechat** 等分支,避免前端猜状态。
- **【乘风】**:本仓库当前焦点在 **玩值 `new/wz-*`**Soul 三端需 **单独检出** 才能改代码;文档层先统一 **契约与自检清单**,避免与玩值 `/api/app` 混淆。
---
## 会议决议
1. **新增/更新开发文档**:在仓库 `开发文档/Soul/` 下建立 **README****《三端路由与联调说明》**,写清路由组、环境变量、小程序↔管理端能力对照表、联调 smoke、与玩值 API 前缀区别。
2. **管理端齐全性**:在 Soul 历史能力范围内 **整体合理且较齐全**;缺口模式统一记为 **「C 端已接、后台无入口/无字段」**,由产品决定是否补管理端。
3. **对接动作**Soul 栈检出后,分别配置 **小程序 baseUrl**、**soul-admin 的 `VITE_API_BASE_URL`**、**soul-api 对外域名与白名单**;本仓库内 **玩值三端** 继续以 `new/wz-api` 路由与 `VITE_API_BASE_URL` 为准,**不与 Soul 混前缀**。
4. **待确认项**:各环境实际 API 域名、是否合并工作区、router/ossConfig 等历史待办仍按后端索引跟进。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 全员 | 检出或挂载 `miniprogram``soul-admin``soul-api` 后按《三端路由与联调说明》做 smoke | 高 | 下次发版前 |
| 后端开发 | soul-api 路由与调用方矩阵文档化(可选:导出 OpenAPI 或维护 md 表) | 中 | 2026-05-15 |
| 管理端开发工程师 | 对照《逆推表》核对近期小程序新功能是否缺列表/配置页 | 中 | 2026-05-15 |
| 小程序开发工程师 | 核对 `app.request` baseUrl 与各环境合法域名 | 高 | 持续 |
| 测试人员 | 增加「跨路径违规」负例到回归清单 | 中 | 2026-05-15 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 当前团队使用的 soul-api 生产/预发基址与证书域名是否已在三端配置统一? | 后端开发 + 运维 | (待补充) |
| 2 | Soul 三端是否计划与玩值仓合并为 monorepo还是保持独立仓库 | 产品经理 | (待补充) |
| 3 | 小程序最近一次发版新增页面是否已全部在《以界面定需求》或索引中登记? | 产品经理 + 小程序开发工程师 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
-**小程序模块 → 管理端配套** 表格做缺口扫描,比纯凭记忆列功能更稳。
### 后端开发
- 文档中强调 **路由组****玩值 `/api/app` 隔离**,减少联调串环境。
### 管理端开发工程师
- 「齐全」= 运营可追溯 + 字段对齐;不等于每个 C 端页都对应一个独立后台页。
### 小程序开发工程师
- baseUrl、合法域名、`err.response` 与后端 errorCode 分支需与联调说明一致。
### 测试人员
- **路径前缀负例** 与 smoke 可快速发现配置错误。
### 团队共享
- 文档位置:`开发文档/Soul/`;未检出 Soul 代码时以 **契约 + 环境** 推进,代码级对接在检出后执行。
---
*会议纪要由助理橙子生成 | 会议收尾已更新 sync-log 与项目索引*

View File

@@ -58,6 +58,7 @@ YYYY-MM-DD_会议主题.md
| 日期 | 主题 | 参与角色 | 文件 |
|------|------|---------|------|
| 2026-04-30 | Soul 三端文档与接口对齐小程序↔管理端↔API 契约、检出与玩值区分) | 产品、后端、管理端、小程序、测试、橙子 | [2026-04-30_Soul三端文档与接口对齐.md](2026-04-30_Soul三端文档与接口对齐.md) |
| 2026-04-27 | 玩值三端迁移完成度与开发文档同步(主迁移完成 / 全量对齐 ongoing | 产品、玩值 API、管理端、移动端、测试、橙子 | [2026-04-27_三端迁移完成度与文档同步.md](2026-04-27_三端迁移完成度与文档同步.md) |
| 2026-04-24 | 玩值全项目进度、三端缺口与收官排期乘风主持P0/P1/P2 | 产品、玩值 API、管理端、移动端、测试、橙子 | [2026-04-24_玩值全项目进度与三端缺口.md](2026-04-24_玩值全项目进度与三端缺口.md) |
| 2026-04-23 | 玩值三端:直播 + 点卡 接口与功能打通 | 玩值 API、管理端、移动端、产品 | [2026-04-23_玩值三端直播点卡打通.md](2026-04-23_玩值三端直播点卡打通.md) |

View File

@@ -21,7 +21,7 @@ alwaysApply: true
| **新版管理端** | **new-soul/soul-admin/** | 新版参考实现,迁移时对照 | soul-api |
| **玩值 API** | **new/wz-api/** | 玩值 Go + Gin + GORM + MySQL | - |
| **玩值管理端** | **new/wz-admin/** | 玩值 React + Vite 管理后台 | wz-api |
| **玩值 C 端(主线)** | **`new/wz-app/`** | Vite + **React**玩值用户侧 **唯一新功能交付** | wz-api `/api/app/*`;见 `new/wz-app/README.md` |
| **玩值移动端(主线)** | **`new/wz-app/`** | Vite + **React**玩值用户侧 **不叫「微信小程序」**,见 `README.md` | wz-api `/api/app/*` |
| **玩值 old C 端(对照)** | **`old/app/` 等** | Next.js 历史实现:**仅对照** 是否漏页/漏接口,**不** 作为新需求真源 | wz-api `/api/app/*`(双轨期可读) |
| **old 非 App** | **old/** 脚本/Mongo 工具等 | 种子与对照,与 wz-api 双轨期按需 | 非 wz 管理端调用的 C 口 |
@@ -29,7 +29,8 @@ alwaysApply: true
## 核心原则
- 小程序只调 `/api/miniprogram/*`;管理端只调 `/api/admin/*`、`/api/db/*`;禁止混用
- **术语****玩值用户侧(`new/wz-app`)称「移动端 / C 端」****不称「小程序」**。**「微信小程序 / 小程序」** 在本仓库默认指 Soul **`miniprogram/`**(微信原生),另有路由与角色,勿与玩值移动端混称
- Soul **微信原生小程序**只调 `/api/miniprogram/*`Soul 管理端只调 `/api/admin/*`、`/api/db/*`**禁止混用**。玩值 C 端只调 **`/api/app/*`**(见 `wanzhi-project-boundary.mdc`)。
- 变更完成必过 soul-change-checklist.mdc聊天中说「变更完成」「检查一下」「准备提交」时主动触发检查。
- **需求即执行 + 零提问**:收到需求后**禁止**列出分析表格再问用户选哪个执行,**禁止**「是否帮你执行」类确认。正确做法:内部定方案 → **直接改代码/跑命令** → 回复结果。缺信息:先查仓库与配置推断;**仅**在验证码、缺失密钥、不可逆删除等无法代劳时极简说明。
- **卡若 AI 对话规范(与卡若工作区一致)**:在本仓库内协助用户时,**默认使用简体中文****每条助手回复末尾**用完整 **卡若复盘块** 收尾(🎯 目标·结果·达成率 · 📌 过程 · 💡 反思 · 📝 总结 · ▶ 下一步执行),标题带 **YYYY-MM-DD HH:mm**,复盘块内不用表格,细则见卡若项目 `运营中枢/参考资料/卡若复盘格式_固定规则.md`(多根工作区时请 Read 该文件。Mongo 同步、飞书 webhook 等以卡若 `.cursor/rules/karuo-ai.mdc` 为准(本仓库 Agent 在能执行脚本时同样执行对话留存脚本)。
@@ -53,7 +54,7 @@ alwaysApply: true
| soul-api/ | 后端开发 | `.cursor/skills/api-dev/SKILL.md` |
| new/wz-api/ | 玩值 API 工程师 | `.cursor/skills/wz-api-dev/SKILL.md` |
| new/wz-admin/ | 玩值管理端工程师 | `.cursor/skills/wz-admin-dev/SKILL.md` |
| **new/wz-app/**Vite+React,玩值 C 端主线 | 玩值移动端工程师 | `.cursor/skills/wz-app-dev/SKILL.md` |
| **`new/wz-app/`**玩值移动端,Vite+React | 玩值移动端工程师 | `.cursor/skills/wz-app-dev/SKILL.md` |
| old/app/、old/components/**对照**,非新功能真源) | 玩值移动端工程师 | `.cursor/skills/wz-app-dev/SKILL.md` |
| 开发文档/玩值/ | 助理橙子 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
| 开发文档/1、需求/、临时需求池/ | 产品经理 | `.cursor/skills/product-manager/SKILL.md` |
@@ -69,8 +70,8 @@ alwaysApply: true
| wz-api、玩值 API、new/wz-api | 玩值 API 工程师 | `.cursor/skills/wz-api-dev/SKILL.md` |
| 管理端、soul-admin、React、后台管理 | 管理端开发工程师 | `.cursor/skills/admin-dev/SKILL.md` |
| wz-admin、玩值管理端、new/wz-admin | 玩值管理端工程师 | `.cursor/skills/wz-admin-dev/SKILL.md` |
| 小程序、miniprogram、C 端、微信小程序 | 小程序开发工程师 | `.cursor/skills/miniprogram-dev/SKILL.md` |
| 玩值移动端、玩值 C 端、**new/wz-app**、Vite、对照 **old/app** | 玩值移动端工程师 | `.cursor/skills/wz-app-dev/SKILL.md` |
| **Soul** 微信小程序、**Soul** 小程序、miniprogram、WXML/WXSS、微信原生**非**玩值) | 小程序开发工程师 | `.cursor/skills/miniprogram-dev/SKILL.md` |
| **玩值**移动端、玩值 C 端、**`new/wz-app`**、Vite+H5、`wz-app`、(对照 **`old/app`** | 玩值移动端工程师 | `.cursor/skills/wz-app-dev/SKILL.md` |
| 产品、需求、验收、排期、需求文档 | 产品经理 | `.cursor/skills/product-manager/SKILL.md` |
| 测试、测试用例、回归测试、功能测试、QA | 测试人员 | `.cursor/skills/testing/SKILL.md` |

View File

@@ -16,16 +16,18 @@ alwaysApply: false
|--------|------|------|----------|
| API | `new/wz-api/` | Go + Gin + GORM + MySQL | - |
| 管理端 | `new/wz-admin/` | React + Vite 管理后台 | wz-api `/api/admin/*`、`/api/db/*` |
| **玩值 C 端(主线)** | **`new/wz-app/`** | **Vite + React**:玩值用户侧 H5/壳 **唯一交付目录****非** uni-app无根 `pages.json`),勿用 HBuilderX uni 编译;见 `README.md` | wz-api **`/api/app/*`**(禁止直连 admin 前缀Vite 代理见该目录配置) |
| **玩值移动端(主线)** | **`new/wz-app/`** | **Vite + React**:玩值用户侧 **不叫「微信小程序/小程序」****唯一新功能交付**;见 `README.md` | wz-api **`/api/app/*`**(禁止直连 admin 前缀Vite 代理见该目录配置) |
| 对照/考古(不扩新功能) | **`old/app/`** 等 | **Next.js** 历史 C 端:仅用于 **与 `new/wz-app` 对照** 是否漏页、漏接口;新需求 **不写** 于此 | 可读 wz-api**不得**作为新功能真源 |
| 原始/对照 | 其它 `old/**` 脚本与 Mongo 工具等 | 种子与迁移脚本对照 | 与 wz-api 双轨迁移期按需 |
## 核心原则
- **玩值 C 端**只调 `/api/app/*`(及文档规定的 `/api/public/*`、`/api/screen/*` 若适用);**管理端**只调 `/api/admin/*`、`/api/db/*`**禁止混用**
- **称谓**:编辑 **`new/wz-app`** 时角色为 **玩值移动端工程师**;对外描述用 **移动端 / H5+C 端****不叫「微信小程序」或「小程序」**(二者在本仓库留给 Soul **`miniprogram/`**
- **玩值 C 端(`new/wz-app`**只调 `/api/app/*`(及文档规定的 `/api/public/*`、`/api/screen/*` 若适用);**管理端**只调 `/api/admin/*`、`/api/db/*`**禁止混用**。
- 契约变更必须同步 **`开发文档/玩值/迁移-API对照表.md`** 与 **`进度-看板.md`**。
- 变更完成走 **`.cursor/skills/change-checklist/SKILL.md`**玩值三端wz-api、wz-admin、**`new/wz-app` C 端**`old/` 仅对照)。
- **需求即执行 + 零提问**:缺信息先查 **`new/`** 与开发文档,**`old/`** 仅作漏项核对;仅验证码、密钥、不可逆删除等不可代劳时极简说明。
- **玩值 C 端用户可见文案**`new/wz-app` 的页面/Tost 等面向用户的文字 **禁止** 暴露 **`/api/...` 路径**、**`Mongo`/库表/collection**、**`wz-api`** 及同类开发运维术语(详细清单见 **`.cursor/skills/wz-app-dev/SKILL.md` §7**);契约与排障说明写在 **`开发文档/玩值/迁移-API对照表.md`** 或注释。
## 角色推断与 Skill 加载(必须 Read 完整 SKILL
@@ -35,7 +37,7 @@ alwaysApply: false
|----------|----------|----------------------|
| `new/wz-api/` | 玩值 API 工程师 | `.cursor/skills/wz-api-dev/SKILL.md` |
| `new/wz-admin/` | 玩值管理端工程师 | `.cursor/skills/wz-admin-dev/SKILL.md` |
| **`new/wz-app/`** | **玩值 C 端主线**Vite+React**非** uni、**勿** HBuilderX uni 编译 | `.cursor/skills/wz-app-dev/SKILL.md` |
| **`new/wz-app/`** | **玩值移动端主线**Vite+React**不叫微信小程序****非** uni、**勿** HBuilderX uni 编译 | `.cursor/skills/wz-app-dev/SKILL.md` |
| `old/app/`、`old/components/` | **对照/考古**;新功能 **不** 在此实现 | `.cursor/skills/wz-app-dev/SKILL.md`(读 Skill 知边界;优先改 `new/wz-app` |
| `开发文档/玩值/` | 助理橙子(文档) | `.cursor/skills/assistant-doc-sync/SKILL.md` |
@@ -45,6 +47,6 @@ alwaysApply: false
|--------|-----------|
| wz-api、玩值 API、new/wz-api、Go、Gin、玩值后端 | `.cursor/skills/wz-api-dev/SKILL.md` |
| wz-admin、玩值管理端、new/wz-admin | `.cursor/skills/wz-admin-dev/SKILL.md` |
| 玩值移动端、玩值 C 端、Next / Vite、**`new/wz-app`**`old/app`**wz-app 角色** | `.cursor/skills/wz-app-dev/SKILL.md` |
| **玩值**移动端、`new/wz-app`、wz-app、玩值 C 端Vite/H5、Next**仅** **`old/app`** 对照) | `.cursor/skills/wz-app-dev/SKILL.md` |
| 跨端、迁移闭环、垂直切片 | `.cursor/skills/role-flow-control/SKILL.md` |
| old 对照、Next 迁移、路由矩阵 | `开发文档/玩值/迁移-API对照表.md` + `.cursor/skills/next-split/SKILL.md` |

View File

@@ -34,7 +34,7 @@ alwaysApply: true
1. 从对话中提取:问题描述、解决过程、关键决策、可提炼的规则方向
2. **推断目标角色**(可多选):
- **`new/wz-app` 玩值 C Vite+React主线**→**玩值移动端工程师**`old/app` 仅对照/考古,经验可写但须注明**非交付真源**`miniprogram/` 微信原生→**小程序开发工程师**(经验归档目录)
- **`new/wz-app` 玩值移动Vite+React主线**→**玩值移动端工程师****不叫「小程序智能体」**;玩值移动端 **≠** 微信原生小程序)`old/app` 仅对照/考古,经验可写但须注明**非交付真源**Soul **`miniprogram/`** 微信原生→**小程序开发工程师**(经验归档目录)
- `new/wz-admin`→**玩值管理端工程师**`soul-admin/`→**管理端开发工程师**(归档)
- `new/wz-api`→**玩值 API 工程师**`soul-api/`→**后端工程师**(归档)
- 产品/需求/config→**开发助理**(新经验默认)或按需读归档「产品经理」
@@ -50,9 +50,9 @@ alwaysApply: true
| 推断场景 | target_roles |
|----------|--------------|
| **`new/wz-app` 玩值 C 端、Vite/React主线** | `["玩值移动端工程师"]` |
| `old/app` 玩值 C 端(**对照**非新功能真源) | `["玩值移动端工程师"]` |
| 小程序/WXML/微信Soul | `["小程序开发工程师"]` |
| **`new/wz-app` 玩值移动端、Vite/React主线口语「C 端用户侧」** | `["玩值移动端工程师"]` |
| `old/app` 玩值对照**非新功能真源** | `["玩值移动端工程师"]` |
| Soul 微信原生、WXML/**`miniprogram/`****非 `new/wz-app`** | `["小程序开发工程师"]` |
| `new/wz-admin` | `["玩值管理端工程师"]` |
| 管理端/React/adminSoul | `["管理端开发工程师"]` |
| `new/wz-api` | `["玩值API工程师"]` |

View File

@@ -6,6 +6,12 @@
---
## 2026-04-30
- 会议收尾Soul 三端文档与接口对齐;新增 `开发文档/Soul/README.md``三端路由与联调说明.md`;纪要 `meeting/2026-04-30_Soul三端文档与接口对齐.md`;团队+开发助理 evolution后端/管理端/小程序/测试索引各一行(本行)
- **术语**:玩值 `new/wz-app` 称「移动端」不称「小程序」;已同步 soul-project-boundary、wanzhi boundary、wz-app Skill、老板分身、`玩值移动端工程师/README`、paths 注释、change-checklist、智能体总索引、`agent/开发助理/evolution/2026-04-30.md`
- **吸收经验**:玩值移动端 + 开发助理 — wz-app 目录对接 `/api/app`、去 mock、**C 端 UI 禁止技术向文案**`玩值移动端工程师/evolution/2026-04-30-wz-app目录对接与用户可见文案.md``wz-app-dev` §7`wanzhi-project-boundary` 一条,经验清单/项目索引玩值/sync-log
## 2026-04-28
- 玩值继续落地wz-admin `ScreenViewPage``GET /api/screen/data/dashboard`(失败回退展示值),`wanzhiAdmin.ts` 新增 `fetchScreenDashboardStats``开发文档/玩值/进度-看板.md``2026-04-28-日志.md`、项目索引玩值 已同步

View File

@@ -1,12 +1,12 @@
---
name: soul-change-check
description: Soul/玩值变更关联检查。miniprogram/soul-admin/soul-api 或 new/wz-api、new/wz-admin、new/wz-app 玩值 C 端 任一变更后必过。三端关联、防漏改、字段/接口同步。Use when 变更完成, 改完检查, 关联层, 防漏改.
description: Soul/玩值变更关联检查。miniprogram/soul-admin/soul-api 或玩值 new/wz-api、new/wz-admin、new/wz-app(移动端)任一变更后必过。「小程序」= Soul miniprogram玩值移动不叫小程序。
---
# Soul 创业派对 - 变更关联检查 Skill
当你在 **miniprogram**、**soul-admin**、**soul-api**,或 **玩值****`new/wz-api`**、**`new/wz-admin`**、**玩值 C 端 `old/app` 等** 中做**功能新增、字段/接口修改、优化**时,使用本 Skill 做「关联层检查」,减少漏改。
玩值三端前缀:**管理端** `/api/admin/*``/api/db/*`**C 端(`new/wz-app`** `/api/app/*`(禁止混用)。**`old/app`** 仅对照、非新功能真源。每处接口变更须同步 **`开发文档/玩值/迁移-API对照表.md`** 与 **`进度-看板.md`**(若该功能已登记)。
玩值三端前缀:**管理端** `/api/admin/*``/api/db/*`**移动端(`new/wz-app`** 使用 **`/api/app/*`**(本条线用户侧**不叫「微信小程序」**。Soul **微信小程序**仍为 **`miniprogram/`** + **`/api/miniprogram/*`**。**`old/app`** 仅对照、非新功能真源。每处接口变更须同步 **`开发文档/玩值/迁移-API对照表.md`** 与 **`进度-看板.md`**(若该功能已登记)。
---

View File

@@ -1,9 +1,9 @@
---
name: wz-app-dev
description: Trigger when 编辑 new/wz-app 玩值 C 端、或对照 old/app 时必读。新功能只在 new/wz-app + wz-apiold 仅核对遗漏。仅调 wz-api /api/app/*。勿用 HBuilderX 将 new/wz-app 当 uni 编译
description: Trigger when 编辑 new/wz-app玩值移动端 / H5+Vite React或对照 old/app 时必读。本角色条线称「移动端」,不称「微信小程序/小程序」。新功能只在 new/wz-app + wz-api仅调 /api/app/*。勿用 HBuilderX 当 uni。
---
# 玩值 - 移动端 / C 端开发 Skill`new/wz-app` 主线 + `old/` 对照
# 玩值移动端开发 Skill`new/wz-app`,不称小程序
**交付真源**:玩值用户侧功能 **一律在 `new/wz-app/`Vite + React****`new/wz-api`** 上实现;**`old/app/``old/components/`** 为 **Next 历史 C 端****仅用于对照**「是否漏页面、漏接口、漏交互」,**不在此写新业务能力**(紧急修复线上等例外由负责人定)。
@@ -48,7 +48,8 @@ description: Trigger when 编辑 new/wz-app 玩值 C 端、或对照 old/app 时
## 5. `new/wz-app` 能力清单(持续与 wz-api 对齐)
- **点卡/商城**`src/api/mall.ts``GET /api/app/mall/point-cards``POST /api/app/orders/point-card` 等;页面如 `MallPointsPage`
- **Mongo 目录只读anonymous OK**`src/api/catalog.ts`guilds / venues / products / content/items / stars / game-accounts / leveling / party rooms 等与 wz-api 注册的 **`/api/app/*`** 只读一览;列表页优先封装在此,避免页面内散落硬编码数组
- **点卡/商城**`src/api/mall.ts``GET /api/app/mall/point-cards``POST /api/app/orders/point-card`**订单/钱包**`src/api/orders.ts``GET /api/app/orders/me``POST /api/app/wallet/recharge``POST /api/app/wallet/consume``AppProvider.pay` 已登录走 consume页面如 `MallPointsPage``OrdersPage``RechargePage`
- **直播**`src/api/live.ts``live/context``comments``gift``LivePage` 等。
- **大屏**:如 **`/screen`** — `GET /api/screen/data/dashboard`;可并联 **`GET /api/app/health`**、**`GET /api/public/build-info`**。
- **旅程/埋点****`POST /api/app/analytics/journey-events`**`src/lib/journey.ts` 等)。
@@ -62,3 +63,25 @@ description: Trigger when 编辑 new/wz-app 玩值 C 端、或对照 old/app 时
- **漏项核对**:新需求在 `new/wz-app` 落地后,可用 **`old/app` 同路由** 扫一眼是否少入口、少字段、少错误态。
- **禁止长期双轨**:同一业务不要在 `old``new/wz-app` 各写一套;契约以 **wz-api + 文档** 为真源,**只维护 `new/wz-app` 调用侧**。
---
## 7. 用户可见文案(红线 · 产品与合规)
适用于 **`new/wz-app`** 所有用户能看见的文案:**页面正文、标题、空态、按钮说明、Toast 标题与描述** 等。「日志 / PR / `.md` / 代码注释」可保留技术表述。
### 禁止出现在 UI 的字面内容
- **接口路径与方法**:如 `GET /api/app/...``/api/admin/...``/api/screen/...`,以及反引号包裹的路径。
- **内部实现称呼**:如 `Mongo``wz-api``collection``contentItems``ext_id``SKU`、供应链专有名词、`埋点``streamerId`(面向用户的句子中)、`Vite`、代理、`HBuilderX` 等开发与运维术语。
- **「开发向空态」**:例如「暂无数据 · 接口 xxx」「请在 Mongo xxx 维护」——应改为用户语言,如「暂时没有可展示的点卡,请稍后再来」「敬请期待」。
### 应当怎么做
- 接口说明写在 **`开发文档/玩值/迁移-API对照表.md`** 或代码注释,**不写进** C 端界面。
- 无数据的模块:**加载态 + 简短业务说明**,可附带站内跳转(如「前往点卡馆」);与后端能力相关的排障留给客服/后台,不向终端暴露路径。
- 若需串联 **多条 API** 或对账说明,用词保持 **订单/上架/客服** 等产品词,不出现库表名。
### 经验与案例
详见 **`.cursor/agent/玩值移动端工程师/evolution/2026-04-30-wz-app目录对接与用户可见文案.md`**(内含目录 mock Removal、catalog 封装、Toast/大屏注意点)。

View File

@@ -164,8 +164,9 @@ export async function fetchFinance(): Promise<FinancePayload | null> {
return r.success && r.data ? r.data : null
}
export async function fetchUserValueSummary(): Promise<Record<string, unknown> | null> {
const r = await get<WanzhiEnvelope<Record<string, unknown>>>(`${WZ}/user-value-summary`)
export async function fetchUserValueSummary(userId?: string): Promise<Record<string, unknown> | null> {
const q = userId ? `?userId=${encodeURIComponent(userId)}` : ''
const r = await get<WanzhiEnvelope<Record<string, unknown>>>(`${WZ}/user-value-summary${q}`)
return r.success && r.data ? r.data : null
}

View File

@@ -20,12 +20,12 @@ export default function UserValuePage() {
setLoading(true)
try {
if (adminUseApi()) {
const json = await fetchUserValueSummary()
const json = await fetchUserValueSummary(userId.trim())
if (!json) {
setErr('接口返回为空')
return
}
setData({ ...json, queriedUserId: userId.trim() })
setData(json)
} else {
setData({ ...MOCK_USER_VALUE_SUMMARY, userId: userId.trim() })
}
@@ -41,7 +41,8 @@ export default function UserValuePage() {
<div>
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
GET /api/admin/wz/user-value-summary ID
GET /api/admin/wz/user-value-summary ID <code className="text-xs">userId</code>{' '}
</p>
</div>

View File

@@ -12,10 +12,12 @@ APP_VERSION=0.0.0
SKIP_PROD_SECRET_CHECK=staging
# 必填MongoDB唯一业务库
MONGODB_URI=mongodb://wzdj:NR6aLyyEaZKWZXky@43.139.27.93:27017/wzdj?authSource=wzdj
MONGODB_URI=mongodb://wzdj:NR6aLyyEaZKWZXky@127.0.0.1:27017/wzdj?authSource=wzdj
# 已废弃Mongo-only原 MySQL可留空或删除
# DB_DSN=
# 玩值 API 公网根地址(无尾斜杠;回调与 H5 依赖)
API_BASE_URL=https://wzapi.quwanzhi.com

View File

@@ -3,4 +3,7 @@ log/
wz-api
server.exe
wz-api.exe
deploy/
deploy/
# mongodump 持久目录(可能含业务数据,勿提交)
build/mongo_dump_wz/

View File

@@ -304,6 +304,18 @@ func playhubRecentOrdersSlice(orders []playhubUnifiedOrder) []gin.H {
return out
}
// playhubDashboardEmpty 与前端 DashboardData 同形Mongo 不可读或未接入时使用,不向管理端伪造业务规模。
func playhubDashboardEmpty() gin.H {
return gin.H{
"stats": gin.H{
"totalUsers": 0, "totalStreamers": 0, "totalOrders": 0, "totalRevenue": 0.0,
"todayNewUsers": 0, "todayOrders": 0, "activeStreamers": 0, "pendingPawns": 0,
},
"recentUsers": []gin.H{},
"recentOrders": []gin.H{},
}
}
func playhubScreenDashboardStats() gin.H {
u, _ := wzstore.List(wzstore.CollUsers, 5000)
s, _ := wzstore.List(wzstore.CollStreamers, 2000)

View File

@@ -53,7 +53,7 @@ func WzAdminUsers(c *gin.Context) {
return
}
if len(rows) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubMockUsers()})
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
var out []interface{}
@@ -63,12 +63,6 @@ func WzAdminUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
func playhubMockUsers() []gin.H {
return []gin.H{
{"id": "1", "name": "张伟", "phone": "13812345678", "status": "active", "isVip": true, "balance": 1280, "createdAt": time.Now().UTC().Format(time.RFC3339)},
}
}
// WzAdminUserGet GET /api/admin/wz/users/:id
func WzAdminUserGet(c *gin.Context) { wzAdminJSONGet(c, wzstore.CollUsers, c.Param("id")) }
@@ -147,8 +141,12 @@ func WzAdminStreamers(c *gin.Context) {
return
}
rows, err := wzstore.List(wzstore.CollStreamers, 500)
if err != nil || len(rows) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubLytiaoSeed()})
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "data": []interface{}{}, "message": err.Error()})
return
}
if len(rows) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
out := make([]interface{}, 0, len(rows))
@@ -171,17 +169,6 @@ func WzAdminStreamers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
func playhubLytiaoSeed() []gin.H {
now := time.Now().UTC().Format(time.RFC3339)
return []gin.H{{
"id": "lytiao-0-demo", "name": "示例导师", "title": "王者荣耀", "intro": "占位种子",
"game": "王者荣耀", "gameId": 1, "tags": []string{"电竞"}, "avatar": "", "coverImage": "",
"level": 20, "fans": 100000, "hotValue": 0, "isLive": false, "totalGifts": 0, "totalIncome": 0,
"commissionRate": 0.7, "status": "approved", "source": "lytiao", "city": "深圳",
"bindingCount": 0, "guildName": nil, "createdAt": now, "updatedAt": now,
}}
}
// WzAdminStreamerGet GET
func WzAdminStreamerGet(c *gin.Context) {
id := c.Param("id")
@@ -251,26 +238,15 @@ func WzAdminSeedScreen(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "seed-screen 占位:可在数据库写入 streamers 后重试"})
}
// WzAdminDashboard GET
// WzAdminDashboard GET — 仅从 Mongo 聚合;库不可用时返回「全零」结构,不注入演示数据。
func WzAdminDashboard(c *gin.Context) {
if _, err := wzstore.List(wzstore.CollUsers, 1); err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardMock()})
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardEmpty()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardPayload()})
}
func playhubDashboardMock() gin.H {
return gin.H{
"stats": gin.H{
"totalUsers": 2847, "totalStreamers": 36, "totalOrders": 12568, "totalRevenue": 386520,
"todayNewUsers": 23, "todayOrders": 156, "activeStreamers": 12, "pendingPawns": 5,
},
"recentUsers": []gin.H{{"id": "1", "name": "张伟", "phone": "138****5678", "createdAt": time.Now().UTC().Format(time.RFC3339)}},
"recentOrders": []gin.H{{"orderNo": "WZ001", "itemName": "示例订单", "amount": 99, "createdAt": time.Now().UTC().Format(time.RFC3339)}},
}
}
// WzAdminFinance GET
func WzAdminFinance(c *gin.Context) {
tx, _ := wzstore.List(wzstore.CollTransactions, 300)
@@ -335,9 +311,62 @@ func WzAdminOrderStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// WzAdminUserValueSummary GET
// WzAdminUserValueSummary GET ?userId= — 无 userId 时返回平台量级;有 userId 时返回该用户文档 + 聚合统计(基于统一订单与用户旅程采样)。
func WzAdminUserValueSummary(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "占位汇总:接入业务指标后可替换", "stats": gin.H{}}})
uid := strings.TrimSpace(c.Query("userId"))
if uid == "" {
nu, _ := wzstore.Count(wzstore.CollUsers)
ns, _ := wzstore.Count(wzstore.CollStreamers)
nOrders := len(playhubCollectUnifiedOrders())
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"scope": "platform",
"stats": gin.H{
"registeredUsers": nu, "streamers": ns, "unifiedOrderRows": int64(nOrders),
},
"hint": "附带查询参数 userId=<users.ext_id> 可查看单用户在库摘要",
}})
return
}
doc, err := wzstore.Get(wzstore.CollUsers, uid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "用户不存在"})
return
}
orders := playhubCollectUnifiedOrders()
var nUserOrders int
var paidLikeSum float64
for _, o := range orders {
if o.UserID != uid {
continue
}
nUserOrders++
switch strings.ToLower(o.Status) {
case "paid", "completed", "delivered":
paidLikeSum += o.Amount
}
}
bindCnt, _ := wzstore.CountActiveBindingsForUser(uid)
jrows, _ := wzstore.List(wzstore.CollUserJourneyEvents, 800)
jn := 0
for _, r := range jrows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if playhubStr(m["userId"]) == uid {
jn++
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"queriedUserId": uid,
"user": wzstore.ToResponse(doc),
"stats": gin.H{
"unifiedOrdersForUser": nUserOrders,
"amountSumPaidCompletedLike": paidLikeSum,
"activeStreamerBindings": bindCnt,
"journeyEventsInRecentSample": jn,
},
}})
}
// WzAdminMallImport POST

View File

@@ -0,0 +1,574 @@
package handler
import (
"net/http"
"sort"
"strconv"
"strings"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
)
func playhubListingOkForApp(m map[string]interface{}) bool {
st := strings.ToLower(strings.TrimSpace(playhubStr(m["status"])))
if st == "" {
return true
}
switch st {
case "active", "approved", "listed", "selling", "completed", "open", "available":
return true
case "inactive", "suspended", "dissolved", "deleted", "banned", "off_shelf":
return false
default:
return true
}
}
func playhubVenueMatchesType(m map[string]interface{}, filter string) bool {
filter = strings.ToLower(strings.TrimSpace(filter))
if filter == "" {
return true
}
t := strings.ToLower(playhubStr(m["type"]))
switch filter {
case "cafe":
return strings.Contains(t, "cafe") || strings.Contains(t, "网咖") || strings.Contains(t, "电竞馆")
case "hotel":
return strings.Contains(t, "hotel") || strings.Contains(t, "酒店")
default:
return t == filter || strings.Contains(t, filter)
}
}
func playhubTruthy(v interface{}) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
default:
return strings.ToLower(playhubStr(v)) == "true" || playhubStr(v) == "1"
}
}
func playhubFansShortLabel(f float64) string {
if f >= 10000 {
return strconv.FormatFloat(f/10000, 'f', 1, 64) + "万"
}
if f <= 0 {
return "0"
}
return strconv.FormatFloat(f, 'f', 0, 64)
}
// WzAppGuildsList GET /api/app/guilds
func WzAppGuildsList(c *gin.Context) {
rows, err := wzstore.List(wzstore.CollGuilds, 200)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if strings.ToLower(playhubStr(m["status"])) == "dissolved" {
continue
}
if !playhubListingOkForApp(m) && strings.TrimSpace(playhubStr(m["status"])) != "" {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"logo": playhubStr(m["logo"]),
"coverImage": playhubStr(m["coverImage"]),
"description": playhubStr(m["description"]),
"memberCount": playhubFloat(m["memberCount"]),
"maxMembers": playhubFloat(m["maxMembers"]),
"level": playhubFloat(m["level"]),
"totalIncome": playhubFloat(m["totalIncome"]),
"games": m["games"],
"tags": m["tags"],
"status": playhubStr(m["status"]),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppGuildByID GET /api/app/guilds/:id
func WzAppGuildByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollGuilds, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
m, err := wzstore.BodyMap(d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
o := gin.H{
"id": d.ExtID,
"name": playhubStr(m["name"]),
"logo": playhubStr(m["logo"]),
"coverImage": playhubStr(m["coverImage"]),
"description": playhubStr(m["description"]),
"ownerId": playhubStr(m["ownerId"]),
"memberCount": playhubFloat(m["memberCount"]),
"maxMembers": playhubFloat(m["maxMembers"]),
"level": playhubFloat(m["level"]),
"games": m["games"],
"tags": m["tags"],
"benefits": m["benefits"],
"requirements": playhubStr(m["requirements"]),
"totalIncome": playhubFloat(m["totalIncome"]),
"commissionRate": playhubFloat(m["commissionRate"]),
"status": playhubStr(m["status"]),
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": o})
}
// WzAppVenuesList GET /api/app/venues?type=cafe|hotel宽松匹配 Mongo type 字段)
func WzAppVenuesList(c *gin.Context) {
typeFilter := c.Query("type")
rows, err := wzstore.List(wzstore.CollEsportsVenues, 300)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
if !playhubVenueMatchesType(m, typeFilter) {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"type": playhubStr(m["type"]),
"city": playhubStr(m["city"]),
"address": playhubStr(m["address"]),
"image": playhubStr(m["coverImage"]),
"coverImage": playhubStr(m["coverImage"]),
"priceNote": playhubStr(m["priceNote"]),
"discount": playhubFloat(m["discount"]),
"status": playhubStr(m["status"]),
"lat": playhubFloat(m["lat"]),
"lng": playhubFloat(m["lng"]),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppVenueByID GET /api/app/venues/:id
func WzAppVenueByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollEsportsVenues, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": wzstore.ToResponse(d)})
}
// WzAppProductsList GET /api/app/products?category=
func WzAppProductsList(c *gin.Context) {
cat := strings.ToLower(strings.TrimSpace(c.Query("category")))
rows, err := wzstore.List(wzstore.CollProducts, 500)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
pc := strings.ToLower(playhubStr(m["category"]))
pg := strings.ToLower(playhubStr(m["game"]))
if cat != "" && !strings.Contains(pc, cat) && !strings.Contains(pg, cat) {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"category": playhubStr(m["category"]),
"game": playhubStr(m["game"]),
"price": playhubFloat(m["price"]),
"coverImage": playhubStr(m["coverImage"]),
"image": coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"])),
"stock": playhubFloat(m["stock"]),
"status": playhubStr(m["status"]),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func coalesceImg(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// WzAppProductByID GET /api/app/products/:id
func WzAppProductByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollProducts, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
m, err := wzstore.BodyMap(d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
o := gin.H{
"id": d.ExtID,
"name": playhubStr(m["name"]),
"description": playhubStr(m["description"]),
"category": playhubStr(m["category"]),
"game": playhubStr(m["game"]),
"price": playhubFloat(m["price"]),
"coverImage": playhubStr(m["coverImage"]),
"image": coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"])),
"detailImages": m["detailImages"],
"stock": playhubFloat(m["stock"]),
"status": playhubStr(m["status"]),
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": o})
}
// WzAppContentItemsList GET /api/app/content/items (课程等内容)
func WzAppContentItemsList(c *gin.Context) {
itemType := strings.TrimSpace(strings.ToLower(c.Query("type")))
rows, err := wzstore.List(wzstore.CollContentItems, 400)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
t := strings.ToLower(playhubStr(m["type"]))
if itemType != "" && t != "" && !strings.Contains(t, itemType) {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"title": playhubStr(m["title"]),
"name": playhubStr(m["title"]),
"type": playhubStr(m["type"]),
"author": playhubStr(m["author"]),
"price": playhubFloat(m["price"]),
"coverImage": coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"])),
"image": coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"])),
"students": playhubFloat(m["students"]),
"game": playhubStr(m["game"]),
"status": playhubStr(m["status"]),
})
}
sort.Slice(data, func(i, j int) bool {
return playhubFloat(data[i]["students"]) > playhubFloat(data[j]["students"])
})
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppContentItemByID GET /api/app/content/items/:id
func WzAppContentItemByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollContentItems, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
m, err := wzstore.BodyMap(d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
img := coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"]))
o := gin.H{
"id": d.ExtID,
"title": playhubStr(m["title"]),
"type": playhubStr(m["type"]),
"author": playhubStr(m["author"]),
"price": playhubFloat(m["price"]),
"coverImage": img,
"cover": img,
"description": playhubStr(m["description"]),
"sessions": m["sessions"],
"chapters": m["chapters"],
"game": playhubStr(m["game"]),
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": o})
}
// WzAppStarsList GET /api/app/stars大神/Coach 数据源)
func WzAppStarsList(c *gin.Context) {
rows, err := wzstore.List(wzstore.CollStars, 300)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if strings.ToLower(playhubStr(m["status"])) == "suspended" {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"title": playhubStr(m["title"]),
"game": playhubStr(m["game"]),
"avatar": playhubStr(m["avatar"]),
"coverImage": playhubStr(m["coverImage"]),
"tags": m["tags"],
"fans": playhubFloat(m["fans"]),
"hotValue": playhubFloat(m["hotValue"]),
"rating": playhubFloat(m["rating"]),
"price": playhubFloat(m["hourlyRate"]),
"hourlyRate": playhubFloat(m["hourlyRate"]),
"isOnline": playhubTruthy(m["isOnline"]),
"status": playhubStr(m["status"]),
})
}
sort.Slice(data, func(i, j int) bool {
return playhubFloat(data[i]["hotValue"]) > playhubFloat(data[j]["hotValue"])
})
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppStarByID GET /api/app/stars/:id
func WzAppStarByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollStars, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
m, err := wzstore.BodyMap(d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
partyID := playhubStr(m["partyRoomId"])
img := coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["avatar"]))
o := gin.H{
"id": d.ExtID,
"name": playhubStr(m["name"]),
"title": playhubStr(m["title"]),
"intro": playhubStr(m["intro"]),
"game": playhubStr(m["game"]),
"games": m["games"],
"avatar": playhubStr(m["avatar"]),
"cover": img,
"coverImage": playhubStr(m["coverImage"]),
"rating": playhubFloat(m["rating"]),
"fans": playhubFansShortLabel(playhubFloat(m["fans"])),
"followers": playhubFloat(m["fans"]),
"tags": m["tags"],
"achievements": m["achievements"],
"hourlyRate": playhubFloat(m["hourlyRate"]),
"price": playhubFloat(m["hourlyRate"]),
"isOnline": playhubTruthy(m["isOnline"]),
"partyRoomId": partyID,
"socialLinks": m["socialLinks"],
}
services := m["services"]
if services != nil {
o["services"] = services
} else {
rate := playhubFloat(m["hourlyRate"])
o["services"] = []gin.H{
{"id": "default-1", "name": "单局陪玩", "price": rate, "desc": "接单后商议时间"},
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": o})
}
// WzAppGameAccountsList GET /api/app/game-accounts?game=
func WzAppGameAccountsList(c *gin.Context) {
gameQ := strings.ToLower(strings.TrimSpace(c.Query("game")))
rows, err := wzstore.List(wzstore.CollGameAccounts, 400)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
g := strings.ToLower(playhubStr(m["game"]))
if gameQ != "" && !strings.Contains(g, gameQ) {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"title": playhubStr(m["title"]),
"name": playhubStr(m["characterName"]),
"characterName": playhubStr(m["characterName"]),
"game": playhubStr(m["game"]),
"server": playhubStr(m["server"]),
"rank": playhubStr(m["rank"]),
"price": playhubFloat(m["price"]),
"level": playhubFloat(m["level"]),
"heroCount": playhubFloat(m["heroCount"]),
"skins": playhubFloat(m["skins"]),
"image": coalesceImg(playhubStr(m["coverImage"]), playhubStr(m["image"])),
"sellerRating": playhubFloat(m["sellerRating"]),
"verified": playhubTruthy(m["verified"]),
"status": playhubStr(m["status"]),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppLevelingProductsList GET /api/app/leveling/products
func WzAppLevelingProductsList(c *gin.Context) {
rows, err := wzstore.List(wzstore.CollLevelingProducts, 300)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if !playhubListingOkForApp(m) && playhubStr(m["status"]) != "" {
continue
}
priceNote := strings.TrimSpace(playhubStr(m["priceNote"]))
if priceNote == "" {
priceNote = playhubStr(m["priceHint"])
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"type": playhubStr(m["type"]),
"game": playhubStr(m["game"]),
"subtitle": playhubStr(m["subtitle"]),
"priceNote": priceNote,
"delivery": playhubStr(m["delivery"]),
"icon": playhubStr(m["icon"]),
"image": coalesceImg(playhubStr(m["image"]), playhubStr(m["coverImage"])),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppPartyRoomsList GET /api/app/party/rooms
func WzAppPartyRoomsList(c *gin.Context) {
rows, err := wzstore.List(wzstore.CollStarPartyRooms, 200)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
}
var data []gin.H
for _, r := range rows {
m, e := wzstore.BodyMap(&r)
if e != nil {
continue
}
if v, ok := m["isActive"].(bool); ok && !v {
continue
}
data = append(data, gin.H{
"id": r.ExtID,
"name": playhubStr(m["name"]),
"host": playhubStr(m["hostName"]),
"avatar": playhubStr(m["coverImage"]),
"game": playhubStr(m["game"]),
"background": coalesceImg(playhubStr(m["background"]), playhubStr(m["coverImage"])),
"memberCount": playhubFloat(m["memberCount"]),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// WzAppPartyRoomByID GET /api/app/party/rooms/:id
func WzAppPartyRoomByID(c *gin.Context) {
id := c.Param("id")
if !playhubIsHexID(id) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
d, err := wzstore.Get(wzstore.CollStarPartyRooms, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
m, err := wzstore.BodyMap(d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
}
bg := coalesceImg(playhubStr(m["background"]), playhubStr(m["coverImage"]))
o := gin.H{
"id": d.ExtID,
"name": playhubStr(m["name"]),
"host": coalesceImg(playhubStr(m["hostName"]), playhubStr(m["ownerName"])),
"avatar": coalesceImg(playhubStr(m["avatar"]), playhubStr(m["coverImage"])),
"game": playhubStr(m["game"]),
"background": bg,
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": o})
}

View File

@@ -0,0 +1,277 @@
package handler
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
mongostore "wz-api/internal/store/mongo"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
)
type appOrderSort struct {
t time.Time
row gin.H
}
func normalizeAppConsumeKind(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case "service", "product", "recharge", "hotel", "cafe", "course", "pawn":
return s
default:
return "product"
}
}
func normalizeAppConsumerOrderStatus(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case "completed", "paid", "success", "done", "delivered":
return "completed"
case "cancelled", "canceled", "closed":
return "cancelled"
case "refunded":
return "refunded"
case "pending", "pending_pay", "processing":
return "pending"
default:
return "pending"
}
}
func parseAppOrderTime(v interface{}) time.Time {
s := strings.TrimSpace(playhubStr(v))
if s == "" {
return time.Time{}
}
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05"}
for _, l := range layouts {
if t, e := time.Parse(l, s); e == nil {
return t
}
}
if len(s) >= 10 {
if t, e := time.Parse("2006-01-02", s[:10]); e == nil {
return t
}
}
return time.Time{}
}
func formatAppOrderDate(t time.Time) string {
if t.IsZero() {
return time.Now().UTC().Format("2006-01-02")
}
return t.UTC().Format("2006-01-02")
}
// WzAppOrdersMeGET GET /api/app/orders/me — 当前用户点卡订单 + rechargeOrders与统一订单源一致
func WzAppOrdersMeGET(c *gin.Context) {
uid := playhubAppUserID(c)
if uid == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
var acc []appOrderSort
pointRows, err := wzstore.List(wzstore.CollPointCardOrders, 500)
if err == nil {
for i := range pointRows {
m, e := wzstore.BodyMap(&pointRows[i])
if e != nil {
continue
}
if playhubStr(m["userId"]) != uid {
continue
}
t := parseAppOrderTime(m["createdAt"])
title := strings.TrimSpace(playhubStr(m["skuName"]))
if title == "" {
title = strings.TrimSpace(playhubStr(m["game"]))
if title == "" {
title = "游戏点卡"
} else {
title = title + " 点卡"
}
}
acc = append(acc, appOrderSort{t: t, row: gin.H{
"id": pointRows[i].ExtID,
"orderNo": playhubStr(m["orderNo"]),
"type": "product",
"title": title,
"price": playhubFloat(m["totalAmount"]),
"status": normalizeAppConsumerOrderStatus(playhubStr(m["status"])),
"date": formatAppOrderDate(t),
"image": playhubStr(m["gameLogo"]),
}})
}
}
reRows, err2 := wzstore.List(wzstore.CollRechargeOrders, 400)
if err2 == nil {
for i := range reRows {
m, e := wzstore.BodyMap(&reRows[i])
if e != nil {
continue
}
if playhubStr(m["userId"]) != uid {
continue
}
t := parseAppOrderTime(m["createdAt"])
amt := playhubFloat(m["amount"])
if amt == 0 {
amt = playhubFloat(m["totalAmount"])
}
acc = append(acc, appOrderSort{t: t, row: gin.H{
"id": reRows[i].ExtID,
"orderNo": playhubStr(m["orderNo"]),
"type": "recharge",
"title": "充值 " + strconv.FormatFloat(amt, 'f', -1, 64) + " 玩值币",
"price": amt,
"status": normalizeAppConsumerOrderStatus(playhubStr(m["status"])),
"date": formatAppOrderDate(t),
}})
}
}
prodRows, err3 := wzstore.List(wzstore.CollProductOrders, 500)
if err3 == nil {
for i := range prodRows {
m, e := wzstore.BodyMap(&prodRows[i])
if e != nil {
continue
}
if cb, ok := m["wzAppConsume"].(bool); !ok || !cb {
continue
}
if playhubStr(m["userId"]) != uid {
continue
}
t := parseAppOrderTime(m["createdAt"])
title := strings.TrimSpace(playhubStr(m["title"]))
if title == "" {
title = "商城消费"
}
kind := normalizeAppConsumeKind(playhubStr(m["wzConsumeKind"]))
amt := playhubFloat(m["finalAmount"])
if amt == 0 {
amt = playhubFloat(m["totalAmount"])
}
acc = append(acc, appOrderSort{t: t, row: gin.H{
"id": prodRows[i].ExtID,
"orderNo": playhubStr(m["orderNo"]),
"type": kind,
"title": title,
"price": amt,
"status": normalizeAppConsumerOrderStatus(playhubStr(m["status"])),
"date": formatAppOrderDate(t),
"image": playhubStr(m["image"]),
}})
}
}
sort.Slice(acc, func(i, j int) bool {
ti, tj := acc[i].t, acc[j].t
if ti.Equal(tj) {
return playhubStr(acc[i].row["orderNo"]) > playhubStr(acc[j].row["orderNo"])
}
return ti.After(tj)
})
out := make([]gin.H, len(acc))
for i := range acc {
out[i] = acc[i].row
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// WzAppWalletRechargePOST POST /api/app/wallet/recharge — body: {"amount"} 递增 C 端「币」(与移动端充值档位一致);落 rechargeOrders。
func WzAppWalletRechargePOST(c *gin.Context) {
uid := playhubAppUserID(c)
if uid == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
var body struct {
Amount float64 `json:"amount"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的金额"})
return
}
if body.Amount > 99999999 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "单笔金额过大"})
return
}
bal, err := mongostore.AddAppUserBalanceDelta(uid, body.Amount)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "余额更新失败"})
return
}
orderNo := fmt.Sprintf("WZRC%s%06d", time.Now().UTC().Format("20060102150405"), time.Now().Nanosecond()%1000000)
_, _ = wzstore.Insert(wzstore.CollRechargeOrders, map[string]interface{}{
"orderNo": orderNo,
"userId": uid,
"amount": body.Amount,
"totalAmount": body.Amount,
"status": "completed",
"createdAt": time.Now().UTC().Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": bal, "orderNo": orderNo}})
}
// WzAppWalletConsumePOST POST /api/app/wallet/consume — 扣 Mongo 余额并写入 productOrders带 wzAppConsume仅供 C 端玩值币虚拟消费)。
func WzAppWalletConsumePOST(c *gin.Context) {
uid := playhubAppUserID(c)
if uid == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
var body struct {
Amount float64 `json:"amount"`
Title string `json:"title"`
Kind string `json:"kind"`
Image string `json:"image"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的金额"})
return
}
if body.Amount > 99999999 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "单笔金额过大"})
return
}
title := strings.TrimSpace(body.Title)
if title == "" {
title = "玩值币消费"
}
bal, ok, err := mongostore.TryDecrementAppUserBalance(uid, body.Amount)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "扣款失败"})
return
}
if !ok {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "余额不足"})
return
}
orderNo := fmt.Sprintf("WZVO%s%06d", time.Now().UTC().Format("20060102150405"), time.Now().Nanosecond()%1000000)
kind := normalizeAppConsumeKind(body.Kind)
_, _ = wzstore.Insert(wzstore.CollProductOrders, map[string]interface{}{
"orderNo": orderNo,
"userId": uid,
"title": title,
"totalAmount": body.Amount,
"finalAmount": body.Amount,
"status": "completed",
"wzConsumeKind": kind,
"wzAppConsume": true,
"image": strings.TrimSpace(body.Image),
"createdAt": time.Now().UTC().Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": bal, "orderNo": orderNo}})
}

View File

@@ -219,6 +219,15 @@ func WzAppLiveGift(c *gin.Context) {
if price <= 0 {
price = 1
}
newBal, ok, decErr := mongostore.TryDecrementAppUserBalance(uid, price)
if decErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "扣款失败"})
return
}
if !ok {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "玩值币余额不足"})
return
}
gdoc := map[string]interface{}{
"streamerId": sid,
"userId": uid,
@@ -228,6 +237,7 @@ func WzAppLiveGift(c *gin.Context) {
"createdAt": time.Now().UTC().Format(time.RFC3339),
}
if _, err := wzstore.Insert(wzstore.CollLiveGifts, gdoc); err != nil {
_, _ = mongostore.AddAppUserBalanceDelta(uid, price)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "送礼失败"})
return
}
@@ -243,6 +253,7 @@ func WzAppLiveGift(c *gin.Context) {
"data": gin.H{
"orderNo": orderNo,
"deducted": price,
"balance": newBal,
"giftId": "new",
"split": gin.H{"platformCoins": price * 0.2, "guildCoins": price * 0.3, "streamerCoins": price * 0.5},
},

View File

@@ -77,10 +77,28 @@ func RegisterPlayhubAppPlayhubRoutes(app *gin.RouterGroup) {
app.GET("/mall/point-cards", WzAppMallPointCardsList)
app.GET("/mall/point-cards/:cardId", WzAppMallPointCardByID)
app.POST("/orders/point-card", WzAppOrderPointCardPOST)
app.GET("/orders/me", WzAppOrdersMeGET)
app.POST("/wallet/recharge", WzAppWalletRechargePOST)
app.POST("/wallet/consume", WzAppWalletConsumePOST)
app.GET("/streamers", WzAppStreamersList)
app.GET("/streamers/:id", WzAppStreamerByID)
app.GET("/games", WzAppGamesList)
app.GET("/guilds", WzAppGuildsList)
app.GET("/guilds/:id", WzAppGuildByID)
app.GET("/venues", WzAppVenuesList)
app.GET("/venues/:id", WzAppVenueByID)
app.GET("/products", WzAppProductsList)
app.GET("/products/:id", WzAppProductByID)
app.GET("/content/items", WzAppContentItemsList)
app.GET("/content/items/:id", WzAppContentItemByID)
app.GET("/stars", WzAppStarsList)
app.GET("/stars/:id", WzAppStarByID)
app.GET("/game-accounts", WzAppGameAccountsList)
app.GET("/leveling/products", WzAppLevelingProductsList)
app.GET("/party/rooms", WzAppPartyRoomsList)
app.GET("/party/rooms/:id", WzAppPartyRoomByID)
app.POST("/analytics/journey-events", WzAppAnalyticsJourney)
app.POST("/customer", WzAppCustomerPOST)

View File

@@ -4,6 +4,7 @@ package mongostore
import (
"context"
"errors"
"time"
"wz-api/internal/database"
@@ -11,6 +12,7 @@ import (
"go.mongodb.org/mongo-driver/bson"
mongodriver "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
@@ -229,6 +231,71 @@ func decodeAppUser(raw bson.M) (*model.User, error) {
return u, nil
}
// AddAppUserBalanceDelta 在用户余额上递增 deltaMongo user_balances文档不存在则 upsert。
func AddAppUserBalanceDelta(userID string, delta float64) (float64, error) {
if userID == "" || delta == 0 {
return GetAppUserBalance(userID), nil
}
ctx, cancel := opCtx()
defer cancel()
now := time.Now().UTC()
_, err := coll(CollUserBalances).UpdateOne(ctx,
bson.M{"user_id": userID},
bson.M{
"$inc": bson.M{"balance": delta},
"$set": bson.M{"updated_at": now},
"$setOnInsert": bson.M{
"user_id": userID,
"created_at": now,
},
},
options.Update().SetUpsert(true),
)
if err != nil {
return 0, err
}
return GetAppUserBalance(userID), nil
}
// TryDecrementAppUserBalance 若余额足够则原子扣减;余额不足或未建档则 ok=false。
func TryDecrementAppUserBalance(userID string, amount float64) (newBalance float64, ok bool, err error) {
if userID == "" || amount <= 0 {
return GetAppUserBalance(userID), false, nil
}
ctx, cancel := opCtx()
defer cancel()
now := time.Now().UTC()
res := coll(CollUserBalances).FindOneAndUpdate(ctx,
bson.M{
"user_id": userID,
"balance": bson.M{"$gte": amount},
},
bson.M{
"$inc": bson.M{"balance": -amount},
"$set": bson.M{"updated_at": now},
},
options.FindOneAndUpdate().SetReturnDocument(options.After),
)
var raw bson.M
de := res.Decode(&raw)
if de != nil {
if errors.Is(de, mongodriver.ErrNoDocuments) {
return GetAppUserBalance(userID), false, nil
}
return 0, false, de
}
switch x := raw["balance"].(type) {
case float64:
return x, true, nil
case int32:
return float64(x), true, nil
case int64:
return float64(x), true, nil
default:
return GetAppUserBalance(userID), true, nil
}
}
// GetAppUserBalance 读取 user_balances.balance。
func GetAppUserBalance(userID string) float64 {
ctx, cancel := opCtx()

View File

@@ -12,7 +12,10 @@ from __future__ import print_function
python master.py --skip-bt-ping
环境变量: DEPLOY_HOST、BT_PANEL_URL、BT_API_KEY、DEPLOY_PROJECT_PATH、
BT_GO_PROJECT_NAME默认 wzdjOptionsApi与面板一致、BT_GO_SITE_ID、DEPLOY_API_PORT默认 4040
BT_GO_PROJECT_NAME默认 wzApi与面板一致、BT_GO_SITE_ID、DEPLOY_API_PORT默认 4040
Mongo 数据上云(本地 → 宝塔 wzdj推荐走面板 API 上传+服务端 mongorestore见 scripts/mongo_push_baota_api.py
亦可用手动 mongorestore 见 scripts/MONGO_宝塔同步.md 与 scripts/mongo_sync_baota.py非本脚本职责
依赖: pip install requests
"""
@@ -51,8 +54,8 @@ def get_cfg():
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"bt_panel_url": bt_url,
"bt_api_key": (os.environ.get("BT_API_KEY") or BT_API_KEY_DEFAULT).strip(),
# 与宝塔「Go 项目」名称一致;若启动失败可改环境变量 BT_GO_PROJECT_NAME常见: wzApi / wzdjOptionsApi
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "wzdjOptionsApi"),
# 与宝塔「Go 项目」名称一致;若启动失败可改环境变量 BT_GO_PROJECT_NAME常见: wzApi / wzApi
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "wzApi"),
}

View File

@@ -0,0 +1,110 @@
# 本地 Mongo → 宝塔 Mongo`wzdj` 库)同步说明
## 你需要什么
1. 本机安装 **MongoDB Database Tools**(含 `mongodump``mongorestore`),且已在 PATH 里能直接运行。
2. 本机能 **访问** 宝塔服务器上的 Mongo 端口(默认 `27017`;若只对内网开放,需 VPN/SSH 隧道)。
3. 线上库与面板一致:**数据库名 `wzdj`**,用户 **`wzdj`**`authSource` 一般为 **`wzdj`**(与连接串一致即可)。
## 一键脚本(推荐)
在仓库 `new/wz-api` 目录下:
```bash
# 生产连接串请用宝塔/面板里复制的 URI主机不要用 127.0.0.1,应是服务器 IP 或域名)
set REMOTE_MONGODB_URI=mongodb://wzdj:密码@43.139.27.93:27017/wzdj?authSource=wzdj
python scripts/mongo_sync_baota.py
```
可选环境变量:
| 变量 | 含义 | 默认 |
|------|------|------|
| `LOCAL_MONGODB_URI` | 本机源库 | `mongodb://127.0.0.1:27017/wz` |
| `LOCAL_DB_NAME` | 源逻辑库名(与 URI 路径一致) | 从 URI 解析,失败则为 `wz` |
| `REMOTE_MONGODB_URI` | 宝塔目标库 | 未设时尝试读 `.env.production``MONGODB_URI` |
| `REMOTE_DB_NAME` | 目标库名 | `wzdj` |
| `MONGO_DUMP_OUT` | `mongodump --out` 持久目录(不设则用临时目录) | 空 |
| `DEPLOY_HOST` | 本机执行 `mongorestore` 时,若线上 `.env` 里 Mongo 主机为 `127.0.0.1`/`localhost`,可自动替换为该主机(通常与 `master.py` 部署 IP 一致) | 未设时为 `43.139.27.93` |
未显式传远端 URI 时,脚本默认从 `new/wz-api/.env.development` 读本机、从 `.env.production` 读远端(键名均为 `MONGODB_URI`)。
## 宝塔 API 一键覆盖线上(推荐)
**`new/wz-api`** 下执行(需与本仓库 `master.py` 相同的 `BT_PANEL_URL``BT_API_KEY``DEPLOY_PROJECT_PATH`;本机 PATH 有 `mongodump``tar`
```bash
python scripts/mongo_push_baota_api.py
```
流程:本机 `mongodump``wz` → 打 `tar.gz` → 宝塔「文件」接口上传 → 远程解压到「Go 项目目录下的 `wz_mongo_bt_push/`」→ 调用面板 **`/files?action=ExecShell`** 在服务器本机执行 `mongorestore``MONGODB_URI`**`/www/.../api/.env`** 读取,不写进请求体)。使用 **`--drop -d wzdj`** 指向解压后的 `wz/` 目录,覆盖 **`wzdj`** 中对应集合。
仅当面板 API 已开启、当前出口 IP 在白名单、且允许 `ExecShell` 时可用。若 ExecShell 被拒绝,仍可改用 `mongo_sync_baota.py`(隧道)或面板「数据库」手动导入。
```bash
python scripts/mongo_push_baota_api.py --skip-dump # 使用已有 build/mongo_dump_wz
```
仅生成导出目录、不推线上:
```bash
python scripts/mongo_sync_baota.py --dump-only --dump-out ./build/mongo_dump_wz
```
若不想自动把远端 URI 里的本机地址换成 `DEPLOY_HOST`,可加:`--no-fix-remote-host`
**用本地数据覆盖线上 `wzdj`(先删集合再导入)**:在确认无误后加 `--drop`
```bash
python scripts/mongo_sync_baota.py --drop
```
**已有 dump、只执行恢复**(例如隧道建立后重试,免再 dump
```bash
python scripts/mongo_sync_baota.py --from-dump ./build/mongo_dump_wz --drop --no-fix-remote-host
```
(配合隧道时,请先把 `REMOTE_MONGODB_URI` 指到 `127.0.0.1:隧道本地端口`**必须**加 `--no-fix-remote-host`,否则 `127.0.0.1` 会被改成公网 IP 导致仍连不上。)
## 公网直连 `43.x.x.x:27017` 被拒绝(常见)
现象:`connectex: actively refused` / `context deadline exceeded`。多半是 **Mongo 只监听 `127.0.0.1`****云安全组/防火墙未放行 27017**(生产环境一般会拒绝公网直连数据库)。
**做法 ASSH 本地转发(推荐)**
1. 修正本机 `~/.ssh/config`OpenSSH **不支持** `Password` 这类非法选项;有的话删掉或改成密钥登录。
2. 在一个终端保持运行:
```bash
ssh -N -L 27018:127.0.0.1:27017 root@你的服务器IP
```
3. 另开终端(`new/wz-api` 下),用与线上一致的账号,**仅把主机与端口改成隧道**
```bash
set REMOTE_MONGODB_URI=mongodb://wzdj:线上密码@127.0.0.1:27018/wzdj?authSource=wzdj
python scripts/mongo_sync_baota.py --from-dump ./build/mongo_dump_wz --drop --no-fix-remote-host
```
**做法 B把 `build/mongo_dump_wz` 拷到服务器**SSH 登录后装 Database Tools`mongodb://127.0.0.1:27017/...` 执行与脚本等价的 `mongorestore --nsFrom=wz.* --nsTo=wzdj.* --drop`
## 宝塔面板「导入」在干什么
你备份里出现的 **`xxx.bson` + `xxx.metadata.json`** 是 `mongodump` 的标准产物。面板里的 **导入** 一般是把这类目录/压缩包恢复到**当前选中的库(如 `wzdj`**。
若你只在本地执行脚本推送到线上,**不必**再点面板导入;两种方式二选一即可,避免重复覆盖。
## 手动命令(等价于脚本)
```bash
mongodump --uri="mongodb://127.0.0.1:27017/wz" -o ./dump_wz_export
mongorestore --uri="mongodb://wzdj:密码@服务器IP:27017/wzdj?authSource=wzdj" --nsFrom="wz.*" --nsTo="wzdj.*" ./dump_wz_export
```
**注意**:本地开发常用库名 **`wz`**,宝塔侧是 **`wzdj`**,必须用 `--nsFrom` / `--nsTo` 改名,否则集合会进错库。
## 与安全
- 不要把含密码的 URI 提交到 Git用环境变量或本机临时导出。
- `master.py` 只管 **Go 二进制部署****不包含** Mongo 同步;数据库请用本脚本或面板导入。

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本地 Mongo库 wz→ 打包 → 宝塔 API 上传/解压 → 面板 API 在服务器本机执行 mongorestore 覆盖 wzdj。
依赖:本机 mongodump、tarWindows 10+ 自带 bsdtar 或需装 Git tarpip: requests。
配置:与 master.py 相同BT_PANEL_URL、BT_API_KEY、DEPLOY_PROJECT_PATH 等),另读 .env.development / .env.production 仅用于本机 dump。
服务器侧:使用「网站/API 目录」里的 .env部署后名为 .env中 MONGODB_URI 连接 127.0.0.1 Mongo
故无需把数据库密码塞进 API 请求体。
用法(在 new/wz-api 下):
python scripts/mongo_push_baota_api.py
python scripts/mongo_push_baota_api.py --skip-dump # 已有 build/mongo_dump_wz
"""
from __future__ import print_function
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import time
try:
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
except ImportError:
requests = None
def _bt_signed_post(base_url, key, path, extra_data, timeout=120):
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
data = {"request_time": req_time, "request_token": req_token}
data.update(extra_data or {})
return requests.post(base_url + path, data=data, timeout=timeout, verify=False)
def _bt_parse_json(r):
if r is None or r.status_code != 200:
return None
try:
return r.json()
except Exception:
t = (r.text or "").lstrip()
if t.startswith("{"):
try:
return __import__("json").loads(r.text)
except Exception:
return None
return None
def _read_uri_from_env_file(path, key="MONGODB_URI"):
if not os.path.isfile(path):
return ""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
s = line.strip()
if s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
if k.strip() == key:
return v.strip().strip('"').strip("'")
except Exception:
pass
return ""
def _import_master(root):
sys.path.insert(0, root)
import importlib.util
p = os.path.join(root, "master.py")
spec = importlib.util.spec_from_file_location("wz_api_master", p)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def _which(cmd):
return shutil.which(cmd) is not None
def _run_local(cmd, cwd=None):
print("[local]", " ".join(cmd))
r = subprocess.run(cmd, cwd=cwd, shell=False)
return r.returncode == 0
def _make_tgz_from_dump(dump_parent, local_db):
"""dump_parent 下须有子目录 local_db如 wz"""
src = os.path.join(dump_parent, local_db)
if not os.path.isdir(src):
return None
fd, tgz = tempfile.mkstemp(prefix="wz_mongo_", suffix=".tar.gz")
os.close(fd)
try:
# GNU tar: tar czf tgz -C dump_parent local_db
if not _run_local(["tar", "-czf", tgz, "-C", dump_parent, local_db]):
return None
return tgz
except Exception:
try:
os.remove(tgz)
except Exception:
pass
return None
def bt_exec_shell(cfg, shell, cwd_path):
url = (cfg["bt_panel_url"] or "").rstrip("/")
key = cfg["bt_api_key"]
j = _bt_parse_json(
_bt_signed_post(
url,
key,
"/files?action=ExecShell",
{"path": cwd_path, "shell": shell},
timeout=60,
)
)
if not isinstance(j, dict) or j.get("status") is not True:
print("[失败] ExecShell 未接受: %s" % (j,))
return False
return True
def bt_get_exec_msg(cfg):
url = (cfg["bt_panel_url"] or "").rstrip("/")
key = cfg["bt_api_key"]
return _bt_parse_json(_bt_signed_post(url, key, "/files?action=GetExecShellMsg", {}, timeout=60))
def wait_exec_shell(cfg, timeout_sec=300, poll=2):
"""轮询 GetExecShellMsg直到任务结束或超时。"""
deadline = time.time() + timeout_sec
last_msg = None
while time.time() < deadline:
j = bt_get_exec_msg(cfg)
last_msg = j
if isinstance(j, dict):
st = j.get("status")
if st is True:
print("[信息] 远程命令已结束(面板返回 status=True")
if j.get("msg"):
print(str(j.get("msg"))[-4000:])
return True
time.sleep(poll)
print("[失败] 等待远程 Shell 超时(%ss。最后一次面板返回: %s" % (timeout_sec, last_msg))
return False
def main():
parser = argparse.ArgumentParser(description="Mongo 覆盖到线上(宝塔 API + 服务端 mongorestore")
parser.add_argument("--skip-dump", action="store_true", help="跳过本机 mongodump使用已有目录")
parser.add_argument(
"--dump-dir",
default="",
help="mongodump 输出父目录(内含 wz 子目录),默认 build/mongo_dump_wz",
)
parser.add_argument("--local-db", default=os.environ.get("LOCAL_DB_NAME", "wz"))
parser.add_argument("--remote-db", default=os.environ.get("REMOTE_DB_NAME", "wzdj"))
parser.add_argument("--skip-bt-ping", action="store_true")
parser.add_argument("--exec-timeout", type=int, default=int(os.environ.get("BT_MONGO_EXEC_TIMEOUT", "300") or "300"))
args = parser.parse_args()
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
m = _import_master(root)
cfg = m.get_cfg()
if not requests:
print("[失败] pip install requests")
return 1
if not args.skip_bt_ping and not m.bt_panel_ping(cfg):
print("[失败] 宝塔 API 探活失败")
return 1
local_db = (args.local_db or "wz").strip()
remote_db = (args.remote_db or "wzdj").strip()
dump_dir = (args.dump_dir or "").strip() or os.path.join(root, "build", "mongo_dump_wz")
if not args.skip_dump:
if not _which("mongodump"):
print("[失败] 未找到 mongodump")
return 1
os.makedirs(dump_dir, exist_ok=True)
env_dev = os.path.join(root, ".env.development")
local_uri = _read_uri_from_env_file(env_dev) or "mongodb://127.0.0.1:27017/wz"
if not _run_local(["mongodump", "--uri=%s" % local_uri, "--db", local_db, "--out", dump_dir]):
print("[失败] mongodump 失败")
return 1
else:
print("[信息] --skip-dump使用已有: %s" % dump_dir)
if not os.path.isdir(os.path.join(dump_dir, local_db)):
print("[失败] 缺少子目录 %s/%s" % (dump_dir, local_db))
return 1
tgz = _make_tgz_from_dump(dump_dir, local_db)
if not tgz:
print("[失败] 打包 tar.gz 失败(需要系统 tar 命令)")
return 1
project_path = cfg["project_path"].rstrip("/")
remote_work = project_path + "/wz_mongo_bt_push"
remote_tgz = remote_work + "/mongo_wz_dump.tar.gz"
# 解压后布局remote_work/<local_db>/*.bson用 -d <remote_db> 指向目标库(避免 nsFrom/nsTo 与目录扫描兼容问题)
remote_dump_parent = remote_work
remote_db_dir = remote_work + "/" + local_db
try:
print("[1/4] 上传 dump 压缩包 ...")
if not m.bt_upload_file_resumable(cfg, tgz, remote_work, "mongo_wz_dump.tar.gz"):
print("[失败] 上传失败")
return 1
print("[2/4] 远程解压 ...")
if not m.bt_unzip_remote(cfg, remote_tgz, remote_work + "/"):
print("[失败] 远程解压失败")
return 1
if m.bt_delete_file(cfg, remote_tgz):
print("[信息] 已删除远程压缩包,避免 mongorestore 误扫 tar.gz")
else:
print("[警告] 删除远程 tar 失败mongorestore 可能跳过集合;请在面板手动删: %s" % remote_tgz)
pp = project_path.replace("'", "'\\''")
rd = remote_dump_parent.replace("'", "'\\''")
rdbdir = remote_db_dir.replace("'", "'\\''")
shell = (
"set -e;"
"MRS=$(command -v mongorestore 2>/dev/null || true);"
"if [ -z \"$MRS\" ] && [ -x /www/server/mongodb/bin/mongorestore ]; then MRS=/www/server/mongodb/bin/mongorestore; fi;"
'if [ -z "$MRS" ]; then echo "mongorestore not found"; exit 127; fi;'
+ "ENVF='"
+ pp
+ "/.env';"
+ 'if [ ! -f "$ENVF" ]; then echo "missing $ENVF"; exit 1; fi;'
+ "URI=$(grep '^MONGODB_URI=' \"$ENVF\" | head -1 | cut -d= -f2- | tr -d '\\r');"
+ "URI=$(printf '%s' \"$URI\" | sed \"s/^\\\"//;s/\\\"$//\");"
+ "rm -f '"
+ rd
+ "/mongo_wz_dump.tar.gz' || true;"
+ "if [ ! -d '"
+ rdbdir
+ "' ]; then echo missing_dump; ls -la '"
+ rd
+ "'; exit 2; fi;"
+ '"$MRS" --uri="$URI" --drop -d '
+ remote_db
+ " '"
+ rdbdir
+ "'"
)
print("[3/4] 请求面板在服务器执行 mongorestore读取线上 .env 的 MONGODB_URI...")
if not bt_exec_shell(cfg, shell, "/tmp"):
return 1
print("[4/4] 等待执行结果(最多 %ss..." % args.exec_timeout)
ok = wait_exec_shell(cfg, timeout_sec=args.exec_timeout)
if ok:
print("[成功] 远程 mongorestore 已完成(请核对上方的 bson 日志)。工作目录: %s" % remote_work)
return 0
return 1
finally:
try:
os.remove(tgz)
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本地 MongoDB → 宝塔 Mongo默认目标库 wzdj一遍式同步。
依赖mongodump、mongorestoreMongoDB Database Tools且在 PATH 中。
示例PowerShell
$env:REMOTE_MONGODB_URI = "mongodb://wzdj:你的密码@服务器IP:27017/wzdj?authSource=wzdj"
python scripts/mongo_sync_baota.py
仅导出、不恢复(并保留目录便于宝塔「导入」打包):
python scripts/mongo_sync_baota.py --dump-only --dump-out ./build/mongo_dump_wz
本机推送时若 .env.production 里 Mongo 写的是 127.0.0.1(进程在服务器本机连库),脚本会读取环境变量
DEPLOY_HOST未设置则 43.139.27.93,与 master.py 默认一致)自动替换远端主机再 mongorestore
可用 --no-fix-remote-host 关闭。
"""
from __future__ import print_function
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
from urllib.parse import urlparse, urlunparse, unquote
# 与 master.py 默认 DEPLOY_HOST 一致:用于本机 push 时把 .env.production 里的 127.0.0.1 换成可访问的服务器
DEFAULT_DEPLOY_HOST = "43.139.27.93"
def _replace_uri_hostname(uri, new_host):
"""仅替换 hostname保留 userinfo、port、path、query。"""
try:
u = urlparse(uri.replace("mongodb+srv://", "mongodb://", 1))
if not u.scheme:
return uri
netloc = ""
if u.username:
netloc = u.username
if u.password:
netloc += ":" + u.password
netloc += "@"
netloc += new_host
if u.port:
netloc += ":%s" % u.port
return urlunparse((u.scheme, netloc, u.path, u.params, u.query, u.fragment))
except Exception:
return uri
def _db_name_from_uri(uri):
"""从 mongodb://host:port/dbname 取 dbname忽略 query"""
try:
u = urlparse(uri.replace("mongodb+srv://", "mongodb://", 1))
path = (u.path or "").lstrip("/")
if not path:
return ""
return path.split("/")[0] if "/" in path else path.split("?")[0]
except Exception:
return ""
def _mask_uri(uri):
if not uri:
return uri
try:
u = urlparse(uri)
if u.username:
host = u.hostname or ""
port = ":%s" % u.port if u.port else ""
path = u.path or ""
q = "?%s" % u.query if u.query else ""
return "%s://%s:***@%s%s%s%s" % (u.scheme, u.username, host, port, path, q)
except Exception:
pass
return "***"
def _which(cmd):
return shutil.which(cmd) is not None
def _run(cmd, cwd=None):
disp = []
for x in cmd:
if x.startswith("--uri="):
disp.append("--uri=%s" % _mask_uri(x[6:]))
else:
disp.append(x)
print("[exec]", " ".join(disp))
r = subprocess.run(cmd, cwd=cwd, shell=False)
return r.returncode == 0
def _read_uri_from_env_file(path, key="MONGODB_URI"):
if not os.path.isfile(path):
return ""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
s = line.strip()
if s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
if k.strip() == key:
return v.strip().strip('"').strip("'")
except Exception:
pass
return ""
def main():
parser = argparse.ArgumentParser(description="mongodump local -> mongorestore Baota wzdj")
parser.add_argument("--dump-only", action="store_true", help="只 dump 到临时目录并打印路径,不 restore")
parser.add_argument("--local-uri", default=os.environ.get("LOCAL_MONGODB_URI", ""))
parser.add_argument("--remote-uri", default=os.environ.get("REMOTE_MONGODB_URI", ""))
parser.add_argument("--local-db", default=os.environ.get("LOCAL_DB_NAME", ""))
parser.add_argument("--remote-db", default=os.environ.get("REMOTE_DB_NAME", "wzdj"))
parser.add_argument("--env-local", default="", help="从该文件读 LOCAL MONGODB_URIkey MONGODB_URI")
parser.add_argument("--env-remote", default="", help="从该文件读 REMOTE MONGODB_URIkey MONGODB_URI")
parser.add_argument(
"--dump-out",
default=os.environ.get("MONGO_DUMP_OUT", ""),
help="mongodump --out 目录(将生成 子目录/%s);设置后不删目录。空则使用临时目录" % "库名",
)
parser.add_argument(
"--no-fix-remote-host",
action="store_true",
help="不要用 DEPLOY_HOST 替换远端 URI 中的 127.0.0.1/localhost",
)
parser.add_argument(
"--drop",
action="store_true",
help="mongorestore 在导入每个集合前先 drop实现覆盖线上同库数据慎用",
)
parser.add_argument(
"--from-dump",
default="",
help="跳过 mongodump直接使用已有 dump 目录mongodump --out 的父目录,内含子目录=源库名)",
)
args = parser.parse_args()
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
local_uri = (args.local_uri or "").strip()
if not local_uri:
env_path = (args.env_local or os.path.join(root, ".env.development")).strip()
local_uri = _read_uri_from_env_file(env_path)
if not local_uri:
local_uri = "mongodb://127.0.0.1:27017/wz"
remote_uri = (args.remote_uri or "").strip()
if not remote_uri:
env_path = (args.env_remote or os.path.join(root, ".env.production")).strip()
remote_uri = _read_uri_from_env_file(env_path)
if remote_uri and not args.no_fix_remote_host:
try:
u = urlparse(remote_uri.replace("mongodb+srv://", "mongodb://", 1))
h = (u.hostname or "").lower()
if h in ("127.0.0.1", "localhost"):
deploy_host = (os.environ.get("DEPLOY_HOST") or "").strip() or DEFAULT_DEPLOY_HOST
remote_uri = _replace_uri_hostname(remote_uri, deploy_host)
print("[信息] 远端 URI 原为主机本机地址,已改为 DEPLOY_HOST=%s 便于本机 mongorestore" % deploy_host)
except Exception:
pass
elif "127.0.0.1" in remote_uri or "localhost" in remote_uri:
print("[警告] REMOTE_MONGODB_URI 指向本机。可调 REMOTE_MONGODB_URI 或使用默认自动替换(勿加 --no-fix-remote-host")
local_db = (args.local_db or "").strip() or _db_name_from_uri(local_uri) or "wz"
remote_db = (args.remote_db or "wzdj").strip()
from_dump = (args.from_dump or "").strip()
if from_dump and args.dump_only:
print("[失败] 不能同时使用 --from-dump 与 --dump-only")
return 1
if not _which("mongorestore"):
print("[失败] 未找到 mongorestore请先安装 MongoDB Database Tools 并加入 PATH。")
return 1
if not from_dump and not _which("mongodump"):
print("[失败] 未找到 mongodump请先安装 MongoDB Database Tools 并加入 PATH。")
return 1
persistent = (args.dump_out or "").strip()
tmp = None
if from_dump:
dump_parent = os.path.abspath(from_dump)
elif persistent:
os.makedirs(persistent, exist_ok=True)
dump_parent = os.path.abspath(persistent)
else:
tmp = tempfile.mkdtemp(prefix="wz_mongo_dump_")
dump_parent = os.path.join(tmp, "out")
os.makedirs(dump_parent, exist_ok=True)
try:
if not from_dump:
# mongodump --out dump_parent 会在 dump_parent/<db>/ 下生成 .bson
dump_cmd = ["mongodump", "--uri=%s" % local_uri, "--db", local_db, "--out", dump_parent]
if not _run(dump_cmd):
print("[失败] mongodump 退出非 0")
return 1
else:
print("[信息] 使用已有 dump--from-dump跳过 mongodump")
src_dir = os.path.join(dump_parent, local_db)
if not os.path.isdir(src_dir):
print("[失败] 未找到 dump 子目录: %s" % src_dir)
return 1
if not from_dump:
print("[信息] 源库: %s (%s)" % (_mask_uri(local_uri), local_db))
print("[信息] dump 目录(含子库 %s: %s" % (local_db, dump_parent))
if args.dump_only:
print("[信息] --dump-only跳过后续 mongorestore。宝塔导入可压缩「%s」整目录或按面板要求选择。" % local_db)
return 0
if not remote_uri.strip():
print("[失败] 未设置远端 URI。请设置环境变量 REMOTE_MONGODB_URI 或 --remote-uri或 --env-remote 指向含线上 MONGODB_URI 的文件)。")
return 1
ns_from = "%s.*" % local_db
ns_to = "%s.*" % remote_db
restore_cmd = [
"mongorestore",
"--uri=%s" % remote_uri,
"--nsFrom=%s" % ns_from,
"--nsTo=%s" % ns_to,
]
if args.drop:
restore_cmd.append("--drop")
restore_cmd.append(dump_parent)
print("[信息] 目标库: %s (%s)" % (_mask_uri(remote_uri), remote_db))
if not _run(restore_cmd):
print("[失败] mongorestore 退出非 0。")
print(" 若报错为 connection refused / timeout线上 Mongo 常仅监听 127.0.0.1,公网连不到。")
print(" 做法:本机开 SSH 隧道 ssh -N -L 27018:127.0.0.1:27017 你的用户@服务器IP")
print(" 再设 REMOTE_MONGODB_URI 为 mongodb://wzdj:***@127.0.0.1:27018/wzdj?authSource=wzdj 并加 --no-fix-remote-host")
print(" 或把 dump 拷到服务器后对 127.0.0.1 执行 mongorestore见 scripts/MONGO_宝塔同步.md")
return 1
print("[成功] 已从 %s 恢复到 %s" % (local_db, remote_db))
return 0
finally:
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,4 +1,6 @@
# wz-app玩值 C 端 · Vite + React
# wz-app玩值移动端 · Vite + React
> **不叫「微信小程序」**:本仓库中「小程序/微信原生」指 Soul 目录 **`miniprogram/`**。本目录是 **玩值用户侧移动端H5 等)** 的唯一新功能交付,智能体角色为 **玩值移动端工程师**。
## 不要用 HBuilderX 的「uni-app / 运行到浏览器」编译本目录
@@ -36,7 +38,7 @@
| 项 | 说明 |
|----|------|
| `new/wz-api` | 玩值 Go 后端C 端 `/api/app/*` |
| **`new/wz-app/`(本目录)** | **玩值 C 端唯一新功能交付**Vite + React |
| **`new/wz-app/`(本目录)** | **玩值移动端唯一新功能交付**Vite + React |
| `old/app/` | Next 历史 C 端,**仅对照** 是否漏页/漏接口,不在此扩新功能 |
## 技术栈

View File

@@ -11,8 +11,8 @@
rel="stylesheet"
/>
<title>玩值电竞 - WanZhi Esports</title>
<script type="module" crossorigin src="/assets/index-CnJ4AXBQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CeISOfIA.css">
<script type="module" crossorigin src="/assets/index-CilfF9Zo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfFz-CMK.css">
</head>
<body class="min-h-screen antialiased">
<div id="root"></div>

View File

@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"hls.js": "^1.6.16",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
@@ -2905,6 +2906,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",

View File

@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"hls.js": "^1.6.16",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",

View File

@@ -0,0 +1,191 @@
/**
* wz-api 玩值 Mongo 目录只读接口anonymous OK与 `/api/admin/wz/*` 同源数据、字段已由后端裁剪。
*/
import { request } from "@/lib/request"
export type AppGuildDTO = {
id: string
name?: string
logo?: string
coverImage?: string
description?: string
memberCount?: number
maxMembers?: number
level?: number
totalIncome?: number
games?: unknown
tags?: unknown
benefits?: unknown
requirements?: string
status?: string
}
export async function appGuilds(): Promise<AppGuildDTO[]> {
const r = await request<AppGuildDTO[]>("/api/app/guilds", { method: "GET", skipAuth: true })
return Array.isArray(r.data) ? r.data : []
}
export async function appGuild(id: string) {
return request<AppGuildDTO>(`/api/app/guilds/${encodeURIComponent(id)}`, { method: "GET", skipAuth: true })
}
export type AppVenueDTO = {
id: string
name?: string
type?: string
city?: string
address?: string
image?: string
coverImage?: string
priceNote?: string
discount?: number
lat?: number
lng?: number
}
export async function appVenues(type?: string): Promise<AppVenueDTO[]> {
const q = type?.trim() ? `?type=${encodeURIComponent(type)}` : ""
const r = await request<AppVenueDTO[]>(`/api/app/venues${q}`, { method: "GET", skipAuth: true })
return Array.isArray(r.data) ? r.data : []
}
export type AppProductDTO = {
id: string
name?: string
category?: string
game?: string
price?: number
image?: string
coverImage?: string
}
export async function appProducts(category?: string): Promise<AppProductDTO[]> {
const q = category?.trim() ? `?category=${encodeURIComponent(category)}` : ""
const r = await request<AppProductDTO[]>(`/api/app/products${q}`, { method: "GET", skipAuth: true })
return Array.isArray(r.data) ? r.data : []
}
export type AppContentItemDTO = {
id: string
title?: string
name?: string
author?: string
price?: number
image?: string
coverImage?: string
students?: number
game?: string
}
export async function appContentItems(opts?: { type?: string }) {
const q = opts?.type ? `?type=${encodeURIComponent(opts.type)}` : ""
const r = await request<AppContentItemDTO[]>(`/api/app/content/items${q}`, {
method: "GET",
skipAuth: true,
})
return Array.isArray(r.data) ? r.data : []
}
export async function appContentItem(id: string) {
return request<AppContentItemDTO & { description?: string; chapters?: unknown; sessions?: unknown; cover?: string }>(
`/api/app/content/items/${encodeURIComponent(id)}`,
{ method: "GET", skipAuth: true },
)
}
export type AppStarDTO = {
id: string
name?: string
title?: string
game?: string
avatar?: string
coverImage?: string
tags?: string[]
fans?: number
rating?: number
price?: number
hourlyRate?: number
isOnline?: boolean
}
export async function appStars(): Promise<AppStarDTO[]> {
const r = await request<AppStarDTO[]>("/api/app/stars", { method: "GET", skipAuth: true })
return Array.isArray(r.data) ? r.data : []
}
export async function appStar(id: string) {
return request<
AppStarDTO & {
intro?: string
cover?: string
partyRoomId?: string
services?: { id: string; name: string; price: number; desc?: string; courseId?: string; hot?: boolean }[]
achievements?: unknown
}
>(`/api/app/stars/${encodeURIComponent(id)}`, { method: "GET", skipAuth: true })
}
export type AppGameAccountDTO = {
id: string
title?: string
name?: string
characterName?: string
game?: string
server?: string
rank?: string
price?: number
heroCount?: number
skins?: number
verified?: boolean
image?: string
}
export async function appGameAccounts(game?: string): Promise<AppGameAccountDTO[]> {
const q = game?.trim() ? `?game=${encodeURIComponent(game)}` : ""
const r = await request<AppGameAccountDTO[]>(`/api/app/game-accounts${q}`, {
method: "GET",
skipAuth: true,
})
return Array.isArray(r.data) ? r.data : []
}
export type AppLevelProductDTO = {
id: string
name?: string
type?: string
game?: string
subtitle?: string
priceNote?: string
delivery?: string
icon?: string
image?: string
}
export async function appLevelingProducts(): Promise<AppLevelProductDTO[]> {
const r = await request<AppLevelProductDTO[]>("/api/app/leveling/products", {
method: "GET",
skipAuth: true,
})
return Array.isArray(r.data) ? r.data : []
}
export type AppPartyRoomDTO = {
id: string
name?: string
host?: string
avatar?: string
game?: string
background?: string
}
export async function appPartyRooms(): Promise<AppPartyRoomDTO[]> {
const r = await request<AppPartyRoomDTO[]>("/api/app/party/rooms", { method: "GET", skipAuth: true })
return Array.isArray(r.data) ? r.data : []
}
export async function appPartyRoom(id: string) {
return request<AppPartyRoomDTO>(`/api/app/party/rooms/${encodeURIComponent(id)}`, {
method: "GET",
skipAuth: true,
})
}

View File

@@ -0,0 +1,17 @@
import { request } from "@/lib/request"
export type AppGame = {
id?: string
name?: string
shortName?: string
category?: string
status?: string
sortOrder?: number
}
/** GET /api/app/games — Mongo `games` 集合,前端用于分区入口 */
export async function getAppGames(): Promise<AppGame[]> {
const r = await request<AppGame[]>("/api/app/games", { method: "GET", skipAuth: true })
if (!Array.isArray(r.data)) return []
return r.data as AppGame[]
}

View File

@@ -56,14 +56,15 @@ export async function postLiveComment(
export async function postLiveGift(streamerId: string, body: { giftId: string; quantity: number; amount: number }): Promise<{
orderNo: string
deducted: number
balance?: number
}> {
const r = await request<{ orderNo: string; deducted: number }>("/api/app/live/gift", {
const r = await request<{ orderNo: string; deducted: number; balance?: number }>("/api/app/live/gift", {
method: "POST",
body: JSON.stringify({ streamerId, ...body }),
})
if (!r.data || typeof r.data !== "object") {
return { orderNo: "", deducted: 0 }
}
const d = r.data as { orderNo?: string; deducted?: number }
return { orderNo: d.orderNo ?? "", deducted: d.deducted ?? 0 }
const d = r.data as { orderNo?: string; deducted?: number; balance?: number }
return { orderNo: d.orderNo ?? "", deducted: d.deducted ?? 0, balance: d.balance }
}

View File

@@ -0,0 +1,39 @@
/**
* C 端订单与钱包:`/api/app/orders/me`、`/api/app/wallet/recharge`
*/
import { request } from "@/lib/request"
export type AppOrderDTO = {
id: string
orderNo?: string
type: string
title: string
price: number
status: string
date: string
image?: string
}
export async function appOrdersMe() {
return request<AppOrderDTO[]>("/api/app/orders/me", { method: "GET" })
}
export async function appWalletRecharge(amount: number) {
return request<{ balance?: number; orderNo?: string }>("/api/app/wallet/recharge", {
method: "POST",
body: JSON.stringify({ amount }),
})
}
/** 虚拟消费:扣 Mongo 余额并写入带 wzAppConsume 的商品订单占位 */
export async function appWalletConsume(payload: {
amount: number
title: string
kind: string
image?: string
}) {
return request<{ balance?: number; orderNo?: string }>("/api/app/wallet/consume", {
method: "POST",
body: JSON.stringify(payload),
})
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react"
import Hls from "hls.js"
import { ExternalLink } from "lucide-react"
import { FillImage } from "@/components/media/FillImage"
import { Button } from "@/components/ui/button"
export type PlaybackMode =
| { kind: "iframe"; src: string }
| { kind: "video"; src: string; useHls: boolean }
| { kind: "openExternal"; url: string }
/** 将主播填写的拉流/第三方地址分类为 iframe、HLS/直链 video、或外链打开 */
export function classifyPlaybackUrl(raw: string): PlaybackMode | null {
const s = raw.trim()
if (!s) return null
let u: URL
try {
u = new URL(s)
} catch {
return null
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null
const host = u.hostname.toLowerCase()
const pathname = u.pathname
if (host === "youtu.be") {
const id = pathname.replace(/^\//, "").split("/")[0] ?? ""
if (id && /^[\w-]{6,}$/.test(id)) {
return { kind: "iframe", src: `https://www.youtube.com/embed/${encodeURIComponent(id)}` }
}
}
if (host.includes("youtube.com")) {
let id = u.searchParams.get("v") ?? ""
if (!id && pathname.toLowerCase().startsWith("/embed/")) {
id = pathname.slice("/embed/".length).split("/")[0] ?? ""
}
if (!id && pathname.toLowerCase().startsWith("/shorts/")) {
id = pathname.slice("/shorts/".length).split("/")[0] ?? ""
}
if (id && /^[\w-]{6,}$/.test(id)) {
return { kind: "iframe", src: `https://www.youtube.com/embed/${encodeURIComponent(id)}` }
}
}
const bvMatch = s.match(/(BV[\w]+)/i)
if (bvMatch) {
const bvid = bvMatch[1]!
return {
kind: "iframe",
src: `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=1&high_quality=1&danmaku=0`,
}
}
const pathAndQuery = (pathname + u.search).toLowerCase()
if (pathAndQuery.includes(".m3u8") || s.toLowerCase().includes(".m3u8")) {
return { kind: "video", src: s, useHls: true }
}
if (/\.(mp4|webm|ogg)(\?|#|$)/i.test(pathAndQuery)) {
return { kind: "video", src: s, useHls: false }
}
if (host.includes("twitch.tv") || host.includes("douyin.com") || host.includes("tiktok.com")) {
return { kind: "openExternal", url: s }
}
return { kind: "openExternal", url: s }
}
type Props = {
url: string
coverUrl?: string
/** 直播中但无法在本页解码时兜底文案 */
isLive?: boolean
}
export function LivePlaybackSurface({ url, coverUrl, isLive }: Props) {
const mode = classifyPlaybackUrl(url)
const videoRef = useRef<HTMLVideoElement>(null)
const [hlsUnsupported, setHlsUnsupported] = useState(false)
useEffect(() => {
setHlsUnsupported(false)
if (!mode || mode.kind !== "video") return
const el = videoRef.current
if (!el) return
if (!mode.useHls) {
el.src = mode.src
return
}
if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: true })
hls.loadSource(mode.src)
hls.attachMedia(el)
return () => {
hls.destroy()
}
}
if (el.canPlayType("application/vnd.apple.mpegurl")) {
el.src = mode.src
return
}
setHlsUnsupported(true)
}, [mode])
if (!mode) return null
if (mode.kind === "iframe") {
return (
<iframe
title="直播画面"
src={mode.src}
className="h-full w-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
/>
)
}
if (mode.kind === "openExternal") {
return (
<div className="relative h-full w-full">
{coverUrl ? <FillImage src={coverUrl} alt="" className="h-full w-full object-cover opacity-70" /> : null}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/50 p-4 text-center">
<p className="text-sm text-white/90"></p>
<Button type="button" variant="secondary" className="gap-2" onClick={() => window.open(mode.url, "_blank", "noopener,noreferrer")}>
<ExternalLink className="h-4 w-4" />
</Button>
{isLive ? <p className="text-[11px] text-white/50"> · </p> : null}
</div>
</div>
)
}
if (hlsUnsupported) {
return (
<div className="relative h-full w-full">
{coverUrl ? <FillImage src={coverUrl} alt="" className="h-full w-full object-cover opacity-70" /> : null}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/50 p-4 text-center">
<p className="text-sm text-white/90"></p>
<Button type="button" variant="secondary" className="gap-2" onClick={() => window.open(mode.src, "_blank", "noopener,noreferrer")}>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
)
}
return <video ref={videoRef} className="h-full w-full object-contain bg-black" controls playsInline />
}

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import { createContext, useCallback, useContext, useEffect, useState } from "react"
import { toast } from "@/hooks/use-toast"
import { appAuthLogin, appAuthLogout, appUserMe, type AppUserMePayload } from "@/api/app"
import { appOrdersMe, appWalletConsume, appWalletRecharge, type AppOrderDTO } from "@/api/orders"
import { clearToken, getToken, setToken } from "@/lib/token"
type User = {
@@ -34,14 +35,27 @@ type Order = {
orderNo?: string
}
function mapAppOrderToOrder(row: AppOrderDTO): Order {
return {
id: String(row.id),
orderNo: row.orderNo,
type: (row.type ?? "product") as Order["type"],
title: row.title ?? "",
price: Number(row.price) || 0,
status: (row.status ?? "pending") as Order["status"],
date: row.date ?? "",
image: row.image,
}
}
type AppContextType = {
user: User
wallet: Wallet
orders: Order[]
isLoggedIn: boolean
authChecked: boolean
recharge: (amount: number) => void
pay: (amount: number, title: string, type: Order["type"], image?: string) => boolean
recharge: (amount: number) => Promise<boolean>
pay: (amount: number, title: string, type: Order["type"], image?: string) => Promise<boolean>
updateUser: (updates: Partial<User>) => void
login: (phone: string, password: string) => Promise<boolean>
logout: () => Promise<void>
@@ -66,11 +80,26 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [wallet, setWallet] = useState<Wallet>({ balance: 0 })
const [orders, setOrders] = useState<Order[]>([])
const fetchOrdersForUser = useCallback(async () => {
if (!getToken()) {
setOrders([])
return
}
try {
const r = await appOrdersMe()
const list = Array.isArray(r.data) ? r.data : []
setOrders(list.map(mapAppOrderToOrder))
} catch {
setOrders([])
}
}, [])
const syncFromDatabase = useCallback(async () => {
if (!getToken()) {
setIsLoggedIn(false)
setUser({ ...defaultUser })
setWallet({ balance: 0 })
setOrders([])
setAuthChecked(true)
return
}
@@ -89,19 +118,22 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
})
setWallet({ balance: Number(d.balance) ?? 0 })
setIsLoggedIn(true)
await fetchOrdersForUser()
} else {
setIsLoggedIn(false)
setUser({ ...defaultUser })
setWallet({ balance: 0 })
setOrders([])
}
} catch {
setIsLoggedIn(false)
setUser({ ...defaultUser })
setWallet({ balance: 0 })
setOrders([])
} finally {
setAuthChecked(true)
}
}, [])
}, [fetchOrdersForUser])
useEffect(() => {
const t = setTimeout(() => {
@@ -120,7 +152,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const r = await appAuthLogin(phone, password)
const d = r.data
if (!d?.token) {
toast({ title: "登录失败", description: "未返回 token请检查 wz-api 与网络" })
toast({ title: "登录失败", description: "账号或网络异常,请稍后重试", variant: "destructive" })
return false
}
setToken(d.token)
@@ -154,45 +186,73 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setOrders([])
}, [])
const recharge = (amount: number) => {
const orderNo = `RCH${Date.now()}`
setWallet((prev) => ({ ...prev, balance: prev.balance + amount }))
setOrders((prev) => [
{
id: Math.random().toString(36).slice(2, 11),
orderNo,
type: "recharge",
title: `充值 ${amount} 玩值币`,
price: amount,
status: "completed",
date: new Date().toISOString().split("T")[0]!,
},
...prev,
])
}
const recharge = useCallback(
async (amount: number): Promise<boolean> => {
if (!getToken()) {
toast({ title: "请先登录", description: "登录后再充值", variant: "destructive" })
return false
}
try {
await appWalletRecharge(amount)
await syncFromDatabase()
return true
} catch (e) {
toast({
title: "充值失败",
description: e instanceof Error ? e.message : "请稍后重试",
variant: "destructive",
})
return false
}
},
[syncFromDatabase],
)
const pay = (amount: number, title: string, type: Order["type"], image?: string): boolean => {
if (wallet.balance < amount) {
toast({ title: "余额不足", description: "您的玩值币不足,请充值" })
return false
}
const orderNo = `PAY${Date.now()}`
setWallet((prev) => ({ ...prev, balance: prev.balance - amount }))
setOrders((prev) => [
{
id: Math.random().toString(36).slice(2, 11),
orderNo,
type,
title,
price: amount,
status: "completed",
date: new Date().toISOString().split("T")[0]!,
image,
},
...prev,
])
return true
}
const pay = useCallback(
async (amount: number, title: string, type: Order["type"], image?: string): Promise<boolean> => {
if (amount <= 0) {
toast({ title: "金额无效", variant: "destructive" })
return false
}
if (!getToken()) {
if (wallet.balance < amount) {
toast({ title: "余额不足", description: "您的玩值币不足,请充值或登录后与账户同步" })
return false
}
const orderNo = `PAY${Date.now()}`
setWallet((prev) => ({ balance: prev.balance - amount }))
setOrders((prev) => [
{
id: Math.random().toString(36).slice(2, 11),
orderNo,
type,
title,
price: amount,
status: "completed",
date: new Date().toISOString().split("T")[0]!,
image,
},
...prev,
])
return true
}
try {
const r = await appWalletConsume({ amount, title, kind: type, image })
const bal = r.data?.balance
if (typeof bal === "number") setWallet({ balance: bal })
await fetchOrdersForUser()
return true
} catch (e) {
toast({
title: "支付失败",
description: e instanceof Error ? e.message : "请稍后重试",
variant: "destructive",
})
return false
}
},
[fetchOrdersForUser, wallet.balance],
)
const updateUser = (updates: Partial<User>) => {
setUser((prev) => ({ ...prev, ...updates }))

View File

@@ -1,130 +1,140 @@
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Star, Trophy, Users, Shield, Gamepad2, Award, MessageCircle, Check } from "lucide-react"
import { ArrowLeft, Star, Trophy, Users, Shield, Gamepad2, Award, MessageCircle, Check, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { appStar } from "@/api/catalog"
import { useAppContext } from "@/components/providers/app-provider"
const COACH_DATA: Record<
string,
{
name: string
title: string
avatar: string
cover: string
games: string[]
rank: string
orders: number
rating: number
goodRate: string
bio: string
tags: string[]
services: { name: string; price: number; desc: string; duration: string; hot?: boolean }[]
online: boolean
type CoachService = { name: string; price: number; desc: string; duration: string; hot?: boolean }
type CoachView = {
name: string
title: string
avatar: string
cover: string
games: string[]
rank: string
orders: number
rating: number
goodRate: string
bio: string
tags: string[]
services: CoachService[]
online: boolean
}
function buildCoachFromApi(raw: Record<string, unknown>): CoachView {
const tags = Array.isArray(raw.tags) ? (raw.tags as string[]).filter(Boolean) : []
const game = typeof raw.game === "string" ? raw.game : ""
const games = Array.isArray(raw.games) ? (raw.games as string[]) : game ? [game] : []
let services: CoachService[] = []
if (Array.isArray(raw.services)) {
for (const item of raw.services as Record<string, unknown>[]) {
const name = String(item.name ?? "陪练套餐")
const price = Number(item.price ?? 0) || Number(raw.hourlyRate ?? raw.price ?? 80)
services.push({
name,
price,
desc: String(item.desc ?? ""),
duration: String(item.duration ?? "局"),
hot: Boolean(item.hot || item.hotValue),
})
}
}
if (!services.length) {
const p = Number(raw.hourlyRate ?? raw.price ?? 80) || 80
services = [
{ name: "单局陪练", price: Math.round(p), desc: String(raw.intro ?? "按局计费"), duration: "局" },
{ name: "5局套餐", price: Math.round(p * 4.5), desc: "多局优惠示意", duration: "5局", hot: true },
]
}
const followers = Number(raw.followers ?? 0)
return {
name: String(raw.name ?? "大神"),
title: String(raw.title ?? game ?? ""),
avatar: String(raw.avatar ?? "/placeholder.svg"),
cover: String(raw.cover ?? raw.coverImage ?? "/placeholder.svg"),
games,
rank: String(raw.title ?? "—"),
orders: followers > 0 ? Math.round(followers) : 0,
rating: typeof raw.rating === "number" ? raw.rating : 4.8,
goodRate: "—",
bio: String(raw.intro ?? raw.title ?? ""),
tags: [...new Set([...tags, ...games])].slice(0, 12),
services,
online: Boolean(raw.isOnline),
}
> = {
"1": {
name: "职业打野-小明",
title: "前LPL职业选手",
avatar: "/coach-avatar-1.jpg",
cover: "/coach-cover-1.jpg",
games: ["英雄联盟"],
rank: "王者2000分",
orders: 2456,
rating: 4.9,
goodRate: "98%",
bio: "前LPL职业选手 · 国服第一打野 · 5年教学经验",
tags: ["职业", "打野", "带飞", "耐心教学"],
services: [
{ name: "单局陪练", price: 80, desc: "边玩边教,即时指导", duration: "1局" },
{ name: "5局套餐", price: 360, desc: "系统提升优惠45元", duration: "5局", hot: true },
{ name: "10局特训", price: 650, desc: "深度培训,包上分", duration: "10局" },
],
online: true,
},
"2": {
name: "上分小助手",
title: "国服路人王",
avatar: "/coach-avatar-2.jpg",
cover: "/coach-cover-2.jpg",
games: ["王者荣耀", "和平精英"],
rank: "荣耀王者100星",
orders: 1823,
rating: 4.8,
goodRate: "97%",
bio: "多区路人王 · 全英雄精通 · 上分效率高",
tags: ["温柔", "耐心", "指导", "效率高"],
services: [
{ name: "单局陪练", price: 50, desc: "轻松开黑", duration: "1局" },
{ name: "3局套餐", price: 135, desc: "连续开黑优惠", duration: "3局", hot: true },
{ name: "包天陪练", price: 300, desc: "全天候服务", duration: "不限局数" },
],
online: true,
},
"3": {
name: "FPS大神",
title: "前职业选手",
avatar: "/coach-avatar-3.jpg",
cover: "/coach-cover-3.jpg",
games: ["无畏契约", "三角洲行动"],
rank: "超凡入圣",
orders: 987,
rating: 4.9,
goodRate: "99%",
bio: "FPS专业选手 · 精准枪法 · 战术意识强",
tags: ["枪法", "意识", "专业", "战术指导"],
services: [
{ name: "单局陪练", price: 100, desc: "职业级指导", duration: "1局" },
{ name: "技术特训", price: 450, desc: "系统提升枪法", duration: "5局", hot: true },
{ name: "冲分套餐", price: 800, desc: "保证上段", duration: "10局" },
],
online: false,
},
"4": {
name: "温柔陪练师",
title: "全能型选手",
avatar: "/coach-avatar-4.jpg",
cover: "/coach-cover-4.jpg",
games: ["王者荣耀", "英雄联盟"],
rank: "多区王者",
orders: 3201,
rating: 5.0,
goodRate: "100%",
bio: "全能选手 · 声音好听 · 超级耐心",
tags: ["声音好听", "技术强", "幽默", "好评如潮"],
services: [
{ name: "欢乐开黑", price: 60, desc: "快乐游戏为主", duration: "1局" },
{ name: "技术提升", price: 270, desc: "边玩边学", duration: "5局", hot: true },
{ name: "专属陪练", price: 500, desc: "定制化服务", duration: "10局" },
],
online: true,
},
}
export function CoachDetailPage() {
const { id = "1" } = useParams()
const { id = "" } = useParams()
const navigate = useNavigate()
const { pay } = useAppContext()
const [coach, setCoach] = useState<CoachView | null>(null)
const [loading, setLoading] = useState(true)
const [selectedService, setSelectedService] = useState<number | null>(null)
const [isPaying, setIsPaying] = useState(false)
const coach = COACH_DATA[id] ?? COACH_DATA["1"]!
const load = useCallback(() => {
if (!id) {
setCoach(null)
setLoading(false)
return
}
setLoading(true)
void appStar(id)
.then((r) => {
const d = r.data
if (d && typeof d === "object") setCoach(buildCoachFromApi(d as Record<string, unknown>))
else setCoach(null)
})
.catch(() => setCoach(null))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
load()
}, [load])
const handleBook = () => {
if (selectedService === null) {
toast({ title: "请选择服务", description: "请先选择一个陪练服务套餐" })
toast({ title: "请选择服务", description: "请先选择一个套餐" })
return
}
setIsPaying(true)
}
const handlePay = () => {
if (selectedService === null) return
toast({
title: "支付成功",
description: `已成功预约${coach.name}${coach.services[selectedService]!.name}`,
})
const handlePay = async () => {
if (selectedService === null || !coach) return
const s = coach.services[selectedService]!
const ok = await pay(s.price, `${coach.name} · ${s.name}`, "service", coach.avatar)
if (ok) {
toast({ title: "支付成功", description: `已下单 ${coach.name} · ${s.name}` })
void navigate("/orders")
}
setIsPaying(false)
void navigate("/messages")
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background pb-24">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
</div>
)
}
if (!coach) {
return (
<div className="min-h-screen bg-background px-4 pb-24 pt-16">
<p className="text-center text-sm text-white/60"></p>
<Button type="button" variant="outline" className="mt-6 mx-auto flex" onClick={() => void navigate("/coach")}>
</Button>
</div>
)
}
return (
@@ -164,7 +174,7 @@ export function CoachDetailPage() {
<h1 className="text-2xl font-bold">{coach.name}</h1>
{coach.rating >= 4.9 && <Award className="text-yellow-400" size={20} />}
</div>
<p className="text-sm text-white/60 mb-3">{coach.bio}</p>
<p className="text-sm text-white/60 mb-3">{coach.bio || coach.title}</p>
<div className="flex items-center gap-4 text-xs text-white/40">
<span className="flex items-center gap-1">
<Star size={12} className="text-primary fill-primary" />
@@ -172,7 +182,7 @@ export function CoachDetailPage() {
</span>
<span className="flex items-center gap-1">
<Trophy size={12} className="text-accent" />
{coach.orders}
{coach.orders}
</span>
<span className="flex items-center gap-1">
<Users size={12} className="text-secondary" />
@@ -189,7 +199,7 @@ export function CoachDetailPage() {
<div className="space-y-3">
{coach.services.map((service, i) => (
<div
key={service.name}
key={`${service.name}-${i}`}
className={`glass-card p-4 rounded-xl relative cursor-pointer transition-all ${selectedService === i ? "ring-2 ring-green-500 bg-green-500/10" : "hover:bg-white/5"}`}
onClick={() => setSelectedService(i)}
onKeyDown={(e) => e.key === "Enter" && setSelectedService(i)}
@@ -197,7 +207,9 @@ export function CoachDetailPage() {
tabIndex={0}
>
{service.hot && (
<span className="absolute top-2 right-2 px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 text-[10px] font-bold"></span>
<span className="absolute top-2 right-2 px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 text-[10px] font-bold">
HOT
</span>
)}
{selectedService === i && (
<div className="absolute top-2 left-2 w-5 h-5 rounded-full bg-green-500 flex items-center justify-center z-10">
@@ -211,7 +223,7 @@ export function CoachDetailPage() {
</div>
<div className="text-right">
<div className="text-2xl font-bold text-green-500">{service.price}</div>
<div className="text-[10px] text-white/50">/{service.duration}</div>
<div className="text-[10px] text-white/50"> / {service.duration}</div>
</div>
</div>
</div>
@@ -220,7 +232,7 @@ export function CoachDetailPage() {
</div>
<div className="space-y-4 mb-6">
<h3 className="font-bold text-sm"></h3>
<h3 className="font-bold text-sm"></h3>
<div className="flex flex-wrap gap-2">
{coach.tags.map((tag) => (
<span key={tag} className="px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 text-xs text-green-400">
@@ -234,14 +246,14 @@ export function CoachDetailPage() {
<div className="flex items-center gap-3">
<Shield size={24} className="text-green-500" />
<div>
<h4 className="font-bold text-sm"></h4>
<p className="text-xs text-white/60">退</p>
<h4 className="font-bold text-sm"></h4>
<p className="text-xs text-white/60">使</p>
</div>
</div>
</div>
</div>
{isPaying && selectedService !== null && (
{isPaying && selectedService !== null && coach && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-end justify-center">
<div className="w-full max-w-lg bg-background rounded-t-3xl p-6 space-y-6">
<div className="flex items-center justify-between">
@@ -261,14 +273,13 @@ export function CoachDetailPage() {
</div>
</div>
<div className="flex items-center justify-between py-3 border-t border-white/10">
<span className="text-white/60"></span>
<span className="text-2xl font-bold text-green-500">¥{coach.services[selectedService]!.price}</span>
<span className="text-white/60"></span>
<span className="text-2xl font-bold text-green-500">{coach.services[selectedService]!.price}</span>
</div>
</div>
<Button className="w-full h-12 bg-green-500 hover:bg-green-600 text-lg font-bold" type="button" onClick={handlePay}>
<Button className="w-full h-12 bg-green-500 hover:bg-green-600 text-lg font-bold" type="button" onClick={() => void handlePay()}>
</Button>
<p className="text-center text-xs text-white/40"></p>
</div>
</div>
)}

View File

@@ -1,25 +1,49 @@
import { ArrowLeft, Trophy, Star, Gamepad2, MessageCircle, Award } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, Trophy, Star, Gamepad2, MessageCircle, Award, Loader2 } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { appStars, type AppStarDTO } from "@/api/catalog"
const COACH_LIST = [
{ id: "1", name: "职业打野-小明", title: "前LPL职业选手", avatar: "/coach-avatar-1.jpg", games: ["英雄联盟"], rank: "王者2000分", orders: 2456, rating: 4.9, price: 80, tags: ["职业", "打野", "带飞"], online: true },
{ id: "2", name: "上分小助手", title: "国服路人王", avatar: "/coach-avatar-2.jpg", games: ["王者荣耀", "和平精英"], rank: "荣耀王者100星", orders: 1823, rating: 4.8, price: 50, tags: ["温柔", "耐心", "指导"], online: true },
{ id: "3", name: "FPS大神", title: "前职业选手", avatar: "/coach-avatar-3.jpg", games: ["无畏契约", "三角洲"], rank: "超凡入圣", orders: 987, rating: 4.9, price: 100, tags: ["枪法", "意识", "专业"], online: false },
{ id: "4", name: "温柔陪练师", title: "全能型选手", avatar: "/coach-avatar-4.jpg", games: ["王者荣耀", "英雄联盟"], rank: "多区王者", orders: 3201, rating: 5.0, price: 60, tags: ["声音好听", "技术强", "幽默"], online: true },
] as const
type CoachRow = {
id: string
name: string
title: string
avatar: string
games: string[]
rank: string
orders: number
rating: number
price: number
tags: string[]
online: boolean
}
type Coach = (typeof COACH_LIST)[number]
function starToCoach(s: AppStarDTO): CoachRow {
const tags = Array.isArray(s.tags) ? s.tags.filter(Boolean) : [s.game].filter(Boolean).map(String)
return {
id: s.id,
name: (s.name && s.name.trim()) || "大神",
title: (s.title && s.title.trim()) || s.game || "",
avatar: (s.avatar && s.avatar.trim()) || "/placeholder.svg",
games: s.game ? [s.game] : [],
rank: (s.title && s.title.trim()) || "—",
orders: Math.max(0, Math.round(Number(s.fans) || 0)),
rating: typeof s.rating === "number" && s.rating > 0 ? s.rating : 4.8,
price: Math.round(Number(s.hourlyRate ?? s.price) || 50),
tags: [...new Set(tags)].slice(0, 8),
online: !!s.isOnline,
}
}
function CoachCard({
coach,
navigate,
onBook,
}: {
coach: Coach
coach: CoachRow
navigate: ReturnType<typeof useNavigate>
onBook: (name: string) => void
}) {
@@ -34,14 +58,14 @@ function CoachCard({
<div className="flex items-start gap-3 mb-3">
<div className="relative">
<div className="w-16 h-16 rounded-lg bg-muted relative overflow-hidden">
<FillImage src={coach.avatar || "/placeholder.svg"} alt={coach.name} className="h-16 w-16" />
<FillImage src={coach.avatar} alt={coach.name} className="h-16 w-16" />
</div>
{coach.online && <div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500 border-2 border-background" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold truncate">{coach.name}</h4>
{(coach.rating === 5.0 || coach.rating >= 4.9) && <Award className="w-4 h-4 text-yellow-400" />}
{coach.rating >= 4.9 && <Award className="w-4 h-4 text-yellow-400" />}
</div>
<p className="text-xs text-white/60 mb-2">{coach.title}</p>
<div className="flex items-center gap-3 text-xs text-white/50 mb-2">
@@ -51,7 +75,7 @@ function CoachCard({
</span>
<span className="flex items-center gap-1">
<Trophy size={12} className="text-accent" />
{coach.orders}
{coach.orders}
</span>
</div>
<div className="flex flex-wrap gap-1">
@@ -100,8 +124,22 @@ function CoachCard({
export function CoachListPage() {
const navigate = useNavigate()
const [list, setList] = useState<CoachRow[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
void appStars()
.then((rows) => setList(rows.map(starToCoach)))
.catch(() => setList([]))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
const handleBookCoach = (coachName: string) => {
toast({ title: "预约成功", description: `成功预约${coachName}的陪练服务` })
toast({ title: "已记录意向", description: `${coachName} 发起意向(接单以订单为准)` })
}
return (
@@ -110,60 +148,58 @@ export function CoachListPage() {
<button onClick={() => void navigate(-1)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors" type="button">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-lg"></h1>
<h1 className="font-bold text-lg"></h1>
</header>
<div className="p-4 space-y-6">
<div className="glass-card p-4 rounded-xl border-l-4 border-green-500">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-sm"></h3>
<span className="text-xs text-white/50">1000+ </span>
</div>
<p className="text-xs text-white/60 mb-3"></p>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-white/5 rounded-lg p-2">
<div className="text-xs font-bold text-green-500"></div>
<div className="text-[10px] text-white/50 mt-0.5"></div>
</div>
<div className="bg-white/5 rounded-lg p-2">
<div className="text-xs font-bold text-blue-500"></div>
<div className="text-[10px] text-white/50 mt-0.5"></div>
</div>
<div className="bg-white/5 rounded-lg p-2">
<div className="text-xs font-bold text-purple-500"></div>
<div className="text-[10px] text-white/50 mt-0.5"></div>
</div>
<h3 className="font-bold text-sm"></h3>
<span className="text-xs text-white/50">{list.length} </span>
</div>
<p className="text-xs text-white/60 mb-3"></p>
</div>
<Tabs defaultValue="hot" className="w-full">
<TabsList className="w-full bg-white/5 p-1 rounded-xl">
<TabsTrigger value="hot" className="flex-1 text-xs">
</TabsTrigger>
<TabsTrigger value="pro" className="flex-1 text-xs">
</TabsTrigger>
<TabsTrigger value="king" className="flex-1 text-xs">
</TabsTrigger>
</TabsList>
<TabsContent value="hot" className="mt-4 space-y-3">
{COACH_LIST.map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
<TabsContent value="pro" className="mt-4 space-y-3">
{COACH_LIST.filter((c) => c.title.includes("职业")).map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
<TabsContent value="king" className="mt-4 space-y-3">
{COACH_LIST.filter((c) => c.rank.includes("王者") || c.rating === 5.0).map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
</Tabs>
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : list.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> · stars</p>
) : (
<Tabs defaultValue="hot" className="w-full">
<TabsList className="w-full bg-white/5 p-1 rounded-xl">
<TabsTrigger value="hot" className="flex-1 text-xs">
</TabsTrigger>
<TabsTrigger value="pro" className="flex-1 text-xs">
</TabsTrigger>
<TabsTrigger value="king" className="flex-1 text-xs">
</TabsTrigger>
</TabsList>
<TabsContent value="hot" className="mt-4 space-y-3">
{list.map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
<TabsContent value="pro" className="mt-4 space-y-3">
{list
.filter((c) => c.title.includes("职业") || c.tags.some((t) => t.includes("职业")))
.map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
<TabsContent value="king" className="mt-4 space-y-3">
{list
.filter((c) => c.rating >= 4.9 || (c.games[0] && c.games[0].includes("王者")))
.map((coach) => (
<CoachCard key={coach.id} coach={coach} navigate={navigate} onBook={handleBookCoach} />
))}
</TabsContent>
</Tabs>
)}
</div>
</div>
)

View File

@@ -1,168 +1,126 @@
import { useState } from "react"
import { useParams, useNavigate, Link } from "react-router-dom"
import { ArrowLeft, Star, Clock, Users, PlayCircle, Lock, Share2, Coins } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Star, Clock, Users, PlayCircle, Lock, Share2, Coins, Loader2 } from "lucide-react"
import { useAppContext } from "@/components/providers/app-provider"
import { FillImage } from "@/components/media/FillImage"
import { toast } from "@/hooks/use-toast"
import { appContentItem } from "@/api/catalog"
import { Button } from "@/components/ui/button"
const courseData: Record<string, {
id: string
streamerId: string
title: string
cover: string
streamer: { name: string; avatar: string; title: string }
price: number
originalPrice: number
students: number
duration: string
rating: number
lessons: { title: string; duration: string; isFree: boolean }[]
}> = {
"1": {
id: "1",
streamerId: "1",
title: "打野进阶课:从入门到王者",
cover: "/live-lol.jpg",
streamer: { name: "GM-远洋", avatar: "/streamer-1.jpg", title: "国服第一盲僧" },
price: 299,
originalPrice: 499,
students: 1205,
duration: "8节课 (120分钟)",
rating: 4.9,
lessons: [
{ title: "01. 打野基础思路与路线规划", duration: "15:00", isFree: true },
{ title: "02. 如何高效刷野与反野", duration: "18:30", isFree: false },
{ title: "03. Gank时机与路线选择", duration: "20:00", isFree: false },
{ title: "04. 控龙与峡谷先锋运营", duration: "16:45", isFree: false },
{ title: "05. 团战切入时机详解", duration: "22:10", isFree: false },
{ title: "06. 逆风局如何翻盘", duration: "19:20", isFree: false },
],
},
"2": {
id: "2",
streamerId: "2",
title: "无畏契约枪法训练营",
cover: "/live-valorant.jpg",
streamer: { name: "卡若", avatar: "/streamer-2.jpg", title: "前职业选手" },
price: 399,
originalPrice: 599,
students: 856,
duration: "10节课 (150分钟)",
rating: 4.8,
lessons: [
{ title: "01. 准星设置与灵敏度调整", duration: "12:00", isFree: true },
{ title: "02. 爆头线与预瞄点位", duration: "20:00", isFree: false },
{ title: "03. 各类枪械弹道控制", duration: "25:00", isFree: false },
{ title: "04. 常用身法技巧详解", duration: "18:00", isFree: false },
{ title: "05. 地图道具使用教学", duration: "22:00", isFree: false },
],
},
"3": {
id: "3",
streamerId: "3",
title: "和平精英四指操作教学",
cover: "/live-pubg.jpg",
streamer: { name: "小美", avatar: "/streamer-3.jpg", title: "全国冠军" },
price: 199,
originalPrice: 299,
students: 2340,
duration: "6节课 (90分钟)",
rating: 4.9,
lessons: [
{ title: "01. 四指操作键位设置", duration: "10:00", isFree: true },
{ title: "02. 基础移动与射击配合", duration: "15:00", isFree: false },
{ title: "03. 压枪技巧与弹道控制", duration: "20:00", isFree: false },
{ title: "04. 载具战斗与转移技巧", duration: "18:00", isFree: false },
],
},
"4": {
id: "4",
streamerId: "4",
title: "原神深渊满星攻略",
cover: "/live-genshin.jpg",
streamer: { name: "星辰", avatar: "/streamer-4.jpg", title: "原神攻略大神" },
price: 149,
originalPrice: 249,
students: 3450,
duration: "5节课 (75分钟)",
rating: 4.7,
lessons: [
{ title: "01. 深渊机制与阵容搭配", duration: "18:00", isFree: true },
{ title: "02. 各层怪物特性解析", duration: "20:00", isFree: false },
{ title: "03. 圣遗物选择与词条优化", duration: "22:00", isFree: false },
{ title: "04. 手法与连招技巧", duration: "15:00", isFree: false },
],
},
"5": {
id: "5",
streamerId: "5",
title: "王者荣耀上分秘籍",
cover: "/live-hok.jpg",
streamer: { name: "阿杰", avatar: "/streamer-5.jpg", title: "国服百星" },
price: 249,
originalPrice: 399,
students: 4560,
duration: "8节课 (120分钟)",
rating: 4.8,
lessons: [
{ title: "01. 段位机制与上分心态", duration: "12:00", isFree: true },
{ title: "02. 各路位选英雄推荐", duration: "18:00", isFree: false },
{ title: "03. 对线技巧与兵线控制", duration: "20:00", isFree: false },
{ title: "04. 节奏把控与支援时机", duration: "22:00", isFree: false },
{ title: "05. 团战站位与技能释放", duration: "25:00", isFree: false },
],
},
"6": {
id: "6",
streamerId: "6",
title: "魔兽世界团本攻略",
cover: "/live-wow.jpg",
streamer: { name: "暗影", avatar: "/streamer-6.jpg", title: "首杀团队成员" },
price: 499,
originalPrice: 799,
students: 890,
duration: "12节课 (200分钟)",
rating: 4.9,
lessons: [
{ title: "01. 团队配置与职责分工", duration: "15:00", isFree: true },
{ title: "02. BOSS机制详细解析", duration: "30:00", isFree: false },
{ title: "03. 治疗与坦克配合要点", duration: "25:00", isFree: false },
{ title: "04. DPS输出循环优化", duration: "28:00", isFree: false },
{ title: "05. 困难模式特殊机制", duration: "35:00", isFree: false },
],
},
type LessonRow = { title: string; duration: string; isFree: boolean }
function normLessons(chapters: unknown, sessions: unknown): LessonRow[] {
const pick = chapters ?? sessions
if (!Array.isArray(pick) || pick.length === 0) {
return [{ title: "01. 课程目录整理中", duration: "—", isFree: true }]
}
return pick.map((raw, idx) => {
if (typeof raw === "string") {
return { title: `${String(idx + 1).padStart(2, "0")}. ${raw}`, duration: "—", isFree: idx === 0 }
}
const o = raw as Record<string, unknown>
const title = String(o.title ?? o.name ?? `章节 ${idx + 1}`)
const duration = String(o.duration ?? o.length ?? "—")
const isFree = Boolean(o.isFree ?? o.free ?? idx === 0)
return { title: title.startsWith("0") ? title : `${String(idx + 1).padStart(2, "0")}. ${title}`, duration, isFree }
})
}
export function CourseDetailPage() {
const { id = "1" } = useParams()
const { id = "" } = useParams()
const navigate = useNavigate()
const { pay } = useAppContext()
const [showShareModal, setShowShareModal] = useState(false)
const course = courseData[id] ?? courseData["1"]!
const [loading, setLoading] = useState(true)
const [title, setTitle] = useState("")
const [cover, setCover] = useState("/placeholder.svg")
const [price, setPrice] = useState(0)
const [students, setStudents] = useState(0)
const [rating, setRating] = useState(4.8)
const [duration, setDuration] = useState("")
const [author, setAuthor] = useState("教官")
const [avatar, setAvatar] = useState("/placeholder.svg")
const [lessons, setLessons] = useState<LessonRow[]>([])
const [desc, setDesc] = useState("")
const handleBuy = () => {
const ok = pay(course.price, course.title, "course", course.cover)
const load = useCallback(() => {
if (!id) {
setLoading(false)
return
}
setLoading(true)
void appContentItem(id)
.then((r) => {
const d = r.data as Record<string, unknown> | undefined
if (!d) throw new Error("empty")
setTitle(String(d.title ?? d.name ?? "课程"))
const img = String(d.cover ?? d.coverImage ?? d.image ?? "/placeholder.svg")
setCover(img)
setPrice(Math.round(Number(d.price) || 0))
setStudents(Math.round(Number(d.students) || 0))
setRating(Number(d.rating) > 0 ? Number(d.rating) : 4.8)
setDuration(typeof d.duration === "string" ? d.duration : "")
setAuthor(String(d.author ?? "教官"))
setAvatar(img)
setDesc(String(d.description ?? ""))
setLessons(normLessons(d.chapters, d.sessions))
})
.catch(() => {
setTitle("")
setLessons([])
})
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
load()
}, [load])
const handleBuy = async () => {
if (!title) return
const ok = await pay(price || 0, title, "course", cover)
if (ok) {
toast({ title: "购买成功" })
void navigate("/messages")
}
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
</div>
)
}
if (!title) {
return (
<div className="min-h-screen bg-background px-4 pt-16 pb-28">
<p className="text-center text-sm text-muted-foreground"></p>
<Button type="button" variant="outline" className="mt-6 mx-auto flex" onClick={() => void navigate("/mall/courses")}>
</Button>
</div>
)
}
return (
<div className="min-h-screen bg-background pb-28">
<div className="relative aspect-video w-full">
<FillImage src={course.cover} alt="Course Cover" className="aspect-video" />
<FillImage src={cover} alt="Course Cover" className="aspect-video" />
<div className="absolute inset-0 bg-black/30" />
<Link
to="/"
<button
type="button"
onClick={() => void navigate(-1)}
className="absolute top-4 left-4 w-10 h-10 rounded-full bg-black/50 backdrop-blur-md flex items-center justify-center text-white hover:bg-black/70 transition-colors"
aria-label="返回"
>
<ArrowLeft size={20} />
</Link>
</button>
<button
type="button"
onClick={() => setShowShareModal(true)}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-black/50 backdrop-blur-md flex items-center justify-center text-white hover:bg-black/70 transition-colors"
aria-label="分享"
>
<Share2 size={20} />
</button>
@@ -172,124 +130,75 @@ export function CourseDetailPage() {
</div>
</div>
<div className="absolute bottom-4 right-4 px-3 py-1.5 rounded-lg bg-primary/90 backdrop-blur-sm flex items-center gap-1">
<span className="text-black font-bold text-lg">{course.price}</span>
<span className="text-black font-bold text-lg">{price}</span>
<Coins size={14} className="text-black/70" />
<span className="text-black/70 text-xs"></span>
<span className="text-black/50 text-xs ml-2 line-through">{course.originalPrice}</span>
</div>
</div>
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold mb-2">{course.title}</h1>
<div className="flex items-center gap-4 text-xs text-white/60">
<div className="px-4 pt-6 space-y-4">
<h1 className="text-xl font-bold leading-snug">{title}</h1>
{desc ? <p className="text-sm text-muted-foreground whitespace-pre-wrap">{desc}</p> : null}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star size={12} className="text-yellow-400 fill-yellow-400" /> {rating}
</span>
<span className="flex items-center gap-1">
<Users size={12} /> {students}
</span>
{duration ? (
<span className="flex items-center gap-1">
<Star size={14} className="text-primary fill-primary" />
{course.rating}
</span>
<span className="flex items-center gap-1">
<Clock size={14} /> {course.duration}
</span>
<span className="flex items-center gap-1">
<Users size={14} /> {course.students}
<Clock size={12} /> {duration}
</span>
) : null}
</div>
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<div className="h-11 w-11 overflow-hidden rounded-full bg-muted">
<FillImage src={avatar} alt="" className="h-full" />
</div>
<div className="min-w-0">
<div className="text-sm font-bold truncate">{author}</div>
<div className="text-[11px] text-muted-foreground"> contentItems</div>
</div>
</div>
<Link
to={`/live/${course.streamerId}`}
className="flex items-center gap-3 p-3 rounded-xl bg-white/5 mb-6 hover:bg-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-primary relative shrink-0">
<FillImage src={course.streamer.avatar} alt="Instructor" className="h-12 w-12 rounded-full" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-sm">{course.streamer.name}</h3>
<p className="text-xs text-white/50">{course.streamer.title}</p>
</div>
<span className="px-4 py-1.5 rounded-full border border-primary text-primary text-xs font-bold"></span>
</Link>
<div className="mb-6">
<h3 className="font-bold text-lg mb-4"></h3>
<div className="space-y-3">
{course.lessons.map((lesson, i) => (
<div key={lesson.title} className="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-xs font-bold text-white/50 group-hover:bg-primary group-hover:text-black transition-colors">
{i + 1}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium mb-1 group-hover:text-primary transition-colors">{lesson.title}</h4>
<span className="text-xs text-white/40">{lesson.duration}</span>
</div>
<div className="space-y-3">
<h2 className="text-sm font-bold"></h2>
{lessons.map((lesson, idx) => (
<div
key={`${lesson.title}-${idx}`}
className="flex items-center justify-between rounded-lg border border-white/10 bg-black/20 px-3 py-2.5"
>
<div className="flex min-w-0 items-center gap-2">
{lesson.isFree ? (
<div className="px-2 py-0.5 rounded bg-green-500/20 text-green-500 text-[10px] font-bold"></div>
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"></span>
) : (
<Lock size={16} className="text-white/30 shrink-0" />
<Lock size={14} className="shrink-0 text-muted-foreground" />
)}
<span className="truncate text-xs">{lesson.title}</span>
</div>
))}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">{lesson.duration}</span>
</div>
))}
</div>
<Button type="button" className="w-full" onClick={() => void handleBuy()}>
· {price}
</Button>
</div>
{showShareModal && (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<button type="button" className="absolute inset-0 bg-black/60 cursor-default" onClick={() => setShowShareModal(false)} aria-label="关闭" />
<div className="relative w-full bg-zinc-900 rounded-t-3xl p-6 animate-in slide-in-from-bottom-4">
<h3 className="text-lg font-bold mb-4 text-center"></h3>
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{ name: "微信", icon: "💬", color: "bg-green-500" },
{ name: "朋友圈", icon: "🌐", color: "bg-green-600" },
{ name: "QQ", icon: "🐧", color: "bg-blue-500" },
{ name: "微博", icon: "📱", color: "bg-red-500" },
].map((platform) => (
<button
key={platform.name}
type="button"
onClick={() => {
setShowShareModal(false)
toast({ title: "已选择", description: platform.name })
}}
className="flex flex-col items-center gap-2"
>
<div className={`w-14 h-14 rounded-full ${platform.color} flex items-center justify-center text-2xl`}>{platform.icon}</div>
<span className="text-xs text-white/70">{platform.name}</span>
</button>
))}
</div>
<button
type="button"
onClick={() => setShowShareModal(false)}
className="w-full py-3 rounded-xl bg-white/10 text-white font-medium"
>
</button>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" role="presentation" onClick={() => setShowShareModal(false)}>
<div role="dialog" className="w-full max-w-sm rounded-xl border border-white/10 bg-zinc-900 p-4" onClick={(e) => e.stopPropagation()}>
<p className="text-sm text-muted-foreground"></p>
<Button type="button" className="mt-4 w-full" onClick={() => setShowShareModal(false)}>
</Button>
</div>
</div>
)}
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-white/10 bg-background/95 backdrop-blur-md px-4 py-3 flex items-center justify-between gap-3 safe-pb">
<div>
<div className="text-xs text-white/50"></div>
<div className="text-lg font-bold text-primary">
{course.price} <span className="text-sm font-normal text-white/50"></span>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void navigate(`/chat/course-${id}`)}
className="px-4 py-2 rounded-full border border-white/20 text-sm"
>
</button>
<button type="button" onClick={handleBuy} className="px-6 py-2 rounded-full bg-primary text-black text-sm font-bold">
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,20 +1,4 @@
import {
Search,
Bell,
Flame,
Sword,
Crosshair,
Crown,
Gift,
Zap,
Building2,
Gamepad2,
Target,
Shield,
Wand2,
Users,
Coins,
} from "lucide-react"
import { Search, Bell, Flame, Gift, Zap, Building2, Gamepad2, Users, Coins } from "lucide-react"
import { Link, useNavigate } from "react-router-dom"
import { useEffect, useState } from "react"
import { useAppContext } from "@/components/providers/app-provider"
@@ -22,6 +6,7 @@ import { useStreamers } from "@/hooks/use-streamers"
import { getStreamerAvatarUrl } from "@/lib/streamer-avatar"
import { FillImage } from "@/components/media/FillImage"
import { getMallPointCards, type MallPointCard } from "@/api/mall"
import { getAppGames, type AppGame } from "@/api/games"
function formatHeat(val?: number): string {
if (val == null || val === 0) return "0"
@@ -35,11 +20,15 @@ export function HomePage() {
const { wallet } = useAppContext()
const { streamers, loading } = useStreamers()
const [apiPointCards, setApiPointCards] = useState<MallPointCard[]>([])
const [apiGames, setApiGames] = useState<AppGame[]>([])
useEffect(() => {
void getMallPointCards()
.then(setApiPointCards)
.catch(() => setApiPointCards([]))
void getAppGames()
.then(setApiGames)
.catch(() => setApiGames([]))
}, [])
const starPlayers = streamers.slice(0, 8).map((s) => ({
@@ -52,27 +41,6 @@ export function HomePage() {
isLive: !!s.isLive,
}))
const gamePointCards = [
{ name: "暗黑破坏神4", icon: "/game-diablo.jpg", discount: "85折", tag: "热卖", link: "/mall/points/diablo" },
{ name: "炉石传说", icon: "/game-hearthstone.jpg", discount: "88折", tag: "新品", link: "/mall/points/hearthstone" },
{ name: "守望先锋2", icon: "/game-overwatch.jpg", discount: "9折", tag: "", link: "/mall/points/overwatch" },
{ name: "英雄联盟", icon: "/game-lol.jpg", discount: "92折", tag: "热门", link: "/mall/points/lol" },
{ name: "王者荣耀", icon: "/game-hok.jpg", discount: "95折", tag: "", link: "/mall/points/hok" },
{ name: "原神", icon: "/game-genshin.jpg", discount: "9折", tag: "限量", link: "/mall/points/genshin" },
{ name: "和平精英", icon: "/game-pubg.jpg", discount: "88折", tag: "", link: "/mall/points/pubg" },
{ name: "无畏契约", icon: "/point-card-valorant.jpg", discount: "9折", tag: "", link: "/mall/points/valorant" },
]
const gameIcons = [
{ name: "英雄联盟", icon: Sword, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20", link: "/zone/lol" },
{ name: "无畏契约", icon: Crosshair, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20", link: "/zone/val" },
{ name: "王者荣耀", icon: Crown, color: "text-yellow-400", bg: "bg-yellow-500/10", border: "border-yellow-500/20", link: "/zone/hok" },
{ name: "CS2", icon: Target, color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20", link: "/zone/csgo" },
{ name: "原神", icon: Wand2, color: "text-cyan-400", bg: "bg-cyan-500/10", border: "border-cyan-500/20", link: "/zone/genshin" },
{ name: "和平精英", icon: Shield, color: "text-green-400", bg: "bg-green-500/10", border: "border-green-500/20", link: "/zone/pubg" },
{ name: "Apex", icon: Gamepad2, color: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20", link: "/zone/apex" },
]
return (
<div className="bg-background flex min-h-screen flex-col pb-24">
<header className="border-b border-white/5 bg-[#0D0D15]/80 sticky top-0 z-40 flex min-w-0 items-center justify-between gap-2 px-4 py-3 backdrop-blur-md transition-all duration-300">
@@ -138,52 +106,40 @@ export function HomePage() {
</Link>
</div>
<div className="no-scrollbar flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: "none" }}>
{apiPointCards.length > 0
? apiPointCards.slice(0, 8).map((card) => (
<Link
key={card.id}
to={`/mall/points/item/${encodeURIComponent(card.id)}`}
className="border-white/5 bg-[#1A1A22] hover:bg-[#20202A] group relative flex w-[72px] flex-shrink-0 flex-col items-center gap-2 overflow-hidden rounded-xl border p-2 transition-colors"
>
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
<img
src={
card.gameLogo?.startsWith("http")
{apiPointCards.length > 0 ? (
apiPointCards.slice(0, 8).map((card) => (
<Link
key={card.id}
to={`/mall/points/item/${encodeURIComponent(card.id)}`}
className="border-white/5 bg-[#1A1A22] hover:bg-[#20202A] group relative flex w-[72px] flex-shrink-0 flex-col items-center gap-2 overflow-hidden rounded-xl border p-2 transition-colors"
>
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
<img
src={
card.gameLogo?.startsWith("http")
? card.gameLogo
: card.gameLogo?.startsWith("/")
? card.gameLogo
: card.gameLogo?.startsWith("/")
? card.gameLogo
: "/point-card-valorant.jpg"
}
alt={card.name}
className="h-full w-full object-cover"
/>
</div>
<div className="text-center">
<p className="w-full truncate text-[9px] text-white/80">{card.game}</p>
<p className="text-[10px] font-bold text-yellow-400">¥{card.price}</p>
</div>
</Link>
))
: gamePointCards.map((card, i) => (
<Link
key={i}
to="/mall/points"
className="border-white/5 bg-[#1A1A22] hover:bg-[#20202A] group relative flex w-[72px] flex-shrink-0 flex-col items-center gap-2 overflow-hidden rounded-xl border p-2 transition-colors"
>
{card.tag && (
<div className="bg-secondary text-secondary-foreground absolute top-1 right-1 rounded px-1 py-0.5 text-[8px] font-bold text-white">
{card.tag}
</div>
)}
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
<img src={card.icon || "/placeholder.svg"} alt={card.name} className="h-full w-full object-cover" />
</div>
<div className="text-center">
<p className="w-full truncate text-[9px] text-white/80">{card.name}</p>
<p className="text-[10px] font-bold text-yellow-400">{card.discount}</p>
</div>
</Link>
))}
: "/point-card-valorant.jpg"
}
alt={card.name}
className="h-full w-full object-cover"
/>
</div>
<div className="text-center">
<p className="w-full truncate text-[9px] text-white/80">{card.game}</p>
<p className="text-[10px] font-bold text-yellow-400">¥{card.price}</p>
</div>
</Link>
))
) : (
<div className="flex w-full flex-col items-center gap-2 py-6 text-center text-sm text-white/50">
<p></p>
<Link to="/mall/points" className="text-primary text-xs underline">
</Link>
</div>
)}
</div>
</section>
@@ -222,21 +178,33 @@ export function HomePage() {
</div>
</section>
<div className="grid grid-cols-4 gap-3 px-1">
{gameIcons.map((game, i) => {
const IconComponent = game.icon
return (
<Link to={game.link} key={i} className="group flex cursor-pointer flex-col items-center gap-2">
<div
className={`h-12 w-12 rounded-xl border ${game.bg} ${game.border} relative flex items-center justify-center overflow-hidden shadow-lg transition-transform group-hover:scale-105`}
>
<IconComponent className={game.color} size={22} />
</div>
<span className="text-[10px] font-medium text-white/60 transition-colors group-hover:text-white">{game.name}</span>
</Link>
)
})}
</div>
<section className="space-y-3 px-1">
<div className="flex items-center justify-between">
<h3 className="text-base font-bold text-white flex items-center gap-2">
<Gamepad2 size={16} className="text-violet-400" />
</h3>
</div>
{apiGames.length > 0 ? (
<div className="grid grid-cols-4 gap-3">
{apiGames.slice(0, 8).map((game) => {
const slug = encodeURIComponent((game.shortName || game.name || game.id || "game").trim())
return (
<Link to={`/zone/${slug}`} key={game.id ?? slug} className="group flex cursor-pointer flex-col items-center gap-2">
<div className="border-white/10 relative flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border bg-violet-500/10 shadow-lg transition-transform group-hover:scale-105">
<Gamepad2 className="text-violet-400" size={22} />
</div>
<span className="text-center text-[10px] font-medium text-white/60 transition-colors group-hover:text-white">
{(game.name || game.shortName || "游戏").slice(0, 6)}
</span>
</Link>
)
})}
</div>
) : (
<p className="col-span-full py-6 text-center text-sm text-white/50"></p>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -1,37 +1,86 @@
import { useState, Suspense } from "react"
import { Search, MapPin, Filter, Star, ArrowLeft, ChevronDown, CheckCircle2, X, Map, Navigation } from "lucide-react"
import { useState, Suspense, useEffect, useCallback } from "react"
import { Search, MapPin, Filter, Star, ArrowLeft, ChevronDown, CheckCircle2, X, Map, Navigation, Loader2 } from "lucide-react"
import { useNavigate, useSearchParams } from "react-router-dom"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { FillImage } from "@/components/media/FillImage"
import { appVenues, type AppVenueDTO } from "@/api/catalog"
const hotels = [
{ id: 1, name: "玩值电竞酒店(旗舰店)", rating: 4.9, reviews: 1205, price: 398, distance: "0.8km", tags: ["RTX4090", "电竞椅", "24h餐饮"], district: "思明区/SM广场", image: "/hotel-1.jpg", lat: 24.4798, lng: 118.0894 },
{ id: 2, name: "极客空间电竞公寓", rating: 4.7, reviews: 856, price: 268, distance: "1.2km", tags: ["五黑房", "投影", "免费停车"], district: "湖里区/万达广场", image: "/hotel-2.jpg", lat: 24.5103, lng: 118.1287 },
{ id: 3, name: "异次元电竞酒店", rating: 4.8, reviews: 2341, price: 328, distance: "2.5km", tags: ["情侣房", "PS5", "VR体验"], district: "集美区/软件园", image: "/hotel-3.jpg", lat: 24.5745, lng: 118.1012 },
]
type VenueRow = AppVenueDTO & {
rating: number
reviews: number
price: number
distance: string
tags: string[]
district: string
lat: number
lng: number
}
const cafes = [
{ id: 101, name: "网鱼网咖(万达店)", rating: 4.8, reviews: 5620, price: 12, distance: "0.5km", tags: ["3080显卡", "特权公馆", "现磨咖啡"], district: "湖里区/万达广场", image: "/cafe-1.jpg", lat: 24.5089, lng: 118.1256 },
{ id: 102, name: "杰拉电竞馆", rating: 4.6, reviews: 3210, price: 10, distance: "1.8km", tags: ["赛事级配置", "美女陪玩", "独立包间"], district: "思明区/中山路", image: "/cafe-2.jpg", lat: 24.4512, lng: 118.0823 },
]
function inferPrice(v: AppVenueDTO): number {
const s = v.priceNote ?? ""
const m = s.match(/(\d+(\.\d+)?)/)
if (m) return parseFloat(m[1]!)
if (typeof v.discount === "number" && v.discount > 10) return v.discount
return 199
}
function mapVenueRow(v: AppVenueDTO, i: number): VenueRow {
const tags = [
...(v.city ? [v.city] : []),
...(v.address && v.address.length < 24 ? [v.address] : []),
v.type ?? "",
].filter(Boolean)
return {
...v,
rating: typeof v.discount === "number" && v.discount > 1 && v.discount <= 5 ? v.discount : 4.7,
reviews: Math.max(36, Math.round(Math.abs(parseInt(v.id.slice(0, 6), 16) % 5000))),
price: inferPrice(v),
distance: "—",
tags: tags.slice(0, 4),
district: `${v.city ?? ""}`.trim() || "—",
lat: typeof v.lat === "number" && v.lat !== 0 ? v.lat : 24.4798 + i * 0.01,
lng: typeof v.lng === "number" && v.lng !== 0 ? v.lng : 118.0894 + i * 0.01,
}
}
function HotelContent() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const tabFromUrl = searchParams.get("tab") === "cafe" ? "cafe" : "hotel"
const [activeTab, setActiveTab] = useState(tabFromUrl)
const [hotelRows, setHotelRows] = useState<VenueRow[]>([])
const [cafeRows, setCafeRows] = useState<VenueRow[]>([])
const [venuesLoading, setVenuesLoading] = useState(true)
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false)
const [paymentInfo, setPaymentInfo] = useState({ name: "", price: 0, type: "" as "hotel" | "cafe" })
const [showMap, setShowMap] = useState(false)
const [selectedLocation, setSelectedLocation] = useState<{ name: string; lat: number; lng: number } | null>(null)
const loadVenues = useCallback(async () => {
setVenuesLoading(true)
try {
const [h, c] = await Promise.all([appVenues("hotel"), appVenues("cafe")])
setHotelRows(h.map(mapVenueRow))
setCafeRows(c.map(mapVenueRow))
} catch {
setHotelRows([])
setCafeRows([])
} finally {
setVenuesLoading(false)
}
}, [])
useEffect(() => {
void loadVenues()
}, [loadVenues])
const handleBook = (name: string, price: number, type: "hotel" | "cafe") => {
setPaymentInfo({ name, price, type })
setShowPaymentSuccess(true)
}
const handleShowMap = (item: { name: string; lat: number; lng: number }) => {
setSelectedLocation(item)
const handleShowMap = (item: VenueRow) => {
setSelectedLocation({ name: item.name ?? "门店", lat: item.lat, lng: item.lng })
setShowMap(true)
}
@@ -132,7 +181,10 @@ function HotelContent() {
/>
</div>
<button
onClick={() => handleShowMap({ name: "厦门市", lat: 24.4798, lng: 118.0894 })}
onClick={() => {
setSelectedLocation({ name: "厦门市", lat: 24.4798, lng: 118.0894 })
setShowMap(true)
}}
className="flex items-center gap-1 text-primary hover:bg-primary/10 px-2 py-1 rounded-full transition-colors"
type="button"
>
@@ -174,13 +226,21 @@ function HotelContent() {
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold text-white/90"></span>
</div>
{hotels.map((hotel) => (
{venuesLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : hotelRows.length === 0 ? (
<p className="py-12 text-center text-sm text-white/50"></p>
) : null}
{!venuesLoading &&
hotelRows.map((hotel, idx) => (
<div key={hotel.id} className="flex gap-3 py-3 border-b border-white/5 last:border-0">
<div className="relative w-24 h-24 rounded-lg overflow-hidden shrink-0 bg-muted">
<FillImage src={hotel.image || "/placeholder.svg"} alt={hotel.name} className="h-24 w-24" />
{hotel.id === 1 && (
<FillImage src={hotel.image || hotel.coverImage || "/placeholder.svg"} alt={hotel.name ?? ""} className="h-24 w-24" />
{idx === 0 ? (
<div className="absolute top-0 left-0 bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-br-lg font-bold z-10"></div>
)}
) : null}
</div>
<div className="flex-1 flex flex-col justify-between min-w-0">
<div>
@@ -219,7 +279,7 @@ function HotelContent() {
<span className="text-xs text-white/50 ml-1 font-normal"></span>
</div>
<button
onClick={() => handleBook(hotel.name, hotel.price, "hotel")}
onClick={() => handleBook(hotel.name ?? "门店", hotel.price, "hotel")}
className="px-3 py-1 bg-primary text-white text-xs rounded-full font-bold active:scale-95 transition-transform"
type="button"
>
@@ -235,10 +295,18 @@ function HotelContent() {
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold text-white/90"></span>
</div>
{cafes.map((cafe) => (
{venuesLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : cafeRows.length === 0 ? (
<p className="py-12 text-center text-sm text-white/50"></p>
) : null}
{!venuesLoading &&
cafeRows.map((cafe) => (
<div key={cafe.id} className="flex gap-3 py-3 border-b border-white/5 last:border-0">
<div className="relative w-24 h-24 rounded-lg overflow-hidden shrink-0 bg-muted">
<FillImage src={cafe.image || "/placeholder.svg"} alt={cafe.name} className="h-24 w-24" />
<FillImage src={cafe.image || cafe.coverImage || "/placeholder.svg"} alt={cafe.name ?? ""} className="h-24 w-24" />
</div>
<div className="flex-1 flex flex-col justify-between min-w-0">
<div>
@@ -273,7 +341,7 @@ function HotelContent() {
<span className="text-xs text-white/40">/</span>
</div>
<button
onClick={() => handleBook(cafe.name, cafe.price, "cafe")}
onClick={() => handleBook(cafe.name ?? "门店", cafe.price, "cafe")}
className="px-3 py-1 bg-white/10 text-white text-xs rounded-full font-bold active:scale-95 transition-transform hover:bg-white/20"
type="button"
>

View File

@@ -11,6 +11,8 @@ import {
type LiveContextData,
} from "@/api/live"
import { FillImage } from "@/components/media/FillImage"
import { LivePlaybackSurface } from "@/components/live/LivePlaybackSurface"
import { useAppContext } from "@/components/providers/app-provider"
const HEX24 = /^[0-9a-fA-F]{24}$/
@@ -27,6 +29,7 @@ function formatViewerCount(n: number): string {
export function LivePage() {
const { streamerId = "" } = useParams<{ streamerId: string }>()
const valid = HEX24.test(streamerId)
const { syncFromDatabase } = useAppContext()
const [ctx, setCtx] = useState<LiveContextData | null>(null)
const [comments, setComments] = useState<LiveComment[]>([])
@@ -95,12 +98,17 @@ export function LivePage() {
if (!valid) return
try {
const r = await postLiveGift(streamerId, { giftId: "heart", quantity: 1, amount: 10 })
toast({ title: "礼物已送出", description: r.orderNo ? `单号 ${r.orderNo}` : undefined })
toast({
title: "礼物已送出",
description:
typeof r.balance === "number" ? `已扣除 10 玩值币,当前余额 ${r.balance}` : "已从您的玩值币扣除",
})
await syncFromDatabase()
await loadComments()
} catch (e) {
toast({
title: "送礼失败",
description: e instanceof Error ? e.message : "请登录重试",
description: e instanceof Error ? e.message : "请登录重试",
variant: "destructive",
})
}
@@ -125,7 +133,7 @@ export function LivePage() {
<ArrowLeft className="h-5 w-5" />
</Link>
<p className="text-red-400"> ID 24 </p>
<p className="text-red-400"></p>
</div>
)
}
@@ -178,12 +186,12 @@ export function LivePage() {
) : err ? (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-red-300">{err}</div>
) : pull ? (
<video className="h-full w-full object-contain" controls playsInline src={pull} />
<LivePlaybackSurface url={pull} coverUrl={cover} isLive={isLive} />
) : (
<>
<FillImage src={cover} alt="" className="h-full w-full object-cover opacity-80" />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 text-sm text-white/80">
{isLive ? "暂无播放地址,请主播填写 pullPlaybackUrl" : "未开播,暂无画面"}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 px-4 text-center text-sm text-white/80">
{isLive ? "主播未配置可播放的拉流地址,请稍后再试或联系主播" : "当前未开播"}
</div>
</>
)}

View File

@@ -53,8 +53,8 @@ export function MallPage() {
const navigate = useNavigate()
const [dressCategory, setDressCategory] = useState<DressCategory>("热门推荐")
const handleBuy = (price: number, name: string, type: "product" | "service") => {
pay(price, name, type)
const handleBuy = (price: number, name: string, type: "product" | "service", img?: string) => {
void pay(price, name, type, img)
}
const filteredDressItems =
@@ -205,7 +205,7 @@ export function MallPage() {
<button
className="w-full py-2 mt-2 rounded-lg bg-white/5 hover:bg-primary hover:text-black transition-all text-xs font-bold relative z-10"
onClick={() => handleBuy(item.price, item.name, "product")}
onClick={() => handleBuy(item.price, item.name, "product", item.image)}
>
</button>

View File

@@ -2,11 +2,13 @@ import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { ArrowLeft, CreditCard, ChevronRight, Loader2 } from "lucide-react"
import { getMallPointCards, postPointCardOrder, type MallPointCard } from "@/api/mall"
import { useAppContext } from "@/components/providers/app-provider"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { Button } from "@/components/ui/button"
export function MallPointsPage() {
const { syncFromDatabase } = useAppContext()
const [list, setList] = useState<MallPointCard[]>([])
const [loading, setLoading] = useState(true)
const [ordering, setOrdering] = useState<string | null>(null)
@@ -19,7 +21,7 @@ export function MallPointsPage() {
} catch (e) {
toast({
title: "加载点卡失败",
description: e instanceof Error ? e.message : "请检查登录与 wz-api",
description: e instanceof Error ? e.message : "请稍后重试或检查网络",
variant: "destructive",
})
setList([])
@@ -37,6 +39,7 @@ export function MallPointsPage() {
try {
const r = await postPointCardOrder({ cardId: c.id, quantity: 1 })
toast({ title: "下单成功", description: `订单号 ${r.orderNo},状态 ${r.status}` })
await syncFromDatabase()
} catch (e) {
toast({
title: "下单失败",
@@ -59,13 +62,13 @@ export function MallPointsPage() {
</header>
<div className="px-4 pt-4">
<p className="mb-3 text-xs text-muted-foreground"> wz-api · GET /api/app/mall/point-cards</p>
<p className="mb-3 text-xs text-muted-foreground"></p>
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : list.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"> SKU</p>
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
<ul className="space-y-3">
{list.map((c) => (

View File

@@ -74,9 +74,7 @@ export function MessagesPage() {
<MoreHorizontal size={20} />
</button>
</header>
<p className="text-foreground/45 border-border/50 border-b px-4 py-2 text-xs">
wz-api //-API对照表
</p>
<p className="text-foreground/45 border-border/50 border-b px-4 py-2 text-xs"></p>
<Tabs defaultValue="chat" className="w-full">
<div className="mt-2 px-4">

View File

@@ -110,7 +110,7 @@ export function MomentsPage() {
navigate(`/live/${s.id}`)
return
}
toast({ title: "暂无主播数据", description: "请先在管理端配置并通过审核的主播,或检查后端 wz-api" })
toast({ title: "暂无主播", description: "请稍后再试或从直播广场进入" })
}
const handleLike = (id: number, originalLikes: string) => {
@@ -145,7 +145,7 @@ export function MomentsPage() {
[feedId]: [...(prev[feedId] ?? []), { text: t, at }],
}))
setCommentDraft("")
toast({ title: "评论已发布", description: "本地展示;全站评论将接 wz-api" })
toast({ title: "评论已发布", description: "仅在本设备展示" })
}
const commentCount = (item: (typeof mixedFeed)[0]) => item.comments + (localComments[item.id]?.length ?? 0)
@@ -154,9 +154,15 @@ export function MomentsPage() {
navigate(`/moments/${id}`)
}
/** MobileNav`h-16`(4rem) + `pb-safe`;勿混用 pb-20(5rem) 避免出现底栏上方黑缝 */
const shellH =
"min-h-0 h-[calc(100dvh-4rem-env(safe-area-inset-bottom))] max-h-[calc(100dvh-4rem-env(safe-area-inset-bottom))]"
const slideH =
"h-[calc(100dvh-4rem-env(safe-area-inset-bottom))] w-full shrink-0 snap-start snap-always"
return (
<div className="text-white h-screen flex flex-col bg-black pb-20">
<div className="pt-safe from-black/80 absolute top-0 right-0 left-0 z-30 bg-linear-to-b to-transparent px-4 py-3">
<div className={`text-white flex flex-col overflow-hidden bg-black ${shellH}`}>
<div className="from-black/80 absolute top-0 right-0 left-0 z-30 bg-linear-to-b to-transparent px-4 pb-3 pt-[calc(env(safe-area-inset-top,0)+0.625rem)]">
{apiStreamers.length > 0 ? (
<div className="mb-2 flex gap-2 overflow-x-auto pb-1 no-scrollbar">
{apiStreamers.slice(0, 12).map((s) => (
@@ -190,16 +196,20 @@ export function MomentsPage() {
</button>
</div>
<Search className="text-white/80 absolute top-4 right-4 h-6 w-6" />
<Search className="text-white/80 absolute right-4 top-[calc(env(safe-area-inset-top,0)+1rem)] h-6 w-6" />
</div>
<div className="relative flex-1 snap-y snap-mandatory overflow-y-auto bg-black no-scrollbar">
{activeTab === "recommend" && (
<div className="h-full snap-y snap-mandatory overflow-y-scroll no-scrollbar">
{mixedFeed.map((item) => (
<div key={item.id} className="group relative h-full w-full snap-start bg-zinc-900">
<div
className="relative min-h-0 flex-1 snap-y snap-mandatory overflow-y-auto overscroll-y-contain bg-black [-webkit-overflow-scrolling:touch] no-scrollbar scroll-smooth"
>
{activeTab === "recommend" &&
mixedFeed.map((item) => (
<div
key={item.id}
className={`group relative bg-zinc-900 ${slideH}`}
>
<div className="absolute inset-0 h-full w-full">
<FillImage src={item.video || "/placeholder.svg"} alt="Content" className="h-full min-h-dvh" />
<FillImage src={item.video || "/placeholder.svg"} alt="Content" className="h-full w-full object-cover" />
</div>
<button
type="button"
@@ -290,10 +300,8 @@ export function MomentsPage() {
</div>
</div>
))}
</div>
)}
{activeTab === "follow" && (
<div className="text-white/50 flex h-full flex-col items-center justify-center gap-4">
<div className={`text-white/50 flex flex-col items-center justify-center gap-4 ${slideH}`}>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/10">
<Heart size={32} className="text-white/30" />
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useParams, useNavigate } from "react-router-dom"
import {
ArrowLeft,
@@ -17,29 +17,18 @@ import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useAppContext } from "@/components/providers/app-provider"
import { FillImage } from "@/components/media/FillImage"
import { appPartyRoom } from "@/api/catalog"
const PARTY_DATA: Record<string, { name: string; host: string; avatar: string; game: string; background: string }> = {
"1": {
name: "GM-远洋的派对群",
host: "GM-远洋",
avatar: "/streamer-1.jpg",
game: "英雄联盟",
background: "/party-lol.jpg",
},
"2": {
name: "甜心女王粉丝派对",
host: "甜心女王",
avatar: "/streamer-5.jpg",
game: "王者荣耀",
background: "/party-hok.jpg",
},
"3": {
name: "带粉上分车队",
host: "路人王",
avatar: "/streamer-3.jpg",
game: "和平精英",
background: "/party-team.jpg",
},
type ChatMsg = { user: string; content: string; type: "system" | "user" }
type PartyUi = { name: string; host: string; avatar: string; game: string; background: string }
const FALLBACK_PARTY: PartyUi = {
name: "派对房",
host: "房主",
avatar: "/placeholder.svg",
game: "",
background: "/placeholder.svg",
}
const GIFTS = [
@@ -55,28 +44,43 @@ export function PartyDetailPage() {
const { id = "1" } = useParams()
const navigate = useNavigate()
const { pay } = useAppContext()
const partyInfo = useMemo(() => PARTY_DATA[id] ?? PARTY_DATA["1"]!, [id])
const [partyInfo, setPartyInfo] = useState<PartyUi>(FALLBACK_PARTY)
const [isMicOn, setIsMicOn] = useState(false)
const [showGifts, setShowGifts] = useState(false)
const [showGiftSuccess, setShowGiftSuccess] = useState(false)
const [lastGift, setLastGift] = useState("")
const [messages, setMessages] = useState([
{ user: "系统", content: "欢迎来到派对房间", type: "system" as const },
{ user: "小柠檬", content: "大家好呀~", type: "user" as const },
{ user: partyInfo.host, content: "今天带大家上分!", type: "user" as const },
])
const [messages, setMessages] = useState<ChatMsg[]>([{ user: "系统", content: "加载房间信息…", type: "system" }])
const [inputValue, setInputValue] = useState("")
const seats = [
{ id: 0, user: { name: partyInfo.host, avatar: partyInfo.avatar, isMuted: false, isHost: true } },
{ id: 1, user: { name: "小柠檬", avatar: "/streamer-2.jpg", isMuted: false, isHost: false } },
{ id: 2, user: { name: "电竞萌妹", avatar: "/streamer-6.jpg", isMuted: true, isHost: false } },
{ id: 3, user: null },
{ id: 4, user: null },
{ id: 5, user: null },
{ id: 6, user: null },
{ id: 7, user: null },
]
useEffect(() => {
let cancelled = false
void appPartyRoom(id).then((r) => {
if (cancelled || !r.success || !r.data) return
const d = r.data
setPartyInfo({
name: d.name ?? FALLBACK_PARTY.name,
host: d.host ?? FALLBACK_PARTY.host,
avatar: d.avatar || FALLBACK_PARTY.avatar,
game: d.game ?? "",
background: d.background || FALLBACK_PARTY.background,
})
})
return () => {
cancelled = true
}
}, [id])
useEffect(() => {
setMessages([{ user: "系统", content: `欢迎来到 ${partyInfo.name}(即时互动待 WebSocket`, type: "system" }])
}, [partyInfo.name])
const seats = useMemo(
() => [
{ id: 0, user: { name: partyInfo.host, avatar: partyInfo.avatar, isMuted: false, isHost: true } },
...[1, 2, 3, 4, 5, 6, 7].map((n) => ({ id: n, user: null as null })),
],
[partyInfo.host, partyInfo.avatar],
)
const handleSendMessage = () => {
if (!inputValue.trim()) return
@@ -84,8 +88,9 @@ export function PartyDetailPage() {
setInputValue("")
}
const handleSendGift = (gift: (typeof GIFTS)[0]) => {
pay(gift.price, `送出${gift.name}`, "product")
const handleSendGift = async (gift: (typeof GIFTS)[0]) => {
const ok = await pay(gift.price, `送出${gift.name}`, "product")
if (!ok) return
setLastGift(`${gift.icon} ${gift.name}`)
setShowGifts(false)
setShowGiftSuccess(true)
@@ -125,7 +130,7 @@ export function PartyDetailPage() {
<p className="text-white/60 text-[10px] flex items-center gap-2">
<span className="flex items-center gap-1">
<Users size={10} />
125
线
</span>
<span>{partyInfo.game}</span>
</p>

View File

@@ -2,138 +2,78 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Mic, Users, Gamepad2, Headphones, Heart, Sparkles, Filter, Trophy, Crosshair, Star } from "lucide-react"
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { useNavigate } from "react-router-dom"
import { toast } from "@/hooks/use-toast"
import { appPartyRooms, appGuilds, appStars, type AppPartyRoomDTO } from "@/api/catalog"
type MatchCategory = "star" | "guild" | "cp" | "coach"
const matchResultsData = {
star: [
{
id: 1,
name: "GM-远洋",
title: "国服第一盲僧",
avatar: "/streamer-1.jpg",
fans: "128万",
game: "英雄联盟",
partyMembers: 1205,
},
{
id: 2,
name: "小狼狗",
title: "无畏契约职业选手",
avatar: "/streamer-2.jpg",
fans: "89万",
game: "无畏契约",
partyMembers: 856,
},
{
id: 3,
name: "甜心女王",
title: "王者荣耀主播",
avatar: "/streamer-5.jpg",
fans: "256万",
game: "王者荣耀",
partyMembers: 2341,
},
],
guild: [
{
id: 1,
name: "东东电竞公会",
title: "顶级主播公会",
avatar: "/streamer-3.jpg",
members: "1.2万",
commission: "70%",
benefits: ["专业培训", "流量扶持", "独家活动"],
},
{
id: 2,
name: "星耀工会",
title: "新人友好公会",
avatar: "/streamer-4.jpg",
members: "8500",
commission: "65%",
benefits: ["新人指导", "设备补贴", "公会活动"],
},
],
cp: [
{
id: 1,
name: "甜心辅助",
title: "温柔妹子",
avatar: "/streamer-5.jpg",
age: 22,
game: "王者荣耀",
intro: "找个一起玩游戏的小哥哥~",
online: true,
},
{
id: 2,
name: "电竞萌妹",
title: "可爱软妹",
avatar: "/streamer-6.jpg",
age: 20,
game: "和平精英",
intro: "喜欢吃鸡,求带飞~",
online: true,
},
],
coach: [
{
id: 1,
name: "职业陪练-小明",
title: "前职业选手",
avatar: "/streamer-4.jpg",
rank: "王者50星",
price: 30,
game: "王者荣耀",
rating: 4.9,
orders: 2356,
},
{
id: 2,
name: "上分大师",
title: "国服前100",
avatar: "/streamer-2.jpg",
rank: "超凡入圣",
price: 50,
game: "无畏契约",
rating: 4.8,
orders: 1823,
},
],
}
export function PlanetPage() {
const navigate = useNavigate()
const [isMatching, setIsMatching] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<MatchCategory>("star")
const [partyRooms, setPartyRooms] = useState<AppPartyRoomDTO[]>([])
const loadParties = useCallback(() => {
void appPartyRooms()
.then(setPartyRooms)
.catch(() => setPartyRooms([]))
}, [])
useEffect(() => {
loadParties()
}, [loadParties])
const handleMatch = () => {
setIsMatching(true)
setTimeout(() => {
setIsMatching(false)
const results = matchResultsData[selectedCategory]
const randomResult = results[Math.floor(Math.random() * results.length)]
switch (selectedCategory) {
case "star":
// 直接跳转到明星的派对群聊天室
navigate(`/party/${randomResult.id}`)
break
case "guild":
navigate(`/guild/${randomResult.id}`)
break
case "cp":
navigate(`/chat/cp-${randomResult.id}`)
break
case "coach":
navigate(`/coach/${randomResult.id}`)
break
}
void (async () => {
try {
switch (selectedCategory) {
case "star": {
const rooms = await appPartyRooms()
if (!rooms.length) {
toast({ title: "暂无派对房间", description: "可关注活动或稍后重试" })
break
}
const pick = rooms[Math.floor(Math.random() * rooms.length)]!
navigate(`/party/${pick.id}`)
break
}
case "guild": {
const guilds = await appGuilds()
if (!guilds.length) {
toast({ title: "暂无公会", description: "请稍后再试" })
break
}
const g = guilds[Math.floor(Math.random() * guilds.length)]!
navigate(`/guild/${g.id}`)
break
}
case "cp": {
toast({ title: "暂未开放一键配对", description: "请前往 CP 广场浏览" })
navigate("/cp")
break
}
case "coach": {
const stars = await appStars()
if (!stars.length) {
toast({ title: "暂无大神", description: "请稍后再试" })
break
}
const s = stars[Math.floor(Math.random() * stars.length)]!
navigate(`/coach/${s.id}`)
break
}
}
} catch {
toast({ title: "请求失败", description: "请检查网络后重试" })
} finally {
setIsMatching(false)
}
})()
}, 1500)
}
@@ -302,84 +242,41 @@ export function PlanetPage() {
</TabsContent>
<TabsContent value="party" className="space-y-4">
{[
{
id: 1,
title: "GM-远洋的派对群",
tag: "直播中",
count: 1205,
image: "/party-lol.jpg",
isLive: true,
streamer: "GM-远洋",
},
{
id: 2,
title: "甜心女王粉丝派对",
tag: "热门",
count: 2341,
image: "/party-hok.jpg",
isLive: true,
streamer: "甜心女王",
},
{
id: 3,
title: "带粉上分车队",
tag: "游戏",
count: 89,
image: "/party-team.jpg",
isLive: false,
streamer: "路人王",
},
].map((room) => (
<Card
key={room.id}
className="bg-white/5 border-white/10 overflow-hidden hover:bg-white/10 transition-colors cursor-pointer relative min-w-0"
onClick={() => navigate(`/party/${room.id}`)}
>
<div className="flex p-3 gap-4 min-w-0">
<div className="relative w-20 h-20 shrink-0 rounded-lg overflow-hidden border border-white/10">
<img
src={room.image || "/placeholder.svg"}
alt={room.title}
className="absolute inset-0 h-full w-full object-cover"
/>
{room.isLive && (
<div className="absolute top-1 left-1 bg-primary/90 text-[8px] px-1.5 py-0.5 rounded text-white backdrop-blur-sm flex items-center gap-1 animate-pulse">
<span className="w-1 h-1 rounded-full bg-white" /> LIVE
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
<div className="min-w-0">
<h3 className="font-bold text-white truncate">{room.title}</h3>
<p className="text-xs text-white/60 mt-1 truncate">
{room.streamer} {room.isLive ? "正在直播" : "离线"}
</p>
{partyRooms.length === 0 ? (
<p className="py-12 text-center text-sm text-white/50"></p>
) : (
partyRooms.map((room) => (
<Card
key={room.id}
className="bg-white/5 border-white/10 overflow-hidden hover:bg-white/10 transition-colors cursor-pointer relative min-w-0"
onClick={() => navigate(`/party/${room.id}`)}
>
<div className="flex p-3 gap-4 min-w-0">
<div className="relative w-20 h-20 shrink-0 rounded-lg overflow-hidden border border-white/10">
<img
src={room.background || room.avatar || "/placeholder.svg"}
alt={room.name ?? ""}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex -space-x-2">
{[1, 2, 3].map((avatar) => (
<div
key={avatar}
className="w-6 h-6 rounded-full border border-black bg-gray-800 overflow-hidden relative"
>
<img
src={`/streamer-${avatar}.jpg`}
alt="User"
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
))}
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
<div className="min-w-0">
<h3 className="font-bold text-white truncate">{room.name ?? "派对房"}</h3>
<p className="text-xs text-white/60 mt-1 truncate">
{room.host ?? "—"} {room.game ? `· ${room.game}` : ""}
</p>
</div>
<div className="flex items-center text-xs text-primary">
<Headphones className="w-3 h-3 mr-1" />
{room.count}
<div className="flex items-center justify-end mt-2">
<div className="flex items-center text-xs text-primary">
<Headphones className="w-3 h-3 mr-1" />
</div>
</div>
</div>
</div>
</div>
</Card>
))}
</Card>
))
)}
</TabsContent>
<TabsContent value="team" className="space-y-4">
@@ -400,29 +297,7 @@ export function PlanetPage() {
<Crosshair size={16} className="text-primary" />
</h3>
{[
{ game: "LOL", mode: "灵活组排", rank: "钻石", waiting: "3/5", title: "钻石冲大师" },
{ game: "无畏契约", mode: "竞技模式", rank: "超凡", waiting: "1/5", title: "缺个烟位" },
].map((team, i) => (
<div key={i} className="glass-card p-3 rounded-xl flex justify-between items-center gap-3 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 min-w-0">
<span className="text-xs font-bold bg-white/10 px-1.5 py-0.5 rounded text-white/80 shrink-0">{team.game}</span>
<span className="text-sm font-bold truncate">{team.title}</span>
</div>
<div className="flex items-center gap-3 text-xs text-white/50 truncate">
<span>{team.mode}</span>
<span>{team.rank}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className="text-xs font-bold text-primary">{team.waiting}</span>
<Button size="sm" className="h-7 text-xs bg-primary hover:bg-primary/90">
</Button>
</div>
</div>
))}
<p className="py-8 text-center text-sm text-white/50"></p>
</TabsContent>
</Tabs>
</div>

View File

@@ -21,11 +21,12 @@ export function RechargePage() {
{ value: 648, coins: 6480, bonus: 800 },
]
const handleRecharge = () => {
const handleRecharge = async () => {
if (selectedAmount === null) return
const item = amounts[selectedAmount]!
const totalCoins = item.coins + item.bonus
recharge(totalCoins)
const ok = await recharge(totalCoins)
if (!ok) return
setPurchasedCoins(totalCoins)
setShowSuccess(true)
}
@@ -138,7 +139,8 @@ export function RechargePage() {
</div>
</div>
<p className="text-center text-xs text-white/30"></p>
<p className="text-center text-xs text-white/30"></p>
<p className="text-center text-xs text-white/25"></p>
</div>
<div className="fixed bottom-16 left-0 right-0 p-4 bg-gradient-to-t from-background via-background to-transparent">

View File

@@ -53,7 +53,7 @@ export function ScreenPage() {
<LayoutDashboard className="h-6 w-6 text-primary shrink-0" />
<div className="min-w-0">
<h1 className="text-lg font-bold truncate"> · </h1>
<p className="text-xs text-white/50 truncate"> `/api/screen/data/dashboard` </p>
<p className="text-xs text-white/50 truncate"></p>
</div>
</div>
<Button type="button" variant="secondary" size="sm" className="shrink-0 gap-1" onClick={() => void load()}>
@@ -67,9 +67,9 @@ export function ScreenPage() {
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 flex gap-3 text-sm">
<AlertCircle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-100"></p>
<p className="font-medium text-amber-100"></p>
<p className="text-white/70 mt-1">{err}</p>
<p className="text-xs text-white/50 mt-2"> new/wz-app Vite wz-api API </p>
<p className="text-xs text-white/50 mt-2"></p>
</div>
</div>
) : null}
@@ -107,7 +107,7 @@ export function ScreenPage() {
<div className="rounded-xl border border-white/10 bg-slate-900/60 p-4 space-y-2 text-sm">
<p className="text-xs text-white/50 font-medium"></p>
<p className="text-white/80">wz-api {healthOk === null ? "—" : healthOk ? "正常" : "异常"}</p>
<p className="text-white/80">{healthOk === null ? "—" : healthOk ? "正常" : "异常"}</p>
<p className="text-white/60 text-xs">{buildMsg || "—"}</p>
</div>
@@ -126,7 +126,7 @@ export function ScreenPage() {
>
</Button>
<p className="text-[10px] text-white/40">C `/api/app/*` `/api/screen/*``/api/public/*`</p>
<p className="text-[10px] text-white/40"></p>
</div>
</div>

View File

@@ -1,76 +1,112 @@
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { useParams, useNavigate, Link } from "react-router-dom"
import { ArrowLeft, Star, Trophy, Users, MessageCircle, Share2, Sparkles, PlayCircle, Heart } from "lucide-react"
import {
ArrowLeft,
Star,
Trophy,
Users,
MessageCircle,
Sparkles,
Share2,
Heart,
PlayCircle,
Loader2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAppContext } from "@/components/providers/app-provider"
import { FillImage } from "@/components/media/FillImage"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { getStreamerAvatarUrl } from "@/lib/streamer-avatar"
import { getStreamerById, type AppStreamer } from "@/api/streamers"
const starData: Record<
string,
{
name: string
title: string
avatar: string
cover: string
rating: number
fans: string
intro: string
games: string[]
services: { id: string; name: string; price: number; desc: string; courseId?: string; hot?: boolean }[]
}
> = {
"1": {
name: "GM-远洋",
title: "国服第一盲僧",
avatar: "/streamer-1.jpg",
cover: "/live-lol.jpg",
rating: 4.9,
fans: "46.8万",
intro: "前职业战队打野选手,擅长盲僧、千珏。教学耐心,带你轻松上分。",
games: ["英雄联盟", "无畏契约"],
services: [
{ id: "s1", name: "单局陪玩", price: 50, desc: "一起开黑边玩边教" },
{ id: "s2", name: "5局套餐", price: 220, desc: "连续5局享优惠" },
{ id: "s3", name: "打野进阶课", price: 299, desc: "8节系统课", courseId: "1" },
],
},
"2": {
name: "卡若",
title: "前职业选手",
avatar: "/streamer-2.jpg",
cover: "/live-valorant.jpg",
rating: 4.8,
fans: "12万",
intro: "无畏契约、FPS 全精通,枪法与意识双修。",
games: ["无畏契约", "CS:GO"],
services: [
{ id: "s1", name: "单局陪玩", price: 60, desc: "排位陪练" },
{ id: "s2", name: "枪法训练营", price: 399, desc: "完整课程", courseId: "2", hot: true },
],
},
type SvcRow = { id: string; name: string; price: number; desc: string; courseId?: string; hot?: boolean }
function streamerToServices(s: AppStreamer): SvcRow[] {
const p = typeof s["hourlyRate"] === "number" ? s["hourlyRate"] : 88
const name = String(s.name ?? "主播")
return [
{ id: "s1", name: "单局陪玩", price: Math.round(Number(p) || 80), desc: `${name} 实时带飞`, hot: !!(s.isLive && Boolean(s.hotValue && Number(s.hotValue) > 50)) },
{
id: "s-course",
name: "系统课程(若有 contentItems",
price: Math.round(Number(p) * 3 || 240),
desc: "可在大神页选购或联系客服选课",
},
]
}
export function StarDetailPage() {
const { id = "1" } = useParams()
const { id = "" } = useParams()
const navigate = useNavigate()
const { pay } = useAppContext()
const [followed, setFollowed] = useState(false)
const star = starData[id] ?? starData["1"]!
const [st, setSt] = useState<AppStreamer | null>(null)
const [loading, setLoading] = useState(true)
const onBook = (svc: (typeof star.services)[0]) => {
if (svc.courseId) {
void navigate(`/course/${svc.courseId}`)
const load = useCallback(() => {
if (!id) {
setSt(null)
setLoading(false)
return
}
const ok = pay(svc.price, `${star.name} · ${svc.name}`, "service", star.avatar)
if (ok) toast({ title: "下单成功", description: "可在「消息」中查看" })
setLoading(true)
void getStreamerById(id)
.then(setSt)
.catch(() => setSt(null))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
load()
}, [load])
const onBook = async (svc: SvcRow) => {
if (svc.courseId) {
void navigate(`/course/${encodeURIComponent(svc.courseId)}`)
return
}
const name = String(st?.name ?? "主播")
const avatar = getStreamerAvatarUrl(name, typeof st?.avatar === "string" ? st.avatar : null, 200)
const ok = await pay(svc.price, `${name} · ${svc.name}`, "service", avatar)
if (ok) toast({ title: "下单成功", description: "可在订单中心查看" })
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background pb-28">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
</div>
)
}
if (!st) {
return (
<div className="min-h-screen bg-background px-4 pt-16 pb-28">
<p className="text-center text-sm text-muted-foreground"></p>
<Button type="button" variant="outline" className="mt-6 mx-auto flex" onClick={() => void navigate("/")}>
</Button>
</div>
)
}
const name = String(st.name ?? "主播")
const avatarUrl = getStreamerAvatarUrl(name, typeof st.avatar === "string" ? st.avatar : null, 280)
const cover = typeof st.coverImage === "string" && st.coverImage ? st.coverImage : typeof st.avatar === "string" ? st.avatar : avatarUrl
const title = typeof st.title === "string" ? st.title : String(st.game ?? "电竞主播")
const rating = typeof st["rating"] === "number" ? st["rating"] : 4.9
const fans = formatFans(st.fans as number | undefined)
const intro =
typeof (st as { intro?: string }).intro === "string" && (st as { intro?: string }).intro
? (st as { intro: string }).intro
: `擅长:${String(st.game ?? "—")}`
const games: string[] = Array.isArray(st.tags) ? (st.tags as string[]) : st.game ? [String(st.game)] : []
const services = streamerToServices(st)
return (
<div className="min-h-screen bg-background pb-28">
<div className="relative h-56">
<FillImage src={star.cover} alt="cover" className="h-56" />
<FillImage src={cover} alt="cover" className="h-56" />
<div className="absolute inset-0 bg-gradient-to-t from-background to-transparent" />
<div className="absolute top-0 left-0 right-0 p-4 flex items-center justify-between">
<button type="button" onClick={() => void navigate(-1)} className="p-2 rounded-full bg-black/30 text-white" aria-label="返回">
@@ -97,22 +133,22 @@ export function StarDetailPage() {
<div className="px-4 -mt-16 relative z-10">
<div className="flex items-end gap-4 mb-4">
<div className="w-28 h-28 rounded-2xl border-4 border-background overflow-hidden relative shadow-xl">
<FillImage src={star.avatar} alt={star.name} className="h-28 w-28" />
<FillImage src={avatarUrl} alt={name} className="h-28 w-28" />
</div>
<div className="flex-1 min-w-0 pb-1">
<h1 className="text-xl font-bold flex items-center gap-2">
{star.name}
{star.services.some((s) => s.hot) && <Sparkles className="text-primary w-4 h-4" />}
{name}
{st.isLive && <Sparkles className="text-primary w-4 h-4" />}
</h1>
<p className="text-sm text-primary">{star.title}</p>
<p className="text-sm text-primary">{title}</p>
<div className="flex flex-wrap gap-2 mt-2 text-xs text-white/50">
<span className="flex items-center gap-1">
<Star className="w-3 h-3 text-primary fill-primary" />
{star.rating}
{rating}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{star.fans}
{fans}
</span>
</div>
</div>
@@ -125,7 +161,7 @@ export function StarDetailPage() {
className="flex-1"
onClick={() => {
setFollowed(!followed)
toast({ title: followed ? "已取消关注" : "已关注" })
toast({ title: followed ? "已取消关注" : "已关注(仅本地演示)" })
}}
>
<Heart className="w-4 h-4 mr-1" />
@@ -133,13 +169,13 @@ export function StarDetailPage() {
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => void navigate("/messages")}>
<MessageCircle className="w-4 h-4 mr-1" />
</Button>
</div>
<p className="text-sm text-white/70 mb-4 leading-relaxed">{star.intro}</p>
<p className="text-sm text-white/70 mb-4 leading-relaxed">{intro}</p>
<div className="flex flex-wrap gap-2 mb-6">
{star.games.map((g) => (
{games.map((g) => (
<span key={g} className="px-2 py-1 rounded-full bg-white/5 text-xs">
{g}
</span>
@@ -148,10 +184,10 @@ export function StarDetailPage() {
<h3 className="font-bold mb-3 flex items-center gap-2">
<Trophy className="w-4 h-4 text-primary" />
·
</h3>
<div className="space-y-3">
{star.services.map((svc) => (
{services.map((svc) => (
<div key={svc.id} className="glass-card p-4 rounded-xl flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="font-bold text-sm flex items-center gap-2">
@@ -162,7 +198,7 @@ export function StarDetailPage() {
</div>
<div className="text-right shrink-0">
<div className="text-primary font-bold">{svc.price} </div>
<Button size="sm" className="mt-1 h-7 text-xs" type="button" onClick={() => onBook(svc)}>
<Button size="sm" className="mt-1 h-7 text-xs" type="button" onClick={() => void onBook(svc)}>
{svc.courseId ? "查看课程" : "下单"}
</Button>
</div>
@@ -170,14 +206,14 @@ export function StarDetailPage() {
))}
</div>
<Link to={`/live/${id}`} className="mt-6 flex items-center gap-3 p-3 rounded-xl bg-white/5 hover:bg-white/10">
<Link to={`/live/${encodeURIComponent(id)}`} className="mt-6 flex items-center gap-3 p-3 rounded-xl bg-white/5 hover:bg-white/10">
<div className="w-20 h-12 rounded overflow-hidden relative shrink-0">
<FillImage src={star.cover} alt="live" className="h-12" />
<FillImage src={cover} alt="live" className="h-12" />
<span className="absolute top-0.5 left-0.5 px-1 rounded bg-primary text-[8px] text-black font-bold">LIVE</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold"></div>
<div className="text-[10px] text-primary"></div>
<div className="text-[10px] text-white/50"></div>
</div>
<PlayCircle className="w-6 h-6 text-white/60" />
</Link>
@@ -185,3 +221,9 @@ export function StarDetailPage() {
</div>
)
}
function formatFans(val?: number): string {
if (val == null || Number.isNaN(val)) return "0"
if (val >= 10000) return (val / 10000).toFixed(1) + "万"
return String(Math.round(val))
}

View File

@@ -25,9 +25,9 @@ export function VipPage() {
{ icon: Crown, title: "尊贵标识", desc: "个人主页专属展示" },
]
const handleSubscribe = () => {
const handleSubscribe = async () => {
const plan = plans[selectedPlan]
const success = pay(plan.coins, `玩值黑卡会员${plan.label}`, "service")
const success = await pay(plan.coins, `玩值黑卡会员${plan.label}`, "service")
if (success) {
updateUser({ isVip: true })
void navigate("/profile")

View File

@@ -1,23 +1,50 @@
import { ArrowLeft, Users, Trophy, Sword, Flame } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { ArrowLeft, Users, Trophy, Sword, Flame, Loader2 } from "lucide-react"
import { useParams, useNavigate } from "react-router-dom"
import { FillImage } from "@/components/media/FillImage"
import { toast } from "@/hooks/use-toast"
import { getAppGames, type AppGame } from "@/api/games"
type ZoneGame = { name: string; barClass: string; btnClass: string; image: string; query: string }
const GAMES: Record<string, ZoneGame> = {
lol: { name: "英雄联盟", barClass: "bg-blue-500", btnClass: "bg-blue-600", image: "/game-lol.jpg", query: "lol" },
val: { name: "无畏契约", barClass: "bg-red-500", btnClass: "bg-red-600", image: "/point-card-valorant.jpg", query: "valorant" },
hok: { name: "王者荣耀", barClass: "bg-yellow-500", btnClass: "bg-yellow-600", image: "/live-hok.jpg", query: "hok" },
csgo: { name: "CS:GO", barClass: "bg-orange-500", btnClass: "bg-orange-600", image: "/game-csgo.jpg", query: "csgo" },
wow: { name: "魔兽世界", barClass: "bg-amber-600", btnClass: "bg-stone-600", image: "/wow-bg.jpg", query: "wow" },
function themeForSlug(slug: string): { barClass: string; btnClass: string; banner: string } {
const s = slug.toLowerCase()
if (s.includes("lol") || s.includes("英雄")) return { barClass: "bg-blue-500", btnClass: "bg-blue-600", banner: "/game-lol.jpg" }
if (s.includes("val") || s.includes("瓦") || s.includes("契约")) return { barClass: "bg-red-500", btnClass: "bg-red-600", banner: "/point-card-valorant.jpg" }
if (s.includes("hok") || s.includes("王者")) return { barClass: "bg-yellow-500", btnClass: "bg-yellow-600", banner: "/live-hok.jpg" }
if (s.includes("cs")) return { barClass: "bg-orange-500", btnClass: "bg-orange-600", banner: "/game-csgo.jpg" }
if (s.includes("wow") || s.includes("魔兽")) return { barClass: "bg-amber-600", btnClass: "bg-stone-600", banner: "/wow-bg.jpg" }
return { barClass: "bg-violet-500", btnClass: "bg-violet-600", banner: "/placeholder.svg" }
}
export function ZoneDetailPage() {
const { id = "lol" } = useParams()
const { id: rawId = "game" } = useParams()
const navigate = useNavigate()
const game = GAMES[id] ?? GAMES.lol!
const teams = [1, 2, 3]
const id = useMemo(() => {
try {
return decodeURIComponent(rawId)
} catch {
return rawId
}
}, [rawId])
const [apiGames, setApiGames] = useState<AppGame[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
void getAppGames()
.then(setApiGames)
.catch(() => setApiGames([]))
.finally(() => setLoading(false))
}, [])
const current = useMemo(() => {
return apiGames.find((g) => {
const key = (g.shortName || g.name || g.id || "").trim()
if (!key) return false
return key === id || encodeURIComponent(key) === rawId
})
}, [apiGames, id, rawId])
const displayName = current?.name ?? current?.shortName ?? id
const theme = themeForSlug(id)
return (
<div className="min-h-screen bg-zinc-950 text-white pb-24">
@@ -25,84 +52,74 @@ export function ZoneDetailPage() {
<button type="button" onClick={() => void navigate(-1)} className="p-2 bg-white/10 rounded-full hover:bg-white/20">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-lg">{game.name}</h1>
<h1 className="font-bold text-lg truncate">{displayName} </h1>
</div>
<div className="relative aspect-video w-full">
<FillImage src={game.image} alt={game.name} className="aspect-video object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent" />
<div className="absolute bottom-4 left-4">
<h2 className="text-2xl font-bold mb-2">{game.name}</h2>
<div className="flex gap-2">
<span className="px-2 py-1 bg-white/10 rounded text-xs backdrop-blur-md"> 1205</span>
<span className="px-2 py-1 bg-white/10 rounded text-xs backdrop-blur-md"> 3.2</span>
</div>
{loading ? (
<div className="flex justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<div className="grid grid-cols-4 gap-2 p-4">
{[
{ icon: Sword, label: "找车队" },
{ icon: Trophy, label: "打比赛" },
{ icon: Users, label: "找陪练" },
{ icon: Flame, label: "看直播" },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
if (item.label === "看直播") {
void navigate("/live")
return
}
if (item.label === "找车队") {
void navigate("/planet")
return
}
if (item.label === "找陪练") {
void navigate("/mall")
return
}
if (item.label === "打比赛") {
void navigate("/events/today")
return
}
}}
className="bg-white/5 rounded-xl p-3 flex flex-col items-center gap-2 hover:bg-white/10"
>
<item.icon className="text-white/80" />
<span className="text-xs">{item.label}</span>
</button>
))}
</div>
<div className="px-4 space-y-4">
<h3 className="font-bold flex items-center gap-2">
<div className={`w-1 h-4 ${game.barClass} rounded-full`} />
</h3>
{teams.map((i) => (
<div key={i} className="bg-white/5 rounded-xl p-4 flex items-center gap-4 min-w-0">
<div className="w-12 h-12 shrink-0 rounded-full bg-white/10 relative overflow-hidden">
<FillImage src="/avatar-cute.jpg" alt="Avatar" className="h-12 w-12" />
) : (
<>
<div className="relative aspect-video w-full">
<FillImage src={theme.banner} alt={displayName} className="aspect-video object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent" />
<div className="absolute bottom-4 left-4">
<h2 className="text-2xl font-bold mb-2">{displayName}</h2>
<div className="flex gap-2 flex-wrap">
{current?.category ? (
<span className="px-2 py-1 bg-white/10 rounded text-xs backdrop-blur-md">{current.category}</span>
) : null}
<span className="px-2 py-1 bg-white/10 rounded text-xs backdrop-blur-md">/</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-sm truncate"> {i}</h4>
<p className="text-xs text-white/50 truncate"> | | {game.query}</p>
</div>
<button
type="button"
onClick={() => {
void navigate("/messages")
toast({ title: "已发起车队沟通", description: "请在消息中查看系统提示" })
}}
className={`shrink-0 px-4 py-1.5 rounded-full text-xs font-bold text-white ${game.btnClass}`}
>
</button>
</div>
))}
</div>
<div className="grid grid-cols-4 gap-2 p-4">
{[
{ icon: Sword, label: "找车队" },
{ icon: Trophy, label: "打比赛" },
{ icon: Users, label: "找陪练" },
{ icon: Flame, label: "看直播" },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
if (item.label === "看直播") {
void navigate("/live")
return
}
if (item.label === "找车队") {
void navigate("/planet")
return
}
if (item.label === "找陪练") {
void navigate("/mall")
return
}
if (item.label === "打比赛") {
void navigate("/events/today")
return
}
}}
className="bg-white/5 rounded-xl p-3 flex flex-col items-center gap-2 hover:bg-white/10"
>
<item.icon className="text-white/80" />
<span className="text-xs">{item.label}</span>
</button>
))}
</div>
<div className="px-4 space-y-4">
<h3 className="font-bold flex items-center gap-2">
<div className={`w-1 h-4 ${theme.barClass} rounded-full`} />
</h3>
<p className="text-sm text-white/50 py-6 text-center"></p>
</div>
</>
)}
</div>
)
}

View File

@@ -1,78 +1,115 @@
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Users, Trophy, Star, Shield, CheckCircle2, MessageCircle } from "lucide-react"
import { ArrowLeft, Users, Trophy, Star, Shield, CheckCircle2, MessageCircle, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { appGuild, type AppGuildDTO } from "@/api/catalog"
const guildData: Record<
string,
{
name: string
logo: string
cover: string
level: number
members: number
rank: number
rating: number
description: string
benefits: { title: string; desc: string; color: string }[]
requirements: { label: string; text: string }[]
function normBenefits(raw: unknown): { title: string; desc: string; color: string }[] {
if (!Array.isArray(raw)) return []
const out: { title: string; desc: string; color: string }[] = []
let i = 0
for (const item of raw) {
const colors = ["border-primary", "border-secondary", "border-amber-500/50"]
if (typeof item === "string" && item.trim()) {
out.push({ title: item, desc: "", color: colors[i % colors.length]! })
i++
continue
}
if (item && typeof item === "object" && "title" in item) {
const o = item as { title?: string; desc?: string }
out.push({
title: o.title ?? "权益",
desc: typeof o.desc === "string" ? o.desc : "",
color: colors[i % colors.length]!,
})
i++
}
}
> = {
"1": {
name: "东东电竞",
logo: "/esports-team-logo.jpg",
cover: "/esports-tournament-stadium.jpg",
level: 10,
members: 1205,
rank: 1,
rating: 4.9,
description: "国服顶尖路人王聚集地,专注高端局陪练与代练业务。",
benefits: [
{ title: "专属培训体系", desc: "职业教练一对一指导", color: "border-primary" },
{ title: "优先派单特权", desc: "优质高价单优先", color: "border-secondary" },
],
requirements: [
{ label: "游戏段位", text: "荣耀王者50星 / LOL大师以上" },
{ label: "在线时间", text: "每天稳定在线不低于4小时" },
],
},
"2": {
name: "星耀公会",
logo: "/esports-team-logo.jpg",
cover: "/esports-tournament-stadium.jpg",
level: 8,
members: 856,
rank: 3,
rating: 4.7,
description: "专注王者荣耀高端陪练,新人友好。",
benefits: [
{ title: "新人培训", desc: "系统培训快速上手", color: "border-primary" },
{ title: "稳定派单", desc: "收入有保障", color: "border-secondary" },
],
requirements: [
{ label: "游戏段位", text: "王者荣耀荣耀王者以上" },
{ label: "在线时间", text: "每天在线不低于3小时" },
],
},
return out
}
function normRequirements(desc: string, rawBenefitsUnknown: unknown): { label: string; text: string }[] {
const reqStr = desc?.trim()
if (reqStr) {
return [{ label: "说明", text: reqStr }]
}
if (Array.isArray(rawBenefitsUnknown)) {
/* fallthrough */
}
return [{ label: "入驻要求", text: "请通过「申请加入」联系公会管理员" }]
}
export function GuildDetailPage() {
const { id = "1" } = useParams()
const { id = "" } = useParams()
const navigate = useNavigate()
const [isApplied, setIsApplied] = useState(false)
const guild = guildData[id] ?? guildData["1"]!
const [loading, setLoading] = useState(true)
const [guild, setGuild] = useState<AppGuildDTO | null>(null)
const load = useCallback(() => {
if (!id) {
setGuild(null)
setLoading(false)
return
}
setLoading(true)
void appGuild(id)
.then((r) => {
if (r.success && r.data) setGuild(r.data as AppGuildDTO)
else setGuild(null)
})
.catch(() => setGuild(null))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
load()
}, [load])
const handleApply = () => {
setIsApplied(true)
toast({ title: "申请已提交", description: "公会管理员将在24小时内审核您的申请" })
toast({ title: "申请已提交", description: "公会管理员将在可用时审核您的申请" })
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background pb-24">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
</div>
)
}
if (!guild) {
return (
<div className="min-h-screen bg-background px-4 pb-24 pt-16">
<p className="text-center text-sm text-white/60"></p>
<Button type="button" variant="outline" className="mt-6 mx-auto flex" onClick={() => void navigate("/guild")}>
</Button>
</div>
)
}
const level = Math.max(1, Math.round(Number(guild.level) || 1))
const members = Math.round(Number(guild.memberCount) || 0)
const logo = guild.logo?.trim() ? guild.logo : "/esports-team-logo.jpg"
const cover = guild.coverImage?.trim() ? guild.coverImage : "/esports-tournament-stadium.jpg"
const benefits = normBenefits(guild.benefits)
const displayBenefits =
benefits.length > 0
? benefits
: [
{ title: "成员协作", desc: "加入后与队友保持沟通,遵从公会规范", color: "border-primary" },
{ title: "资源整合", desc: "游戏与标签见下方数据字段", color: "border-secondary" },
]
const requirements = normRequirements(guild.description ?? "", guild.benefits)
return (
<div className="min-h-screen bg-background pb-24 relative">
<div className="h-48 relative">
<FillImage src={guild.cover || "/placeholder.svg"} alt="Cover" className="h-48" />
<FillImage src={cover} alt="Cover" className="h-48" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background" />
<button onClick={() => void navigate(-1)} className="absolute top-4 left-4 p-2 rounded-full bg-black/20 backdrop-blur-md text-white z-10" type="button">
<ArrowLeft size={20} />
@@ -82,7 +119,7 @@ export function GuildDetailPage() {
<div className="px-4 -mt-12 relative z-10">
<div className="flex items-end justify-between mb-4">
<div className="w-24 h-24 rounded-2xl border-4 border-background bg-muted relative overflow-hidden shadow-xl">
<FillImage src={guild.logo || "/placeholder.svg"} alt="Logo" className="h-24 w-24 rounded-2xl" />
<FillImage src={logo} alt="Logo" className="h-24 w-24 rounded-2xl" />
</div>
<div className="flex gap-2 mb-2">
<Button size="sm" variant="outline" className="h-8 text-xs gap-1 bg-transparent" type="button">
@@ -98,22 +135,22 @@ export function GuildDetailPage() {
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-bold">{guild.name}</h1>
<span className="px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-400 text-[10px] border border-yellow-500/30 font-bold">LV.{guild.level} </span>
<h1 className="text-2xl font-bold">{guild.name ?? "公会"}</h1>
<span className="px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-400 text-[10px] border border-yellow-500/30 font-bold">LV.{level}</span>
</div>
<p className="text-sm text-white/60 mb-3">{guild.description}</p>
<p className="text-sm text-white/60 mb-3">{guild.description?.trim() || "暂无简介"}</p>
<div className="flex items-center gap-4 text-xs text-white/40">
<span className="flex items-center gap-1">
<Users size={12} />
{guild.members.toLocaleString()}
{members.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<Trophy size={12} />
{guild.rank}
· {guild.id.slice(0, 8)}
</span>
<span className="flex items-center gap-1">
<Star size={12} />
{guild.rating}
{guild.status === "active" ? "进行中" : guild.status ?? "—"}
</span>
</div>
</div>
@@ -124,10 +161,10 @@ export function GuildDetailPage() {
</h3>
<div className="grid grid-cols-2 gap-3">
{guild.benefits.map((benefit) => (
<div key={benefit.title} className={`glass-card p-3 rounded-xl border-l-2 ${benefit.color}`}>
{displayBenefits.map((benefit) => (
<div key={`${benefit.title}-${benefit.desc}`} className={`glass-card p-3 rounded-xl border-l-2 ${benefit.color}`}>
<div className="text-sm font-bold mb-1">{benefit.title}</div>
<div className="text-xs text-white/50">{benefit.desc}</div>
{benefit.desc ? <div className="text-xs text-white/50">{benefit.desc}</div> : null}
</div>
))}
</div>
@@ -139,7 +176,7 @@ export function GuildDetailPage() {
</h3>
<div className="glass-card p-4 rounded-xl space-y-3">
{guild.requirements.map((req) => (
{requirements.map((req) => (
<div key={req.label} className="flex items-start gap-3">
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5" />
<div className="flex-1 text-xs text-white/70">

View File

@@ -1,4 +1,4 @@
import { useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { ArrowLeft, Users, Trophy, Star, Crown, TrendingUp, MessageCircle, Loader2 } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button"
@@ -8,17 +8,39 @@ import { Input } from "@/components/ui/input"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { postJourneyEvent } from "@/lib/journey"
import { appGuilds, type AppGuildDTO } from "@/api/catalog"
const GUILDS = [
{ id: "1", name: "东东电竞", level: 10, members: 1205, income: "月入50万+", tags: ["全能", "陪玩", "培训"], rank: 1, isNew: false },
{ id: "2", name: "机枪电竞", level: 8, members: 680, income: "月入30万+", tags: ["LOL", "云顶", "代练"], rank: 2, isNew: false },
{ id: "3", name: "洲越电竞", level: 6, members: 320, income: "月入15万+", tags: ["三角洲", "无畏契约"], rank: 3, isNew: true },
{ id: "4", name: "星辰公会", level: 7, members: 450, income: "月入20万+", tags: ["王者", "吃鸡", "陪玩"], rank: 4, isNew: true },
]
type GuildCardRow = {
id: string
name: string
level: number
members: number
income: string
tags: string[]
rank: number
logo?: string
}
const HOT_LIST = GUILDS
const NEW_LIST = GUILDS.filter((g) => g.isNew).sort((a, b) => b.members - a.members)
const RANK_LIST = [...GUILDS].sort((a, b) => a.rank - b.rank)
function mapGuildRow(g: AppGuildDTO, provisionalRank: number): GuildCardRow {
const tags: string[] = []
if (Array.isArray(g.tags)) tags.push(...g.tags.filter((t): t is string => typeof t === "string" && Boolean(t.trim())))
if (Array.isArray(g.games)) for (const x of g.games) tags.push(String(x))
const ti = g.totalIncome ?? 0
return {
id: g.id,
name: (g.name && g.name.trim()) || "公会",
level: Math.max(1, Math.round(Number(g.level) || 1)),
members: Math.round(Number(g.memberCount) || 0),
income: ti > 0 ? `平台流水 ${ti}` : "—",
tags: [...new Set(tags)].slice(0, 8),
rank: provisionalRank,
logo: g.logo?.trim() || undefined,
}
}
function rerank(list: GuildCardRow[]): GuildCardRow[] {
return list.map((g, i) => ({ ...g, rank: i + 1 }))
}
function GuildCard({
guild,
@@ -27,18 +49,20 @@ function GuildCard({
onOpen,
rankMode,
}: {
guild: (typeof GUILDS)[0]
guild: GuildCardRow
i: number
onJoin: (name: string) => void
onOpen: (id: string) => void
rankMode?: boolean
}) {
const logoSrc =
guild.logo && (guild.logo.startsWith("http") || guild.logo.startsWith("/")) ? guild.logo : `/guild-logo-${(i % 3) + 1}.jpg`
return (
<div className="glass-card p-4 rounded-xl hover:bg-white/5 transition-colors">
<div className="flex items-start gap-3 mb-3">
<div className="relative">
<div className="w-16 h-16 rounded-lg bg-muted relative overflow-hidden">
<FillImage src={`/guild-logo-${(i % 3) + 1}.jpg`} alt={guild.name} className="h-16 w-16" />
<FillImage src={logoSrc} alt={guild.name} className="h-16 w-16" />
</div>
{rankMode ? (
<div
@@ -105,12 +129,30 @@ function GuildCard({
export function GuildListPage() {
const navigate = useNavigate()
const [apiGuilds, setApiGuilds] = useState<AppGuildDTO[]>([])
const [guildsLoading, setGuildsLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState("")
const [createGame, setCreateGame] = useState("")
const [createContact, setCreateContact] = useState("")
const [submitting, setSubmitting] = useState(false)
const loadGuilds = useCallback(() => {
setGuildsLoading(true)
void appGuilds()
.then((rows) => setApiGuilds(rows))
.catch(() => setApiGuilds([]))
.finally(() => setGuildsLoading(false))
}, [])
useEffect(() => {
loadGuilds()
}, [loadGuilds])
const baseRows = useMemo(() => apiGuilds.map((g, i) => mapGuildRow(g, i + 1)), [apiGuilds])
const hotList = useMemo(() => rerank([...baseRows].sort((a, b) => b.members - a.members)), [baseRows])
const newList = useMemo(() => rerank([...baseRows].sort((a, b) => a.level - b.level || b.members - a.members)), [baseRows])
const rankList = useMemo(() => hotList, [hotList])
const handleJoinGuild = (guildName: string) => {
toast({ title: "申请已发送", description: `您的加入申请已发送至${guildName},请等待审核` })
}
@@ -176,39 +218,63 @@ export function GuildListPage() {
</TabsList>
<TabsContent value="hot" className="mt-4 space-y-3">
{HOT_LIST.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))}
{guildsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : hotList.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50"></p>
) : (
hotList.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))
)}
</TabsContent>
<TabsContent value="new" className="mt-4 space-y-3">
{NEW_LIST.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))}
<p className="text-xs text-white/40 text-center pt-1"> 30 </p>
{guildsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : newList.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50"></p>
) : (
newList.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))
)}
<p className="text-xs text-white/40 text-center pt-1"></p>
</TabsContent>
<TabsContent value="top" className="mt-4 space-y-3">
{RANK_LIST.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
rankMode
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))}
{guildsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : rankList.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50"></p>
) : (
rankList.map((guild, i) => (
<GuildCard
key={guild.id}
guild={guild}
i={i}
rankMode
onJoin={handleJoinGuild}
onOpen={(id) => void navigate(`/guild/${id}`)}
/>
))
)}
</TabsContent>
</Tabs>
@@ -219,7 +285,7 @@ export function GuildListPage() {
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-sm mb-1"></h3>
<p className="text-xs text-white/60"></p>
<p className="text-xs text-white/60"></p>
</div>
<Button size="sm" className="bg-primary hover:bg-primary/90 h-8 text-xs shrink-0" type="button" onClick={() => setCreateOpen(true)}>

View File

@@ -1,11 +1,25 @@
import { useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Star, Play, Mic, Video, Gamepad2, GraduationCap } from "lucide-react"
import { Star, Play, Mic, Video, Gamepad2, GraduationCap, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { appContentItems, type AppContentItemDTO } from "@/api/catalog"
import { getStreamersForApp, type AppStreamer } from "@/api/streamers"
import { getStreamerAvatarUrl } from "@/lib/streamer-avatar"
const games = [
function slugOrAll(streamerGame?: string): string {
const g = String(streamerGame ?? "")
.toLowerCase()
.trim()
if (/lol|英雄联盟|撸啊撸/.test(g)) return "lol"
if (/hok|王者荣耀|王者/.test(g)) return "hok"
if (/val|无畏契约|瓦/.test(g)) return "val"
if (/pubg|和平精英|吃鸡/.test(g)) return "pubg"
if (/genshin|原神/.test(g)) return "genshin"
return g.slice(0, 12) || "other"
}
const TAB_GAMES = [
{ name: "全部", id: "all" },
{ name: "英雄联盟", id: "lol" },
{ name: "王者荣耀", id: "hok" },
@@ -14,104 +28,46 @@ const games = [
{ name: "原神", id: "genshin" },
]
const companions = [
{
id: 1,
name: "GM-远洋",
title: "国服第一盲僧",
price: 50,
unit: "局",
image: "/avatar-1.jpg",
tags: ["技术流", "风趣", "职业退役"],
game: "lol",
rating: 5.0,
orders: 1205,
voice: true,
video: true,
},
{
id: 2,
name: "七七",
title: "甜美声优",
price: 40,
unit: "小时",
image: "/avatar-5.jpg",
tags: ["声优", "聊天", "辅助"],
game: "hok",
rating: 4.9,
orders: 890,
voice: true,
video: false,
},
{
id: 3,
name: "卡若",
title: "前职业选手",
price: 80,
unit: "局",
image: "/avatar-2.jpg",
tags: ["包赢", "教学", "高分局"],
game: "lol",
rating: 5.0,
orders: 3400,
voice: true,
video: true,
},
{
id: 4,
name: "小柠檬",
title: "人皮话多",
price: 30,
unit: "局",
image: "/avatar-3.jpg",
tags: ["娱乐", "开心", "相声"],
game: "val",
rating: 4.8,
orders: 560,
voice: true,
video: false,
},
{
id: 5,
name: "大魔王",
title: "中单法王",
price: 100,
unit: "局",
image: "/avatar-6.jpg",
tags: ["技术教学", "上分", "严肃"],
game: "lol",
rating: 5.0,
orders: 8800,
voice: true,
video: true,
},
{
id: 6,
name: "阿伟",
title: "绝地刚枪王",
price: 60,
unit: "小时",
image: "/avatar-4.jpg",
tags: ["带妹", "吃鸡", "指挥"],
game: "pubg",
rating: 4.9,
orders: 1100,
voice: true,
video: false,
},
]
const courseList = [
{ id: 1, title: "中单瑞兹顶级教学", author: "Faker", price: 199, image: "/course-1.jpg", students: 12000, game: "lol" },
{ id: 2, title: "下路对线细节全解", author: "Uzi", price: 199, image: "/course-2.jpg", students: 35000, game: "lol" },
{ id: 3, title: "上单压制力养成", author: "TheShy", price: 159, image: "/course-3.jpg", students: 8900, game: "lol" },
{ id: 4, title: "瓦罗兰特特工教学", author: "康康", price: 99, image: "/course-4.jpg", students: 4500, game: "val" },
]
export function MallCompanionPage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<"play" | "learn">("play")
const [activeGame, setActiveGame] = useState("all")
const [streamers, setStreamers] = useState<AppStreamer[]>([])
const [courses, setCourses] = useState<AppContentItemDTO[]>([])
const [loadingPlay, setLoadingPlay] = useState(true)
const [loadingLearn, setLoadingLearn] = useState(true)
const loadStreamers = useCallback(() => {
setLoadingPlay(true)
void getStreamersForApp()
.then(setStreamers)
.catch(() => setStreamers([]))
.finally(() => setLoadingPlay(false))
}, [])
const loadCourses = useCallback(() => {
setLoadingLearn(true)
void appContentItems({ type: "course" })
.then(setCourses)
.catch(() => setCourses([]))
.finally(() => setLoadingLearn(false))
}, [])
useEffect(() => {
loadStreamers()
}, [loadStreamers])
useEffect(() => {
if (activeTab === "learn") loadCourses()
}, [activeTab, loadCourses])
const filteredStreamers = useMemo(() => {
if (activeGame === "all") return streamers
return streamers.filter((s) => slugOrAll(s.game) === activeGame || String(s.game ?? "").includes(activeGame))
}, [streamers, activeGame])
const filteredCourses = useMemo(() => {
if (activeGame === "all") return courses
return courses.filter((c) => slugOrAll(c.game) === activeGame || String(c.game ?? "").includes(activeGame))
}, [courses, activeGame])
return (
<div className="min-h-screen pb-24 bg-background">
@@ -145,7 +101,7 @@ export function MallCompanionPage() {
<div className="px-4 mt-4 overflow-x-auto no-scrollbar pb-2">
<div className="flex gap-2">
{games.map((g) => (
{TAB_GAMES.map((g) => (
<button
key={g.id}
type="button"
@@ -160,92 +116,109 @@ export function MallCompanionPage() {
<div className="px-4 mt-2 space-y-4">
{activeTab === "play" ? (
<div className="grid gap-3">
{companions
.filter((c) => activeGame === "all" || c.game === activeGame)
.map((c) => (
<div
key={c.id}
className="glass-card p-3 rounded-xl flex gap-3 cursor-pointer hover:bg-white/5 transition-colors group"
onClick={() => void navigate(`/live/${c.id}`)}
onKeyDown={(e) => e.key === "Enter" && void navigate(`/live/${c.id}`)}
role="button"
tabIndex={0}
>
<div className="relative w-24 h-24 rounded-lg overflow-hidden shrink-0">
<FillImage src={c.image || "/placeholder.svg"} alt={c.name} className="h-24 w-24" />
<div className="absolute top-1 left-1 bg-black/60 backdrop-blur-sm px-1.5 py-0.5 rounded flex items-center gap-1 text-[10px] text-white font-bold z-10">
<Star size={8} className="text-yellow-500 fill-yellow-500" />
{c.rating}
</div>
</div>
<div className="flex-1 flex flex-col justify-between py-0.5">
<div>
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm flex items-center gap-1">
{c.name}
{c.voice && <Mic size={10} className="text-green-400" />}
{c.video && <Video size={10} className="text-blue-400" />}
</h3>
<span className="text-[10px] text-white/40">{c.orders}</span>
loadingPlay ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : filteredStreamers.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-3">
{filteredStreamers.map((c) => {
const img = getStreamerAvatarUrl(c.name, c.avatar, 200)
const tags = Array.isArray(c.tags) ? c.tags.slice(0, 4) : []
const rating = "5.0"
return (
<div
key={c.id}
className="glass-card p-3 rounded-xl flex gap-3 cursor-pointer hover:bg-white/5 transition-colors group"
onClick={() => void navigate(`/live/${c.id}`)}
onKeyDown={(e) => e.key === "Enter" && void navigate(`/live/${c.id}`)}
role="button"
tabIndex={0}
>
<div className="relative w-24 h-24 rounded-lg overflow-hidden shrink-0">
<FillImage src={img || "/placeholder.svg"} alt={c.name} className="h-24 w-24" />
<div className="absolute top-1 left-1 bg-black/60 backdrop-blur-sm px-1.5 py-0.5 rounded flex items-center gap-1 text-[10px] text-white font-bold z-10">
<Star size={8} className="text-yellow-500 fill-yellow-500" />
{rating}
</div>
<p className="text-xs text-primary font-medium mt-0.5">{c.title}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{c.tags.map((tag, i) => (
<span key={i} className="text-[10px] bg-white/5 px-1.5 py-0.5 rounded text-white/50">
{tag}
</div>
<div className="flex-1 flex flex-col justify-between py-0.5">
<div>
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm flex items-center gap-1">
{c.name}
<Mic size={10} className="text-green-400" />
<Video size={10} className="text-blue-400" />
</h3>
<span className="text-[10px] text-white/40">{c.fans ?? 0}</span>
</div>
<p className="text-xs text-primary font-medium mt-0.5">{c.title ?? c.game ?? ""}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.map((tag, i) => (
<span key={i} className="text-[10px] bg-white/5 px-1.5 py-0.5 rounded text-white/50">
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-end gap-1">
<span className="text-lg font-bold text-secondary">
{(c as { hourlyRate?: number }).hourlyRate ?? 88}
</span>
))}
<span className="text-xs text-white/50 mb-1">/</span>
</div>
<Button
size="sm"
className="h-7 text-xs px-3 font-bold bg-white/10 hover:bg-primary hover:text-black"
onClick={(e) => e.stopPropagation()}
>
</Button>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-end gap-1">
<span className="text-lg font-bold text-secondary">{c.price}</span>
<span className="text-xs text-white/50 mb-1">/{c.unit}</span>
</div>
<Button
size="sm"
className="h-7 text-xs px-3 font-bold bg-white/10 hover:bg-primary hover:text-black"
onClick={(e) => e.stopPropagation()}
>
</Button>
</div>
</div>
</div>
))}
)
})}
</div>
)
) : loadingLearn ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-secondary" />
</div>
) : filteredCourses.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-4">
{courseList
.filter((c) => activeGame === "all" || c.game === activeGame)
.map((c) => (
<div
key={c.id}
className="glass-card p-0 rounded-xl overflow-hidden cursor-pointer group"
onClick={() => toast({ title: "购买成功", description: "课程已添加至学习中心" })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div className="relative aspect-video">
<FillImage src={c.image || "/placeholder.svg"} alt={c.title} className="h-full" />
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={40} className="text-white fill-white" />
</div>
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-sm rounded text-[10px] font-bold">
{c.author}
</div>
{filteredCourses.map((c) => (
<div
key={c.id}
className="glass-card p-0 rounded-xl overflow-hidden cursor-pointer group"
onClick={() => void navigate(`/course/${c.id}`)}
onKeyDown={(e) => e.key === "Enter" && void navigate(`/course/${c.id}`)}
role="button"
tabIndex={0}
>
<div className="relative aspect-video">
<FillImage src={c.image || c.coverImage || "/placeholder.svg"} alt={c.title ?? ""} className="h-full" />
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={40} className="text-white fill-white" />
</div>
<div className="p-3">
<h3 className="font-bold text-sm mb-1">{c.title}</h3>
<div className="flex items-center justify-between">
<span className="text-xs text-white/50">{c.students} </span>
<span className="text-primary font-bold">¥{c.price}</span>
</div>
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-sm rounded text-[10px] font-bold">
{c.author ?? "讲师"}
</div>
</div>
))}
<div className="p-3">
<h3 className="font-bold text-sm mb-1">{c.title ?? c.name ?? "课程"}</h3>
<div className="flex items-center justify-between">
<span className="text-xs text-white/50">{c.students ?? 0} </span>
<span className="text-primary font-bold">¥{c.price ?? 0}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>

View File

@@ -1,18 +1,25 @@
import { useCallback, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Play, Users } from "lucide-react"
import { Play, Users, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
const courses = [
{ id: 1, title: "Faker 中单瑞兹教学", author: "Faker", price: 199, image: "/course-1.jpg", students: 12000 },
{ id: 2, title: "Uzi 下路对线细节", author: "Uzi", price: 199, image: "/course-2.jpg", students: 35000 },
{ id: 3, title: "TheShy 上单压制力", author: "TheShy", price: 159, image: "/course-3.jpg", students: 8900 },
{ id: 4, title: "10分钟学会云顶吃鸡", author: "云顶之弈", price: 49, image: "/course-4.jpg", students: 5000 },
]
import { appContentItems, type AppContentItemDTO } from "@/api/catalog"
export function MallCoursesPage() {
const navigate = useNavigate()
const [courses, setCourses] = useState<AppContentItemDTO[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
void appContentItems({ type: "course" })
.then(setCourses)
.catch(() => setCourses([]))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="min-h-screen pb-24 bg-background">
@@ -20,41 +27,57 @@ export function MallCoursesPage() {
<button onClick={() => void navigate(-1)} className="text-white/70" type="button">
</button>
<h1 className="font-bold text-lg"></h1>
<h1 className="font-bold text-lg"></h1>
</header>
<div className="p-4 space-y-4">
{courses.map((c) => (
<div
key={c.id}
className="glass-card p-3 rounded-xl flex gap-3 cursor-pointer hover:bg-white/10"
onClick={() => toast({ title: "购买成功", description: "课程已添加至学习中心" })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div className="relative w-32 aspect-video rounded-lg overflow-hidden shrink-0">
<FillImage src={c.image || "/placeholder.svg"} alt={c.title} className="h-full" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Play size={24} className="text-white/80" />
</div>
</div>
<div className="flex-1 flex flex-col justify-between">
<h3 className="font-bold text-sm">{c.title}</h3>
<div className="flex items-center gap-2 text-xs text-white/50">
<span className="bg-white/10 px-1.5 py-0.5 rounded text-white/70">{c.author}</span>
<span className="flex items-center gap-1">
<Users size={10} /> {c.students}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-primary font-bold">¥{c.price}</span>
<Button size="sm" className="h-6 text-[10px]" type="button" onClick={(e) => e.stopPropagation()}>
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
))}
) : courses.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
courses.map((c) => (
<div
key={c.id}
className="glass-card p-3 rounded-xl flex gap-3 cursor-pointer hover:bg-white/10"
onClick={() => void navigate(`/course/${encodeURIComponent(c.id)}`)}
onKeyDown={(e) => e.key === "Enter" && void navigate(`/course/${encodeURIComponent(c.id)}`)}
role="button"
tabIndex={0}
>
<div className="relative w-32 aspect-video rounded-lg overflow-hidden shrink-0">
<FillImage src={c.coverImage || c.image || "/placeholder.svg"} alt={c.title ?? ""} className="h-full" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Play size={24} className="text-white/80" />
</div>
</div>
<div className="flex-1 flex flex-col justify-between">
<h3 className="font-bold text-sm">{c.title ?? c.name ?? "课程"}</h3>
<div className="flex items-center gap-2 text-xs text-white/50">
<span className="bg-white/10 px-1.5 py-0.5 rounded text-white/70">{c.author ?? "教官"}</span>
<span className="flex items-center gap-1">
<Users size={10} /> {Math.round(c.students ?? 0)}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-primary font-bold">¥{Math.round(Number(c.price) || 0)}</span>
<Button
size="sm"
className="h-6 text-[10px]"
type="button"
onClick={(e) => {
e.stopPropagation()
void navigate(`/course/${encodeURIComponent(c.id)}`)
}}
>
</Button>
</div>
</div>
</div>
))
)}
</div>
</div>
)

View File

@@ -1,20 +1,28 @@
import { useCallback, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
import { ArrowLeft, ShoppingCart } from "lucide-react"
import { ArrowLeft, ShoppingCart, Loader2 } from "lucide-react"
import { FillImage } from "@/components/media/FillImage"
const products = [
{ id: 1, name: "罗技 G Pro X Superlight", price: 899, image: "/gear-mouse.jpg", tag: "职业首选", category: "鼠标" },
{ id: 2, name: "雷蛇 猎魂光蛛 V3", price: 1299, image: "/gear-keyboard.jpg", tag: "光轴科技", category: "键盘" },
{ id: 3, name: "卓威 XL2546K", price: 3499, image: "/gear-monitor.jpg", tag: "360Hz", category: "显示器" },
{ id: 4, name: "HyperX Cloud II", price: 599, image: "/gear-headset.jpg", tag: "7.1声道", category: "耳机" },
{ id: 5, name: "赛睿 QcK Heavy", price: 199, image: "/gear-mousepad.jpg", tag: "加厚版", category: "鼠标垫" },
{ id: 6, name: "电竞座椅 GT900", price: 1999, image: "/gear-chair.jpg", tag: "人体工学", category: "座椅" },
]
import { appProducts, type AppProductDTO } from "@/api/catalog"
import { useAppContext } from "@/components/providers/app-provider"
export function MallGearPage() {
const navigate = useNavigate()
const { pay } = useAppContext()
const [products, setProducts] = useState<AppProductDTO[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
void appProducts()
.then(setProducts)
.catch(() => setProducts([]))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="min-h-screen pb-24 bg-background">
@@ -30,54 +38,58 @@ export function MallGearPage() {
</Button>
</header>
<div className="px-4 py-3 flex gap-2 overflow-x-auto no-scrollbar">
{["全部", "鼠标", "键盘", "耳机", "显示器", "鼠标垫", "座椅"].map((cat, i) => (
<button
key={cat}
type="button"
className={`whitespace-nowrap text-xs font-bold px-4 py-2 rounded-full transition-colors ${
i === 0 ? "bg-primary text-black" : "bg-white/5 text-white/60 hover:bg-white/10"
}`}
>
{cat}
</button>
))}
</div>
<p className="px-4 pt-3 text-[11px] text-muted-foreground"></p>
<div className="p-4 grid grid-cols-2 gap-4">
{products.map((p) => (
<div
key={p.id}
className="glass-card rounded-xl overflow-hidden group cursor-pointer border border-white/5 hover:border-primary/30 transition-colors"
>
<div className="relative aspect-square bg-zinc-800 overflow-hidden">
<FillImage src={p.image || "/placeholder.svg"} alt={p.name} className="h-full group-hover:scale-105 transition-transform" />
<div className="absolute top-2 left-2 bg-red-500 text-white text-[10px] px-2 py-0.5 rounded font-bold z-10">
{p.tag}
</div>
<div className="absolute top-2 right-2 bg-black/50 text-white/80 text-[10px] px-2 py-0.5 rounded z-10">
{p.category}
</div>
</div>
<div className="p-3">
<h3 className="font-bold text-sm text-white truncate mb-2">{p.name}</h3>
<div className="flex items-center justify-between">
<span className="text-primary font-bold">¥{p.price}</span>
<Button
size="sm"
className="h-7 text-xs px-3"
type="button"
onClick={(e) => {
e.stopPropagation()
toast({ title: "已加入购物车", description: p.name })
}}
>
</Button>
</div>
</div>
{loading ? (
<div className="col-span-2 flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
))}
) : products.length === 0 ? (
<p className="col-span-2 py-12 text-center text-sm text-muted-foreground"></p>
) : (
products.map((p) => (
<div
key={p.id}
className="glass-card rounded-xl overflow-hidden group cursor-pointer border border-white/5 hover:border-primary/30 transition-colors"
onClick={() => void navigate(-1)}
onKeyDown={(e) => e.key === "Enter" && void navigate(-1)}
role="button"
tabIndex={0}
>
<div className="relative aspect-square bg-zinc-800 overflow-hidden">
<FillImage
src={p.image || p.coverImage || "/placeholder.svg"}
alt={p.name ?? ""}
className="h-full group-hover:scale-105 transition-transform"
/>
<div className="absolute top-2 left-2 bg-red-500 text-white text-[10px] px-2 py-0.5 rounded font-bold z-10">
{p.category || "潮品"}
</div>
</div>
<div className="p-3">
<h3 className="font-bold text-sm text-white truncate mb-2">{p.name ?? "商品"}</h3>
<div className="flex items-center justify-between">
<span className="text-primary font-bold">¥{Math.round(p.price ?? 0)}</span>
<Button
size="sm"
className="h-7 text-xs px-3"
type="button"
onClick={(e) => {
e.stopPropagation()
void (async () => {
const ok = await pay(Math.round(p.price ?? 0), p.name ?? "外设", "product", p.image || p.coverImage)
if (ok) toast({ title: "购买成功", description: p.name ?? "" })
})()
}}
>
</Button>
</div>
</div>
</div>
))
)}
</div>
</div>
)

View File

@@ -200,13 +200,13 @@ export function MallPointsGamePage() {
)
}
const handlePurchase = () => {
const handlePurchase = async () => {
if (selectedOption === null) {
toast({ title: "请选择充值金额" })
return
}
const option = game.options[selectedOption]!
const ok = pay(option.price, `${game.name} ${option.name}`, "product", game.image)
const ok = await pay(option.price, `${game.name} ${option.name}`, "product", game.image)
if (ok) void navigate("/mall")
}

View File

@@ -15,7 +15,7 @@ function coverSrc(card: MallPointCard | null): string {
export function MallPointsItemPage() {
const { cardId = "" } = useParams<{ cardId: string }>()
const navigate = useNavigate()
const { isLoggedIn } = useAppContext()
const { isLoggedIn, syncFromDatabase } = useAppContext()
const [card, setCard] = useState<MallPointCard | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
@@ -62,6 +62,7 @@ export function MallPointsItemPage() {
title: "订单已创建",
description: `单号 ${r.orderNo}${r.status}`,
})
await syncFromDatabase()
} catch (e) {
toast({
title: "下单失败",

View File

@@ -1,56 +1,18 @@
import { Suspense, useState } from "react"
import { Search, Filter, Shield, Wallet, Gamepad2, FileText, Clock } from "lucide-react"
import { Suspense, useCallback, useEffect, useState } from "react"
import { Search, Filter, Shield, Wallet, Gamepad2, FileText, Clock, Loader2 } from "lucide-react"
import { useNavigate, useSearchParams } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/hooks/use-toast"
import { FillImage } from "@/components/media/FillImage"
import { appGameAccounts, type AppGameAccountDTO } from "@/api/catalog"
const games = [
{ name: "全部", icon: null as string | null },
{ name: "英雄联盟", icon: "/account-icon-lol.jpg" },
{ name: "无畏契约", icon: "/account-icon-val.jpg" },
{ name: "王者荣耀", icon: "/account-icon-hok.jpg" },
{ name: "原神", icon: "/account-icon-gen.jpg" },
]
const accounts = [
{
id: 1,
title: "英雄联盟-全英雄 全皮肤 龙瞎",
price: 2888,
server: "艾欧尼亚",
security: "找回包赔",
image: "/account-lol.jpg",
tags: ["顶号", "满级"] as const,
},
{
id: 2,
title: "无畏契约-满特工 冠军套",
price: 1500,
server: "国服",
security: "保险护航",
image: "/account-val.jpg",
tags: ["成品号"] as const,
},
{
id: 3,
title: "王者荣耀-V10 300皮肤",
price: 5600,
server: "安卓QQ",
security: "官方验号",
image: "/account-hok.jpg",
tags: ["贵族10", "国标"] as const,
},
{
id: 4,
title: "原神-满命雷神 草神",
price: 3200,
server: "天空岛",
security: "签署合同",
image: "/account-genshin.jpg",
tags: ["三神", "高练度"] as const,
},
const GAME_ROW: { name: string; slug: string | null; icon: string | null }[] = [
{ name: "全部", slug: null, icon: null },
{ name: "英雄联盟", slug: "英雄联盟", icon: "/account-icon-lol.jpg" },
{ name: "无畏契约", slug: "无畏契约", icon: "/account-icon-val.jpg" },
{ name: "王者荣耀", slug: "王者荣耀", icon: "/account-icon-hok.jpg" },
{ name: "原神", slug: "原神", icon: "/account-icon-gen.jpg" },
]
function AccountTradeContent() {
@@ -60,6 +22,20 @@ function AccountTradeContent() {
const [pawnPhone, setPawnPhone] = useState("")
const [pawnGame, setPawnGame] = useState("王者荣耀")
const [pawnNote, setPawnNote] = useState("")
const [accounts, setAccounts] = useState<AppGameAccountDTO[]>([])
const [accountsLoading, setAccountsLoading] = useState(true)
const [gameSlug, setGameSlug] = useState<string | null>(null)
const loadAccounts = useCallback(() => {
setAccountsLoading(true)
void appGameAccounts(gameSlug ?? undefined)
.then(setAccounts)
.catch(() => setAccounts([]))
.finally(() => setAccountsLoading(false))
}, [gameSlug])
useEffect(() => {
loadAccounts()
}, [loadAccounts])
return (
<div className="min-h-screen pb-24 bg-background">
@@ -67,7 +43,7 @@ function AccountTradeContent() {
<button onClick={() => void navigate(-1)} className="text-white/70 hover:text-white" type="button">
</button>
<h1 className="font-bold text-lg"></h1>
<h1 className="font-bold text-lg"></h1>
<div className="ml-auto" />
</header>
@@ -100,62 +76,79 @@ function AccountTradeContent() {
<div className="overflow-x-auto no-scrollbar pb-2">
<div className="flex gap-3">
{games.map((g) => (
<div key={g.name} className="flex flex-col items-center gap-1 min-w-[60px] cursor-pointer group">
{GAME_ROW.map((g) => (
<button
key={g.name}
type="button"
className={`flex flex-col items-center gap-1 min-w-[60px] cursor-pointer group ${gameSlug === g.slug ? "opacity-100" : "opacity-80"}`}
onClick={() => setGameSlug(g.slug)}
>
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center border border-white/10 overflow-hidden transition-all group-hover:border-primary/50 ${!g.icon ? "bg-white/5" : "bg-black"}`}
className={`w-12 h-12 rounded-xl flex items-center justify-center border overflow-hidden transition-all group-hover:border-primary/50 ${gameSlug === g.slug ? "border-primary ring-1 ring-primary/40" : "border-white/10"} ${!g.icon ? "bg-white/5" : "bg-black"}`}
>
{g.icon ? <img src={g.icon} alt={g.name} className="w-12 h-12 object-cover" /> : <Gamepad2 className="text-white/50" />}
</div>
<span className="text-[10px] text-white/60 group-hover:text-white">{g.name}</span>
</div>
</button>
))}
</div>
</div>
<div className="grid gap-3">
{accounts.map((acc) => (
<div
key={acc.id}
className="glass-card p-3 rounded-xl flex gap-3 hover:bg-white/5 transition-colors cursor-pointer group"
onClick={() => toast({ title: "即将跳转详情", description: "该功能为模拟演示" })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div className="w-28 h-24 bg-muted rounded-lg relative overflow-hidden shrink-0 border border-white/5">
<FillImage src={acc.image || "/placeholder.svg"} alt={acc.title} className="h-24 w-28" />
<div className="absolute top-1 left-1 bg-black/60 text-[9px] px-1.5 py-0.5 rounded backdrop-blur-sm text-white/90 font-medium z-10">
{acc.server}
</div>
</div>
<div className="flex-1 flex flex-col justify-between py-0.5">
<div>
<h3 className="font-bold text-sm line-clamp-2 leading-snug">{acc.title}</h3>
<div className="flex flex-wrap gap-1 mt-1.5">
{acc.tags.map((tag) => (
<span key={tag} className="text-[9px] bg-white/5 text-white/50 px-1 rounded">
{tag}
</span>
))}
{accountsLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : accounts.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-3">
{accounts.map((acc) => {
const title = acc.title || acc.name || acc.characterName || "游戏账号"
const tags = [acc.rank, acc.game, acc.verified ? "已验号" : null].filter(Boolean) as string[]
return (
<div
key={acc.id}
className="glass-card p-3 rounded-xl flex gap-3 hover:bg-white/5 transition-colors cursor-pointer group"
onClick={() => toast({ title: "详询客服", description: title })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div className="w-28 h-24 bg-muted rounded-lg relative overflow-hidden shrink-0 border border-white/5">
<FillImage src={acc.image || "/placeholder.svg"} alt={title} className="h-24 w-28" />
<div className="absolute top-1 left-1 bg-black/60 text-[9px] px-1.5 py-0.5 rounded backdrop-blur-sm text-white/90 font-medium z-10">
{acc.server ?? "—"}
</div>
</div>
</div>
<div className="flex-1 flex flex-col justify-between py-0.5">
<div>
<h3 className="font-bold text-sm line-clamp-2 leading-snug">{title}</h3>
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.map((tag) => (
<span key={tag} className="text-[9px] bg-white/5 text-white/50 px-1 rounded">
{tag}
</span>
))}
</div>
</div>
<div className="flex items-end justify-between mt-auto">
<div className="flex flex-col">
<span className="text-[10px] text-green-400 flex items-center gap-1 mb-0.5">
<Shield size={10} /> {acc.security}
</span>
<span className="text-lg font-bold text-primary leading-none">¥{acc.price}</span>
<div className="flex items-end justify-between mt-auto">
<div className="flex flex-col">
<span className="text-[10px] text-green-400 flex items-center gap-1 mb-0.5">
<Shield size={10} /> {acc.verified ? "平台验号" : "以实际验号为准"}
</span>
<span className="text-lg font-bold text-primary leading-none">¥{acc.price ?? "议价"}</span>
</div>
<Button size="sm" className="h-7 text-xs px-3 font-bold bg-white/10 hover:bg-primary hover:text-black" type="button" onClick={(e) => e.stopPropagation()}>
</Button>
</div>
</div>
<Button size="sm" className="h-7 text-xs px-3 font-bold bg-white/10 hover:bg-primary hover:text-black" type="button" onClick={(e) => e.stopPropagation()}>
</Button>
</div>
</div>
</div>
))}
</div>
)
})}
</div>
)}
</TabsContent>
<TabsContent value="pawn" className="flex-1 px-4 space-y-4 mt-4 data-[state=inactive]:hidden">
@@ -171,25 +164,11 @@ function AccountTradeContent() {
<p className="text-xs text-amber-200/60 mt-0.5"> · · </p>
</div>
</div>
<div className="text-right">
<div className="text-xs text-white/40 mb-1"></div>
<div className="text-xl font-mono font-bold text-white tracking-tight">8,526</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 relative z-10">
<div className="bg-black/30 rounded-xl p-3 text-center border border-white/5">
<div className="text-[10px] text-white/40 mb-1.5 uppercase tracking-wider"></div>
<div className="text-sm font-bold text-amber-300">¥ 8600</div>
</div>
<div className="bg-black/30 rounded-xl p-3 text-center border border-white/5">
<div className="text-[10px] text-white/40 mb-1.5 uppercase tracking-wider"></div>
<div className="text-sm font-bold text-amber-300">¥ 5,000+</div>
</div>
<div className="bg-black/30 rounded-xl p-3 text-center border border-white/5">
<div className="text-[10px] text-white/40 mb-1.5 uppercase tracking-wider"></div>
<div className="text-sm font-bold text-amber-300">98.5%</div>
<div className="text-right max-w-[140px]">
<div className="text-[10px] text-white/50 leading-relaxed">/</div>
</div>
</div>
<p className="relative z-10 text-center text-xs text-amber-200/50"></p>
</div>
<div className="glass-card p-4 rounded-2xl">
@@ -216,7 +195,7 @@ function AccountTradeContent() {
</div>
<div className="glass-card p-4 rounded-xl space-y-3 border border-amber-500/20">
<h3 className="font-bold text-sm">使/线</h3>
<h3 className="font-bold text-sm">线</h3>
<div>
<label className="text-xs text-white/60"></label>
<input

View File

@@ -1,17 +1,25 @@
import { useCallback, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Star, Zap, Trophy } from "lucide-react"
import { Star, Zap, Trophy, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/hooks/use-toast"
const boosters = [
{ id: 1, title: "王者荣耀-荣耀王者直通车", price: 25, unit: "星", tags: ["极速", "金牌打手"], sales: 5000 },
{ id: 2, title: "LOL-钻石大师晋级赛", price: 88, unit: "局", tags: ["包赢", "职业退役"], sales: 1200 },
{ id: 3, title: "无畏契约-赋能战神代打", price: 50, unit: "胜场", tags: ["MVP", "直播"], sales: 800 },
{ id: 4, title: "原神-深渊满星/神瞳全收集", price: 10, unit: "层", tags: ["纯手工", "安全"], sales: 3000 },
]
import { appLevelingProducts, type AppLevelProductDTO } from "@/api/catalog"
export function LevelingPage() {
const navigate = useNavigate()
const [items, setItems] = useState<AppLevelProductDTO[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
void appLevelingProducts()
.then(setItems)
.catch(() => setItems([]))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="min-h-screen pb-24 bg-background">
@@ -25,64 +33,61 @@ export function LevelingPage() {
<div className="p-4 space-y-4">
<div className="relative w-full h-32 rounded-xl overflow-hidden bg-gradient-to-r from-orange-500 to-red-600 flex items-center px-6">
<div className="relative z-10">
<h2 className="text-xl font-bold text-white mb-1"></h2>
<p className="text-xs text-white/80"> · · </p>
<h2 className="text-xl font-bold text-white mb-1"></h2>
<p className="text-xs text-white/80"></p>
</div>
<Trophy className="absolute right-4 bottom-[-10px] text-white/20 w-24 h-24 rotate-12" />
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-2">
{["全部", "王者荣耀", "英雄联盟", "无畏契约", "和平精英", "永劫无间"].map((tag, i) => (
<button
key={tag}
type="button"
className={`whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-medium ${i === 0 ? "bg-primary text-black" : "bg-white/5 text-white/60"}`}
>
{tag}
</button>
))}
</div>
<div className="grid gap-3">
{boosters.map((item) => (
<div
key={item.id}
className="glass-card p-4 rounded-xl flex justify-between items-center hover:bg-white/10 transition-colors"
onClick={() => toast({ title: "下单成功", description: "打手将在5分钟内上号" })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div>
<h3 className="font-bold text-sm mb-1">{item.title}</h3>
<div className="flex gap-2 mb-2">
{item.tags.map((t) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-white/5 text-white/50 border border-white/5">
{t}
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : items.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-3">
{items.map((item) => (
<div
key={item.id}
className="glass-card p-4 rounded-xl flex justify-between items-center hover:bg-white/10 transition-colors"
onClick={() => toast({ title: "请咨询客服", description: item.name })}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
role="button"
tabIndex={0}
>
<div>
<h3 className="font-bold text-sm mb-1">{item.name}</h3>
<p className="text-[11px] text-muted-foreground mb-2">{item.subtitle}</p>
<div className="flex gap-2 mb-2">
{[item.game, item.type].filter(Boolean).map((t) => (
<span
key={String(t)}
className="text-[10px] px-1.5 py-0.5 rounded bg-white/5 text-white/50 border border-white/5"
>
{t}
</span>
))}
</div>
<div className="flex items-center gap-3 text-xs text-white/40">
<span className="flex items-center gap-1">
<Zap size={12} className="text-yellow-500" /> {item.delivery ?? "接单时效以运营为准"}
</span>
))}
<span className="flex items-center gap-1">
<Star size={12} className="text-primary" /> SKU
</span>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-white/40">
<span className="flex items-center gap-1">
<Zap size={12} className="text-yellow-500" /> 15
</span>
<span className="flex items-center gap-1">
<Star size={12} className="text-primary" /> {item.sales}+
</span>
<div className="text-right shrink-0">
<div className="text-lg font-bold text-primary">{item.priceNote || "议价"}</div>
<Button size="sm" className="h-7 text-xs mt-1" type="button" onClick={(e) => e.stopPropagation()}>
</Button>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-primary">
¥{item.price}
<span className="text-xs text-white/50 font-normal">/{item.unit}</span>
</div>
<Button size="sm" className="h-7 text-xs mt-1" type="button" onClick={(e) => e.stopPropagation()}>
</Button>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
)

View File

@@ -18,7 +18,7 @@ export function LiveListPage() {
) : error ? (
<p className="text-destructive text-sm">{error}</p>
) : streamers.length === 0 ? (
<p className="text-foreground/50 text-sm"></p>
<p className="text-foreground/50 text-sm"></p>
) : (
<ul className="space-y-3">
{streamers.map((s) => (

View File

@@ -18,7 +18,6 @@ function aggregateByEventName(rows: AudienceInsightRow[]) {
export function StreamerAudiencePage() {
const [rows, setRows] = useState<AudienceInsightRow[]>([])
const [windowLabel, setWindowLabel] = useState("7d")
const [streamerId, setStreamerId] = useState<string>("")
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
@@ -29,7 +28,6 @@ export function StreamerAudiencePage() {
const r = await appAudienceInsights()
if (r.success && r.data) {
setRows(r.data.rows ?? [])
setStreamerId(r.data.streamerId ?? "")
setWindowLabel(r.data.window ?? "7d")
} else {
setErr(typeof r.message === "string" ? r.message : "无法加载数据")
@@ -66,7 +64,7 @@ export function StreamerAudiencePage() {
<div>
<p className="font-medium text-amber-100"></p>
<p className="text-white/70 mt-1">{err}</p>
<p className="text-xs text-white/50 mt-2"> 7 streamerId </p>
<p className="text-xs text-white/50 mt-2">使</p>
<Button className="mt-3" type="button" variant="secondary" onClick={() => void load()}>
</Button>
@@ -90,7 +88,7 @@ export function StreamerAudiencePage() {
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{windowLabel} · wz-api GET `/api/app/streamer/audience-insights`streamerId={streamerId || "—"}
{windowLabel} ·
</p>
</div>
<Button variant="outline" size="sm" className="ml-auto" type="button" onClick={() => void load()} disabled={loading}>
@@ -106,7 +104,7 @@ export function StreamerAudiencePage() {
) : null}
<div className="text-xs text-muted-foreground mb-3">
<span className="font-semibold text-foreground">{rows.length}</span>
<span className="font-semibold text-foreground">{rows.length}</span>
</div>
{byEvent.length > 0 ? (
@@ -119,7 +117,7 @@ export function StreamerAudiencePage() {
))}
</div>
) : !loading ? (
<p className="text-sm text-muted-foreground mb-6"></p>
<p className="text-sm text-muted-foreground mb-6"></p>
) : null}
{recent.length > 0 ? (

View File

@@ -138,7 +138,7 @@ export function StreamerStudioPage() {
<AlertCircle className="h-5 w-5 text-amber-400 shrink-0" />
<div>
<p className="font-medium text-amber-100">使</p>
<p className="text-white/70 mt-1">{meError ?? "需要已审核的主播档案。请在管理端完成主播审核,或先申请成为大神"}</p>
<p className="text-white/70 mt-1">{meError ?? "需要完成主播认证后方可使用工作台,可先申请成为平台大神"}</p>
<Button className="mt-3" type="button" onClick={() => void navigate("/creator/apply")}>
</Button>
@@ -167,7 +167,7 @@ export function StreamerStudioPage() {
)}
</div>
<p className="text-xs text-muted-foreground mb-4"> wz-api </p>
<p className="text-xs text-muted-foreground mb-4"></p>
<div className="space-y-4">
<div className="space-y-2">
@@ -176,7 +176,11 @@ export function StreamerStudioPage() {
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground"> URL</label>
<Input value={pullUrl} onChange={(e) => setPullUrl(e.target.value)} placeholder="https://…/xxx.m3u8" />
<Input
value={pullUrl}
onChange={(e) => setPullUrl(e.target.value)}
placeholder="第三方拉流https://…playlist.m3u8 或页面分享链接B站/油管等)"
/>
</div>
<div className="rounded-xl border border-primary/25 bg-primary/5 p-4 space-y-3">

View File

@@ -0,0 +1,9 @@
# Soul 创业派对 · 开发与联调文档
本目录存放 **Soul 三端**(微信原生小程序 `miniprogram/``soul-admin/``soul-api/`)在项目边界与运维上的说明。
> **与本仓库主线区别**:当前部分工作区克隆**未包含**上述三目录时,无法进行本地改代码级「对接」,请先按 [`三端路由与联调说明.md`](三端路由与联调说明.md) 检出 Soul 源码或挂载工作区。**玩值**交付以 `开发文档/玩值/`、`new/wz-api`、`new/wz-admin`、`new/wz-app` 为准。
| 文档 | 说明 |
|------|------|
| [三端路由与联调说明.md](三端路由与联调说明.md) | 路由组、配置基址、从小程序模块逆推管理端配套、检出脚本 |

View File

@@ -0,0 +1,78 @@
# Soul 三端:路由契约、环境与联调
更新日期:**2026-04-30**(会议由乘风主持,橙子整理文档)。
---
## 1. 路由隔离(硬性约束)
| 使用方 | 允许调用的前缀 | 说明 |
|--------|----------------|------|
| 小程序 | `/api/miniprogram/*` | 禁止调 `admin` / `db` |
| 管理端 | `/api/admin/*``/api/db/*` | 禁止调 `miniprogram` |
| 支付回调等 | `/api/payment/*` 等 | 按 soul-api 实际注册为准 |
联调前在 DevTools / 管理端 Network 中确认 **无跨组路径**,否则属于边界违规,线上易导致鉴权与审计混乱。
---
## 2. 环境变量与基址(对接步骤)
### 2.1 soul-api
- 本地或内网部署后记录 **对外 origin**(如 `https://api.example.com`),须与小程序「合法 request 域名」、管理端 `VITE_API_BASE_URL` 一致(仅路径前缀一致即可,域名必须白名单)。
### 2.2 soul-adminVite
-`.env.development` / `.env.production` 中设置 **`VITE_API_BASE_URL`**,值为 soul-api 根地址**无尾斜杠**,例如:`https://api.example.com`
- 业务请求应拼出 `/api/admin/...``/api/db/...`(与现有 `client.ts` 约定一致)。
### 2.3 miniprogram
-**`app.js`(或统一封装 `app.request` 的配置处)** 设置与生产一致的 **baseUrl**,指向 soul-api 根地址;所有业务请求路径以 `/api/miniprogram/` 开头。
- 微信后台配置:**request 合法域名**、**uploadFile**、**downloadFile** 与 API 域名一致。
### 2.4 本仓库未检出 Soul 目录时
使用仓库内脚本浅克隆含三端的仓库(按需改 `REPO` / `BRANCH`
- `.cursor/scripts/clone-soul-yongping-windows.bat`
克隆完成后,在 **同一工作区** 打开 `miniprogram``soul-admin``soul-api` 再执行对接与联调。
---
## 3. 从小程序功能逆推管理端是否齐全(结论型对照)
以下基于历史项目索引与会议共识,按 **C 端能力** 核对 **运营/配置侧** 是否应有入口;**「应有」** 表示标准 Soul 业务闭环下管理端通常具备,若代码缺失则记为缺口。
| 小程序侧能力(概括) | 管理端应有配套 | 齐全性说明 |
|----------------------|----------------|------------|
| 登录 / 手机号 / 微信绑定 | 用户列表、详情、必要的绑定/解绑或查询字段 | 应有用户与跟踪类接口;异常绑定需后台可追溯 |
| 章节阅读、付费、全书/单章 | 章节/内容管理、订单与购买状态相关查询 | 需内容与订单联动;预览长度等规则以后端为准 |
| VIP 购买、会员身份 | VIP 管理、手动设置/角色、分润或推广配置 | 历史已有 SetVip、推广设置等变更需双端字段对齐 |
| 分销、推广、我的收益 | 分销订单、推广中心数据、用户余额/调整(若开放) | 管理端需可核对佣金与异常单 |
| 提现申请 | 提现审核列表、备注/失败原因、手续费与自动审批配置 | 应与小程序双入口、错误文案可联调 |
| 代付gift-pay | 代付请求列表、详情、与订单/用户关联 | 专项页与 Tab 历史已落地,需保持接口一致 |
| 找伙伴 / 匹配 / 存客宝留资 | 链接计划、留资、CKB 相关配置与列表 | 管理端「链接人与事」等模块应对应 |
| 阅读统计 / dashboard | 可选:运营统计或大盘;小程序侧已接聚合接口时后端需稳定 | C 端 `dashboard-stats` 类接口对应管理端可视需求按产品定 |
| 分享 / scene / 单页模式 | 非管理端页面,但 **活动参数、开关、海报配置** 常需后台 | 缺配置则 C 端只能硬编码,不符合可运营原则 |
**结论**:在 **历史稳定版** 语境下管理端对用户、订单、提现、VIP、内容、分销、代付、链接/CKB、推广配置等 **整体配套较全**;若某小程序新页面仅调新 `miniprogram` 接口而管理端无列表/审核/配置,即视为 **闭环缺口**,需补 `admin`/`db` 或文档注明「纯只读、无运营入口」。
---
## 4. 联调自检清单smoke
1. **GET** 后端健康或版本接口(如有),确认数据库/Redis 状态与部署一致。
2. 管理端登录成功,任意 **列表页** 拉取 `/api/admin/*` 返回 200 且数据结构非 HTML。
3. 小程序开发版 **登录 → 首页/我的 → 一条读接口**,确认域名与 TLS 合法。
4. 选一个 **写操作**(如收藏/下单/提现申请)核对 **小程序只走 miniprogram 组**
5. 管理端对同一用户/订单做一次 **查询或审核**,核对字段与 C 端展示一致。
---
## 5. 与玩值(`new/wz-*`)的关系
- **玩值** C 端使用 **`/api/app/*`**,管理端 **`/api/admin/*`**`wz-api/internal/router/router.go`),与 Soul 的 **`/api/miniprogram/*`** **不是同一前缀**
- 勿在小程序Soul中指向 `wz-api` 的 app 前缀,除非产品明确做多应用合一且另立契约。

View File

@@ -0,0 +1,5 @@
# 2026-04-30
- **new/wz-appC 端)**:多块页面去本地 mock改接 `src/api/catalog.ts` 与既有 `mall` / `games` / `streamers`**`/api/app/*`** 契约;派对/星球/专区/账号/代练/陪伴/课程等能力与 wz-api Mongo 目录只读口径对齐。
- **用户可见文案规范**:页面与 Toast **不再**出现 `GET /api/...`、`Mongo`、`wz-api` 等技术向空态;红线写入 **`.cursor/skills/wz-app-dev/SKILL.md` §7****`.cursor/rules/wanzhi-project-boundary.mdc`** 一条呼应。
- **经验与索引****`.cursor/agent/玩值移动端工程师/evolution/2026-04-30-wz-app目录对接与用户可见文案.md`**`开发助理/经验清单.md``项目索引/玩值.md``开发助理/evolution/2026-04-30.md` 补充;`进度-看板.md` 同日更新。

View File

@@ -1,6 +1,6 @@
# 玩值三端对齐项目清单
最后更新2026-04-27
最后更新2026-04-30
本文档与 [迁移-API对照表.md](迁移-API对照表.md)(接口级)、[进度-看板.md](进度-看板.md)(状态级)配套:**按业务域**列出 **wz-api / wz-admin / 玩值 C 端(`new/wz-app`Vite+React** 是否交付。**`old/app`Next仅作对照**,用于核对是否漏页/漏接口,**不**作为新功能真源。勾选与备注在联调时更新。

View File

@@ -69,7 +69,7 @@
| 55 | `/api/streamer/audience-insights` | App | `/api/app/streamer/audience-insights` | GET | 已实现(简化) |
| 56 | `/api/live/context/:streamerId` | App/大屏 | `/api/app/live/context/:streamerId` | GET | 已实现 |
| 57 | `/api/live/comments/:streamerId` | App | `/api/app/live/comments/:streamerId` | GET POST | 已实现 |
| 58 | `/api/live/gift` | App | `/api/app/live/gift` | POST | 已实现 |
| 58 | `/api/live/gift` | App | `/api/app/live/gift` | POST | 已实现(登录后从 **user_balances**`amount` 玩值币,不足则失败;写 liveGifts响应 **data.balance** 为扣后余额) |
| 59 | `/api/screen/config` | 大屏 | `/api/screen/config` | GET POST | 已实现 |
| 60 | `/api/screen/data/dashboard` | 大屏 | `/api/screen/data/dashboard` | GET | 已实现 |
| 61 | `/api/screen/streamers` | 大屏 | `/api/screen/streamers` | GET | 已实现 |
@@ -79,8 +79,11 @@
| 65 | C 端点卡目录) | wz-app | `/api/app/mall/point-cards` | GET | 已实现 |
| 66 | C 端点卡详情) | wz-app | `/api/app/mall/point-cards/:cardId` | GET | 已实现 |
| 67 | C 端点卡下单) | wz-app | `/api/app/orders/point-card` | POST | 已实现pending未接支付 |
| 68 | 管理端直播房间详情) | 管理端 | `/api/admin/wz/live/rooms/:roomId` | GET | 已实现 |
| 69 | `/api/build-info` | 运维/CI | `/api/public/build-info` | GET | 已实现;响应含 `buildTime`;可选 `gitCommit`(环境变量 **`GIT_COMMIT`**,服务端截断为短 hash未设为空串 |
| 68 | C 端我的订单) | wz-app | `/api/app/orders/me` | GET | 已实现(点卡 + 充值 Mongo须登录 JWT |
| 69 | C 端钱包充值) | wz-app | `/api/app/wallet/recharge` | POST | 已实现(递增 user_balances写 rechargeOrders**wz-app** 充值成功后 **sync user/me** 刷新余额与订单 |
| 69a | C 端玩值币消费) | wz-app | `/api/app/wallet/consume` | POST | 已实现原子扣余额productOrders + `wzAppConsume``GET /orders/me` 并入展示 |
| 70 | (管理端直播房间详情) | 管理端 | `/api/admin/wz/live/rooms/:roomId` | GET | 已实现 |
| 71 | `/api/build-info` | 运维/CI | `/api/public/build-info` | GET | 已实现;响应含 `buildTime`;可选 `gitCommit`(环境变量 **`GIT_COMMIT`**,服务端截断为短 hash未设为空串 |
| — | 管理端点卡订单 | wz-admin | `GET /api/admin/wz/points/orders` | GET | 已实现;与统一订单详情 `GET /api/admin/wz/orders/detail?id=&sourceTable=pointCardOrders` 联用 |
| — | wz-api 占位) | wz-app 自检 | `GET /api/app/health` | GET | 已实现 |
| — | wz-app `/screen` 只读总览 | `ScreenPage` | `GET /api/screen/data/dashboard`(可匿名经网关) | GET | 已实现;与 `GET /api/app/health``GET /api/public/build-info` 同页展示 |

View File

@@ -1,12 +1,12 @@
# 玩值迁移进度看板
最后更新2026-04-28**决议**:新三端 **主迁移已完成**、新功能 **仅在 `new/`** 交付;**全量域/大屏细节** 与 old 1:1 为持续对齐,见 [三端对齐项目清单](三端对齐项目清单.md) 与 `meeting/2026-04-27_三端迁移完成度与文档同步.md`
最后更新2026-04-30**决议**:新三端 **主迁移已完成**、新功能 **仅在 `new/`** 交付;**全量域/大屏细节** 与 old 1:1 为持续对齐**C 端**持续去 mock、`catalog`/真实接口对齐,**用户界面禁止暴露 API 路径与库表话术**——见 `.cursor/skills/wz-app-dev/SKILL.md` §7
## wz-api`new/wz-api`
| 状态 | 项 |
|------|-----|
| 已完成 | 玩值文档真源;`GET /api/app/health`App 认证与用户;`wz_documents` + `AutoMigrate`;《迁移-API对照表》闭环**`/api/admin/wz/*`** 玩值管理端、`/api/app/*` 业务、`/api/screen/*` 大屏;存客宝 **`POST /api/app/customer`**env脚本 **`scripts/mongo_to_wz_documents.py`****C 端点卡** `GET/POST` **`/api/app/mall/point-cards`**、**`POST /api/app/orders/point-card`****管理端** **`GET /api/admin/wz/live/rooms/:roomId`** |
| 已完成 | 玩值文档真源;`GET /api/app/health`App 认证与用户;`wz_documents` + `AutoMigrate`;《迁移-API对照表》闭环**`/api/admin/wz/*`** 玩值管理端、`/api/app/*` 业务、`/api/screen/*` 大屏;存客宝 **`POST /api/app/customer`**env脚本 **`scripts/mongo_to_wz_documents.py`****C 端点卡** `GET/POST` **`/api/app/mall/point-cards`**、**`POST /api/app/orders/point-card`****C 端订单/钱包** **`GET /api/app/orders/me`**、**`POST /api/app/wallet/recharge`**、**`POST /api/app/wallet/consume`****管理端** **`GET /api/admin/wz/live/rooms/:roomId`** |
| 保留 | Soul 历史路由与 `RegisterWanzhiAdminRoutes` stub 并存;玩值真实实现集中在 `internal/handler/playhub_*.go` + `internal/wzstore/` |
## wz-admin`new/wz-admin`
@@ -21,8 +21,8 @@
| 状态 | 项 |
|------|-----|
| 已完成 | 路由见 **`new/wz-app/src/App.tsx`**;点卡 `src/api/mall.ts` + `MallPointsPage` 等;直播 `src/api/live.ts` + `LivePage``/screen` 只读 dashboard主播工作台/观众洞察、旅程埋点等见代码与 `README.md` |
| 进行中 | 与 [迁移-wz-app路由映射.md](迁移-wz-app路由映射.md) 对齐并 **`npm run build`**;与 wz-api 契约、空错态、JWT 按 [迁移-API对照表](迁移-API对照表.md) 验收;**直播详情** 持续与 old 体验对齐2026-04-27观众数、礼物/进场样式、分享) |
| 已完成 | 路由见 **`new/wz-app/src/App.tsx`**;点卡 `src/api/mall.ts` + `MallPointsPage` 等;直播 `src/api/live.ts` + `LivePage``/screen` 只读 dashboard主播工作台/观众洞察、旅程埋点等见代码与 `README.md`**Mongo 目录只读**封装 **`src/api/catalog.ts`**guilds/venues/products/content/stars/账号/代练/party 等)与多页去硬编码对接 |
| 进行中 | 与 [迁移-wz-app路由映射.md](迁移-wz-app路由映射.md) 对齐并 **`npm run build`**;与 wz-api 契约、空错态、JWT 按 [迁移-API对照表](迁移-API对照表.md) 验收;**直播详情** 持续与 old 体验对齐2026-04-27观众数、礼物/进场样式、分享)**残余 mock 页**(装扮/路演等)与 **请求层用户向错误映射**(可选) |
| 环境 | **`VITE_WZ_API_BASE`**(无尾 `/`+ Vite 代理;请求 **`src/lib/request.ts`** |
## `old/app`Next**仅对照,不扩新功能**