更新wz-app项目,调整文档以明确玩值移动端(new/wz-app)的称谓,禁止使用「微信小程序」的表述,同时新增用户可见文案规范,确保用户界面不暴露技术细节,提升用户体验与合规性。此外,更新了相关API接口和功能,增强了系统的稳定性与可维护性。
This commit is contained in:
3
.cursor/agent/团队/evolution/2026-04-30.md
Normal file
3
.cursor/agent/团队/evolution/2026-04-30.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 团队共享经验 - 2026-04-30
|
||||
|
||||
Soul 三端契约与文档落点:**miniprogram 仅用 `/api/miniprogram/*`,管理端仅用 `/api/admin/*` 与 `/api/db/*`**;文档统一放在 `开发文档/Soul/`。玩值 **`/api/app/*`** 与前缀分离,禁止混用。未检出 Soul 源码时先做环境与 smoke,代码级对接在检出后执行。
|
||||
17
.cursor/agent/开发助理/evolution/2026-04-30.md
Normal file
17
.cursor/agent/开发助理/evolution/2026-04-30.md
Normal 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`** 做温和映射,避免遗留技术句。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,9 +46,10 @@ soul-api(Go + 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
|
||||
|
||||
@@ -52,9 +52,10 @@
|
||||
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
|
||||
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolution;LOOP 需求行 | 进行中 |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-app(C 端)** | 多块页面去 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
|
||||
|
||||
@@ -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 类型错误(与本次迁移无关),待修复。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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/`(另有「小程序开发工程师」归档角色)。
|
||||
|
||||
@@ -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` 对用户可见错误做一次 **文案脱敏映射**。
|
||||
@@ -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) |
|
||||
|
||||
@@ -66,6 +66,8 @@ LOG_EVOLUTION = SCRIPTS / "进化日志.md"
|
||||
TEMPLATE_EXPERIENCE = SCRIPTS / "经验模板.md"
|
||||
|
||||
# ========== 角色 → agent 目录名映射 ==========
|
||||
# 玩值用户侧:**玩值移动端工程师** + `new/wz-app`。勿把该角色称为「微信小程序」;
|
||||
# 「小程序」「小程序开发工程师」映射指 Soul `miniprogram/`(归档目录)。
|
||||
ROLE_TO_AGENT = {
|
||||
# 管理层
|
||||
"老板分身": "老板分身",
|
||||
|
||||
107
.cursor/meeting/2026-04-30_Soul三端文档与接口对齐.md
Normal file
107
.cursor/meeting/2026-04-30_Soul三端文档与接口对齐.md
Normal 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 与项目索引*
|
||||
@@ -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) |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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/admin(Soul) | `["管理端开发工程师"]` |
|
||||
| `new/wz-api` | `["玩值API工程师"]` |
|
||||
|
||||
@@ -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`、项目索引玩值 已同步
|
||||
|
||||
@@ -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`**(若该功能已登记)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: wz-app-dev
|
||||
description: Trigger when 编辑 new/wz-app 玩值 C 端、或对照 old/app 时必读。新功能只在 new/wz-app + wz-api;old 仅核对遗漏。仅调 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/大屏注意点)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
new/wz-api/.gitignore
vendored
5
new/wz-api/.gitignore
vendored
@@ -3,4 +3,7 @@ log/
|
||||
wz-api
|
||||
server.exe
|
||||
wz-api.exe
|
||||
deploy/
|
||||
deploy/
|
||||
|
||||
# mongodump 持久目录(可能含业务数据,勿提交)
|
||||
build/mongo_dump_wz/
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
574
new/wz-api/internal/handler/playhub_wz_app_catalog.go
Normal file
574
new/wz-api/internal/handler/playhub_wz_app_catalog.go
Normal 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})
|
||||
}
|
||||
277
new/wz-api/internal/handler/playhub_wz_app_wallet.go
Normal file
277
new/wz-api/internal/handler/playhub_wz_app_wallet.go
Normal 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}})
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 在用户余额上递增 delta(Mongo 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()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
110
new/wz-api/scripts/MONGO_宝塔同步.md
Normal file
110
new/wz-api/scripts/MONGO_宝塔同步.md
Normal 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**(生产环境一般会拒绝公网直连数据库)。
|
||||
|
||||
**做法 A:SSH 本地转发(推荐)**
|
||||
|
||||
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 同步;数据库请用本脚本或面板导入。
|
||||
Binary file not shown.
BIN
new/wz-api/scripts/__pycache__/mongo_sync_baota.cpython-311.pyc
Normal file
BIN
new/wz-api/scripts/__pycache__/mongo_sync_baota.cpython-311.pyc
Normal file
Binary file not shown.
286
new/wz-api/scripts/mongo_push_baota_api.py
Normal file
286
new/wz-api/scripts/mongo_push_baota_api.py
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地 Mongo(库 wz)→ 打包 → 宝塔 API 上传/解压 → 面板 API 在服务器本机执行 mongorestore 覆盖 wzdj。
|
||||
|
||||
依赖:本机 mongodump、tar(Windows 10+ 自带 bsdtar 或需装 Git tar);pip: 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())
|
||||
252
new/wz-api/scripts/mongo_sync_baota.py
Normal file
252
new/wz-api/scripts/mongo_sync_baota.py
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地 MongoDB → 宝塔 Mongo(默认目标库 wzdj)一遍式同步。
|
||||
|
||||
依赖:mongodump、mongorestore(MongoDB 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_URI(key MONGODB_URI)")
|
||||
parser.add_argument("--env-remote", default="", help="从该文件读 REMOTE MONGODB_URI(key 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())
|
||||
@@ -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 端,**仅对照** 是否漏页/漏接口,不在此扩新功能 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
4
new/wz-app/dist/index.html
vendored
4
new/wz-app/dist/index.html
vendored
@@ -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>
|
||||
|
||||
7
new/wz-app/package-lock.json
generated
7
new/wz-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
191
new/wz-app/src/api/catalog.ts
Normal file
191
new/wz-app/src/api/catalog.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
17
new/wz-app/src/api/games.ts
Normal file
17
new/wz-app/src/api/games.ts
Normal 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[]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
39
new/wz-app/src/api/orders.ts
Normal file
39
new/wz-app/src/api/orders.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
152
new/wz-app/src/components/live/LivePlaybackSurface.tsx
Normal file
152
new/wz-app/src/components/live/LivePlaybackSurface.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}>
|
||||
立即创建
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "下单失败",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
9
开发文档/Soul/README.md
Normal file
9
开发文档/Soul/README.md
Normal 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) | 路由组、配置基址、从小程序模块逆推管理端配套、检出脚本 |
|
||||
78
开发文档/Soul/三端路由与联调说明.md
Normal file
78
开发文档/Soul/三端路由与联调说明.md
Normal 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-admin(Vite)
|
||||
|
||||
- 在 `.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 前缀,除非产品明确做多应用合一且另立契约。
|
||||
5
开发文档/玩值/2026-04-30-日志.md
Normal file
5
开发文档/玩值/2026-04-30-日志.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 2026-04-30
|
||||
|
||||
- **new/wz-app(C 端)**:多块页面去本地 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` 同日更新。
|
||||
@@ -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)仅作对照**,用于核对是否漏页/漏接口,**不**作为新功能真源。勾选与备注在联调时更新。
|
||||
|
||||
|
||||
@@ -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` 同页展示 |
|
||||
|
||||
@@ -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,**仅对照,不扩新功能**)
|
||||
|
||||
Reference in New Issue
Block a user