Compare commits
31 Commits
71742da600
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d411ea77e | |||
| 9ce4683bc0 | |||
| a556a4f06d | |||
| 515bd27528 | |||
| 336c19024a | |||
| 3a757e1fed | |||
| 9aaa8f6656 | |||
| 33fa4f2119 | |||
| 3c2b68bbc3 | |||
| 4c7152ff50 | |||
| 10574845f6 | |||
| 6b9fef1d69 | |||
| c01aef48bd | |||
| 2ed582faf0 | |||
| f96652a8aa | |||
| 5d434ead6f | |||
| 9d224e2fcd | |||
| 81abb4b220 | |||
| c3d34c4660 | |||
| d528358867 | |||
| 60ad9a04cd | |||
| 2d56df940d | |||
| fdd1ae0929 | |||
| 19bf317d85 | |||
| 221a8d7108 | |||
| 09a7fe7708 | |||
| dc11ef90fd | |||
| e4b730c8db | |||
| c5413644f9 | |||
| 625d0baf7d | |||
| c5d3ac01ca |
+8
-1
@@ -35,4 +35,11 @@ backend/pb_migrations.bak/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.cache/.playwright-mcp/
|
||||
.cache/
|
||||
|
||||
# Test screenshots and Playwright data
|
||||
*.png
|
||||
.playwright-mcp/
|
||||
|
||||
# PocketBase UAT data
|
||||
backend/pb_data_uat/
|
||||
|
||||
@@ -4,76 +4,104 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目概述
|
||||
|
||||
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库。
|
||||
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产。
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 前端开发(默认连接 localhost:8090 的 PocketBase)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 前端开发环境(连接远程后端 192.168.1.14:8711,端口 7033)
|
||||
cd frontend && npm run dev:dev
|
||||
|
||||
# 前端 UAT 环境(端口 7034)
|
||||
cd frontend && npm run dev:uat
|
||||
|
||||
# 构建前端
|
||||
cd frontend && npm run build
|
||||
|
||||
# 启动后端(PocketBase,端口 8711)
|
||||
cd backend && docker-compose up -d
|
||||
# 本地开发(一般不用,用 Docker 部署代替)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 部署脚本(根目录)
|
||||
./deploy-backend.sh # 部署后端
|
||||
./deploy-dev.sh # 部署 Dev 前端
|
||||
./deploy-uat.sh # 部署 UAT 前端
|
||||
./deploy-backend.sh # 部署 PocketBase 后端
|
||||
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
|
||||
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
|
||||
./stop-all.sh # 停止所有服务
|
||||
|
||||
# 查看日志
|
||||
docker logs -f gamegroup-pb # 后端
|
||||
docker logs -f gamegroup-frontend-dev # Dev
|
||||
docker logs -f gamegroup-frontend-uat # UAT
|
||||
```
|
||||
|
||||
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: PocketBase 0.22.4 (Docker, `ghcr.io/muchobien/pocketbase`) — 无自定义 JS hooks,业务逻辑全在前端
|
||||
- **前端**: Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + Vite
|
||||
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),cookie 认证
|
||||
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
||||
- **实时通信**: PocketBase realtime subscriptions
|
||||
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
||||
|
||||
## 环境与端口
|
||||
|
||||
| 服务 | Dev | UAT |
|
||||
|------|-----|-----|
|
||||
| 前端 (nginx) | 7033 | 7034 |
|
||||
| PocketBase | 8090 | 8712 |
|
||||
|
||||
Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
|
||||
|
||||
## 架构
|
||||
|
||||
### 前端目录结构 (`frontend/src/`)
|
||||
### 前端核心流程
|
||||
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 PocketBase CRUD 和过滤逻辑。`pocketbase.ts` 初始化客户端并导出认证工具函数
|
||||
- **`stores/`** — Pinia stores(`user`, `group`, `team`, `notification`),组合式 API 风格(`defineStore('name', () => {...})`)
|
||||
```
|
||||
pocketbase.ts (PB 客户端初始化)
|
||||
→ router guards (isAuthenticated 检查)
|
||||
→ stores (user/group/team/notification/poll/ledger/asset/memory)
|
||||
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
|
||||
→ components + views
|
||||
```
|
||||
|
||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`)
|
||||
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)
|
||||
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||
- **`views/`** — 页面级组件,路由懒加载
|
||||
- **`components/`** — 按领域分子目录:`common/`, `game/`, `group/`, `layout/`, `team/`
|
||||
- **`types/index.ts`** — 所有 TypeScript 接口和类型定义集中在一个文件
|
||||
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
||||
|
||||
### 认证流程
|
||||
|
||||
- 注册:用户输入中文昵称存 `name` 字段,`username` 自动生成 ASCII 标识(`'u' + Date.now().toString(36) + random`)
|
||||
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
|
||||
- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页
|
||||
|
||||
### 路由结构
|
||||
|
||||
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
|
||||
|
||||
### Vite 代理 vs Nginx
|
||||
|
||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
|
||||
|
||||
### 数据模型(PocketBase Collections)
|
||||
|
||||
- **users** — 用户,含状态(idle/working/in_team/away)、工作时间设定、积分
|
||||
- **groups** — 群组,owner + members 关系,支持审核加入(requireApproval)
|
||||
- **team_sessions** — 临时组队,关联 sourceGroup,状态流转:recruiting → playing → finished/dissolved
|
||||
- **invitations** — 组队邀请,from/to 用户,pending/accepted/rejected
|
||||
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
||||
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
|
||||
- **invitations** — 组队邀请,pending/accepted/rejected
|
||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||
- **game_comments** / **game_favorites** — 游戏评论和收藏
|
||||
- **join_requests** — 入群申请,pending/approved/rejected
|
||||
- **game_comments** / **game_favorites** — 评论和收藏
|
||||
- **join_requests** — 入群申请
|
||||
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
||||
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
||||
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
|
||||
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
||||
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
||||
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
||||
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
||||
- **notifications** — 站内通知(投票/组队/入群等事件)
|
||||
|
||||
### 关键模式
|
||||
### PocketBase 注意事项
|
||||
|
||||
- **Vite 代理**: 开发时 `/api` 代理到 PocketBase,路径重写去掉 `/api` 前缀(`vite.config.ts`)
|
||||
- **认证流程**: `pocketbase.ts` → cookie 持久化 → 路由守卫检查 `isAuthenticated()` → 未登录跳转 `/login`
|
||||
- **实时订阅**: 通过 `useRealtime` composable 和 `subscribe*` 函数订阅 PocketBase 变更事件,各 store 自行刷新数据
|
||||
- **样式系统**: 自定义 CSS 变量(`design.css`,`--gg-*` 前缀)+ Tailwind + Element Plus,主题色为绿色系
|
||||
|
||||
### 后端
|
||||
|
||||
- PocketBase 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成
|
||||
- `backend/pb_hooks/main.js` 为占位文件,当前镜像不支持 JS VM
|
||||
- 所有业务逻辑(如入群审批、组队邀请)在前端 API 层实现
|
||||
|
||||
## 环境配置
|
||||
|
||||
前端通过 `.env` 文件配置:
|
||||
- `VITE_PB_URL` — PocketBase 地址(默认 `window.location.origin`)
|
||||
- `VITE_PORT` — 开发服务器端口
|
||||
- `users` collection 的 `listRule`/`viewRule` 设为空字符串(公开),以支持登录页查询用户
|
||||
- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代
|
||||
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
||||
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
||||
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
|
||||
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.options = {
|
||||
"allowEmailAuth": true,
|
||||
"allowOAuth2Auth": true,
|
||||
"allowUsernameAuth": true,
|
||||
"exceptEmailDomains": null,
|
||||
"manageRule": null,
|
||||
"minPasswordLength": 6,
|
||||
"onlyEmailDomains": null,
|
||||
"onlyVerified": false,
|
||||
"requireEmail": false
|
||||
}
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.options = {
|
||||
"allowEmailAuth": true,
|
||||
"allowOAuth2Auth": true,
|
||||
"allowUsernameAuth": true,
|
||||
"exceptEmailDomains": null,
|
||||
"manageRule": null,
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": null,
|
||||
"onlyVerified": false,
|
||||
"requireEmail": false
|
||||
}
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = ""
|
||||
collection.viewRule = ""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = ""
|
||||
collection.viewRule = ""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "vfk07d8w8tl2d75",
|
||||
"created": "2026-04-18 09:11:15.379Z",
|
||||
"updated": "2026-04-18 09:11:15.379Z",
|
||||
"name": "polls",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "bdmbfbno",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "hvnldxgq",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "iuuorixx",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "g6ht5xdc",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"option",
|
||||
"rollcall"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "0y0jzy14",
|
||||
"name": "anonymous",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "f0airh3m",
|
||||
"name": "deadline",
|
||||
"type": "date",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "4le4t2ht",
|
||||
"name": "maxParticipants",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "081izbpf",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"active",
|
||||
"settled"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lqsw4nqu",
|
||||
"name": "settledAt",
|
||||
"type": "date",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("vfk07d8w8tl2d75");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "w30law0vgssvgxm",
|
||||
"created": "2026-04-18 09:11:35.737Z",
|
||||
"updated": "2026-04-18 09:11:35.737Z",
|
||||
"name": "poll_options",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "r2ztzdoo",
|
||||
"name": "poll",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "vfk07d8w8tl2d75",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "klakmukb",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "m0pd1wfk",
|
||||
"name": "order",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 0,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\" && poll.creator = @request.auth.id",
|
||||
"updateRule": "poll.creator = @request.auth.id",
|
||||
"deleteRule": "poll.creator = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "liqeya2lycibs4y",
|
||||
"created": "2026-04-18 09:12:13.979Z",
|
||||
"updated": "2026-04-18 09:12:13.979Z",
|
||||
"name": "poll_votes",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "e7aygbae",
|
||||
"name": "poll",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "vfk07d8w8tl2d75",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "uaalzgys",
|
||||
"name": "option",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "w30law0vgssvgxm",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "dvr0tpcl",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_poll_user` ON `poll_votes` (\n `poll`,\n `user`\n)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && poll.group.members ~ @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("liqeya2lycibs4y");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "e7tjqmsm5ck66xl",
|
||||
"created": "2026-04-18 09:12:14.033Z",
|
||||
"updated": "2026-04-18 09:12:14.033Z",
|
||||
"name": "memories",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "itx7thzd",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gezpwnor",
|
||||
"name": "uploader",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "h51c22eh",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "mbzu9zlc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "yfa85qjr",
|
||||
"name": "file",
|
||||
"type": "file",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"mimeTypes": null,
|
||||
"thumbs": null,
|
||||
"maxSelect": 1,
|
||||
"maxSize": 524288000,
|
||||
"protected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "tfclnicu",
|
||||
"name": "fileType",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"document",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pdok0jhi",
|
||||
"name": "size",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 0,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "uploader = @request.auth.id",
|
||||
"deleteRule": "uploader = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("e7tjqmsm5ck66xl");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "s63vtbeeqlv1xzu",
|
||||
"created": "2026-04-18 09:12:14.062Z",
|
||||
"updated": "2026-04-18 09:12:14.062Z",
|
||||
"name": "notifications",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "elgovwo1",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ghhe48ku",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"poll_new",
|
||||
"poll_deadline",
|
||||
"poll_result",
|
||||
"team_invite",
|
||||
"team_starting",
|
||||
"join_request",
|
||||
"member_joined"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gw88luj3",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "qmazbl4u",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "1qdnqn2w",
|
||||
"name": "read",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "i4xrijz1",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "w1lzqcjc",
|
||||
"name": "relatedType",
|
||||
"type": "select",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"poll",
|
||||
"team",
|
||||
"group"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "user = @request.auth.id",
|
||||
"viewRule": "user = @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "h5adxdw9gm0aw8s",
|
||||
"created": "2026-04-18 09:12:14.095Z",
|
||||
"updated": "2026-04-18 09:12:14.095Z",
|
||||
"name": "point_logs",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "xeqacyc7",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sd27cbh8",
|
||||
"name": "action",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"vote",
|
||||
"team",
|
||||
"memory"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "iszqa13h",
|
||||
"name": "points",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pnipfzbd",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "user = @request.auth.id",
|
||||
"viewRule": "user = @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\" && poll.creator = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\" && user = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ledgers_col",
|
||||
"created": "2026-04-18 10:00:01.000Z",
|
||||
"updated": "2026-04-18 10:00:01.000Z",
|
||||
"name": "ledgers",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"income",
|
||||
"expense"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_amount",
|
||||
"name": "amount",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 0.01,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_category",
|
||||
"name": "category",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"gaming",
|
||||
"food",
|
||||
"equipment",
|
||||
"transport",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_members",
|
||||
"name": "relatedMembers",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_occurred",
|
||||
"name": "occurredAt",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "assets_col",
|
||||
"created": "2026-04-18 10:00:02.000Z",
|
||||
"updated": "2026-04-18 10:00:02.000Z",
|
||||
"name": "assets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"game_account",
|
||||
"console",
|
||||
"equipment",
|
||||
"accessory",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_holder",
|
||||
"name": "currentHolder",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_image",
|
||||
"name": "image",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"mimeTypes": ["image/*"],
|
||||
"thumbs": ["200x200"],
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"protected": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("assets_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "gblacklist_col",
|
||||
"created": "2026-04-18 21:00:01.000Z",
|
||||
"updated": "2026-04-18 21:00:01.000Z",
|
||||
"name": "game_blacklist",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_reporter",
|
||||
"name": "reporter",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_game",
|
||||
"name": "game",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "x5adjlc0txf16r8",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_gamename",
|
||||
"name": "gameName",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_reason",
|
||||
"name": "reason",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["behavior", "cheating", "abandonment", "toxic", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_severity",
|
||||
"name": "severity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["mild", "medium", "severe"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": null,
|
||||
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("gblacklist_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "bets_col",
|
||||
"created": "2026-04-18 21:00:02.000Z",
|
||||
"updated": "2026-04-18 21:00:02.000Z",
|
||||
"name": "bets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_title",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 1000,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_minstake",
|
||||
"name": "minStake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_maxstake",
|
||||
"name": "maxStake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["open", "closed", "settled"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_result",
|
||||
"name": "resultOption",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "betopts_col",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_deadline",
|
||||
"name": "deadline",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_settledat",
|
||||
"name": "settledAt",
|
||||
"type": "date",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("bets_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "betopts_col",
|
||||
"created": "2026-04-18 21:00:03.000Z",
|
||||
"updated": "2026-04-18 21:00:03.000Z",
|
||||
"name": "bet_options",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_bet",
|
||||
"name": "bet",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "bets_col",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_content",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_order",
|
||||
"name": "order",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"updateRule": "bet.creator = @request.auth.id",
|
||||
"deleteRule": "bet.creator = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("betopts_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "betentries_col",
|
||||
"created": "2026-04-18 21:00:04.000Z",
|
||||
"updated": "2026-04-18 21:00:04.000Z",
|
||||
"name": "bet_entries",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_bet",
|
||||
"name": "bet",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "bets_col",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_option",
|
||||
"name": "option",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "betopts_col",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_stake",
|
||||
"name": "stake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_won",
|
||||
"name": "won",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && bet.group.members ~ @request.auth.id",
|
||||
"updateRule": "bet.creator = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id && bet.status = \"open\"",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("betentries_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sd27cbh8",
|
||||
"name": "action",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"vote",
|
||||
"team",
|
||||
"memory",
|
||||
"bet",
|
||||
"settle"
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "iszqa13h",
|
||||
"name": "points",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "pnipfzbd",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sd27cbh8",
|
||||
"name": "action",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"vote",
|
||||
"team",
|
||||
"memory"
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "iszqa13h",
|
||||
"name": "points",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "pnipfzbd",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "pblacklist_col",
|
||||
"created": "2026-04-19 10:00:01.000Z",
|
||||
"updated": "2026-04-19 10:00:01.000Z",
|
||||
"name": "player_blacklist",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_reporter",
|
||||
"name": "reporter",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_playerid",
|
||||
"name": "playerId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_platform",
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_tags",
|
||||
"name": "tags",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 5,
|
||||
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_customtag",
|
||||
"name": "customTag",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 50,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_severity",
|
||||
"name": "severity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["mild", "medium", "severe"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": null,
|
||||
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("pblacklist_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
COPY server.js .
|
||||
EXPOSE 7882
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "gamegroup-voice-token",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0",
|
||||
"express": "^4.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import express from 'express'
|
||||
import { AccessToken } from 'livekit-server-sdk'
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
|
||||
const API_KEY = process.env.LIVEKIT_API_KEY || 'APIyxZGQjM2'
|
||||
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi'
|
||||
const PB_URL = process.env.PB_URL || 'http://gamegroup-pb:8090'
|
||||
const PORT = process.env.PORT || 7882
|
||||
|
||||
app.post('/api/voice-token/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params
|
||||
const authHeader = req.headers.authorization
|
||||
console.log('Voice token request:', { sessionId, authHeader: authHeader ? authHeader.slice(0, 20) + '...' : null })
|
||||
|
||||
if (!authHeader) {
|
||||
console.log('Missing auth header')
|
||||
return res.status(401).json({ error: '未登录' })
|
||||
}
|
||||
|
||||
// 验证用户 token — 调用 PocketBase
|
||||
const pbRefreshUrl = `${PB_URL}/api/collections/users/auth-refresh`
|
||||
console.log('Calling PB auth-refresh:', pbRefreshUrl)
|
||||
const pbRes = await fetch(pbRefreshUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
})
|
||||
console.log('PB auth-refresh status:', pbRes.status)
|
||||
if (!pbRes.ok) {
|
||||
const pbBody = await pbRes.text().catch(() => 'unknown')
|
||||
console.log('PB auth-refresh error body:', pbBody)
|
||||
return res.status(401).json({ error: '认证失败', detail: pbBody })
|
||||
}
|
||||
const userData = await pbRes.json()
|
||||
console.log('PB auth-refresh success, userId:', userData.record?.id)
|
||||
const userId = userData.record?.id
|
||||
const userName = userData.record?.name || userData.record?.username || userId
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: '无效用户' })
|
||||
}
|
||||
|
||||
// 获取 session 并验证成员
|
||||
const sessionRes = await fetch(`${PB_URL}/api/collections/team_sessions/records/${sessionId}`, {
|
||||
headers: { Authorization: authHeader },
|
||||
})
|
||||
if (!sessionRes.ok) {
|
||||
return res.status(404).json({ error: '未找到临时小组' })
|
||||
}
|
||||
const session = await sessionRes.json()
|
||||
const members = session.members || []
|
||||
if (!members.includes(userId)) {
|
||||
return res.status(403).json({ error: '你不是该小队的成员' })
|
||||
}
|
||||
|
||||
// 签发 LiveKit token
|
||||
const at = new AccessToken(API_KEY, API_SECRET, {
|
||||
identity: userId,
|
||||
name: userName,
|
||||
})
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: `team-${sessionId}`,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
})
|
||||
|
||||
const token = await at.toJwt()
|
||||
res.json({ token })
|
||||
} catch (err) {
|
||||
console.error('Voice token error:', err)
|
||||
res.status(500).json({ error: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Voice token service listening on :${PORT}`)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb
|
||||
ports:
|
||||
- "8711:8090"
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
@@ -20,6 +20,36 @@ services:
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token
|
||||
ports:
|
||||
- "7882:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
networks:
|
||||
gamegroup-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -20,6 +20,36 @@ services:
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token
|
||||
ports:
|
||||
- "7882:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb-uat:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-uat
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
frontend-uat:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Game Group V2 — 三期功能设计
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | 账目管理 | 群组费用流水记录,纯记录不分摊 |
|
||||
| P0 | 资产管理 | 群组公共资产清单,自由标记持有人 |
|
||||
|
||||
---
|
||||
|
||||
## P0: 账目管理
|
||||
|
||||
### 定位
|
||||
|
||||
群组内费用流水记录。成员记录每笔收入/支出(聚餐、游戏充值、设备采购等),纯记录,不做分摊和结算。
|
||||
|
||||
### 数据模型
|
||||
|
||||
**`ledgers` Collection:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| group | relation → groups | 所属群组 |
|
||||
| creator | relation → users | 记录人 |
|
||||
| type | select: `income` / `expense` | 收入/支出 |
|
||||
| amount | number | 金额 |
|
||||
| category | select: `gaming` / `food` / `equipment` / `transport` / `other` | 分类 |
|
||||
| description | text | 描述 |
|
||||
| relatedMembers | relation → users (multiple) | 相关成员 |
|
||||
| occurredAt | date | 发生时间 |
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 所有群成员可记录和查看
|
||||
- 只有记录人可编辑/删除
|
||||
- 收入用绿色,支出用红色,直观区分
|
||||
|
||||
### 前端页面
|
||||
|
||||
路由:`/group/:groupId/ledger`
|
||||
|
||||
```
|
||||
components/ledger/
|
||||
├── LedgerList.vue # 账目列表(收入/支出分色显示)
|
||||
├── LedgerCard.vue # 单条账目卡片
|
||||
├── CreateLedgerDialog.vue # 新建账目弹窗
|
||||
└── LedgerSummary.vue # 汇总面板(总收入/总支出/余额)
|
||||
```
|
||||
|
||||
### API 层 (`api/ledgers.ts`)
|
||||
|
||||
- `createLedger(groupId, data)` — 新建记录
|
||||
- `listLedgers(groupId, filter?)` — 列表(支持按月筛选)
|
||||
- `updateLedger(ledgerId, data)` — 更新(仅记录人)
|
||||
- `deleteLedger(ledgerId)` — 删除(仅记录人)
|
||||
- `getLedgerSummary(groupId)` — 汇总统计
|
||||
|
||||
---
|
||||
|
||||
## P0: 资产管理
|
||||
|
||||
### 定位
|
||||
|
||||
群组公共资产清单。登记游戏账号、主机、手柄等物品,任何成员可自由标记当前持有人。空持有人表示资产在库。
|
||||
|
||||
### 数据模型
|
||||
|
||||
**`assets` Collection:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| group | relation → groups | 所属群组 |
|
||||
| creator | relation → users | 登记人 |
|
||||
| name | text | 资产名称 |
|
||||
| type | select: `game_account` / `console` / `equipment` / `accessory` / `other` | 类型 |
|
||||
| description | text, 可选 | 描述备注 |
|
||||
| currentHolder | relation → users, 可选 | 当前持有人(空=在库) |
|
||||
| image | file, 可选 | 资产照片 |
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 所有群成员可登记和查看
|
||||
- 任何成员可更新持有人(标记自己拿走/放回)
|
||||
- 只有登记人可编辑/删除资产信息
|
||||
|
||||
### 前端页面
|
||||
|
||||
路由:`/group/:groupId/assets`
|
||||
|
||||
```
|
||||
components/asset/
|
||||
├── AssetList.vue # 资产列表(卡片网格)
|
||||
├── AssetCard.vue # 资产卡片(显示持有人头像)
|
||||
├── CreateAssetDialog.vue # 登记资产弹窗
|
||||
└── TransferAssetDialog.vue # 转移持有人弹窗(选择成员)
|
||||
```
|
||||
|
||||
### API 层 (`api/assets.ts`)
|
||||
|
||||
- `createAsset(groupId, data)` — 登记资产
|
||||
- `listAssets(groupId)` — 资产列表
|
||||
- `updateAsset(assetId, data)` — 更新信息
|
||||
- `deleteAsset(assetId)` — 删除
|
||||
- `transferAsset(assetId, userId?)` — 更新持有人(null=放回在库)
|
||||
|
||||
---
|
||||
|
||||
## 导航入口
|
||||
|
||||
GroupView 群组操作菜单中添加:
|
||||
- "账目"按钮 → 跳转 `/group/:groupId/ledger`
|
||||
- "资产"按钮 → 跳转 `/group/:groupId/assets`
|
||||
|
||||
---
|
||||
|
||||
## 实现文件变更总览
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/api/ledgers.ts` | 账目 API |
|
||||
| `frontend/src/api/assets.ts` | 资产 API |
|
||||
| `frontend/src/components/ledger/*.vue` | 账目组件 (4 个) |
|
||||
| `frontend/src/components/asset/*.vue` | 资产组件 (4 个) |
|
||||
| `frontend/src/views/LedgerView.vue` | 账目页面 |
|
||||
| `frontend/src/views/AssetView.vue` | 资产页面 |
|
||||
| `backend/pb_migrations/xxxx_create_ledgers.js` | 账目表迁移 |
|
||||
| `backend/pb_migrations/xxxx_create_assets.js` | 资产表迁移 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/types/index.ts` | 新增 Ledger、Asset 类型定义 |
|
||||
| `frontend/src/router/index.ts` | 新增 ledger、asset 路由 |
|
||||
| `frontend/src/views/GroupView.vue` | 添加账目/资产入口按钮 |
|
||||
|
||||
### 建议实现顺序
|
||||
|
||||
1. 数据库迁移(ledgers → assets)
|
||||
2. 类型定义 (types/index.ts)
|
||||
3. API 层 (ledgers.ts → assets.ts)
|
||||
4. 账目页面(组件 + 路由 + 导航入口)
|
||||
5. 资产页面(组件 + 路由 + 导航入口)
|
||||
@@ -0,0 +1,920 @@
|
||||
# Phase 3: Ledger + Asset Management 实施计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 为群组添加账目流水记录和公共资产清单功能,两个独立页面。
|
||||
|
||||
**Architecture:** 新增两个 PocketBase Collection(ledgers、assets),对应 API 层、类型定义、Pinia Store、Vue 组件和路由。通过 GroupView 操作入口跳转到独立页面。纯前端实现,无自定义后端逻辑。
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + PocketBase JS SDK
|
||||
|
||||
---
|
||||
|
||||
## Task 1: PocketBase 数据库迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/pb_migrations/1776510001_created_ledgers.js`
|
||||
- Create: `backend/pb_migrations/1776510002_created_assets.js`
|
||||
|
||||
**Step 1: 创建 ledgers 迁移文件**
|
||||
|
||||
```javascript
|
||||
// backend/pb_migrations/1776510001_created_ledgers.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ledgers_col",
|
||||
"name": "ledgers",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["income", "expense"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_amount",
|
||||
"name": "amount",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 0.01,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_category",
|
||||
"name": "category",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["gaming", "food", "equipment", "transport", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_members",
|
||||
"name": "relatedMembers",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_occurred",
|
||||
"name": "occurredAt",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: 创建 assets 迁移文件**
|
||||
|
||||
```javascript
|
||||
// backend/pb_migrations/1776510002_created_assets.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "assets_col",
|
||||
"name": "assets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["game_account", "console", "equipment", "accessory", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_holder",
|
||||
"name": "currentHolder",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_image",
|
||||
"name": "image",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"options": {
|
||||
"mimeTypes": ["image/*"],
|
||||
"thumbs": ["200x200"],
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"protected": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("assets_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3: 部署后端并验证迁移**
|
||||
|
||||
Run: `./deploy-backend.sh`(或在 UAT 环境部署后检查 PocketBase 管理面板,确认 ledgers 和 assets collections 已创建)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pb_migrations/1776510001_created_ledgers.js backend/pb_migrations/1776510002_created_assets.js
|
||||
git commit -m "feat(phase3): add ledgers and assets PocketBase migrations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TypeScript 类型定义
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/index.ts`
|
||||
|
||||
**Step 1: 在 types/index.ts 末尾(`displayName` 函数之前)添加账目和资产类型**
|
||||
|
||||
```typescript
|
||||
// 账目类型
|
||||
export type LedgerType = 'income' | 'expense'
|
||||
export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other'
|
||||
|
||||
export const LedgerTypeMap: Record<LedgerType, string> = {
|
||||
income: '收入',
|
||||
expense: '支出'
|
||||
}
|
||||
|
||||
export const LedgerCategoryMap: Record<LedgerCategory, string> = {
|
||||
gaming: '游戏',
|
||||
food: '聚餐',
|
||||
equipment: '设备',
|
||||
transport: '交通',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
export interface Ledger {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
type: LedgerType
|
||||
amount: number
|
||||
category: LedgerCategory
|
||||
description: string
|
||||
relatedMembers: string[]
|
||||
occurredAt: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
relatedMembers?: User[]
|
||||
}
|
||||
}
|
||||
|
||||
// 资产类型
|
||||
export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other'
|
||||
|
||||
export const AssetTypeMap: Record<AssetType, string> = {
|
||||
game_account: '游戏账号',
|
||||
console: '主机',
|
||||
equipment: '设备',
|
||||
accessory: '配件',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
name: string
|
||||
type: AssetType
|
||||
description?: string
|
||||
currentHolder?: string
|
||||
image?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
currentHolder?: User
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/index.ts
|
||||
git commit -m "feat(phase3): add Ledger and Asset type definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: API 层 — 账目
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/ledgers.ts`
|
||||
|
||||
**Step 1: 创建 ledgers API**
|
||||
|
||||
遵循 `api/memories.ts` 的模式:导入 pb 实例、类型、async 函数、expand 关联数据、subscribe 实时订阅。
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/ledgers.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Ledger } from '@/types'
|
||||
|
||||
export async function createLedger(data: {
|
||||
group: string
|
||||
type: 'income' | 'expense'
|
||||
amount: number
|
||||
category: string
|
||||
description: string
|
||||
relatedMembers?: string[]
|
||||
occurredAt: string
|
||||
}): Promise<Ledger> {
|
||||
const user = pb.authStore.model
|
||||
const record = await pb.collection('ledgers').create({
|
||||
group: data.group,
|
||||
creator: user?.id,
|
||||
type: data.type,
|
||||
amount: data.amount,
|
||||
category: data.category,
|
||||
description: data.description,
|
||||
relatedMembers: data.relatedMembers || [],
|
||||
occurredAt: data.occurredAt,
|
||||
})
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function listLedgers(
|
||||
groupId: string,
|
||||
options?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
category?: string
|
||||
month?: string // "2026-04" 格式
|
||||
}
|
||||
): Promise<{ items: Ledger[]; total: number }> {
|
||||
const { page = 1, limit = 30, type, category, month } = options || {}
|
||||
const filters = [`group="${groupId}"`]
|
||||
if (type) filters.push(`type="${type}"`)
|
||||
if (category) filters.push(`category="${category}"`)
|
||||
if (month) {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(y, m, 1).toISOString().slice(0, 10)
|
||||
filters.push(`occurredAt >= "${start}"`)
|
||||
filters.push(`occurredAt < "${end}"`)
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, limit, {
|
||||
filter: filters.join(' && '),
|
||||
sort: '-occurredAt',
|
||||
expand: 'creator,relatedMembers'
|
||||
})
|
||||
return { items: result.items as unknown as Ledger[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function updateLedger(
|
||||
ledgerId: string,
|
||||
data: Partial<Pick<Ledger, 'type' | 'amount' | 'category' | 'description' | 'relatedMembers' | 'occurredAt'>>
|
||||
): Promise<Ledger> {
|
||||
const record = await pb.collection('ledgers').update(ledgerId, data)
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function deleteLedger(ledgerId: string): Promise<void> {
|
||||
await pb.collection('ledgers').delete(ledgerId)
|
||||
}
|
||||
|
||||
export async function getLedgerSummary(groupId: string, month?: string): Promise<{
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
balance: number
|
||||
}> {
|
||||
const filters = [`group="${groupId}"`]
|
||||
if (month) {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(y, m, 1).toISOString().slice(0, 10)
|
||||
filters.push(`occurredAt >= "${start}"`)
|
||||
filters.push(`occurredAt < "${end}"`)
|
||||
}
|
||||
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('ledgers').getList(page, batchSize, {
|
||||
filter: filters.join(' && '),
|
||||
fields: 'type,amount'
|
||||
})
|
||||
for (const item of result.items as any[]) {
|
||||
if (item.type === 'income') totalIncome += item.amount || 0
|
||||
else totalExpense += item.amount || 0
|
||||
}
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return { totalIncome, totalExpense, balance: totalIncome - totalExpense }
|
||||
}
|
||||
|
||||
export function subscribeLedgers(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('ledgers').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/ledgers.ts
|
||||
git commit -m "feat(phase3): add ledgers API layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: API 层 — 资产
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/assets.ts`
|
||||
|
||||
**Step 1: 创建 assets API**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/assets.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export async function createAsset(data: {
|
||||
group: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
image?: File
|
||||
}): Promise<Asset> {
|
||||
const user = pb.authStore.model
|
||||
const formData = new FormData()
|
||||
formData.append('group', data.group)
|
||||
formData.append('creator', user?.id || '')
|
||||
formData.append('name', data.name)
|
||||
formData.append('type', data.type)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.image) formData.append('image', data.image)
|
||||
|
||||
const record = await pb.collection('assets').create(formData)
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function listAssets(groupId: string): Promise<Asset[]> {
|
||||
const result = await pb.collection('assets').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: 'created',
|
||||
expand: 'creator,currentHolder'
|
||||
})
|
||||
return result as unknown as Asset[]
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
assetId: string,
|
||||
data: Partial<Pick<Asset, 'name' | 'type' | 'description'>>,
|
||||
image?: File
|
||||
): Promise<Asset> {
|
||||
const formData = new FormData()
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.description !== undefined) formData.append('description', data.description)
|
||||
if (image) formData.append('image', image)
|
||||
|
||||
const record = await pb.collection('assets').update(assetId, formData)
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function transferAsset(assetId: string, userId: string | null): Promise<Asset> {
|
||||
const record = await pb.collection('assets').update(assetId, {
|
||||
currentHolder: userId || ''
|
||||
})
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
await pb.collection('assets').delete(assetId)
|
||||
}
|
||||
|
||||
export function subscribeAssets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('assets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
|
||||
return pb.files.getURL({ id: assetId, collectionName: 'assets' }, filename, { thumb })
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/assets.ts
|
||||
git commit -m "feat(phase3): add assets API layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Pinia Store — 账目
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/ledger.ts`
|
||||
|
||||
**Step 1: 创建 ledger store**
|
||||
|
||||
遵循 `stores/poll.ts` 的模式。
|
||||
|
||||
```typescript
|
||||
// frontend/src/stores/ledger.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Ledger } from '@/types'
|
||||
import { listLedgers, getLedgerSummary, subscribeLedgers, createLedger, updateLedger, deleteLedger } from '@/api/ledgers'
|
||||
|
||||
export const useLedgerStore = defineStore('ledger', () => {
|
||||
const ledgers = ref<Ledger[]>([])
|
||||
const loading = ref(false)
|
||||
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
async function loadLedgers(groupId: string, month?: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const m = month || currentMonth.value
|
||||
const result = await listLedgers(groupId, { month: m, limit: 200 })
|
||||
ledgers.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载账目列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary(groupId: string, month?: string) {
|
||||
try {
|
||||
const m = month || currentMonth.value
|
||||
summary.value = await getLedgerSummary(groupId, m)
|
||||
} catch (error) {
|
||||
console.error('加载账目汇总失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addLedger(data: Parameters<typeof createLedger>[0]) {
|
||||
const ledger = await createLedger(data)
|
||||
return ledger
|
||||
}
|
||||
|
||||
async function editLedger(ledgerId: string, data: Parameters<typeof updateLedger>[1]) {
|
||||
return updateLedger(ledgerId, data)
|
||||
}
|
||||
|
||||
async function removeLedger(ledgerId: string) {
|
||||
await deleteLedger(ledgerId)
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
return subscribeLedgers(groupId, () => {
|
||||
loadLedgers(groupId)
|
||||
loadSummary(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ledgers, loading, summary, currentMonth,
|
||||
loadLedgers, loadSummary, addLedger, editLedger, removeLedger, startSubscription
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/ledger.ts
|
||||
git commit -m "feat(phase3): add ledger Pinia store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Pinia Store — 资产
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/asset.ts`
|
||||
|
||||
**Step 1: 创建 asset store**
|
||||
|
||||
```typescript
|
||||
// frontend/src/stores/asset.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Asset } from '@/types'
|
||||
import { listAssets, createAsset, updateAsset, transferAsset, deleteAsset as deleteAssetApi, subscribeAssets } from '@/api/assets'
|
||||
|
||||
export const useAssetStore = defineStore('asset', () => {
|
||||
const assets = ref<Asset[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadAssets(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
assets.value = await listAssets(groupId)
|
||||
} catch (error) {
|
||||
console.error('加载资产列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addAsset(data: Parameters<typeof createAsset>[0]) {
|
||||
return createAsset(data)
|
||||
}
|
||||
|
||||
async function editAsset(assetId: string, data: Parameters<typeof updateAsset>[1], image?: File) {
|
||||
return updateAsset(assetId, data, image)
|
||||
}
|
||||
|
||||
async function transfer(assetId: string, userId: string | null) {
|
||||
return transferAsset(assetId, userId)
|
||||
}
|
||||
|
||||
async function removeAsset(assetId: string) {
|
||||
await deleteAssetApi(assetId)
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
return subscribeAssets(groupId, () => {
|
||||
loadAssets(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
assets, loading,
|
||||
loadAssets, addAsset, editAsset, transfer, removeAsset, startSubscription
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/asset.ts
|
||||
git commit -m "feat(phase3): add asset Pinia store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 账目组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ledger/LedgerSummary.vue`
|
||||
- Create: `frontend/src/components/ledger/LedgerCard.vue`
|
||||
- Create: `frontend/src/components/ledger/LedgerList.vue`
|
||||
- Create: `frontend/src/components/ledger/CreateLedgerDialog.vue`
|
||||
|
||||
**Step 1: 创建 LedgerSummary 组件**
|
||||
|
||||
汇总面板:显示当月总收入、总支出、余额。Element Plus 的 `el-statistic` 或自定义卡片。
|
||||
|
||||
参考项目中 `GroupStatsPanel.vue` 的卡片样式,使用 `var(--gg-*)` CSS 变量。
|
||||
|
||||
**Step 2: 创建 LedgerCard 组件**
|
||||
|
||||
单条账目卡片:类型标签(收入绿/支出红)、金额、分类、描述、相关成员头像、发生日期、操作按钮(编辑/删除,仅记录人可见)。
|
||||
|
||||
**Step 3: 创建 LedgerList 组件**
|
||||
|
||||
账目列表:顶部月份选择器 + 类型/分类筛选 + 新建按钮;下方按日期分组展示 LedgerCard 列表。
|
||||
|
||||
**Step 4: 创建 CreateLedgerDialog 组件**
|
||||
|
||||
新建/编辑弹窗:类型选择、金额输入、分类选择、描述输入、相关成员多选、日期选择器。使用 `el-dialog` + `el-form`。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ledger/
|
||||
git commit -m "feat(phase3): add ledger components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 资产组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/asset/AssetCard.vue`
|
||||
- Create: `frontend/src/components/asset/AssetList.vue`
|
||||
- Create: `frontend/src/components/asset/CreateAssetDialog.vue`
|
||||
- Create: `frontend/src/components/asset/TransferAssetDialog.vue`
|
||||
|
||||
**Step 1: 创建 AssetCard 组件**
|
||||
|
||||
资产卡片(网格布局):资产图片(有则显示缩略图,无则显示类型图标)、名称、类型标签、当前持有人头像和名称、点击可操作。
|
||||
|
||||
**Step 2: 创建 AssetList 组件**
|
||||
|
||||
资产列表:顶部类型筛选 + 新建按钮;下方网格展示 AssetCard。
|
||||
|
||||
**Step 3: 创建 CreateAssetDialog 组件**
|
||||
|
||||
登记/编辑弹窗:名称输入、类型选择、描述输入、图片上传。使用 `el-dialog` + `el-form` + `el-upload`。
|
||||
|
||||
**Step 4: 创建 TransferAssetDialog 组件**
|
||||
|
||||
转移持有人弹窗:显示资产名称,选择群组成员作为新持有人(含"放回在库"选项)。使用 `el-dialog` + 群成员列表。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/asset/
|
||||
git commit -m "feat(phase3): add asset components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 页面与路由
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/LedgerView.vue`
|
||||
- Create: `frontend/src/views/AssetView.vue`
|
||||
- Modify: `frontend/src/router/index.ts` — 新增两条路由
|
||||
|
||||
**Step 1: 创建 LedgerView.vue**
|
||||
|
||||
独立页面:顶部返回群组按钮 + 群组名;LedgerSummary 汇总面板;LedgerList 账目列表。
|
||||
|
||||
加载数据、订阅实时更新、页面卸载时取消订阅。参考 GroupView.vue 的订阅管理模式。
|
||||
|
||||
```typescript
|
||||
// 核心结构
|
||||
const route = useRoute()
|
||||
const groupId = route.params.groupId as string
|
||||
const groupStore = useGroupStore()
|
||||
const ledgerStore = useLedgerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await Promise.all([
|
||||
ledgerStore.loadLedgers(groupId),
|
||||
ledgerStore.loadSummary(groupId)
|
||||
])
|
||||
unsubFns.push(await ledgerStore.startSubscription(groupId))
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: 创建 AssetView.vue**
|
||||
|
||||
独立页面:顶部返回群组按钮 + 群组名;AssetList 资产列表。
|
||||
|
||||
**Step 3: 在 router/index.ts 中添加路由**
|
||||
|
||||
在 Layout children 数组中添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: () => import('@/views/LedgerView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: () => import('@/views/AssetView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/LedgerView.vue frontend/src/views/AssetView.vue frontend/src/router/index.ts
|
||||
git commit -m "feat(phase3): add ledger and asset pages with routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: GroupView 导航入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/GroupView.vue` — 添加账目/资产入口按钮
|
||||
|
||||
**Step 1: 在 GroupView 的 header-meta 区域添加入口按钮**
|
||||
|
||||
在群组信息条中添加"账目"和"资产"两个链接按钮,点击跳转到对应页面。使用 `router.push` 跳转。
|
||||
|
||||
参考现有样式,使用 Element Plus 的 `Wallet` 和 `Box` 图标(或 `Coin`、`Present` 等)。
|
||||
|
||||
```html
|
||||
<!-- 在 header-meta 中添加 -->
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/ledger`" class="meta-link">
|
||||
<el-icon><Wallet /></el-icon> 账目
|
||||
</router-link>
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
|
||||
<el-icon><Box /></el-icon> 资产
|
||||
</router-link>
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/GroupView.vue
|
||||
git commit -m "feat(phase3): add ledger and asset navigation in GroupView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 构建验证与部署
|
||||
|
||||
**Step 1: 构建前端验证无报错**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 2: 部署到 Dev 环境测试**
|
||||
|
||||
Run: `./deploy-dev.sh`
|
||||
|
||||
**Step 3: 在 Dev 环境手动测试**
|
||||
|
||||
访问 `http://192.168.1.14:7033`,测试:
|
||||
- 群组页面能看到"账目"和"资产"入口
|
||||
- 点击进入账目页面,创建收入/支出记录,查看汇总
|
||||
- 点击进入资产页面,登记资产,转移持有人
|
||||
- 其他成员能看到记录变化(实时订阅)
|
||||
|
||||
**Step 4: 最终 Commit**
|
||||
|
||||
如有修复则提交。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 语音房间功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
|
||||
```
|
||||
|
||||
## 技术选型
|
||||
|
||||
- **LiveKit Server** — 开源 WebRTC SFU,Docker 部署
|
||||
- **livekit-client** — 前端核心 SDK
|
||||
- **@livekit/components-vue** — Vue 组件封装
|
||||
- **livekit-server-sdk (Node.js)** — PocketBase hook 中签发 token
|
||||
|
||||
## 数据模型
|
||||
|
||||
team_sessions 新增字段:
|
||||
- `voiceRoom: string` — LiveKit 房间名
|
||||
- `voiceActive: boolean` — 语音房间是否活跃
|
||||
|
||||
## Token 签发
|
||||
|
||||
PocketBase JS hook (`/api/voice-token/{sessionId}`):
|
||||
1. 验证请求者是 session 成员
|
||||
2. 用 LiveKit API key/secret 签发 JWT
|
||||
3. room = `team-{sessionId}`,identity = userId
|
||||
4. 返回 `{ token: "..." }`
|
||||
|
||||
## 前端
|
||||
|
||||
### 路由
|
||||
|
||||
`/group/:groupId/voice/:sessionId` — 独立语音房间页面
|
||||
|
||||
### 页面结构 (VoiceRoom.vue)
|
||||
|
||||
- 顶部:房间名 + 离开按钮
|
||||
- 中部:成员头像网格,说话时绿圈动画
|
||||
- 底部:麦克风开关、扬声器开关、成员列表
|
||||
|
||||
### 入口
|
||||
|
||||
GroupView 组队卡片上加"语音"按钮,recruiting/playing 状态时显示。
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增
|
||||
|
||||
- `frontend/src/views/VoiceRoom.vue`
|
||||
- `frontend/src/api/voice.ts`
|
||||
- `frontend/src/composables/useVoiceRoom.ts`
|
||||
- `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- `frontend/src/components/voice/VoiceControls.vue`
|
||||
- `backend/pb_hooks/voice-token.pb.js`
|
||||
- `backend/pb_hooks/package.json`
|
||||
|
||||
### 修改
|
||||
|
||||
- `frontend/src/types/index.ts` — TeamSession 加字段
|
||||
- `frontend/src/router/index.ts` — 加路由
|
||||
- `frontend/src/views/GroupView.vue` — 加入口按钮
|
||||
- `docker-compose.backend.yml` — 加 LiveKit 容器
|
||||
- `docker-compose.uat.yml` — 同步加 LiveKit
|
||||
|
||||
## 部署
|
||||
|
||||
- PocketBase 启用 JS hooks
|
||||
- LiveKit 端口:7880 (HTTP)、7881 (UDP/RTC)
|
||||
- 局域网无需 TURN 服务器
|
||||
@@ -0,0 +1,924 @@
|
||||
# 语音房间功能 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。
|
||||
|
||||
**Architecture:** LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。
|
||||
|
||||
**Tech Stack:** LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Docker 基础设施 — 添加 LiveKit 容器
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.backend.yml`
|
||||
- Modify: `docker-compose.uat.yml`
|
||||
|
||||
**Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务**
|
||||
|
||||
在 pocketbase 服务之后、networks 之前添加:
|
||||
|
||||
```yaml
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.7
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
environment:
|
||||
- LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
```
|
||||
|
||||
**Step 2: 在 docker-compose.uat.yml 同步添加**
|
||||
|
||||
在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。
|
||||
|
||||
**Step 3: 启动验证**
|
||||
|
||||
Run: `docker compose -f docker-compose.backend.yml up -d livekit`
|
||||
Expected: 容器正常启动,`docker logs gamegroup-livekit` 显示 LiveKit listening on :7880
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.backend.yml docker-compose.uat.yml
|
||||
git commit -m "feat(voice): add LiveKit server container to docker-compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 安装前端 LiveKit 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
**Step 1: 安装依赖**
|
||||
|
||||
Run: `cd frontend && npm install livekit-client @livekit/components-vue`
|
||||
|
||||
**Step 2: 验证安装**
|
||||
|
||||
Run: `cd frontend && npm ls livekit-client @livekit/components-vue`
|
||||
Expected: 两个包正常列出
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "feat(voice): add livekit-client and components-vue dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 API 层 — voice.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/voice.ts`
|
||||
|
||||
**Step 1: 创建 voice.ts API 封装**
|
||||
|
||||
```typescript
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 先验证用户是该 session 的成员
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId)
|
||||
const members: string[] = (session as any).members || []
|
||||
if (!members.includes(user.id)) {
|
||||
throw new Error('你不是该小队的成员')
|
||||
}
|
||||
|
||||
// 调用 PocketBase hook 获取 token
|
||||
// 如果 hook 未部署,用前端 fallback 方案(仅开发用)
|
||||
try {
|
||||
const res = await pb.send(`/api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return res.token
|
||||
} catch {
|
||||
// Hook 不可用时,提示用户
|
||||
throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加 .env 配置**
|
||||
|
||||
在 `frontend/.env.dev` 和 `frontend/.env.uat` 中添加:
|
||||
```
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
|
||||
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 类型更新 — TeamSession 加语音字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/index.ts:80-94`
|
||||
|
||||
**Step 1: 在 TeamSession interface 中添加字段**
|
||||
|
||||
在 `updated: string` 之后、`expand?` 之前添加:
|
||||
|
||||
```typescript
|
||||
voiceRoom?: string
|
||||
voiceActive?: boolean
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/index.ts
|
||||
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: composable — useVoiceRoom.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useVoiceRoom.ts`
|
||||
|
||||
**Step 1: 创建 useVoiceRoom composable**
|
||||
|
||||
```typescript
|
||||
// src/composables/useVoiceRoom.ts
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
|
||||
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string
|
||||
name: string
|
||||
isSpeaking: boolean
|
||||
isMuted: boolean
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export function useVoiceRoom() {
|
||||
const room = ref<Room | null>(null)
|
||||
const connected = ref(false)
|
||||
const participants = ref<Map<string, VoiceParticipant>>(new Map())
|
||||
const micEnabled = ref(true)
|
||||
const speakerEnabled = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function connect(sessionId: string, userId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const token = await fetchVoiceToken(sessionId)
|
||||
const livekitUrl = getLiveKitUrl()
|
||||
|
||||
const newRoom = new Room()
|
||||
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||
participants.value.delete(participant.identity)
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
const speakerIds = new Set(speakers.map(s => s.identity))
|
||||
for (const [id, p] of participants.value) {
|
||||
p.isSpeaking = speakerIds.has(id)
|
||||
}
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
await newRoom.connect(livekitUrl, token)
|
||||
|
||||
// 添加本地参与者
|
||||
updateParticipant(newRoom.localParticipant)
|
||||
|
||||
// 添加已有远端参与者
|
||||
for (const p of newRoom.remoteParticipants.values()) {
|
||||
updateParticipant(p)
|
||||
}
|
||||
|
||||
// 发布本地麦克风
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
room.value = newRoom
|
||||
connected.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '连接语音房间失败'
|
||||
console.error('Voice room connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
|
||||
const isLocal = participant instanceof LocalParticipant
|
||||
const audioTrack = isLocal
|
||||
? participant.audioTrackPublications.values().next().value
|
||||
: participant.audioTrackPublications.values().next().value
|
||||
|
||||
const vp: VoiceParticipant = {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
isMuted: audioTrack?.isMuted ?? true,
|
||||
}
|
||||
|
||||
participants.value.set(participant.identity, vp)
|
||||
participants.value = new Map(participants.value)
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!room.value) return
|
||||
const enabled = !micEnabled.value
|
||||
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
updateParticipant(room.value.localParticipant)
|
||||
}
|
||||
|
||||
async function toggleSpeaker() {
|
||||
if (!room.value) return
|
||||
speakerEnabled.value = !speakerEnabled.value
|
||||
for (const p of room.value.remoteParticipants.values()) {
|
||||
for (const pub of p.audioTrackPublications.values()) {
|
||||
if (pub.track) {
|
||||
pub.track.enabled = speakerEnabled.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room.value) {
|
||||
await room.value.disconnect()
|
||||
room.value = null
|
||||
}
|
||||
connected.value = false
|
||||
participants.value = new Map()
|
||||
micEnabled.value = true
|
||||
speakerEnabled.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRoom.ts
|
||||
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 语音组件 — VoiceMemberGrid + VoiceControls
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- Create: `frontend/src/components/voice/VoiceControls.vue`
|
||||
|
||||
**Step 1: 创建 VoiceMemberGrid.vue**
|
||||
|
||||
成员头像网格,说话时有绿圈呼吸动画。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="p.avatar || '/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-icon muted">🎤✕</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-icon active">🎤</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mic-icon.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-icon.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 创建 VoiceControls.vue**
|
||||
|
||||
底部控制栏。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
background: var(--gg-bg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
|
||||
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 语音房间页面 — VoiceRoom.vue
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/VoiceRoom.vue`
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
|
||||
**Step 1: 创建 VoiceRoom.vue**
|
||||
|
||||
```vue
|
||||
<!-- src/views/VoiceRoom.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useVoiceRoom } from '@/composables/useVoiceRoom'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
|
||||
import VoiceControls from '@/components/voice/VoiceControls.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const teamStore = useTeamStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const sessionId = route.params.sessionId as string
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const gameName = computed(() => session.value?.gameName || '语音房间')
|
||||
|
||||
const {
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
} = useVoiceRoom()
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载 session 数据
|
||||
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
|
||||
await teamStore.loadActiveSession()
|
||||
}
|
||||
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await connect(sessionId, userId)
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
await disconnect()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await disconnect()
|
||||
router.replace(`/group/${groupId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-room">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="voice-header">
|
||||
<button class="back-btn" @click="handleLeave">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="room-title">{{ gameName }}</h1>
|
||||
<div class="conn-status" :class="{ on: connected }">
|
||||
{{ connected ? '已连接' : '连接中...' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 成员区域 -->
|
||||
<main class="voice-body">
|
||||
<VoiceMemberGrid :participants="participants" />
|
||||
<div v-if="participants.size === 0" class="empty-hint">
|
||||
正在连接语音...
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<VoiceControls
|
||||
:mic-enabled="micEnabled"
|
||||
:speaker-enabled="speakerEnabled"
|
||||
:connected="connected"
|
||||
@toggle-mic="toggleMic"
|
||||
@toggle-speaker="toggleSpeaker"
|
||||
@leave="handleLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.conn-status.on {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--gg-danger);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: var(--gg-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 添加路由**
|
||||
|
||||
在 `frontend/src/router/index.ts` 中,在 `blacklist` 路由之后、`games` 路由之前添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
|
||||
git commit -m "feat(voice): add VoiceRoom page and route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 入口按钮 — TeamSessionPanel 加语音入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/team/TeamSessionPanel.vue:110-120`
|
||||
|
||||
**Step 1: 在 TeamSessionPanel 中添加语音按钮**
|
||||
|
||||
在 `end-game-btn` 按钮之后、`dissolve-btn` 之前(约第 116 行区域),添加语音房间入口:
|
||||
|
||||
在 `<script setup>` 中导入 useRoute:
|
||||
```typescript
|
||||
import { useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
```
|
||||
|
||||
在 template 中,`start-game-btn` 按钮之后、`end-game-btn` 之前添加:
|
||||
|
||||
```html
|
||||
<router-link
|
||||
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||
class="voice-btn"
|
||||
>
|
||||
语音房间
|
||||
</router-link>
|
||||
```
|
||||
|
||||
添加对应样式:
|
||||
|
||||
```css
|
||||
.voice-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/team/TeamSessionPanel.vue
|
||||
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: PocketBase hook — 签发 LiveKit token
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pb_hooks/main.js`
|
||||
- Create: `backend/pb_hooks/package.json`
|
||||
|
||||
**注意:** PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。
|
||||
|
||||
**Step 1: 创建 package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gamegroup-pb-hooks",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cd backend/pb_hooks && npm install`
|
||||
|
||||
**Step 2: 更新 main.js — 添加 token 签发路由**
|
||||
|
||||
在现有注释之后添加:
|
||||
|
||||
```javascript
|
||||
// LiveKit voice token endpoint
|
||||
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
|
||||
const sessionId = c.pathParam("sessionId")
|
||||
const user = c.authRecord
|
||||
if (!user) {
|
||||
throw new BadRequestError("未登录")
|
||||
}
|
||||
|
||||
// 验证用户是 session 成员
|
||||
const session = $app.dao().findRecordById("team_sessions", sessionId)
|
||||
const members = session.getStringSlice("members")
|
||||
if (!members.includes(user.id)) {
|
||||
throw new ForbiddenError("你不是该小队的成员")
|
||||
}
|
||||
|
||||
// LiveKit token 签发
|
||||
// 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
|
||||
// 可以改用外部 API 或 PocketBase Go middleware
|
||||
const { AccessToken } = require("livekit-server-sdk")
|
||||
|
||||
const apiKey = "APIyxZGQjM2"
|
||||
const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.getString("name") || user.getString("username"),
|
||||
})
|
||||
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: `team-${sessionId}`,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
})
|
||||
|
||||
const token = at.toJwt()
|
||||
|
||||
return c.json(200, { token: token })
|
||||
}, $apis.requireAuth())
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
|
||||
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 构建验证 + 部署测试
|
||||
|
||||
**Files:** 无新文件
|
||||
|
||||
**Step 1: 前端构建**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: 构建成功,无 TypeScript 错误
|
||||
|
||||
**Step 2: 部署 Dev 环境**
|
||||
|
||||
Run: `./deploy-dev.sh`
|
||||
Expected: 前端容器构建并启动在 7033 端口
|
||||
|
||||
**Step 3: 功能验证**
|
||||
|
||||
打开 `http://192.168.1.14:7033`:
|
||||
1. 登录 → 进入群组 → 创建/查看临时小组
|
||||
2. 组队卡片中应显示"语音房间"按钮
|
||||
3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
|
||||
4. 启动 LiveKit 容器后重试验证完整流程
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"
|
||||
```
|
||||
@@ -0,0 +1,846 @@
|
||||
# 手机端阶段 1:基础设施 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 搭建手机端基础设施——引入 Vant 组件库、设备检测、路由分流、MobileLayout(底部 Tab + 顶部栏)、主题适配,使手机浏览器访问能自动进入手机版框架页。
|
||||
|
||||
**Architecture:** 在现有 Vue 3 项目内新增手机端视图层,路由层根据设备检测结果动态 import 桌面或手机视图。Vant 全量引入(与现有 ElementPlus 全量引入风格一致),主题 CSS 变量映射到现有 `--gg-*` 设计系统。本阶段不改任何桌面端代码,仅新增文件 + 改造路由与入口。
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript + Vite + Vue Router 4 + Vant 4(新增)+ 现有 PocketBase SDK / Pinia(复用)
|
||||
|
||||
**Spec 参考:** `docs/superpowers/specs/2026-06-15-mobile-frontend-design.md` 第 2-5 节
|
||||
|
||||
**项目约定(执行前必读):**
|
||||
- 构建走 Docker,**不要**本地启动 vite dev server。测试通过 `deploy-dev.sh` 部署后访问 `http://192.168.1.14:7033`
|
||||
- Windows 环境,命令行用 cmd 语法
|
||||
- 现有 `main.ts` 全量引入 ElementPlus,手机端 Vant 同样全量引入保持一致
|
||||
- 提交用 `git -c user.name="zcode" -c user.email="zcode@local" commit`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(本阶段涉及)
|
||||
|
||||
**新增:**
|
||||
- `frontend/src/mobile/useDevice.ts` — 设备检测 composable(UA + 屏宽 + localStorage)
|
||||
- `frontend/src/mobile/MobileLayout.vue` — 手机端主布局(顶部栏 + 底部 Tab + router-view)
|
||||
- `frontend/src/assets/mobile.css` — Vant 主题变量覆盖(映射到 `--gg-*`)
|
||||
- `frontend/src/views-mobile/Placeholder.vue` — 占位页(未实现的页面暂用)
|
||||
|
||||
**修改:**
|
||||
- `frontend/package.json` — 新增 vant 依赖
|
||||
- `frontend/src/main.ts` — 注册 Vant + 引入 mobile.css
|
||||
- `frontend/src/router/index.ts` — 路由分流(设备检测 + 动态 import)
|
||||
- `frontend/src/App.vue` — viewport meta 适配(已在 index.html 处理,本阶段确认)
|
||||
|
||||
**不改:**
|
||||
- 所有 `frontend/src/views/*`、`frontend/src/components/*`(桌面端零改动)
|
||||
- `frontend/src/api/*`、`frontend/src/stores/*`、`frontend/src/types/*`、`frontend/src/composables/*`(共享层零改动)
|
||||
- `frontend/src/assets/design.css`(设计系统零改动,手机端只在其之上叠加 mobile.css)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 安装 Vant 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
- [ ] **Step 1: 进入 frontend 目录安装 vant**
|
||||
|
||||
Run(在 `D:\ZcodeProj\FT\frontend` 下):
|
||||
```cmd
|
||||
npm install vant@^4
|
||||
```
|
||||
Expected: package.json 的 dependencies 出现 `"vant": "^4.x.x"`,生成/更新 package-lock.json
|
||||
|
||||
- [ ] **Step 2: 验证安装成功**
|
||||
|
||||
Run:
|
||||
```cmd
|
||||
node -e "console.log(require('vant/package.json').version)"
|
||||
```
|
||||
Expected: 输出 4.x.x 版本号,无报错
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant 4 dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建设备检测 composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/mobile/useDevice.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 useDevice.ts**
|
||||
|
||||
创建 `frontend/src/mobile/useDevice.ts`:
|
||||
|
||||
```ts
|
||||
// src/mobile/useDevice.ts
|
||||
// 设备检测:判断当前是否移动端,结果存 localStorage 避免重复检测
|
||||
|
||||
const STORAGE_KEY = 'device_mode'
|
||||
|
||||
/** UA 关键字匹配移动设备 */
|
||||
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
|
||||
|
||||
/** 屏幕宽度阈值(含平板竖屏) */
|
||||
const MOBILE_WIDTH = 768
|
||||
|
||||
/**
|
||||
* 原始检测:综合 UA 和屏幕宽度判断
|
||||
* - UA 命中移动设备关键字 → 手机
|
||||
* - 屏宽 <= 768 → 手机
|
||||
* - 否则 → 桌面
|
||||
*/
|
||||
function detectRaw(): 'mobile' | 'desktop' {
|
||||
if (typeof navigator === 'undefined') return 'desktop'
|
||||
if (MOBILE_UA.test(navigator.userAgent)) return 'mobile'
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_WIDTH) return 'mobile'
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
/** 当前设备模式(读取 localStorage,无则检测并存入) */
|
||||
export function getDeviceMode(): 'mobile' | 'desktop' {
|
||||
if (typeof localStorage === 'undefined') return detectRaw()
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'mobile' || stored === 'desktop') return stored
|
||||
const detected = detectRaw()
|
||||
localStorage.setItem(STORAGE_KEY, detected)
|
||||
return detected
|
||||
}
|
||||
|
||||
/** 是否移动端(路由分流用,同步函数) */
|
||||
export function isMobile(): boolean {
|
||||
return getDeviceMode() === 'mobile'
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动切换设备模式(设置页"切换到桌面版/手机版"用)
|
||||
* 切换后需整页刷新以重新走路由解析
|
||||
*/
|
||||
export function setDeviceMode(mode: 'mobile' | 'desktop') {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
}
|
||||
|
||||
/** 重置为自动检测(登出时调用,避免下个用户沿用上个用户的偏好) */
|
||||
export function resetDeviceMode() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/mobile/useDevice.ts
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device detection composable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 创建 Vant 主题覆盖 CSS
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/assets/mobile.css`
|
||||
|
||||
- [ ] **Step 1: 创建 mobile.css**
|
||||
|
||||
创建 `frontend/src/assets/mobile.css`,将 Vant 的 CSS 变量映射到现有 `--gg-*` 设计系统(参考 `frontend/src/assets/design.css` 的变量定义):
|
||||
|
||||
```css
|
||||
/* src/assets/mobile.css */
|
||||
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
|
||||
|
||||
:root {
|
||||
/* 主色(Vant primary → gg-primary 绿色) */
|
||||
--van-primary-color: var(--gg-primary); /* #059669 */
|
||||
--van-success-color: var(--gg-success); /* #10b981 */
|
||||
--van-danger-color: var(--gg-danger); /* #ef4444 */
|
||||
--van-warning-color: var(--gg-warning); /* #f59e0b */
|
||||
|
||||
/* 文字色 */
|
||||
--van-text-color: var(--gg-text); /* #1e293b */
|
||||
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
|
||||
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
|
||||
|
||||
/* 背景 */
|
||||
--van-background: var(--gg-bg); /* #f0fdf4 */
|
||||
--van-background-2: var(--gg-bg-card); /* #ffffff */
|
||||
|
||||
/* 边框 */
|
||||
--van-border-color: var(--gg-border); /* #e2e8f0 */
|
||||
|
||||
/* 圆角 */
|
||||
--van-radius-sm: var(--gg-radius-sm);
|
||||
--van-radius-md: var(--gg-radius-md);
|
||||
--van-radius-lg: var(--gg-radius-lg);
|
||||
|
||||
/* Tabbar(底部导航) */
|
||||
--van-tabbar-background: var(--gg-bg-card);
|
||||
--van-tabbar-item-active-color: var(--gg-primary);
|
||||
--van-tabbar-item-text-color: var(--gg-text-muted);
|
||||
--van-tabbar-height: 56px;
|
||||
|
||||
/* NavBar(顶部栏) */
|
||||
--van-nav-bar-background: var(--gg-bg-card);
|
||||
--van-nav-bar-title-font-size: 17px;
|
||||
--van-nav-bar-height: 52px;
|
||||
--van-nav-bar-icon-color: var(--gg-text);
|
||||
|
||||
/* 按钮主色渐变 */
|
||||
--van-button-primary-background: var(--gg-primary);
|
||||
--van-button-primary-border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
|
||||
.mobile-app {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* 禁止移动端点击高亮 */
|
||||
.mobile-app * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/assets/mobile.css
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant theme override css"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 注册 Vant 并引入主题 CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/main.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 main.ts**
|
||||
|
||||
将 `frontend/src/main.ts` 改为(在现有 ElementPlus 之后注册 Vant):
|
||||
|
||||
```ts
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import './assets/design.css'
|
||||
import './assets/mobile.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.use(Vant)
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/main.ts
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): register vant and import theme css"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 创建占位页(未实现的手机页面暂用)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views-mobile/Placeholder.vue`
|
||||
|
||||
- [ ] **Step 1: 创建占位组件**
|
||||
|
||||
创建 `frontend/src/views-mobile/Placeholder.vue`:
|
||||
|
||||
```vue
|
||||
<!-- src/views-mobile/Placeholder.vue -->
|
||||
<!-- 占位页:手机端尚未实现的页面暂时显示此组件 -->
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-page">
|
||||
<van-empty description="该页面手机版开发中">
|
||||
<p class="placeholder-hint">当前页面:{{ route.name }}</p>
|
||||
</van-empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/views-mobile/Placeholder.vue
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add placeholder view for unimplemented pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 创建 MobileLayout 主布局
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/mobile/MobileLayout.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 MobileLayout.vue**
|
||||
|
||||
创建 `frontend/src/mobile/MobileLayout.vue`。这是手机端主布局:顶部栏(返回 + 标题 + 通知铃铛)+ 底部 5 Tab + 内容区。复用现有 `useUserStore`、`useGroupStore`、`useTeamStore`、`useNotificationStore`(与桌面 Layout.vue 相同的初始化逻辑)。
|
||||
|
||||
底部 Tab 对应路由:首页 `/`、群组 `/mobile-groups`、游戏 `/games`、通知 `/mobile-notifications`、我的 `/profile`。其中群组和通知在后续阶段才真正实现,本阶段先指向占位页。
|
||||
|
||||
```vue
|
||||
<!-- src/mobile/MobileLayout.vue -->
|
||||
<!-- 手机端主布局:顶部栏 + 底部 Tab + 内容区 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { displayName } from '@/types'
|
||||
import { resetDeviceMode } from '@/mobile/useDevice'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 与桌面 Layout 相同的初始化
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.initUser()
|
||||
await groupStore.loadGroups()
|
||||
await teamStore.loadActiveSession()
|
||||
await notificationStore.loadPendingInvitations()
|
||||
await notificationStore.startListening()
|
||||
|
||||
refreshTimer = setInterval(async () => {
|
||||
await groupStore.loadGroups()
|
||||
await teamStore.loadActiveSession()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
notificationStore.stopListening()
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 当前激活的底部 Tab(根据路由匹配)
|
||||
const activeTab = computed(() => {
|
||||
if (route.path.startsWith('/mobile-groups')) return 'groups'
|
||||
if (route.path.startsWith('/games')) return 'games'
|
||||
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
|
||||
if (route.path.startsWith('/profile')) return 'profile'
|
||||
return 'home'
|
||||
})
|
||||
|
||||
// 顶部栏标题
|
||||
const pageTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
home: 'Game Group',
|
||||
groups: '我的群组',
|
||||
games: '游戏库',
|
||||
notifications: '通知',
|
||||
profile: '我的'
|
||||
}
|
||||
// 群组详情页显示群组名
|
||||
if (route.name === 'GroupView' && groupStore.currentGroup) {
|
||||
return groupStore.currentGroup.name
|
||||
}
|
||||
return titles[activeTab.value] || 'Game Group'
|
||||
})
|
||||
|
||||
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
|
||||
const showBack = computed(() => {
|
||||
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
|
||||
})
|
||||
|
||||
// 底部 Tab 切换
|
||||
function onTabChange(name: string) {
|
||||
const routes: Record<string, string> = {
|
||||
home: '/',
|
||||
groups: '/mobile-groups',
|
||||
games: '/games',
|
||||
notifications: '/mobile-notifications',
|
||||
profile: '/profile'
|
||||
}
|
||||
const target = routes[name]
|
||||
if (target && route.path !== target) {
|
||||
router.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
function onBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 通知未读数
|
||||
const unreadCount = computed(() => notificationStore.unreadCount)
|
||||
|
||||
function goNotifications() {
|
||||
router.push('/mobile-notifications')
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
function handleLogout() {
|
||||
resetDeviceMode()
|
||||
userStore.logout()
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-app">
|
||||
<!-- 顶部栏 -->
|
||||
<van-nav-bar
|
||||
:title="pageTitle"
|
||||
:left-arrow="showBack"
|
||||
fixed
|
||||
placeholder
|
||||
@click-left="onBack"
|
||||
>
|
||||
<template #right>
|
||||
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
|
||||
<van-icon name="bell" size="22" @click="goNotifications" />
|
||||
</van-badge>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="mobile-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- 底部 Tab -->
|
||||
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
|
||||
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
|
||||
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
|
||||
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
|
||||
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
|
||||
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-app {
|
||||
min-height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
flex: 1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/mobile/MobileLayout.vue
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add MobileLayout with navbar and tabbar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 改造路由实现设备分流
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
|
||||
这是本阶段核心。改造路由:同一 URL,根据 `isMobile()` 动态 import 桌面或手机视图。手机端用 `MobileLayout` 作为布局容器(替代桌面的 `Layout.vue`),子路由指向手机视图(本阶段除 Home 外先用 Placeholder 占位)。
|
||||
|
||||
- [ ] **Step 1: 重写 router/index.ts**
|
||||
|
||||
将 `frontend/src/router/index.ts` 完整替换为:
|
||||
|
||||
```ts
|
||||
// src/router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { isAuthenticated } from '@/api/pocketbase'
|
||||
import { isMobile } from '@/mobile/useDevice'
|
||||
|
||||
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
|
||||
const LayoutComponent = () =>
|
||||
isMobile()
|
||||
? import('@/mobile/MobileLayout.vue')
|
||||
: import('@/views/Layout.vue')
|
||||
|
||||
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
|
||||
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
|
||||
return isMobile() ? mobile : desktop
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: view(
|
||||
() => import('@/views/Login.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 LoginMobile
|
||||
),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: view(
|
||||
() => import('@/views/Register.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 RegisterMobile
|
||||
),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: LayoutComponent,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 HomeMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'mobile-groups',
|
||||
name: 'MobileGroups',
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 GroupsMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'mobile-notifications',
|
||||
name: 'MobileNotifications',
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 NotificationsMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'group/:id',
|
||||
name: 'GroupView',
|
||||
component: view(
|
||||
() => import('@/views/GroupView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 4 替换为 GroupViewMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: view(
|
||||
() => import('@/views/LedgerView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 LedgerMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: view(
|
||||
() => import('@/views/AssetView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 AssetMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/blacklist',
|
||||
name: 'BlacklistView',
|
||||
component: view(
|
||||
() => import('@/views/BlacklistView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 BlacklistMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: view(
|
||||
() => import('@/views/VoiceRoom.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 5 替换为 VoiceRoomMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
component: view(
|
||||
() => import('@/views/GamesLibrary.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 7 替换为 GamesLibraryMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: view(
|
||||
() => import('@/views/Profile.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ProfileMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: view(
|
||||
() => import('@/views/Settings.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 SettingsMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'Changelog',
|
||||
component: view(
|
||||
() => import('@/views/Changelog.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ChangelogMobile
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue')
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫(与原逻辑一致)
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authenticated = isAuthenticated()
|
||||
|
||||
if (to.meta.requiresAuth && !authenticated) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.requiresGuest && authenticated) {
|
||||
next({ name: 'Home' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```cmd
|
||||
git add frontend/src/router/index.ts
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device-based route splitting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 确认 viewport meta 标签
|
||||
|
||||
**Files:**
|
||||
- Check/Modify: `frontend/index.html`
|
||||
|
||||
手机端必须禁止缩放、适配视口宽度。检查 `index.html` 的 `<meta name="viewport">`。
|
||||
|
||||
- [ ] **Step 1: 查看现有 index.html**
|
||||
|
||||
Run:
|
||||
```cmd
|
||||
type frontend\index.html
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认/修改 viewport meta**
|
||||
|
||||
如果 `<head>` 内没有 viewport meta,或不是以下内容,将其改为:
|
||||
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
```
|
||||
|
||||
`viewport-fit=cover` 配合 Task 3 CSS 里的 `env(safe-area-inset-bottom)` 处理刘海屏。保留现有 `<title>` 等其他内容不变。
|
||||
|
||||
> 注意:如果 index.html 已有正确 viewport,此 Task 跳过修改直接进入 Step 3。
|
||||
|
||||
- [ ] **Step 3: 提交(如有修改)**
|
||||
|
||||
```cmd
|
||||
git add frontend/index.html
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): ensure viewport meta for mobile scaling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: TypeScript 类型检查与构建验证
|
||||
|
||||
**Files:**
|
||||
- 无修改(仅验证)
|
||||
|
||||
- [ ] **Step 1: 运行 TypeScript 类型检查**
|
||||
|
||||
Run(在 `D:\ZcodeProj\FT\frontend` 下):
|
||||
```cmd
|
||||
npx vue-tsc --noEmit
|
||||
```
|
||||
Expected: 无类型错误。如果报错,常见原因:
|
||||
- Vant 组件类型未识别 → 确认 `vant` 已安装且 `main.ts` 引入
|
||||
- `@/mobile/useDevice` 路径找不到 → 确认 `tsconfig.json` 的 paths 配置包含 `@/*`(现有项目已有)
|
||||
- 修复后重新运行直到通过
|
||||
|
||||
- [ ] **Step 2: 运行生产构建**
|
||||
|
||||
Run:
|
||||
```cmd
|
||||
npm run build
|
||||
```
|
||||
Expected: 构建成功,`dist/` 目录生成。如果失败,根据报错修复(通常是 import 路径或类型问题)。
|
||||
|
||||
- [ ] **Step 3: 提交(如有修复)**
|
||||
|
||||
如果构建过程中修复了任何问题:
|
||||
```cmd
|
||||
git add -A
|
||||
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): resolve type/build errors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 部署到 Dev 环境并验证
|
||||
|
||||
**Files:**
|
||||
- 无修改(部署与验收测试)
|
||||
|
||||
- [ ] **Step 1: 部署到 Dev 环境**
|
||||
|
||||
Run(在项目根 `D:\ZcodeProj\FT` 下):
|
||||
```cmd
|
||||
bash deploy-dev.sh
|
||||
```
|
||||
> 如果 Windows 无 bash,参考 `deploy-dev.sh` 内容手动执行等价命令(通常是 `cd frontend && npm run build` 后用 docker-compose 重建 `gamegroup-frontend-dev` 容器)。
|
||||
Expected: 容器重建成功,监听 7033 端口。
|
||||
|
||||
- [ ] **Step 2: 桌面浏览器验证(回归不破坏)**
|
||||
|
||||
在桌面浏览器打开 `http://192.168.1.14:7033`:
|
||||
- [ ] 登录页正常显示(桌面版 Login.vue)
|
||||
- [ ] 登录后进入桌面版首页(左侧边栏 + 右侧主区)
|
||||
- [ ] 桌面端布局与改动前完全一致
|
||||
- [ ] 打开浏览器 DevTools Console 无报错
|
||||
|
||||
- [ ] **Step 3: 手机端验证(模拟移动设备)**
|
||||
|
||||
在桌面浏览器 DevTools 切换到移动设备模拟(如 Chrome 的 Toggle device toolbar,选 iPhone 12):
|
||||
- [ ] 刷新页面 → 自动进入手机版(顶部 NavBar + 底部 Tabbar)
|
||||
- [ ] 顶部栏显示标题 + 右侧通知铃铛图标
|
||||
- [ ] 底部 5 个 Tab 显示:首页/群组/游戏/通知/我的
|
||||
- [ ] 点击底部各 Tab → 路由切换,内容区显示 Placeholder 占位页("该页面手机版开发中")
|
||||
- [ ] 顶部栏在非 Tab 根页面(如点群组进入详情占位)显示返回箭头
|
||||
- [ ] 整体绿色主题(按钮、Tab 激活态为 #059669)
|
||||
- [ ] Console 无报错
|
||||
|
||||
- [ ] **Step 4: 真机验证(可选但推荐)**
|
||||
|
||||
用手机浏览器打开 `http://192.168.1.14:7033`(需与电脑同 WiFi):
|
||||
- [ ] 自动进入手机版
|
||||
- [ ] 底部 Tab 单手可达,无横向滚动条
|
||||
- [ ] 顶部栏和底部 Tab 不被刘海/Home 指示条遮挡
|
||||
|
||||
- [ ] **Step 5: 设备切换验证**
|
||||
|
||||
在手机版页面的浏览器 Console 执行:
|
||||
```js
|
||||
localStorage.setItem('device_mode', 'desktop'); location.reload()
|
||||
```
|
||||
- [ ] 刷新后切换到桌面版
|
||||
- [ ] 执行 `localStorage.removeItem('device_mode'); location.reload()` 恢复自动检测
|
||||
|
||||
---
|
||||
|
||||
## 验收标准(本阶段完成标志)
|
||||
|
||||
- [ ] 桌面端完全不受影响(回归测试通过)
|
||||
- [ ] 手机端自动识别设备并加载 MobileLayout
|
||||
- [ ] 底部 5 Tab 导航可切换,路由跳转正常
|
||||
- [ ] 顶部栏标题、返回箭头、通知铃铛显示正常
|
||||
- [ ] 绿色主题统一(Vant 变量映射成功)
|
||||
- [ ] 未实现的页面显示 Placeholder 占位
|
||||
- [ ] TypeScript 类型检查通过,生产构建成功
|
||||
- [ ] 已部署到 Dev(7033)并可访问
|
||||
|
||||
---
|
||||
|
||||
## 后续阶段预告(本计划不实现)
|
||||
|
||||
本阶段完成后,后续按以下顺序逐步将各 Placeholder 替换为真实手机视图:
|
||||
|
||||
| 阶段 | 内容 | 替换的占位路由 |
|
||||
|------|------|--------------|
|
||||
| 2 | 登录/注册 | Login, Register |
|
||||
| 3 | 首页 + 群组列表 + 通知 | Home, MobileGroups, MobileNotifications |
|
||||
| 4 | 群组详情核心(动态 + 成员) | GroupView |
|
||||
| 5 | 组队 + 语音房 UI | VoiceRoom |
|
||||
| 6 | 投票 + 竞猜 | GroupView 内标签 |
|
||||
| 7 | 游戏库 | GamesLibrary |
|
||||
| 8 | 账本 + 资产 + 黑名单 | LedgerView, AssetView, BlacklistView |
|
||||
| 9 | 回忆 + 统计 + 个人/设置/更新日志 | Profile, Settings, Changelog |
|
||||
|
||||
每个阶段一份独立实施计划,每阶段产出可测试的手机页面。
|
||||
@@ -0,0 +1,263 @@
|
||||
# 手机端前端设计文档
|
||||
|
||||
> 日期:2026-06-15
|
||||
> 仓库:gamegroup2(http://jiulu-gameplay.com.cn:13001/congsh/gamegroup2.git)
|
||||
> 状态:已确认,待实施
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为现有 Game Group V2(游戏组队管理平台)增加完整的手机端前端,使移动设备用户通过手机浏览器即可使用全部功能。手机端与桌面端共存于同一项目、同一域名、同一后端,访问时根据设备自动分流。
|
||||
|
||||
**成功标准**:手机浏览器打开 `http://192.168.1.14:7033`(Dev)/ `7034`(UAT),自动进入手机版界面,可完成登录、群组管理、组队、投票、竞猜、账本、资产、黑名单、回忆、游戏库等全部现有功能,交互符合移动端操作习惯。
|
||||
|
||||
## 2. 技术决策
|
||||
|
||||
### 2.1 方案:单项目双视图层 + 路由分流
|
||||
|
||||
在现有 `frontend` 项目内新增手机端视图层,与桌面端共存:
|
||||
|
||||
- **共享层(零改动,手机端直接复用)**:`api/`(PocketBase 封装)、`stores/`(Pinia)、`types/`(接口定义)、`composables/`(useRealtime 等)、`assets/design.css`(设计系统变量)
|
||||
- **桌面层(不动)**:现有 `views/`、`components/` 保持原样
|
||||
- **手机层(全新)**:`views-mobile/`、`components-mobile/`、`mobile/`(布局与设备检测)
|
||||
|
||||
理由:业务逻辑全在前端、API 为标准 PocketBase SDK,共享层与 UI 无关;分成独立项目会导致 api/stores/types/composables 要么复制要么抽包,维护成本翻倍。单项目共享核心 + 双视图层是最低成本、最高复用的方案。
|
||||
|
||||
### 2.2 手机端组件库:Vant 4
|
||||
|
||||
引入 **Vant 4**(Vue 3 移动端组件库)作为手机端 UI 组件库。
|
||||
|
||||
- 现有 Element Plus 面向桌面(弹窗、表格、复杂表单),手机端的 Tabbar、ActionSheet、PullRefresh、Toast、SwipeCell、NavBar 等交互它不擅长
|
||||
- Vant 专注移动端,与 Vite / Vue 3 / TypeScript 无缝集成
|
||||
- 桌面端继续用 Element Plus,两套通过路由分流隔离,互不干扰
|
||||
|
||||
**新增依赖**:`vant@^4` + `@vant/auto-import-resolver`(Vant 按需引入插件,确保未使用的组件不打包进桌面端产物)
|
||||
|
||||
### 2.3 主题一致性
|
||||
|
||||
将 Vant 的 CSS 变量覆盖为现有 `design.css` 的 `--gg-*` 值,保证手机端与桌面端视觉统一:
|
||||
- 主色 `--van-primary-color: var(--gg-primary)` (#059669)
|
||||
- 背景色、文字色、圆角、阴影等全部映射到 `--gg-*` 对应变量
|
||||
|
||||
### 2.4 语音能力
|
||||
|
||||
手机端 VoiceRoom 做完整 UI(成员网格、控制条、房间布局),实际 WebRTC 连接与麦克风采集暂不接入。原因:非 HTTPS / 部分手机浏览器(如微信内置)限制 `mediaDevices`,桌面 Electron 端已遇到同类问题。
|
||||
|
||||
手机端语音房显示明确占位提示:"语音功能请在 App 中使用"。完整语音能力待后续打包为原生 App(Capacitor / Tauri Mobile)时再接入。
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├─ api/ # 共享(PocketBase 封装,零改动)
|
||||
├─ stores/ # 共享(Pinia,零改动)
|
||||
├─ types/ # 共享(接口定义,零改动)
|
||||
├─ composables/ # 共享(useRealtime 等,零改动)
|
||||
├─ assets/
|
||||
│ ├─ design.css # 共享设计系统(桌面+手机)
|
||||
│ └─ mobile.css # 手机端主题覆盖(Vant 变量映射)
|
||||
├─ views/ # 桌面端页面(现有,不动)
|
||||
├─ components/ # 桌面端组件(现有,不动)
|
||||
├─ views-mobile/ # 手机端页面(全新)
|
||||
├─ components-mobile/ # 手机端组件(全新)
|
||||
├─ mobile/ # 手机端专用
|
||||
│ ├─ MobileLayout.vue # 底部 Tab + 顶部栏 主布局
|
||||
│ ├─ useDevice.ts # 设备检测 composable
|
||||
│ └─ DeviceGuard.ts # 路由级设备分流
|
||||
├─ router/
|
||||
│ └─ index.ts # 改造:设备检测 + 分流
|
||||
└─ main.ts # 改造:按需注册 Vant
|
||||
```
|
||||
|
||||
## 4. 设备检测与路由分流
|
||||
|
||||
### 4.1 检测策略
|
||||
|
||||
- **主判据**:`navigator.userAgent` 匹配移动设备关键字(iPhone/Android/Windows Phone 等)
|
||||
- **辅助判据**:`window.innerWidth <= 768`(覆盖平板竖屏)
|
||||
- 检测结果在应用启动时执行一次,存入 `localStorage`(`device_mode`)避免重复检测
|
||||
- 提供手动切换入口(设置页:"切换到桌面版"),少数平板/桌面用户可能需要
|
||||
|
||||
### 4.2 路由结构
|
||||
|
||||
每个业务路由定义 `desktop` 和 `mobile` 两个组件,路由守卫根据设备模式返回对应组件:
|
||||
|
||||
```ts
|
||||
// 示例:路由记录
|
||||
{
|
||||
path: '/group/:id',
|
||||
name: 'GroupView',
|
||||
meta: { requiresAuth: true },
|
||||
component: () => isMobile()
|
||||
? import('@/views-mobile/GroupViewMobile.vue')
|
||||
: import('@/views/GroupView.vue')
|
||||
}
|
||||
```
|
||||
|
||||
- 登录态校验(`requiresAuth` / `requiresGuest`)逻辑不变
|
||||
- 所有路由 URL 与桌面端完全一致(同一 `/group/:id`),只是加载的视图不同
|
||||
- 切换设备模式后整页刷新以重新走路由解析
|
||||
|
||||
## 5. 手机端布局与导航
|
||||
|
||||
### 5.1 整体框架
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ ← 群组名 🔔(3) │ 顶部栏:返回 / 标题 / 通知
|
||||
├──────────────────────────┤
|
||||
│ │
|
||||
│ 页面内容区 │
|
||||
│ (下拉刷新、滚动) │
|
||||
│ │
|
||||
├──────────────────────────┤
|
||||
│🏠首页 👥群组 🎮游戏 🔔通知 👤我│ 底部 5 Tab
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
- **顶部栏(NavBar)**:左侧返回按钮(次级页面显示)、中间页面标题、右侧通知铃铛(带未读数徽标)
|
||||
- **底部 Tab(Tabbar)**:5 个固定入口,单手拇指可达
|
||||
- **内容区**:占满中间空间,支持下拉刷新(列表页)、无限滚动加载
|
||||
|
||||
### 5.2 底部 Tab 定义
|
||||
|
||||
| Tab | 图标 | 页面 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| 首页 | home-o | HomeMobile | 状态 + 当前组队 + 我的群组 + 热门游戏 |
|
||||
| 群组 | friends-o | GroupsMobile | 群组列表 + 创建/加入入口 |
|
||||
| 游戏 | game-o | GamesLibraryMobile | 游戏库浏览 + 搜索 |
|
||||
| 通知 | bell | NotificationsMobile | 全部站内通知(邀请/组队/入群) |
|
||||
| 我的 | user-o | ProfileMobile | 个人信息 + 状态 + 设置 |
|
||||
|
||||
**投票、竞猜、账本、资产、黑名单、回忆、统计** 不占底部位置,通过群组详情内的标签栏进入(见 5.3)。
|
||||
|
||||
### 5.3 群组详情的子页面组织
|
||||
|
||||
群组详情(`GroupViewMobile`)承载桌面端 GroupView 的全部 Tab 内容,手机端用**可横滑的顶部标签栏**组织:
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ ← 群组名 [12人] [⚙] │
|
||||
├──────────────────────────┤
|
||||
│ 动态 投票 竞猜 回忆 → │ 可横滑标签(横向滚动)
|
||||
├──────────────────────────┤
|
||||
│ │
|
||||
│ [当前标签内容] │
|
||||
│ │
|
||||
└──────────────────────────┘
|
||||
(其余标签:成员 账本 资产 黑名单 统计)
|
||||
```
|
||||
|
||||
- 9 个标签:动态 / 投票 / 竞猜 / 回忆 / 成员 / 账本 / 资产 / 黑名单 / 统计
|
||||
- 首次进入默认「动态」Tab
|
||||
- 横向可滑动,超出屏幕的标签左右滑出
|
||||
- 群组级独立页面(账本 `/group/:id/ledger`、资产 `/group/:id/assets`、黑名单 `/group/:id/blacklist`)也可从标签进入,URL 保持一致以支持直接跳转和返回
|
||||
|
||||
## 6. 全部页面设计
|
||||
|
||||
### 6.1 认证
|
||||
|
||||
| 页面 | 文件 | 设计要点 |
|
||||
|------|------|---------|
|
||||
| 登录 | `views-mobile/LoginMobile.vue` | 全屏卡片式,顶部 Logo + 标语,大号输入框(昵称/邮箱/用户名 + 密码),主按钮全宽,底部"去注册"链接。键盘弹出时内容上推不遮挡。 |
|
||||
| 注册 | `views-mobile/RegisterMobile.vue` | 步骤化表单:昵称 → 密码 → 确认密码。复用桌面端自动生成 username 的逻辑(`'u' + Date.now().toString(36) + random`)。 |
|
||||
|
||||
### 6.2 主功能
|
||||
|
||||
| 页面 | 文件 | 设计要点 |
|
||||
|------|------|---------|
|
||||
| 首页 | `views-mobile/HomeMobile.vue` | 顶部:当前用户状态卡片(在线/忙碌/离开 + 一键切换 + 状态备注)。中部:当前临时小组卡片(如有,显示成员头像 + 进入语音房按钮)。下方:我的群组横滑卡片列表(点击进群组详情)。底部:热门游戏横滑封面。无群组时显示引导卡(创建/加入)。 |
|
||||
| 群组列表 | `views-mobile/GroupsMobile.vue` | 群组卡片纵向列表(群名 + 成员数 + 简介 + 未读标记),顶部右侧浮动「+」按钮弹出 ActionSheet(创建群组 / 加入群组)。下拉刷新。 |
|
||||
| 群组详情 | `views-mobile/GroupViewMobile.vue` | 顶部群信息条(群名 / 成员数 / 群主 / 容量)+ 快捷操作(账本/资产/黑名单图标入口)+ 可横滑标签栏(见 5.3)。 |
|
||||
| 语音房 | `views-mobile/VoiceRoomMobile.vue` | 成员头像大网格(2-3 列,显示麦克风开关状态、说话动效)。底部固定控制条(麦克风开关 / 静音 / 退出)。顶部显示房间名和在线人数。WebRTC 未接入时显示占位提示。 |
|
||||
|
||||
### 6.3 群组内功能(标签栏子页面)
|
||||
|
||||
| 功能 | 组件 | 设计要点 |
|
||||
|------|------|---------|
|
||||
| 动态 | `components-mobile/group/ActivityFeed.vue` | 当前组队状态卡 + 空闲成员列表 + 所有成员按状态分组列表。 |
|
||||
| 投票 | `components-mobile/poll/PollListMobile.vue` + `PollDetailMobile.vue` | 卡片列表(标题 / 选项数 / 截止时间 / 状态),下拉刷新,点击进详情投票。创建投票走底部弹出表单。 |
|
||||
| 竞猜 | `components-mobile/bet/BetListMobile.vue` + `BetDetailMobile.vue` | 卡片列表(标题 / 下注范围 / 截止时间 / 我的下注),点击进详情下注。结算展示结果。 |
|
||||
| 回忆 | `components-mobile/memory/MemoryGridMobile.vue` | 九宫格图片视频缩略图,点击全屏浏览(支持滑动切换、捏合缩放)。上传走底部弹出(拍照/相册选择)。 |
|
||||
| 成员 | `components-mobile/group/MemberListMobile.vue` | 按状态分组列表(头像 + 昵称 + 状态备注),群主可管理(踢人/审核入群)。 |
|
||||
| 账本 | `views-mobile/LedgerMobile.vue` | 顶部月度汇总卡片(总收入/支出/结余),下方账目列表,右下角浮动「+」添加(弹出表单:金额/类型/备注)。 |
|
||||
| 资产 | `views-mobile/AssetMobile.vue` | 资产卡片列表(名称 / 类型 / 当前持有者),点击查看详情 / 转移。 |
|
||||
| 黑名单 | `views-mobile/BlacklistMobile.vue` | 顶部 Tab 切换「游戏黑名单 / 玩家黑名单」。游戏:卡片列表(游戏名 + 标签 + 备注)。玩家:列表(昵称 + 标签 + 备注)。 |
|
||||
| 统计 | `components-mobile/stats/StatsPanelMobile.vue` | 桌面 GroupStatsPanel 的简化只读版,展示群组活跃度、游戏偏好等核心数据(图表用简化柱状/环形,避免手机端图表过密)。 |
|
||||
|
||||
### 6.4 全局页面
|
||||
|
||||
| 页面 | 文件 | 设计要点 |
|
||||
|------|------|---------|
|
||||
| 游戏库 | `views-mobile/GamesLibraryMobile.vue` | 顶部搜索栏,下方游戏封面瀑布流(2 列),点击弹出详情底部面板(封面 / 平台 / 标签 / 评论 / 收藏 / 发起组队)。 |
|
||||
| 通知 | `views-mobile/NotificationsMobile.vue` | 全部通知列表(类型图标 + 内容 + 时间),按时间倒序,支持标记已读 / 接受 / 拒绝操作。下拉刷新。 |
|
||||
| 个人中心 | `views-mobile/ProfileMobile.vue` | 头像 + 昵称 + 用户名,状态切换,积分余额,我的群组数 / 组队次数等数据,设置 / 更新日志 / 退出登录入口。 |
|
||||
| 设置 | `views-mobile/SettingsMobile.vue` | 列表式设置项:通知开关、主题(预留)、切换到桌面版、关于。 |
|
||||
| 更新日志 | `views-mobile/ChangelogMobile.vue` | 时间线列表展示版本更新记录。 |
|
||||
|
||||
## 7. 共享层改动说明
|
||||
|
||||
手机端零改动地复用以下现有代码:
|
||||
|
||||
| 层 | 内容 | 说明 |
|
||||
|----|------|------|
|
||||
| `api/pocketbase.ts` | PocketBase 单例、`getCurrentUser()`、`isAuthenticated()`、`logout()` | 手机端直接 import 使用 |
|
||||
| `api/*` | users / groups / sessions / invitations / games / polls / bets / points / ledgers / assets / memories / notifications / gameBlacklist / playerBlacklist | 全部领域 API 封装,手机端复用 |
|
||||
| `stores/*` | user / group / team / notification / poll / ledger / asset / memory | Pinia stores,组合式 API,手机端复用(UI 状态可按需扩展) |
|
||||
| `composables/useRealtime.ts` | PocketBase 实时订阅管理 | 手机端复用,组件卸载自动清理 |
|
||||
| `types/index.ts` | 全部接口定义 + `displayName()` + 状态映射常量 | 手机端复用 |
|
||||
|
||||
**桌面端代码零改动**:现有 `views/`、`components/` 保持原样,不影响桌面用户。
|
||||
|
||||
## 8. 构建与部署
|
||||
|
||||
- **Vite 构建合并**:手机端和桌面端在同一份构建产物中,路由层负责分流。Vant 按需引入(`@vant/auto-import-resolver`),不使用的组件不打包,避免桌面端访问时加载手机端组件库。
|
||||
- **部署不变**:现有 `deploy-dev.sh` / `deploy-uat.sh` 流程不变,nginx 配置不变,同一端口同一域名同时服务两端。
|
||||
- **环境变量**:`.env` 的 `VITE_PB_URL`、`VITE_PORT` 对两端同样生效。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
分 9 个阶段,每阶段产出可验证的成果(可在手机浏览器实测):
|
||||
|
||||
1. **基础设施**:安装 Vant、设备检测(`useDevice`)、路由分流改造、`MobileLayout`(底部 Tab + 顶部栏)、`mobile.css` 主题覆盖
|
||||
2. **认证**:LoginMobile + RegisterMobile(打通登录注册全流程)
|
||||
3. **首页 + 群组列表**:HomeMobile + GroupsMobile(含创建/加入群组弹层)
|
||||
4. **群组详情核心**:GroupViewMobile + 动态标签(组队状态 + 成员列表)+ 成员管理
|
||||
5. **组队 + 语音房 UI**:发起组队、邀请、接受邀请、状态切换、VoiceRoomMobile(占位)
|
||||
6. **投票 + 竞猜**:列表 + 详情 + 创建/下注 + 结算
|
||||
7. **游戏库**:GamesLibraryMobile + 详情弹层 + 搜索
|
||||
8. **账本 + 资产 + 黑名单**:三个管理类页面(列表 + 增删改 + 资产转移)
|
||||
9. **回忆 + 统计 + 个人/设置/更新日志**:收尾页面
|
||||
|
||||
## 10. 不在本次范围内
|
||||
|
||||
以下事项明确排除,待后续迭代:
|
||||
|
||||
- **语音 WebRTC 实际接入**:手机端语音房仅做 UI 和占位提示,实际通话能力待打包为原生 App(Capacitor / Tauri Mobile)时实现
|
||||
- **PWA / 离线支持**:本次不做离线缓存和可安装 Web App
|
||||
- **推送通知(原生)**:本次仅做站内通知,App 级推送待后续
|
||||
- **桌面端改动**:现有桌面端代码不做任何修改
|
||||
|
||||
## 11. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Vant 与 Element Plus 样式冲突 | 两套组件库通过路由分流隔离,不同时挂载;Vant 主题变量全部覆盖为 `--gg-*` 值 |
|
||||
| 手机端浏览器实时订阅稳定性 | 复用现有 `useRealtime`,组件卸载自动清理;移动端切后台时 PocketBase SSE 断连,回前台后自动重连(现有机制) |
|
||||
| 构建体积增长 | Vant 按需引入,仅打包手机端实际用到的组件 |
|
||||
| 微信内置浏览器限制 | 语音功能已有占位;如遇其他 API 限制(如某些路由 history 模式),预留 hash 模式降级方案 |
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
- [ ] 手机浏览器访问 Dev/UAT 自动进入手机版界面
|
||||
- [ ] 桌面浏览器访问仍是原桌面版,行为不变
|
||||
- [ ] 登录 / 注册 / 退出全流程正常
|
||||
- [ ] 首页:状态切换、群组入口、当前组队卡片显示正常
|
||||
- [ ] 群组详情:9 个标签全部可访问,数据正确
|
||||
- [ ] 组队流程:发起 / 邀请 / 接受 / 状态流转正常
|
||||
- [ ] 投票 / 竞猜:创建 / 参与 / 结算正常
|
||||
- [ ] 账本 / 资产 / 黑名单:增删改查正常,资产转移正常
|
||||
- [ ] 回忆:浏览 + 上传正常
|
||||
- [ ] 游戏库:浏览 + 搜索 + 详情正常
|
||||
- [ ] 通知:接收 / 标记已读 / 接受拒绝正常
|
||||
- [ ] 个人中心 / 设置:状态切换、切换桌面版正常
|
||||
- [ ] 语音房界面完整,占位提示明确
|
||||
@@ -0,0 +1 @@
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
@@ -0,0 +1,72 @@
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const ENV_URLS = {
|
||||
dev: 'http://192.168.1.14:7033',
|
||||
uat: 'http://nas.wjl-work.top:7034',
|
||||
}
|
||||
|
||||
function getWindowUrl() {
|
||||
const envArg = process.argv.find(a => a.startsWith('--env='))
|
||||
if (envArg) {
|
||||
const env = envArg.split('=')[1]
|
||||
return ENV_URLS[env] || ENV_URLS.dev
|
||||
}
|
||||
return ENV_URLS.dev
|
||||
}
|
||||
|
||||
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
|
||||
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
|
||||
const insecureOrigins = Object.values(ENV_URLS).join(',')
|
||||
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: 'Game Group',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
},
|
||||
})
|
||||
|
||||
const url = getWindowUrl()
|
||||
win.loadURL(url)
|
||||
|
||||
// 页面标题同步
|
||||
win.on('page-title-updated', (event, title) => {
|
||||
event.preventDefault()
|
||||
win.setTitle(`Game Group - ${title}`)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// 自动批准麦克风/摄像头权限请求,无需用户手动确认
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
|
||||
callback(true)
|
||||
} else {
|
||||
callback(false)
|
||||
}
|
||||
})
|
||||
|
||||
// 绕过权限检查,确保 mediaDevices 可用
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
createWindow()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "gamegroup-electron",
|
||||
"version": "0.3.2",
|
||||
"description": "Game Group V2 桌面客户端",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"start:dev": "electron . --env=dev",
|
||||
"start:uat": "electron . --env=uat",
|
||||
"build": "electron-builder --win",
|
||||
"build:portable": "electron-builder --win portable"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0",
|
||||
"electron-builder": "^26.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.gamegroup.v2",
|
||||
"productName": "GameGroup",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"build/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
|
||||
const { contextBridge } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
# Dev Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7033
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# UAT Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7034
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Game Group V2</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
|
||||
</head>
|
||||
|
||||
+12
-2
@@ -30,8 +30,18 @@ server {
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
client_max_body_size 500m;
|
||||
proxy_pass http://192.168.1.14:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -43,8 +53,8 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# 静态资源缓存(排除 /api/ 路径)
|
||||
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|mp4|mkv|webm|mp3|ogg|pdf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ server {
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
|
||||
+12
-10
@@ -10,20 +10,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.6.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"pocketbase": "^0.21.1"
|
||||
"element-plus": "^2.6.3",
|
||||
"livekit-client": "^2.18.3",
|
||||
"pinia": "^2.1.7",
|
||||
"pocketbase": "^0.21.1",
|
||||
"vant": "^4.9.24",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vue-tsc": "^2.0.11",
|
||||
"vite": "^5.2.8",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38"
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export interface CreateAssetData {
|
||||
group: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
export interface UpdateAssetData {
|
||||
name?: string
|
||||
type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function createAsset(data: CreateAssetData): Promise<Asset> {
|
||||
const user = pb.authStore.model
|
||||
const formData = new FormData()
|
||||
formData.append('group', data.group)
|
||||
formData.append('creator', user?.id || '')
|
||||
formData.append('name', data.name)
|
||||
formData.append('type', data.type)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.image) formData.append('image', data.image)
|
||||
|
||||
return pb.collection('assets').create(formData, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function listAssets(groupId: string): Promise<Asset[]> {
|
||||
const result = await pb.collection('assets').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: 'created',
|
||||
expand: 'creator,currentHolder',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Asset[]
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
assetId: string,
|
||||
data: UpdateAssetData,
|
||||
image?: File
|
||||
): Promise<Asset> {
|
||||
if (image) {
|
||||
const formData = new FormData()
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.description !== undefined) formData.append('description', data.description)
|
||||
formData.append('image', image)
|
||||
|
||||
return pb.collection('assets').update(assetId, formData, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
return pb.collection('assets').update(assetId, data, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function transferAsset(assetId: string, userId: string): Promise<Asset> {
|
||||
return pb.collection('assets').update(assetId, {
|
||||
currentHolder: userId
|
||||
}, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
await pb.collection('assets').delete(assetId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
export function subscribeAssets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
return pb.collection('assets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
|
||||
const record = { id: assetId, image: filename }
|
||||
return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
export async function createBet(data: {
|
||||
group: string
|
||||
title: string
|
||||
description?: string
|
||||
options: string[]
|
||||
minStake?: number
|
||||
maxStake?: number
|
||||
deadline: string
|
||||
}): Promise<Bet> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const bet = await pb.collection('bets').create({
|
||||
group: data.group,
|
||||
creator: user.id,
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
minStake: data.minStake || 1,
|
||||
maxStake: data.maxStake || 10,
|
||||
status: 'open',
|
||||
deadline: data.deadline,
|
||||
})
|
||||
|
||||
for (let i = 0; i < data.options.length; i++) {
|
||||
await pb.collection('bet_options').create({
|
||||
bet: bet.id,
|
||||
content: data.options[i],
|
||||
order: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return bet as unknown as Bet
|
||||
}
|
||||
|
||||
export async function listBets(groupId: string, status?: string): Promise<Bet[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (status) filter += ` && status="${status}"`
|
||||
|
||||
const result = await pb.collection('bets').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as Bet[]
|
||||
}
|
||||
|
||||
export async function getBet(betId: string): Promise<Bet> {
|
||||
const result = await pb.collection('bets').getOne(betId, {
|
||||
expand: 'creator,resultOption',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as Bet
|
||||
}
|
||||
|
||||
export async function getBetOptions(betId: string): Promise<BetOption[]> {
|
||||
const result = await pb.collection('bet_options').getFullList({
|
||||
filter: `bet="${betId}"`,
|
||||
sort: 'order',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BetOption[]
|
||||
}
|
||||
|
||||
export async function getBetEntries(betId: string): Promise<BetEntry[]> {
|
||||
const result = await pb.collection('bet_entries').getFullList({
|
||||
filter: `bet="${betId}"`,
|
||||
expand: 'user,option',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BetEntry[]
|
||||
}
|
||||
|
||||
export async function placeBet(betId: string, optionId: string, stake: number): Promise<BetEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 验证竞猜状态和下注范围 (H1)
|
||||
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
|
||||
if ((betData as any).status !== 'open') throw new Error('竞猜已关闭,无法下注')
|
||||
if (stake < (betData as any).minStake || stake > (betData as any).maxStake) {
|
||||
throw new Error(`下注积分需在 ${(betData as any).minStake}~${(betData as any).maxStake} 之间`)
|
||||
}
|
||||
|
||||
// 防止重复下注
|
||||
const existing = await pb.collection('bet_entries').getList(1, 1, {
|
||||
filter: `bet="${betId}" && user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
if (existing.items.length > 0) throw new Error('你已经下注过了')
|
||||
|
||||
// 重新读取最新积分缓解 TOCTOU (C2)
|
||||
const currentUser = await pb.collection('users').getOne(user.id, { $autoCancel: false })
|
||||
const currentPoints = (currentUser as any).points || 0
|
||||
if (currentPoints < stake) throw new Error('积分不足')
|
||||
|
||||
await pb.collection('users').update(user.id, {
|
||||
points: currentPoints - stake,
|
||||
})
|
||||
|
||||
const entry = await pb.collection('bet_entries').create({
|
||||
bet: betId,
|
||||
user: user.id,
|
||||
option: optionId,
|
||||
stake,
|
||||
})
|
||||
|
||||
await pb.collection('point_logs').create({
|
||||
user: user.id,
|
||||
action: 'bet',
|
||||
points: -stake,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
|
||||
return entry as unknown as BetEntry
|
||||
}
|
||||
|
||||
export async function closeBet(betId: string): Promise<Bet> {
|
||||
const record = await pb.collection('bets').update(betId, { status: 'closed' })
|
||||
return record as unknown as Bet
|
||||
}
|
||||
|
||||
export async function settleBet(betId: string, resultOptionId: string): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 先标记为 settled 防止双重结算 (C1)
|
||||
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
|
||||
if ((betData as any).status === 'settled') throw new Error('竞猜已结算,请勿重复操作')
|
||||
if ((betData as any).creator !== user.id) throw new Error('只有发起人可以开奖') // (H2)
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + '.000Z'
|
||||
await pb.collection('bets').update(betId, {
|
||||
status: 'settled',
|
||||
resultOption: resultOptionId,
|
||||
settledAt: now,
|
||||
})
|
||||
|
||||
const entries = await getBetEntries(betId)
|
||||
const totalPool = entries.reduce((sum, e) => sum + e.stake, 0)
|
||||
|
||||
const winnerEntries = entries.filter(e => e.option === resultOptionId)
|
||||
const loserEntries = entries.filter(e => e.option !== resultOptionId)
|
||||
|
||||
// 并行标记输赢
|
||||
const markPromises: Promise<void>[] = []
|
||||
|
||||
if (winnerEntries.length === 0) {
|
||||
// 无人猜中,退还所有积分
|
||||
for (const entry of entries) {
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(entry.user, {
|
||||
points: ((entryUser as any).points || 0) + entry.stake,
|
||||
})
|
||||
await pb.collection('point_logs').create({
|
||||
user: entry.user,
|
||||
action: 'bet',
|
||||
points: entry.stake,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
await pb.collection('bet_entries').update(entry.id, { won: false })
|
||||
})())
|
||||
}
|
||||
} else {
|
||||
const winnerTotalStake = winnerEntries.reduce((sum, e) => sum + e.stake, 0)
|
||||
let distributed = 0
|
||||
|
||||
for (const entry of winnerEntries) {
|
||||
const winAmount = Math.floor((entry.stake / winnerTotalStake) * totalPool)
|
||||
distributed += winAmount
|
||||
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(entry.user, {
|
||||
points: ((entryUser as any).points || 0) + winAmount,
|
||||
})
|
||||
await pb.collection('point_logs').create({
|
||||
user: entry.user,
|
||||
action: 'bet',
|
||||
points: winAmount,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
await pb.collection('bet_entries').update(entry.id, { won: true })
|
||||
})())
|
||||
}
|
||||
|
||||
// 余数分给最后一个赢家而非庄家 (C3)
|
||||
const remainder = totalPool - distributed
|
||||
if (remainder > 0 && winnerEntries.length > 0) {
|
||||
const lastWinner = winnerEntries[winnerEntries.length - 1]
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(lastWinner.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(lastWinner.user, {
|
||||
points: ((entryUser as any).points || 0) + remainder,
|
||||
})
|
||||
})())
|
||||
}
|
||||
|
||||
for (const entry of loserEntries) {
|
||||
markPromises.push(pb.collection('bet_entries').update(entry.id, { won: false }).then(() => {}))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(markPromises)
|
||||
}
|
||||
|
||||
export async function subscribeBets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('bets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { BlacklistEntry } from '@/types'
|
||||
|
||||
export async function createBlacklistEntry(data: {
|
||||
group: string
|
||||
game?: string
|
||||
gameName: string
|
||||
reason: string
|
||||
description: string
|
||||
severity: string
|
||||
}): Promise<BlacklistEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
group: data.group,
|
||||
reporter: user.id,
|
||||
gameName: data.gameName,
|
||||
reason: data.reason,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
}
|
||||
if (data.game) payload.game = data.game
|
||||
|
||||
const record = await pb.collection('game_blacklist').create(payload)
|
||||
return record as unknown as BlacklistEntry
|
||||
}
|
||||
|
||||
export async function listBlacklist(
|
||||
groupId: string,
|
||||
options?: { reason?: string; severity?: string }
|
||||
): Promise<BlacklistEntry[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (options?.reason) filter += ` && reason="${options.reason}"`
|
||||
if (options?.severity) filter += ` && severity="${options.severity}"`
|
||||
|
||||
const result = await pb.collection('game_blacklist').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'reporter,game',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BlacklistEntry[]
|
||||
}
|
||||
|
||||
export async function deleteBlacklistEntry(entryId: string): Promise<void> {
|
||||
await pb.collection('game_blacklist').delete(entryId)
|
||||
}
|
||||
|
||||
export async function subscribeBlacklist(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('game_blacklist').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
+19
-13
@@ -15,7 +15,8 @@ export async function getGroupGames(groupId: string, options?: {
|
||||
|
||||
const result = await pb.collection('games').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-created'
|
||||
sort: '-created',
|
||||
$autoCancel: false
|
||||
})
|
||||
return { items: result.items as unknown as Game[], total: result.totalItems }
|
||||
}
|
||||
@@ -33,12 +34,12 @@ export async function addGame(groupId: string, data: {
|
||||
group: groupId,
|
||||
addedBy: user?.id,
|
||||
popularCount: 0
|
||||
})
|
||||
}, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 删除游戏
|
||||
export async function deleteGame(gameId: string) {
|
||||
return pb.collection('games').delete(gameId)
|
||||
return pb.collection('games').delete(gameId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 导入游戏(批量添加)
|
||||
@@ -64,7 +65,8 @@ export async function importGames(groupId: string, games: Array<{
|
||||
export async function exportGames(groupId: string): Promise<Game[]> {
|
||||
const result = await pb.collection('games').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-created'
|
||||
sort: '-created',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Game[]
|
||||
}
|
||||
@@ -75,17 +77,18 @@ export async function toggleFavorite(gameId: string): Promise<boolean> {
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const existing = await pb.collection('game_favorites').getList(1, 1, {
|
||||
filter: `game="${gameId}" && user="${user.id}"`
|
||||
filter: `game="${gameId}" && user="${user.id}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
await pb.collection('game_favorites').delete(existing.items[0].id)
|
||||
await pb.collection('game_favorites').delete(existing.items[0].id, { $autoCancel: false })
|
||||
return false
|
||||
} else {
|
||||
await pb.collection('game_favorites').create({
|
||||
game: gameId,
|
||||
user: user.id
|
||||
})
|
||||
}, { $autoCancel: false })
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,8 @@ export async function isFavorite(gameId: string): Promise<boolean> {
|
||||
if (!user) return false
|
||||
|
||||
const result = await pb.collection('game_favorites').getList(1, 1, {
|
||||
filter: `game="${gameId}" && user="${user.id}"`
|
||||
filter: `game="${gameId}" && user="${user.id}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items.length > 0
|
||||
}
|
||||
@@ -111,7 +115,7 @@ export async function addComment(gameId: string, content: string, rating?: numbe
|
||||
author: user.id,
|
||||
content,
|
||||
rating
|
||||
})
|
||||
}, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 获取游戏评论
|
||||
@@ -119,7 +123,8 @@ export async function getGameComments(gameId: string): Promise<GameComment[]> {
|
||||
const result = await pb.collection('game_comments').getList(1, 50, {
|
||||
filter: `game="${gameId}"`,
|
||||
sort: '-created',
|
||||
expand: 'author'
|
||||
expand: 'author',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as GameComment[]
|
||||
}
|
||||
@@ -137,7 +142,8 @@ export async function searchGames(query: string, groupId?: string, limit = 20):
|
||||
|
||||
const result = await pb.collection('games').getList(1, limit, {
|
||||
filter,
|
||||
sort: '-popularCount'
|
||||
sort: '-popularCount',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as Game[]
|
||||
}
|
||||
@@ -147,9 +153,9 @@ export async function getPopularGames(limit = 10): Promise<Game[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
// 获取用户所在群组的游戏
|
||||
const result = await pb.collection('games').getList(1, limit, {
|
||||
sort: '-popularCount'
|
||||
sort: '-popularCount',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as Game[]
|
||||
}
|
||||
|
||||
@@ -111,6 +111,27 @@ export async function respondInvitation(
|
||||
|
||||
// 更新邀请状态
|
||||
await pb.collection('invitations').update(invitationId, updateData)
|
||||
|
||||
// 通知邀请发起人
|
||||
try {
|
||||
const invitation = await pb.collection('invitations').getOne(invitationId, {
|
||||
expand: 'teamSession',
|
||||
$autoCancel: false,
|
||||
}) as any
|
||||
const { createNotification } = await import('./notifications')
|
||||
await createNotification({
|
||||
user: invitation.from,
|
||||
type: response === 'rejected' ? 'team_invite' : 'team_invite',
|
||||
title: response === 'rejected' ? '邀请被拒绝' : '邀请已接受',
|
||||
content: response === 'rejected'
|
||||
? (rejectReason || '对方拒绝了组队邀请')
|
||||
: '对方已接受组队邀请',
|
||||
relatedId: invitation.teamSession,
|
||||
relatedType: 'team',
|
||||
})
|
||||
} catch {
|
||||
// 通知失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅邀请变更
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/api/ledgers.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Ledger } from '@/types'
|
||||
|
||||
interface CreateLedgerData {
|
||||
group: string
|
||||
type: string
|
||||
amount: number
|
||||
category: string
|
||||
description?: string
|
||||
relatedMembers?: string[]
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
interface ListLedgersOptions {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
category?: string
|
||||
month?: string
|
||||
}
|
||||
|
||||
interface LedgerSummary {
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
balance: number
|
||||
}
|
||||
|
||||
export async function createLedger(data: CreateLedgerData): Promise<Ledger> {
|
||||
const user = pb.authStore.model
|
||||
const record = await pb.collection('ledgers').create({
|
||||
...data,
|
||||
creator: user?.id || '',
|
||||
relatedMembers: data.relatedMembers || []
|
||||
}, { $autoCancel: false })
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function listLedgers(
|
||||
groupId: string,
|
||||
options?: ListLedgersOptions
|
||||
): Promise<{ items: Ledger[]; total: number }> {
|
||||
const { page = 1, limit = 30, type, category, month } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (type) filter += ` && type="${type}"`
|
||||
if (category) filter += ` && category="${category}"`
|
||||
if (month) {
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-occurredAt',
|
||||
expand: 'creator,relatedMembers',
|
||||
$autoCancel: false
|
||||
})
|
||||
return { items: result.items as unknown as Ledger[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function updateLedger(
|
||||
ledgerId: string,
|
||||
data: Partial<CreateLedgerData>
|
||||
): Promise<Ledger> {
|
||||
const record = await pb.collection('ledgers').update(ledgerId, data, { $autoCancel: false })
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function deleteLedger(ledgerId: string): Promise<void> {
|
||||
await pb.collection('ledgers').delete(ledgerId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
export async function getLedgerSummary(
|
||||
groupId: string,
|
||||
month?: string
|
||||
): Promise<LedgerSummary> {
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
let filter = `group="${groupId}"`
|
||||
if (month) {
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('ledgers').getList(page, batchSize, {
|
||||
filter,
|
||||
fields: 'type,amount',
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
for (const item of result.items as any[]) {
|
||||
if (item.type === 'income') {
|
||||
totalIncome += item.amount || 0
|
||||
} else if (item.type === 'expense') {
|
||||
totalExpense += item.amount || 0
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLedgers(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('ledgers').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Memory } from '@/types'
|
||||
|
||||
export const GROUP_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024
|
||||
|
||||
export function detectFileType(file: File): 'image' | 'video' | 'audio' | 'document' | 'other' {
|
||||
const mime = file.type || ''
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (
|
||||
mime.startsWith('application/pdf') ||
|
||||
mime.startsWith('application/msword') ||
|
||||
mime.startsWith('application/vnd.') ||
|
||||
mime.startsWith('text/')
|
||||
) return 'document'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export async function uploadMemory(
|
||||
groupId: string,
|
||||
file: File,
|
||||
meta?: { title?: string; description?: string; tags?: string[] }
|
||||
) {
|
||||
const used = await getGroupStorageUsed(groupId)
|
||||
if (used + file.size > GROUP_STORAGE_LIMIT) {
|
||||
throw new Error('群组存储空间不足,无法上传')
|
||||
}
|
||||
|
||||
const user = pb.authStore.model
|
||||
const formData = new FormData()
|
||||
formData.append('group', groupId)
|
||||
formData.append('uploader', user?.id || '')
|
||||
formData.append('file', file)
|
||||
formData.append('fileType', detectFileType(file))
|
||||
formData.append('size', file.size.toString())
|
||||
formData.append('title', meta?.title || file.name)
|
||||
if (meta?.description) formData.append('description', meta.description)
|
||||
|
||||
return pb.collection('memories').create(formData)
|
||||
}
|
||||
|
||||
export async function listMemories(
|
||||
groupId: string,
|
||||
options?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
fileType?: string
|
||||
}
|
||||
): Promise<{ items: Memory[]; total: number }> {
|
||||
const { page = 1, limit = 30, fileType } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (fileType) filter += ` && fileType="${fileType}"`
|
||||
|
||||
const result = await pb.collection('memories').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'uploader'
|
||||
})
|
||||
return { items: result.items as unknown as Memory[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function deleteMemory(memoryId: string) {
|
||||
return pb.collection('memories').delete(memoryId)
|
||||
}
|
||||
|
||||
export async function getGroupStorageUsed(groupId: string): Promise<number> {
|
||||
let total = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('memories').getList(page, batchSize, {
|
||||
filter: `group="${groupId}"`,
|
||||
fields: 'size'
|
||||
})
|
||||
total += result.items.reduce((sum: number, r: any) => sum + (r.size || 0), 0)
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
export function getGroupStorageLimit(): number {
|
||||
return GROUP_STORAGE_LIMIT
|
||||
}
|
||||
|
||||
export function subscribeMemories(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('memories').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { AppNotification } from '@/types'
|
||||
|
||||
export async function listNotifications(options?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
unreadOnly?: boolean
|
||||
}): Promise<{ items: AppNotification[], total: number }> {
|
||||
const { page = 1, limit = 50, unreadOnly = false } = options || {}
|
||||
const user = pb.authStore.model
|
||||
if (!user) return { items: [], total: 0 }
|
||||
|
||||
let filter = `user="${user.id}"`
|
||||
if (unreadOnly) filter += ' && read=false'
|
||||
|
||||
const result = await pb.collection('notifications').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-created'
|
||||
})
|
||||
return { items: result.items as unknown as AppNotification[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function markAsRead(notificationId: string): Promise<AppNotification> {
|
||||
return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise<AppNotification>
|
||||
}
|
||||
|
||||
export async function markAllAsRead(): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return
|
||||
|
||||
let page = 1
|
||||
const batchSize = 50
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('notifications').getList(page, batchSize, {
|
||||
filter: `user="${user.id}" && read=false`
|
||||
})
|
||||
|
||||
if (result.items.length === 0) break
|
||||
|
||||
await Promise.all(
|
||||
result.items.map(item => pb.collection('notifications').update(item.id, { read: true }))
|
||||
)
|
||||
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotification(notificationId: string): Promise<void> {
|
||||
await pb.collection('notifications').delete(notificationId)
|
||||
}
|
||||
|
||||
export async function batchDelete(notificationIds: string[]): Promise<void> {
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < notificationIds.length; i += batchSize) {
|
||||
const batch = notificationIds.slice(i, i + batchSize)
|
||||
await Promise.all(batch.map(id => pb.collection('notifications').delete(id)))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNotification(data: {
|
||||
user: string
|
||||
type: string
|
||||
title: string
|
||||
content?: string
|
||||
relatedId?: string
|
||||
relatedType?: string
|
||||
}): Promise<AppNotification> {
|
||||
return pb.collection('notifications').create(data) as unknown as Promise<AppNotification>
|
||||
}
|
||||
|
||||
export async function subscribeNotifications(
|
||||
callback: (data: { action: string, record: AppNotification }) => void
|
||||
): Promise<() => void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return () => {}
|
||||
|
||||
await pb.collection('notifications').subscribe('*', (e) => {
|
||||
if (e.record?.user === user.id) {
|
||||
callback({ action: e.action, record: e.record as unknown as AppNotification })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('notifications').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { PlayerBlacklistEntry } from '@/types'
|
||||
|
||||
export async function createPlayerBlacklistEntry(data: {
|
||||
group: string
|
||||
playerId: string
|
||||
platform: string
|
||||
tags: string[]
|
||||
customTag?: string
|
||||
description: string
|
||||
severity: string
|
||||
}): Promise<PlayerBlacklistEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
group: data.group,
|
||||
reporter: user.id,
|
||||
playerId: data.playerId,
|
||||
platform: data.platform,
|
||||
tags: data.tags,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
}
|
||||
if (data.customTag) payload.customTag = data.customTag
|
||||
|
||||
const record = await pb.collection('player_blacklist').create(payload)
|
||||
return record as unknown as PlayerBlacklistEntry
|
||||
}
|
||||
|
||||
export async function listPlayerBlacklist(
|
||||
groupId: string,
|
||||
options?: { tag?: string; severity?: string }
|
||||
): Promise<PlayerBlacklistEntry[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (options?.tag) filter += ` && tags~"${options.tag}"`
|
||||
if (options?.severity) filter += ` && severity="${options.severity}"`
|
||||
|
||||
const result = await pb.collection('player_blacklist').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'reporter',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as PlayerBlacklistEntry[]
|
||||
}
|
||||
|
||||
export async function deletePlayerBlacklistEntry(entryId: string): Promise<void> {
|
||||
await pb.collection('player_blacklist').delete(entryId)
|
||||
}
|
||||
|
||||
export async function subscribePlayerBlacklist(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('player_blacklist').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { PointLog, PointAction } from '@/types'
|
||||
|
||||
const POINT_MAP: Record<PointAction, number> = {
|
||||
vote: 1,
|
||||
team: 2,
|
||||
memory: 1,
|
||||
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
|
||||
}
|
||||
|
||||
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return
|
||||
|
||||
const existing = await pb.collection('point_logs').getList(1, 1, {
|
||||
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
if (existing.items.length > 0) return
|
||||
|
||||
const points = POINT_MAP[action]
|
||||
|
||||
try {
|
||||
await pb.collection('point_logs').create({
|
||||
user: user.id,
|
||||
action,
|
||||
points,
|
||||
relatedId
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error?.response?.data?.user || error?.message?.includes('unique')) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const currentUser = await pb.collection('users').getOne(user.id)
|
||||
await pb.collection('users').update(user.id, {
|
||||
points: ((currentUser as any).points || 0) + points
|
||||
})
|
||||
}
|
||||
|
||||
export async function deductPoints(action: PointAction, relatedId: string): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return
|
||||
|
||||
const existing = await pb.collection('point_logs').getList(1, 1, {
|
||||
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
if (existing.items.length === 0) return
|
||||
const log = existing.items[0]
|
||||
|
||||
try {
|
||||
await pb.collection('point_logs').delete(log.id)
|
||||
} catch (error: any) {
|
||||
if (error?.status !== 404) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const pointsToDeduct = POINT_MAP[action]
|
||||
const currentUser = await pb.collection('users').getOne(user.id)
|
||||
const currentPoints = (currentUser as any).points || 0
|
||||
const newPoints = Math.max(0, currentPoints - pointsToDeduct)
|
||||
|
||||
await pb.collection('users').update(user.id, {
|
||||
points: newPoints
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserPointLogs(userId: string): Promise<PointLog[]> {
|
||||
const result = await pb.collection('point_logs').getList(1, 100, {
|
||||
filter: `user="${userId}"`,
|
||||
sort: '-created',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as PointLog[]
|
||||
}
|
||||
|
||||
export async function getGroupMemberRanking(groupId: string, limit = 20) {
|
||||
const group = await pb.collection('groups').getOne(groupId, {
|
||||
expand: 'members',
|
||||
$autoCancel: false
|
||||
}) as any
|
||||
|
||||
const members: any[] = group.expand?.members || []
|
||||
return members
|
||||
.map((m: any) => ({ userId: m.id, points: m.points || 0, name: m.name || m.username }))
|
||||
.sort((a: any, b: any) => b.points - a.points)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { pb } from './pocketbase'
|
||||
import { awardPoints, deductPoints } from './points'
|
||||
import { createNotification } from './notifications'
|
||||
import type { Poll, PollOption, PollVote } from '@/types'
|
||||
|
||||
export async function createPoll(data: {
|
||||
group: string
|
||||
title: string
|
||||
type?: 'option' | 'rollcall'
|
||||
anonymous?: boolean
|
||||
deadline?: string
|
||||
maxParticipants?: number
|
||||
options: string[]
|
||||
}): Promise<Poll> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const poll = await pb.collection('polls').create({
|
||||
group: data.group,
|
||||
title: data.title,
|
||||
type: data.type || 'option',
|
||||
anonymous: data.anonymous || false,
|
||||
deadline: data.deadline || '',
|
||||
maxParticipants: data.type === 'rollcall' ? data.maxParticipants : null,
|
||||
status: 'active',
|
||||
creator: user.id,
|
||||
})
|
||||
|
||||
for (let i = 0; i < data.options.length; i++) {
|
||||
await pb.collection('poll_options').create({
|
||||
poll: poll.id,
|
||||
content: data.options[i],
|
||||
order: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
// 给同群组其他成员发送通知
|
||||
try {
|
||||
const group = await pb.collection('groups').getOne(data.group)
|
||||
const typeLabel = data.type === 'rollcall' ? '接龙报名' : '投票'
|
||||
const otherMembers = (group.members || []).filter((id: string) => id !== user.id)
|
||||
await Promise.all(
|
||||
otherMembers.map((memberId: string) =>
|
||||
createNotification({
|
||||
user: memberId,
|
||||
type: 'poll_new',
|
||||
title: `新${typeLabel}`,
|
||||
content: `${data.title}`,
|
||||
relatedId: poll.id,
|
||||
relatedType: 'poll',
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// 通知发送失败不影响主流程
|
||||
}
|
||||
|
||||
return poll as unknown as Poll
|
||||
}
|
||||
|
||||
export async function listPolls(
|
||||
groupId: string,
|
||||
status?: string
|
||||
): Promise<Poll[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (status) filter += ` && status="${status}"`
|
||||
|
||||
const result = await pb.collection('polls').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as Poll[]
|
||||
}
|
||||
|
||||
export async function getPoll(pollId: string): Promise<Poll> {
|
||||
const result = await pb.collection('polls').getOne(pollId, {
|
||||
expand: 'creator',
|
||||
})
|
||||
return result as unknown as Poll
|
||||
}
|
||||
|
||||
export async function getPollOptions(pollId: string): Promise<PollOption[]> {
|
||||
const result = await pb.collection('poll_options').getFullList({
|
||||
filter: `poll="${pollId}"`,
|
||||
sort: 'order',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as PollOption[]
|
||||
}
|
||||
|
||||
export async function getPollVotes(pollId: string): Promise<PollVote[]> {
|
||||
const result = await pb.collection('poll_votes').getFullList({
|
||||
filter: `poll="${pollId}"`,
|
||||
expand: 'user,option',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as PollVote[]
|
||||
}
|
||||
|
||||
export async function votePoll(
|
||||
pollId: string,
|
||||
optionId: string
|
||||
): Promise<PollVote> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const poll = await pb.collection('polls').getOne(pollId)
|
||||
if (poll.status !== 'active') {
|
||||
throw new Error('投票已结束')
|
||||
}
|
||||
|
||||
const existing = await pb.collection('poll_votes').getList(1, 1, {
|
||||
filter: `poll="${pollId}" && user="${user.id}"`,
|
||||
})
|
||||
if (existing.items.length > 0) {
|
||||
throw new Error('你已经投过票了')
|
||||
}
|
||||
|
||||
try {
|
||||
const vote = await pb.collection('poll_votes').create({
|
||||
poll: pollId,
|
||||
option: optionId,
|
||||
user: user.id,
|
||||
})
|
||||
|
||||
await awardPoints('vote', pollId)
|
||||
|
||||
return vote as unknown as PollVote
|
||||
} catch (error: any) {
|
||||
if (error?.response?.data?.poll || error?.message?.includes('unique')) {
|
||||
throw new Error('你已经投过票了')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelVote(pollId: string): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const poll = await pb.collection('polls').getOne(pollId)
|
||||
if (poll.status !== 'active') {
|
||||
throw new Error('投票已结束,无法取消')
|
||||
}
|
||||
|
||||
const existing = await pb.collection('poll_votes').getFullList({
|
||||
filter: `poll="${pollId}" && user="${user.id}"`,
|
||||
})
|
||||
|
||||
for (const vote of existing) {
|
||||
await pb.collection('poll_votes').delete(vote.id)
|
||||
}
|
||||
|
||||
await deductPoints('vote', pollId)
|
||||
}
|
||||
|
||||
export async function settlePoll(pollId: string): Promise<Poll> {
|
||||
const result = await pb.collection('polls').update(pollId, {
|
||||
status: 'settled',
|
||||
settledAt: new Date().toISOString(),
|
||||
})
|
||||
return result as unknown as Poll
|
||||
}
|
||||
|
||||
export async function updatePoll(pollId: string, data: {
|
||||
title?: string
|
||||
deadline?: string
|
||||
maxParticipants?: number | null
|
||||
}): Promise<Poll> {
|
||||
const result = await pb.collection('polls').update(pollId, data)
|
||||
return result as unknown as Poll
|
||||
}
|
||||
|
||||
export async function addPollOption(pollId: string, content: string): Promise<PollOption> {
|
||||
const existing = await pb.collection('poll_options').getFullList({
|
||||
filter: `poll="${pollId}"`,
|
||||
sort: '-order',
|
||||
$autoCancel: false,
|
||||
})
|
||||
const maxOrder = existing.reduce((max: number, o: any) => Math.max(max, o.order || 0), 0)
|
||||
const result = await pb.collection('poll_options').create({
|
||||
poll: pollId,
|
||||
content,
|
||||
order: maxOrder + 1,
|
||||
})
|
||||
return result as unknown as PollOption
|
||||
}
|
||||
|
||||
export async function updatePollOption(optionId: string, content: string): Promise<PollOption> {
|
||||
const result = await pb.collection('poll_options').update(optionId, { content })
|
||||
return result as unknown as PollOption
|
||||
}
|
||||
|
||||
export async function deletePollOption(optionId: string): Promise<void> {
|
||||
await pb.collection('poll_options').delete(optionId)
|
||||
}
|
||||
|
||||
export async function getUserVote(pollId: string): Promise<PollVote | null> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return null
|
||||
|
||||
const result = await pb.collection('poll_votes').getList(1, 1, {
|
||||
filter: `poll="${pollId}" && user="${user.id}"`,
|
||||
expand: 'option',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result.items.length > 0
|
||||
? (result.items[0] as unknown as PollVote)
|
||||
: null
|
||||
}
|
||||
|
||||
export async function subscribePolls(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
await pb.collection('polls').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('polls').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribePollVotes(
|
||||
pollId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
await pb.collection('poll_votes').subscribe('*', (data) => {
|
||||
if (data.record?.poll === pollId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('poll_votes').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const token = pb.authStore.token
|
||||
console.log('[voice] fetching token for session:', sessionId, 'token prefix:', token?.slice(0, 20))
|
||||
|
||||
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[voice] token service response status:', res.status)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
||||
console.log('[voice] token service error:', data)
|
||||
throw new Error(data.error || data.detail || '语音服务暂不可用')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* src/assets/mobile.css */
|
||||
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
|
||||
|
||||
:root {
|
||||
/* 主色(Vant primary → gg-primary 绿色) */
|
||||
--van-primary-color: var(--gg-primary); /* #059669 */
|
||||
--van-success-color: var(--gg-success); /* #10b981 */
|
||||
--van-danger-color: var(--gg-danger); /* #ef4444 */
|
||||
--van-warning-color: var(--gg-warning); /* #f59e0b */
|
||||
|
||||
/* 文字色 */
|
||||
--van-text-color: var(--gg-text); /* #1e293b */
|
||||
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
|
||||
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
|
||||
|
||||
/* 背景 */
|
||||
--van-background: var(--gg-bg); /* #f0fdf4 */
|
||||
--van-background-2: var(--gg-bg-card); /* #ffffff */
|
||||
|
||||
/* 边框 */
|
||||
--van-border-color: var(--gg-border); /* #e2e8f0 */
|
||||
|
||||
/* 圆角 */
|
||||
--van-radius-sm: var(--gg-radius-sm);
|
||||
--van-radius-md: var(--gg-radius-md);
|
||||
--van-radius-lg: var(--gg-radius-lg);
|
||||
|
||||
/* Tabbar(底部导航) */
|
||||
--van-tabbar-background: var(--gg-bg-card);
|
||||
--van-tabbar-item-active-color: var(--gg-primary);
|
||||
--van-tabbar-item-text-color: var(--gg-text-muted);
|
||||
--van-tabbar-height: 56px;
|
||||
|
||||
/* NavBar(顶部栏) */
|
||||
--van-nav-bar-background: var(--gg-bg-card);
|
||||
--van-nav-bar-title-font-size: 17px;
|
||||
--van-nav-bar-height: 52px;
|
||||
--van-nav-bar-icon-color: var(--gg-text);
|
||||
|
||||
/* 按钮主色渐变 */
|
||||
--van-button-primary-background: var(--gg-primary);
|
||||
--van-button-primary-border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
|
||||
.mobile-app {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* 禁止移动端点击高亮 */
|
||||
.mobile-app * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<!-- src/components-mobile/bet/BetListMobile.vue -->
|
||||
<!-- 手机端竞猜列表 + 详情 + 下注 + 创建 + 结算 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, closeBet, settleBet } from '@/api/bets'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const bets = ref<Bet[]>([])
|
||||
const loading = ref(false)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
const currentBet = ref<Bet | null>(null)
|
||||
const options = ref<BetOption[]>([])
|
||||
const entries = ref<BetEntry[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function loadBets() {
|
||||
loading.value = true
|
||||
try {
|
||||
bets.value = await listBets(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBets()
|
||||
})
|
||||
|
||||
async function viewBet(betId: string) {
|
||||
viewingBetId.value = betId
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [bet, opts, ents] = await Promise.all([
|
||||
getBet(betId),
|
||||
getBetOptions(betId),
|
||||
getBetEntries(betId)
|
||||
])
|
||||
currentBet.value = bet
|
||||
options.value = opts
|
||||
entries.value = ents
|
||||
} catch (e) {
|
||||
showFailToast('加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
viewingBetId.value = null
|
||||
currentBet.value = null
|
||||
loadBets()
|
||||
}
|
||||
|
||||
function optionEntryCount(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).length
|
||||
}
|
||||
|
||||
function optionTotalStake(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).reduce((sum, e) => sum + e.stake, 0)
|
||||
}
|
||||
|
||||
const myEntry = () => entries.value.find(e => e.user === userStore.userId)
|
||||
|
||||
// 下注
|
||||
const showBet = ref(false)
|
||||
const selectedOption = ref<string>('')
|
||||
const stakeAmount = ref(0)
|
||||
const placeLoading = ref(false)
|
||||
|
||||
function openBetSheet(optionId: string) {
|
||||
if (!currentBet.value || currentBet.value.status !== 'open') return
|
||||
if (myEntry()) {
|
||||
showFailToast('你已经下注了')
|
||||
return
|
||||
}
|
||||
selectedOption.value = optionId
|
||||
stakeAmount.value = currentBet.value.minStake
|
||||
showBet.value = true
|
||||
}
|
||||
|
||||
async function handlePlaceBet() {
|
||||
if (!currentBet.value) return
|
||||
if (stakeAmount.value < currentBet.value.minStake || stakeAmount.value > currentBet.value.maxStake) {
|
||||
showFailToast(`下注范围 ${currentBet.value.minStake}-${currentBet.value.maxStake}`)
|
||||
return
|
||||
}
|
||||
placeLoading.value = true
|
||||
try {
|
||||
await placeBet(currentBet.value.id, selectedOption.value, stakeAmount.value)
|
||||
showSuccessToast('下注成功')
|
||||
showBet.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '下注失败')
|
||||
} finally {
|
||||
placeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建竞猜
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
optionsText: '',
|
||||
minStake: 1,
|
||||
maxStake: 100,
|
||||
deadline: ''
|
||||
})
|
||||
const createLoading = ref(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.title.trim()) {
|
||||
showFailToast('请输入标题')
|
||||
return
|
||||
}
|
||||
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
if (opts.length < 2) {
|
||||
showFailToast('至少 2 个选项')
|
||||
return
|
||||
}
|
||||
if (!createForm.value.deadline) {
|
||||
showFailToast('请设置截止时间')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createBet({
|
||||
group: props.groupId,
|
||||
title: createForm.value.title.trim(),
|
||||
description: createForm.value.description.trim() || undefined,
|
||||
options: opts,
|
||||
minStake: createForm.value.minStake,
|
||||
maxStake: createForm.value.maxStake,
|
||||
deadline: createForm.value.deadline
|
||||
})
|
||||
showSuccessToast('创建成功')
|
||||
showCreate.value = false
|
||||
createForm.value = { title: '', description: '', optionsText: '', minStake: 1, maxStake: 100, deadline: '' }
|
||||
await loadBets()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '创建失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭/结算(创建者)
|
||||
async function handleClose() {
|
||||
if (!currentBet.value) return
|
||||
showConfirmDialog({ title: '关闭竞猜', message: '关闭后将停止下注,确定?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await closeBet(currentBet.value!.id)
|
||||
showSuccessToast('已关闭')
|
||||
await viewBet(currentBet.value!.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const showSettle = ref(false)
|
||||
const settleOption = ref('')
|
||||
|
||||
async function handleSettle() {
|
||||
if (!currentBet.value || !settleOption.value) return
|
||||
try {
|
||||
await settleBet(currentBet.value.id, settleOption.value)
|
||||
showSuccessToast('已结算')
|
||||
showSettle.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '结算失败')
|
||||
}
|
||||
}
|
||||
|
||||
const isCreator = () => currentBet.value?.creator === userStore.userId
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-mobile">
|
||||
<!-- 详情 -->
|
||||
<div v-if="viewingBetId && currentBet">
|
||||
<van-nav-bar :title="currentBet.title" left-arrow @click-left="backToList" />
|
||||
|
||||
<div v-if="detailLoading" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-content">
|
||||
<div class="detail-header">
|
||||
<van-tag :type="currentBet.status === 'open' ? 'success' : currentBet.status === 'closed' ? 'warning' : 'default'">
|
||||
{{ currentBet.status === 'open' ? '下注中' : currentBet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="detail-meta">范围 {{ currentBet.minStake }}-{{ currentBet.maxStake }} 积分</span>
|
||||
</div>
|
||||
|
||||
<p v-if="currentBet.description" class="detail-desc">{{ currentBet.description }}</p>
|
||||
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="option-item"
|
||||
@click="currentBet.status === 'open' && openBetSheet(opt.id)"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
<span v-if="currentBet.resultOption === opt.id" class="winner-badge">🏆 胜出</span>
|
||||
</div>
|
||||
<div class="option-stats">
|
||||
<span>{{ optionEntryCount(opt.id) }} 注</span>
|
||||
<span>{{ optionTotalStake(opt.id) }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的结果 -->
|
||||
<div v-if="currentBet.status === 'settled' && myEntry()" class="my-result">
|
||||
<van-notice-bar
|
||||
:type="myEntry()?.won ? 'success' : 'warning'"
|
||||
wrapable
|
||||
>
|
||||
{{ myEntry()?.won ? `🎉 你赢了!` : '本次未中奖' }}
|
||||
</van-notice-bar>
|
||||
</div>
|
||||
|
||||
<!-- 创建者操作 -->
|
||||
<div v-if="isCreator()" class="detail-actions">
|
||||
<van-button v-if="currentBet.status === 'open'" type="warning" block round plain @click="handleClose">
|
||||
关闭下注
|
||||
</van-button>
|
||||
<van-button v-if="currentBet.status === 'closed'" type="primary" block round @click="showSettle = true">
|
||||
结算
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<template v-else>
|
||||
<div class="list-header">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
|
||||
发起竞猜
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="loading" @refresh="loadBets">
|
||||
<div v-if="bets.length === 0 && !loading" class="empty">
|
||||
<van-empty description="暂无竞猜" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div class="bet-list">
|
||||
<div
|
||||
v-for="bet in bets"
|
||||
:key="bet.id"
|
||||
class="bet-card"
|
||||
@click="viewBet(bet.id)"
|
||||
>
|
||||
<div class="bet-card-header">
|
||||
<van-tag :type="bet.status === 'open' ? 'success' : bet.status === 'closed' ? 'warning' : 'default'" size="medium">
|
||||
{{ bet.status === 'open' ? '下注中' : bet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="bet-stake">{{ bet.minStake }}-{{ bet.maxStake }} 积分</span>
|
||||
</div>
|
||||
<div class="bet-title">{{ bet.title }}</div>
|
||||
<div class="bet-card-footer">
|
||||
<span class="bet-time">{{ timeAgo(bet.created) }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
</template>
|
||||
|
||||
<!-- 下注弹层 -->
|
||||
<van-action-sheet v-model:show="showBet" title="下注">
|
||||
<div class="bet-sheet-content">
|
||||
<div class="stake-display">
|
||||
<span class="stake-num">{{ stakeAmount }}</span>
|
||||
<span class="stake-unit">积分</span>
|
||||
</div>
|
||||
<van-stepper
|
||||
v-model="stakeAmount"
|
||||
:min="currentBet?.minStake"
|
||||
:max="currentBet?.maxStake"
|
||||
integer
|
||||
/>
|
||||
<div class="bet-sheet-actions">
|
||||
<van-button type="primary" block round :loading="placeLoading" @click="handlePlaceBet">
|
||||
确认下注
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 创建弹层 -->
|
||||
<van-popup v-model:show="showCreate" position="bottom" round closeable :style="{ height: '85%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起竞猜</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="createForm.title" label="标题" placeholder="竞猜主题" required />
|
||||
<van-field v-model="createForm.description" label="说明" placeholder="可选" />
|
||||
<van-field
|
||||
v-model="createForm.optionsText"
|
||||
type="textarea"
|
||||
label="选项"
|
||||
placeholder="每行一个选项"
|
||||
rows="3"
|
||||
/>
|
||||
<van-field name="stepper" label="最低下注">
|
||||
<template #input><van-stepper v-model="createForm.minStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field name="stepper" label="最高下注">
|
||||
<template #input><van-stepper v-model="createForm.maxStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field v-model="createForm.deadline" label="截止时间" placeholder="如 2026-12-31 23:59" required />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
|
||||
创建
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 结算弹层 -->
|
||||
<van-action-sheet v-model:show="showSettle" title="选择胜出选项">
|
||||
<div class="settle-content">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="settle-option"
|
||||
:class="{ 'settle-selected': settleOption === opt.id }"
|
||||
@click="settleOption = opt.id"
|
||||
>
|
||||
{{ opt.content }}
|
||||
</div>
|
||||
<div class="settle-actions">
|
||||
<van-button type="primary" block round @click="handleSettle">确认结算</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-mobile { padding: 12px; }
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
.empty { padding: 30px 0; }
|
||||
.list-header { display: flex; justify-content: flex-end; margin-bottom: 10px; }
|
||||
|
||||
.bet-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bet-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
|
||||
.bet-card:active { opacity: 0.85; }
|
||||
.bet-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.bet-stake { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.bet-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
|
||||
.bet-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--gg-text-muted); font-size: 13px; }
|
||||
|
||||
.detail-content { padding: 12px; }
|
||||
.detail-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.detail-meta { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.detail-desc { font-size: 14px; color: var(--gg-text-secondary); margin: 0 0 16px; line-height: 1.5; }
|
||||
|
||||
.options-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.option-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 12px 14px; border: 1px solid var(--gg-border); }
|
||||
.option-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.option-text { font-size: 14px; font-weight: 500; color: var(--gg-text); }
|
||||
.winner-badge { font-size: 13px; color: var(--gg-warning); font-weight: 600; }
|
||||
.option-stats { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--gg-text-muted); }
|
||||
|
||||
.my-result { margin-top: 16px; }
|
||||
.detail-actions { margin-top: 20px; }
|
||||
|
||||
/* 下注弹层 */
|
||||
.bet-sheet-content { padding: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.stake-display { display: flex; align-items: baseline; gap: 4px; }
|
||||
.stake-num { font-size: 40px; font-weight: 700; color: var(--gg-primary); }
|
||||
.stake-unit { font-size: 14px; color: var(--gg-text-muted); }
|
||||
.bet-sheet-actions { width: 100%; }
|
||||
|
||||
/* 弹层通用 */
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.popup-actions { padding: 20px 16px 0; }
|
||||
|
||||
.settle-content { padding: 16px; }
|
||||
.settle-option { padding: 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm); margin-bottom: 8px; text-align: center; border: 1px solid var(--gg-border); font-size: 14px; }
|
||||
.settle-selected { background: rgba(5, 150, 105, 0.1); border-color: var(--gg-primary); color: var(--gg-primary); font-weight: 600; }
|
||||
.settle-actions { margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
<!-- src/components-mobile/group/ActivityFeedMobile.vue -->
|
||||
<!-- 群组动态:当前组队状态 + 空闲成员 + 所有成员按状态分组 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createTeamSession } from '@/api/sessions'
|
||||
import { sendBulkInvitations } from '@/api/invitations'
|
||||
import type { User } from '@/types'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
|
||||
// 按状态分组
|
||||
const membersByStatus = computed(() => {
|
||||
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
|
||||
for (const m of members.value) {
|
||||
const s = (m.status || 'idle') as keyof typeof groups
|
||||
if (groups[s]) groups[s].push(m)
|
||||
else groups.away.push(m)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
idle: '空闲',
|
||||
in_team: '组队中',
|
||||
working: '工作中',
|
||||
away: '离开'
|
||||
}
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'var(--gg-success)',
|
||||
in_team: 'var(--gg-info)',
|
||||
working: 'var(--gg-danger)',
|
||||
away: 'var(--gg-text-muted)'
|
||||
}
|
||||
|
||||
// 发起组队
|
||||
const showCreateTeam = ref(false)
|
||||
const teamName = ref('')
|
||||
const teamGame = ref('')
|
||||
const selectedMembers = ref<string[]>([])
|
||||
const teamLoading = ref(false)
|
||||
|
||||
const idleMembers = computed(() => membersByStatus.value.idle)
|
||||
|
||||
async function handleCreateTeam() {
|
||||
if (!teamGame.value.trim()) {
|
||||
showFailToast('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
teamLoading.value = true
|
||||
try {
|
||||
const me = userStore.userId
|
||||
const memberIds = [...new Set([me, ...selectedMembers.value])]
|
||||
const session = await createTeamSession({
|
||||
sourceGroup: props.groupId,
|
||||
name: teamName.value.trim() || `${teamGame.value}小队`,
|
||||
gameName: teamGame.value.trim(),
|
||||
members: memberIds
|
||||
})
|
||||
// 邀请选中的成员
|
||||
const toInvite = selectedMembers.value.filter(id => id !== me)
|
||||
if (toInvite.length > 0 && session?.id) {
|
||||
await sendBulkInvitations(toInvite, session.id)
|
||||
}
|
||||
await teamStore.loadActiveSession()
|
||||
showCreateTeam.value = false
|
||||
teamName.value = ''
|
||||
teamGame.value = ''
|
||||
selectedMembers.value = []
|
||||
showSuccessToast('组队成功')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '组队失败')
|
||||
} finally {
|
||||
teamLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMember(id: string) {
|
||||
const idx = selectedMembers.value.indexOf(id)
|
||||
if (idx === -1) selectedMembers.value.push(id)
|
||||
else selectedMembers.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 当前组队状态
|
||||
const hasSession = computed(() => !!teamStore.currentSession)
|
||||
const sessionInThisGroup = computed(() =>
|
||||
teamStore.currentSession?.sourceGroup === props.groupId
|
||||
)
|
||||
|
||||
function goVoiceRoom() {
|
||||
const s = teamStore.currentSession
|
||||
if (s) {
|
||||
router.push({
|
||||
name: 'VoiceRoom',
|
||||
params: { groupId: s.sourceGroup, sessionId: s.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-feed">
|
||||
<!-- 当前组队卡片 -->
|
||||
<div v-if="hasSession && sessionInThisGroup" class="current-team-card" @click="goVoiceRoom">
|
||||
<div class="team-header">
|
||||
<van-icon name="volume-o" />
|
||||
<span class="team-label">进行中</span>
|
||||
</div>
|
||||
<div class="team-game">{{ teamStore.currentSession?.gameName }}</div>
|
||||
<div class="team-meta">
|
||||
{{ teamStore.currentSession?.name }} · {{ teamStore.currentSession?.members?.length || 0 }} 人
|
||||
</div>
|
||||
<div class="team-go">进入语音房 →</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速组队按钮 -->
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
icon="plus"
|
||||
@click="showCreateTeam = true"
|
||||
>
|
||||
发起组队
|
||||
</van-button>
|
||||
|
||||
<!-- 成员状态分组 -->
|
||||
<div class="members-section">
|
||||
<div
|
||||
v-for="(list, status) in membersByStatus"
|
||||
:key="status"
|
||||
v-show="list.length > 0"
|
||||
class="status-group"
|
||||
>
|
||||
<div class="status-group-header">
|
||||
<span class="status-dot" :style="{ background: statusColors[status] }" />
|
||||
<span class="status-group-label">{{ statusLabels[status] }}</span>
|
||||
<span class="status-group-count">{{ list.length }}</span>
|
||||
</div>
|
||||
<div class="member-list">
|
||||
<div v-for="m in list" :key="m.id" class="member-item">
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||
<div class="member-info">
|
||||
<div class="member-name">{{ m.name || m.username }}</div>
|
||||
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发起组队弹层 -->
|
||||
<van-popup
|
||||
v-model:show="showCreateTeam"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '75%' }"
|
||||
>
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起组队</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="teamGame" label="游戏" placeholder="玩什么游戏" required />
|
||||
<van-field v-model="teamName" label="队名" placeholder="可选,默认游戏+小队" />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="invite-title">邀请成员(空闲:{{ idleMembers.length }})</div>
|
||||
<div class="invite-list">
|
||||
<div
|
||||
v-for="m in idleMembers.filter(x => x.id !== userStore.userId)"
|
||||
:key="m.id"
|
||||
class="invite-member"
|
||||
:class="{ 'invite-selected': selectedMembers.includes(m.id) }"
|
||||
@click="toggleMember(m.id)"
|
||||
>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="invite-avatar" alt="" />
|
||||
<span class="invite-name">{{ m.name || m.username }}</span>
|
||||
<van-icon
|
||||
v-if="selectedMembers.includes(m.id)"
|
||||
name="success"
|
||||
class="invite-check"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="idleMembers.length <= 1" class="no-idle">暂无其他空闲成员</div>
|
||||
</div>
|
||||
|
||||
<div class="popup-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="teamLoading"
|
||||
@click="handleCreateTeam"
|
||||
>
|
||||
开始组队
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-feed {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.current-team-card {
|
||||
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
|
||||
border-radius: var(--gg-radius-lg);
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
|
||||
}
|
||||
|
||||
.team-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.team-game {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.team-meta {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.team-go {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.members-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-group-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.status-group-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 6px 10px 6px 6px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.member-note {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 弹层 */
|
||||
.popup-content {
|
||||
padding: 16px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-title {
|
||||
padding: 16px 16px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--gg-bg);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.invite-member.invite-selected {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.invite-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.invite-name {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-check {
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-idle {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<!-- src/components-mobile/group/MemberListMobile.vue -->
|
||||
<!-- 群组成员列表:按状态展示 + 群主管理(移除成员、审核开关、入群申请) -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getGroupJoinRequests, updateGroupApproval } from '@/api/groups'
|
||||
import { respondJoinRequest } from '@/api/groups'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { JoinRequest, User } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
const isOwner = computed(() => group.value?.owner === userStore.userId)
|
||||
|
||||
const joinRequests = ref<JoinRequest[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isOwner.value && group.value) {
|
||||
try {
|
||||
joinRequests.value = await getGroupJoinRequests(group.value.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
|
||||
// 按状态分组
|
||||
const membersByStatus = computed(() => {
|
||||
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
|
||||
for (const m of members.value) {
|
||||
const s = (m.status || 'idle') as keyof typeof groups
|
||||
if (groups[s]) groups[s].push(m)
|
||||
else groups.away.push(m)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusLabels: Record<string, string> = { idle: '空闲', in_team: '组队中', working: '工作中', away: '离开' }
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'var(--gg-success)', in_team: 'var(--gg-info)',
|
||||
working: 'var(--gg-danger)', away: 'var(--gg-text-muted)'
|
||||
}
|
||||
|
||||
// 移除成员(群主)
|
||||
async function removeMember(userId: string, name: string) {
|
||||
if (!isOwner.value || !group.value) return
|
||||
showConfirmDialog({
|
||||
title: '移除成员',
|
||||
message: `确定要将 ${name} 移出群组吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
const newMembers = group.value!.members.filter(id => id !== userId)
|
||||
await pb.collection('groups').update(group.value!.id, { members: newMembers })
|
||||
await groupStore.setCurrentGroup(group.value!.id)
|
||||
showSuccessToast('已移除')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 审核开关
|
||||
async function toggleApproval(val: boolean) {
|
||||
if (!group.value) return
|
||||
try {
|
||||
await updateGroupApproval(group.value.id, val)
|
||||
await groupStore.setCurrentGroup(group.value.id)
|
||||
showSuccessToast(val ? '已开启审核' : '已关闭审核')
|
||||
} catch (e: any) {
|
||||
showFailToast('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理入群申请
|
||||
const requestLoading = ref<string | null>(null)
|
||||
async function handleRequest(requestId: string, status: 'approved' | 'rejected') {
|
||||
requestLoading.value = requestId
|
||||
try {
|
||||
await respondJoinRequest(requestId, status)
|
||||
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||
await groupStore.setCurrentGroup(props.groupId)
|
||||
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
} finally {
|
||||
requestLoading.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="member-list-mobile">
|
||||
<!-- 群主管理区 -->
|
||||
<div v-if="isOwner" class="owner-panel">
|
||||
<!-- 入群申请 -->
|
||||
<div v-if="joinRequests.length > 0" class="requests-block">
|
||||
<div class="block-title">入群申请({{ joinRequests.length }})</div>
|
||||
<div
|
||||
v-for="req in joinRequests"
|
||||
:key="req.id"
|
||||
class="request-item"
|
||||
>
|
||||
<img
|
||||
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
|
||||
class="req-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<div class="req-info">
|
||||
<div class="req-name">{{ req.expand?.user?.name || req.expand?.user?.username }}</div>
|
||||
</div>
|
||||
<div class="req-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
round
|
||||
:loading="requestLoading === req.id"
|
||||
@click="handleRequest(req.id, 'approved')"
|
||||
>通过</van-button>
|
||||
<van-button
|
||||
size="mini"
|
||||
round
|
||||
:loading="requestLoading === req.id"
|
||||
@click="handleRequest(req.id, 'rejected')"
|
||||
>拒绝</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核开关 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell title="加入审核" center>
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
:model-value="group?.requireApproval"
|
||||
size="22px"
|
||||
@update:model-value="toggleApproval"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 成员分组列表 -->
|
||||
<div
|
||||
v-for="(list, status) in membersByStatus"
|
||||
:key="status"
|
||||
v-show="list.length > 0"
|
||||
class="status-section"
|
||||
>
|
||||
<div class="status-header">
|
||||
<span class="status-dot" :style="{ background: statusColors[status] }" />
|
||||
<span class="status-label">{{ statusLabels[status] }}</span>
|
||||
<span class="status-count">{{ list.length }}</span>
|
||||
</div>
|
||||
<div class="members-grid">
|
||||
<div
|
||||
v-for="m in list"
|
||||
:key="m.id"
|
||||
class="member-card"
|
||||
>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||
<div class="member-info">
|
||||
<div class="member-name">
|
||||
{{ m.name || m.username }}
|
||||
<van-tag v-if="m.id === group?.owner" type="warning" size="medium">群主</van-tag>
|
||||
</div>
|
||||
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
|
||||
</div>
|
||||
<van-button
|
||||
v-if="isOwner && m.id !== group?.owner && m.id !== userStore.userId"
|
||||
size="mini"
|
||||
plain
|
||||
type="danger"
|
||||
@click="removeMember(m.id, m.name || m.username)"
|
||||
>移除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-list-mobile {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.owner-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.request-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
margin-bottom: 8px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.req-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.req-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.req-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.status-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-note {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<!-- src/components-mobile/memory/MemoryGridMobile.vue -->
|
||||
<!-- 手机端回忆九宫格 + 上传 + 全屏浏览 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { listMemories, deleteMemory } from '@/api/memories'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { Memory } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showImagePreview, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const memories = ref<Memory[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadMemories() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listMemories(props.groupId, { limit: 100 })
|
||||
memories.value = result.items
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMemories()
|
||||
})
|
||||
|
||||
// 文件 URL
|
||||
function fileUrl(m: Memory): string {
|
||||
if (!m.file) return ''
|
||||
return pb.files.getUrl(m as any, m.file) as string
|
||||
}
|
||||
|
||||
function thumbUrl(m: Memory): string {
|
||||
if (!m.file) return ''
|
||||
return pb.files.getUrl(m as any, m.file, { thumb: '300x300' }) as string
|
||||
}
|
||||
|
||||
// 图片记忆列表(用于全屏预览)
|
||||
const imageMemories = computed(() => memories.value.filter(m => m.fileType === 'image'))
|
||||
|
||||
function previewImage(index: number) {
|
||||
const urls = imageMemories.value.map(m => fileUrl(m))
|
||||
showImagePreview(urls, index)
|
||||
}
|
||||
|
||||
// 上传
|
||||
const showUpload = ref(false)
|
||||
const uploadFiles = ref<File[]>([])
|
||||
const uploadTitle = ref('')
|
||||
const uploading = ref(false)
|
||||
|
||||
function onFileSelect(items: any) {
|
||||
const arr = Array.isArray(items) ? items : [items]
|
||||
uploadFiles.value = arr.map((it: any) => it.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (uploadFiles.value.length === 0) {
|
||||
showFailToast('请选择文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of uploadFiles.value) {
|
||||
await (await import('@/api/memories')).uploadMemory(props.groupId, file, {
|
||||
title: uploadTitle.value.trim() || file.name
|
||||
})
|
||||
}
|
||||
showSuccessToast(`上传 ${uploadFiles.value.length} 个文件成功`)
|
||||
showUpload.value = false
|
||||
uploadFiles.value = []
|
||||
uploadTitle.value = ''
|
||||
await loadMemories()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(m: Memory) {
|
||||
showConfirmDialog({ title: '删除', message: '确定删除这个回忆吗?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteMemory(m.id)
|
||||
showSuccessToast('已删除')
|
||||
await loadMemories()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function fileTypeIcon(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
image: 'photo-o', video: 'video-o', audio: 'music-o', document: 'description', other: 'description'
|
||||
}
|
||||
return map[type] || 'description'
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-mobile">
|
||||
<div class="list-header">
|
||||
<span class="count-text">{{ memories.length }} 个回忆</span>
|
||||
<van-button type="primary" size="small" round icon="photo-o" @click="showUpload = true">
|
||||
上传
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && memories.length === 0" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else-if="memories.length === 0" class="empty">
|
||||
<van-empty description="还没有回忆" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else class="memory-grid">
|
||||
<div
|
||||
v-for="m in memories"
|
||||
:key="m.id"
|
||||
class="memory-item"
|
||||
>
|
||||
<div class="item-thumb" @click="m.fileType === 'image' ? previewImage(imageMemories.indexOf(m)) : null">
|
||||
<img
|
||||
v-if="m.fileType === 'image' && m.file"
|
||||
:src="thumbUrl(m)"
|
||||
class="thumb-img"
|
||||
alt=""
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<van-icon :name="fileTypeIcon(m.fileType)" size="32" />
|
||||
</div>
|
||||
<van-tag v-if="m.fileType !== 'image'" class="type-tag" size="medium">
|
||||
{{ m.fileType }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-title">{{ m.title }}</div>
|
||||
<div class="item-meta">{{ timeAgo(m.created) }}</div>
|
||||
</div>
|
||||
<van-icon name="delete-o" class="delete-icon" @click="handleDelete(m)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传弹层 -->
|
||||
<van-popup v-model:show="showUpload" position="bottom" round closeable :style="{ height: '60%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">上传回忆</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="uploadTitle" label="标题" placeholder="可选" />
|
||||
</van-cell-group>
|
||||
<div class="upload-area">
|
||||
<van-uploader
|
||||
multiple
|
||||
:after-read="onFileSelect"
|
||||
:max-count="9"
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
</div>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="uploading" @click="handleUpload">
|
||||
上传 {{ uploadFiles.length > 0 ? `(${uploadFiles.length})` : '' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-mobile { padding: 12px; }
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
.empty { padding: 30px 0; }
|
||||
|
||||
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.count-text { font-size: 13px; color: var(--gg-text-muted); }
|
||||
|
||||
.memory-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.memory-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); overflow: hidden; box-shadow: var(--gg-shadow); position: relative; }
|
||||
|
||||
.item-thumb { width: 100%; aspect-ratio: 1; background: var(--gg-bg-elevated); position: relative; }
|
||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.thumb-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--gg-text-muted); }
|
||||
.type-tag { position: absolute; top: 6px; right: 6px; }
|
||||
|
||||
.item-info { padding: 8px 10px; }
|
||||
.item-title { font-size: 13px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
|
||||
|
||||
.delete-icon { position: absolute; top: 6px; left: 6px; color: var(--gg-danger); background: rgba(255,255,255,0.8); border-radius: 50%; padding: 2px; font-size: 16px; }
|
||||
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.upload-area { padding: 16px; }
|
||||
.popup-actions { padding: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<!-- src/components-mobile/poll/PollListMobile.vue -->
|
||||
<!-- 手机端投票列表 + 详情 + 创建 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { listPolls, getPoll, getPollOptions, getPollVotes, getUserVote, votePoll, createPoll, settlePoll } from '@/api/polls'
|
||||
import type { Poll, PollOption, PollVote } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const polls = ref<Poll[]>([])
|
||||
const loading = ref(false)
|
||||
const viewingPollId = ref<string | null>(null)
|
||||
|
||||
// 详情数据
|
||||
const currentPoll = ref<Poll | null>(null)
|
||||
const options = ref<PollOption[]>([])
|
||||
const votes = ref<PollVote[]>([])
|
||||
const userVote = ref<PollVote | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
let unsub: (() => void) | null = null
|
||||
|
||||
async function loadPolls() {
|
||||
loading.value = true
|
||||
try {
|
||||
polls.value = await listPolls(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPolls()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsub) { (unsub as () => void)() }
|
||||
})
|
||||
|
||||
async function viewPoll(pollId: string) {
|
||||
viewingPollId.value = pollId
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [poll, opts, allVotes, myVote] = await Promise.all([
|
||||
getPoll(pollId),
|
||||
getPollOptions(pollId),
|
||||
getPollVotes(pollId),
|
||||
getUserVote(pollId)
|
||||
])
|
||||
currentPoll.value = poll
|
||||
options.value = opts
|
||||
votes.value = allVotes
|
||||
userVote.value = myVote
|
||||
} catch (e) {
|
||||
showFailToast('加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
viewingPollId.value = null
|
||||
currentPoll.value = null
|
||||
loadPolls()
|
||||
}
|
||||
|
||||
// 每个选项的票数
|
||||
function optionVoteCount(optionId: string): number {
|
||||
return votes.value.filter(v => v.option === optionId).length
|
||||
}
|
||||
|
||||
function totalVotes(): number {
|
||||
return votes.value.length || 1
|
||||
}
|
||||
|
||||
async function doVote(optionId: string) {
|
||||
if (!currentPoll.value || currentPoll.value.status === 'settled') return
|
||||
if (userVote.value) {
|
||||
showFailToast('你已经投过票了')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await votePoll(currentPoll.value.id, optionId)
|
||||
showSuccessToast('投票成功')
|
||||
await viewPoll(currentPoll.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '投票失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doSettle() {
|
||||
if (!currentPoll.value) return
|
||||
showConfirmDialog({
|
||||
title: '结算投票',
|
||||
message: '结算后将结束投票,确定吗?'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await settlePoll(currentPoll.value!.id)
|
||||
showSuccessToast('已结算')
|
||||
await viewPoll(currentPoll.value!.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '结算失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 创建投票
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({
|
||||
title: '',
|
||||
type: 'option' as 'option' | 'rollcall',
|
||||
anonymous: false,
|
||||
deadline: '',
|
||||
optionsText: ''
|
||||
})
|
||||
const createLoading = ref(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.title.trim()) {
|
||||
showFailToast('请输入标题')
|
||||
return
|
||||
}
|
||||
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
if (opts.length < 2) {
|
||||
showFailToast('至少输入 2 个选项(每行一个)')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createPoll({
|
||||
group: props.groupId,
|
||||
title: createForm.value.title.trim(),
|
||||
type: createForm.value.type,
|
||||
anonymous: createForm.value.anonymous,
|
||||
deadline: createForm.value.deadline || undefined,
|
||||
options: opts
|
||||
})
|
||||
showSuccessToast('创建成功')
|
||||
showCreate.value = false
|
||||
createForm.value = { title: '', type: 'option', anonymous: false, deadline: '', optionsText: '' }
|
||||
await loadPolls()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '创建失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poll-mobile">
|
||||
<!-- 详情视图 -->
|
||||
<div v-if="viewingPollId && currentPoll" class="poll-detail">
|
||||
<van-nav-bar
|
||||
:title="currentPoll.title"
|
||||
left-arrow
|
||||
@click-left="backToList"
|
||||
/>
|
||||
|
||||
<div v-if="detailLoading" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-content">
|
||||
<div class="detail-header">
|
||||
<van-tag :type="currentPoll.status === 'settled' ? 'default' : 'success'">
|
||||
{{ currentPoll.status === 'settled' ? '已结算' : '进行中' }}
|
||||
</van-tag>
|
||||
<span class="detail-meta">{{ votes.length }} 人参与 · {{ timeAgo(currentPoll.created) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="option-item"
|
||||
:class="{
|
||||
'option-voted': userVote?.option === opt.id,
|
||||
'option-disabled': currentPoll.status === 'settled' || !!userVote
|
||||
}"
|
||||
@click="doVote(opt.id)"
|
||||
>
|
||||
<div class="option-bar">
|
||||
<div
|
||||
class="option-bar-fill"
|
||||
:style="{ width: `${(optionVoteCount(opt.id) / totalVotes()) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
<span class="option-count">{{ optionVoteCount(opt.id) }} 票</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPoll.status === 'active'" class="detail-actions">
|
||||
<van-button
|
||||
type="warning"
|
||||
block
|
||||
round
|
||||
plain
|
||||
@click="doSettle"
|
||||
>
|
||||
结算投票
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<template v-else>
|
||||
<div class="list-header">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
|
||||
发起投票
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="loading" @refresh="loadPolls">
|
||||
<div v-if="polls.length === 0 && !loading" class="empty">
|
||||
<van-empty description="暂无投票" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div class="poll-list">
|
||||
<div
|
||||
v-for="poll in polls"
|
||||
:key="poll.id"
|
||||
class="poll-card"
|
||||
@click="viewPoll(poll.id)"
|
||||
>
|
||||
<div class="poll-card-header">
|
||||
<van-tag :type="poll.status === 'settled' ? 'default' : 'success'" size="medium">
|
||||
{{ poll.status === 'settled' ? '已结算' : '进行中' }}
|
||||
</van-tag>
|
||||
<span class="poll-time">{{ timeAgo(poll.created) }}</span>
|
||||
</div>
|
||||
<div class="poll-title">{{ poll.title }}</div>
|
||||
<div class="poll-card-footer">
|
||||
<span class="poll-type">{{ poll.type === 'rollcall' ? '点名' : '选项投票' }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
</template>
|
||||
|
||||
<!-- 创建弹层 -->
|
||||
<van-popup
|
||||
v-model:show="showCreate"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '80%' }"
|
||||
>
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起投票</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="createForm.title" label="标题" placeholder="投票主题" required />
|
||||
<van-field name="radio" label="类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="createForm.type" direction="horizontal">
|
||||
<van-radio name="option">选项投票</van-radio>
|
||||
<van-radio name="rollcall">点名</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="createForm.optionsText"
|
||||
type="textarea"
|
||||
label="选项"
|
||||
placeholder="每行输入一个选项"
|
||||
rows="4"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.deadline"
|
||||
label="截止时间"
|
||||
placeholder="留空则不截止"
|
||||
/>
|
||||
<van-cell title="匿名投票" center>
|
||||
<template #right-icon>
|
||||
<van-switch v-model="createForm.anonymous" size="22px" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
|
||||
创建
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poll-mobile {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.poll-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.poll-card {
|
||||
background: var(--gg-bg-card);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 14px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.poll-card:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.poll-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.poll-time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.poll-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.poll-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 详情 */
|
||||
.detail-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
position: relative;
|
||||
background: var(--gg-bg-card);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.option-voted {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.option-bar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.option-bar-fill {
|
||||
height: 100%;
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.option-disabled {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 弹层 */
|
||||
.popup-content {
|
||||
padding: 16px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- src/components-mobile/stats/StatsPanelMobile.vue -->
|
||||
<!-- 手机端统计简化版:积分排行 + 群组数据 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getGroupMemberRanking } from '@/api/points'
|
||||
import { getGroupGames } from '@/api/games'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const ranking = ref<{ userId: string; points: number; name?: string }[]>([])
|
||||
const gameCount = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rank, games] = await Promise.all([
|
||||
getGroupMemberRanking(props.groupId, 20),
|
||||
getGroupGames(props.groupId, { limit: 1 })
|
||||
])
|
||||
ranking.value = rank
|
||||
gameCount.value = games.total
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 排名样式
|
||||
function rankColor(index: number): string {
|
||||
if (index === 0) return '#f59e0b'
|
||||
if (index === 1) return '#94a3b8'
|
||||
if (index === 2) return '#d97706'
|
||||
return 'var(--gg-text-muted)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-mobile">
|
||||
<div v-if="loading" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-card">
|
||||
<div class="overview-item">
|
||||
<div class="overview-num">{{ members.length }}</div>
|
||||
<div class="overview-label">成员</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num">{{ gameCount }}</div>
|
||||
<div class="overview-label">游戏</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num">{{ group?.maxMembers || '-' }}</div>
|
||||
<div class="overview-label">上限</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 积分排行 -->
|
||||
<div class="section">
|
||||
<div class="section-title">积分排行</div>
|
||||
<div v-if="ranking.length === 0" class="empty-row">暂无数据</div>
|
||||
<div class="rank-list">
|
||||
<div
|
||||
v-for="(item, idx) in ranking"
|
||||
:key="item.userId"
|
||||
class="rank-item"
|
||||
>
|
||||
<div class="rank-num" :style="{ color: rankColor(idx) }">{{ idx + 1 }}</div>
|
||||
<div class="rank-info">
|
||||
<div class="rank-name">{{ item.name || '玩家' }}</div>
|
||||
</div>
|
||||
<div class="rank-points">{{ item.points }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-mobile { padding: 12px; }
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
|
||||
.overview-card { display: flex; background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 20px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
|
||||
.overview-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.overview-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
|
||||
.overview-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 10px; }
|
||||
.empty-row { text-align: center; padding: 20px; color: var(--gg-text-muted); font-size: 13px; }
|
||||
|
||||
.rank-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rank-item { display: flex; align-items: center; gap: 12px; background: var(--gg-bg-card); padding: 12px 14px; border-radius: var(--gg-radius-sm); box-shadow: var(--gg-shadow); }
|
||||
.rank-num { font-size: 18px; font-weight: 700; width: 28px; text-align: center; }
|
||||
.rank-info { flex: 1; min-width: 0; }
|
||||
.rank-name { font-size: 14px; color: var(--gg-text); }
|
||||
.rank-points { font-size: 15px; font-weight: 600; color: var(--gg-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Edit, Delete, Box, Monitor, SetUp, Headset } from '@element-plus/icons-vue'
|
||||
import { AssetTypeMap, displayName } from '@/types'
|
||||
import { getAssetImageUrl } from '@/api/assets'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
asset: Asset
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [assetId: string]
|
||||
transfer: [assetId: string]
|
||||
delete: [assetId: string]
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 是否有图片
|
||||
const hasImage = computed(() => !!props.asset.image)
|
||||
|
||||
// 图片 URL(缩略图)
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.asset.image) return ''
|
||||
return getAssetImageUrl(props.asset.id, props.asset.image, '200x200')
|
||||
})
|
||||
|
||||
// 类型标签
|
||||
const typeLabel = computed(() => {
|
||||
return AssetTypeMap[props.asset.type as AssetType] || '其他'
|
||||
})
|
||||
|
||||
// 当前持有者信息
|
||||
const holder = computed(() => {
|
||||
return props.asset.expand?.currentHolder
|
||||
})
|
||||
|
||||
const holderName = computed(() => {
|
||||
if (!props.asset.currentHolder) return '在库'
|
||||
return displayName(holder.value)
|
||||
})
|
||||
|
||||
const isInStock = computed(() => !props.asset.currentHolder)
|
||||
|
||||
// 类型图标
|
||||
const typeIcon = computed(() => {
|
||||
switch (props.asset.type) {
|
||||
case 'game_account': return Monitor
|
||||
case 'console': return SetUp
|
||||
case 'equipment': return Monitor
|
||||
case 'accessory': return Headset
|
||||
default: return Box
|
||||
}
|
||||
})
|
||||
|
||||
// 当前用户是否为创建者或群主
|
||||
const canManage = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
if (props.asset.creator === userId) return true
|
||||
if (groupStore.currentGroup?.owner === userId) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 点击卡片
|
||||
function handleCardClick() {
|
||||
emit('transfer', props.asset.id)
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('edit', props.asset.id)
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(e: Event) {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个资产吗?', '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await assetStore.removeAsset(props.asset.id)
|
||||
emit('delete', props.asset.id)
|
||||
ElMessage.success('删除成功')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-card" @click="handleCardClick">
|
||||
<!-- 图片区域 -->
|
||||
<div class="asset-card__image">
|
||||
<img
|
||||
v-if="hasImage"
|
||||
:src="imageUrl"
|
||||
:alt="asset.name"
|
||||
class="asset-card__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="asset-card__placeholder">
|
||||
<el-icon :size="32"><component :is="typeIcon" /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮(悬停显示) -->
|
||||
<div v-if="canManage" class="asset-card__actions">
|
||||
<button class="asset-card__action-btn" title="编辑" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</button>
|
||||
<button class="asset-card__action-btn asset-card__action-btn--danger" title="删除" @click="handleDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="asset-card__info">
|
||||
<!-- 名称 -->
|
||||
<div class="asset-card__name" :title="asset.name">{{ asset.name }}</div>
|
||||
|
||||
<!-- 类型标签 -->
|
||||
<div class="asset-card__meta">
|
||||
<span class="asset-card__type-tag">{{ typeLabel }}</span>
|
||||
<span v-if="isInStock" class="asset-card__stock-tag">在库</span>
|
||||
</div>
|
||||
|
||||
<!-- 持有者 -->
|
||||
<div class="asset-card__holder">
|
||||
<template v-if="holder">
|
||||
<div class="asset-card__avatar">
|
||||
{{ holderName.charAt(0) }}
|
||||
</div>
|
||||
<span class="asset-card__holder-name">{{ holderName }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="asset-card__stock-text">在库</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.asset-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 2px 16px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 图片区域 */
|
||||
.asset-card__image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: var(--gg-bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-card__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-card__placeholder {
|
||||
color: var(--gg-text-hint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.asset-card__actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.asset-card:hover .asset-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asset-card__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.asset-card__action-btn:hover {
|
||||
background: var(--gg-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.asset-card__action-btn--danger:hover {
|
||||
background: var(--gg-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 信息区域 */
|
||||
.asset-card__info {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.asset-card__type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.asset-card__stock-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
/* 持有者 */
|
||||
.asset-card__holder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.asset-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-card__holder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-card__stock-text {
|
||||
color: var(--gg-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
import AssetCard from './AssetCard.vue'
|
||||
import CreateAssetDialog from './CreateAssetDialog.vue'
|
||||
import TransferAssetDialog from './TransferAssetDialog.vue'
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 类型过滤
|
||||
const typeFilter = ref<string>('')
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = computed(() => {
|
||||
return [
|
||||
{ value: '', label: '全部' },
|
||||
...(Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// 过滤后的资产列表
|
||||
const filteredAssets = computed(() => {
|
||||
if (!typeFilter.value) return assetStore.assets
|
||||
return assetStore.assets.filter(a => a.type === typeFilter.value)
|
||||
})
|
||||
|
||||
// 对话框状态
|
||||
const showCreateDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
|
||||
// 编辑/转移目标
|
||||
const editingAsset = ref<Asset | undefined>(undefined)
|
||||
const transferringAsset = ref<Asset | null>(null)
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.loadAssets(groupId)
|
||||
}
|
||||
|
||||
// 订阅变更
|
||||
async function setupSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.startSubscription(groupId)
|
||||
}
|
||||
|
||||
// 登记资产
|
||||
function handleCreate() {
|
||||
editingAsset.value = undefined
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑资产
|
||||
function handleEdit(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
editingAsset.value = asset
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 转移资产
|
||||
function handleTransfer(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
transferringAsset.value = asset
|
||||
showTransferDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 资产删除后刷新
|
||||
function handleDelete() {
|
||||
// store 已经从列表中移除了,无需额外操作
|
||||
}
|
||||
|
||||
// 保存成功回调
|
||||
function handleSaved() {
|
||||
showCreateDialog.value = false
|
||||
editingAsset.value = undefined
|
||||
}
|
||||
|
||||
// 转移成功回调
|
||||
function handleTransferred() {
|
||||
showTransferDialog.value = false
|
||||
transferringAsset.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupStore.currentGroupId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
assetStore.stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="asset-list__header">
|
||||
<h3 class="asset-list__title">资产库</h3>
|
||||
<button class="asset-list__create-btn" @click="handleCreate">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="asset-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
登记资产
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型过滤 -->
|
||||
<div class="asset-list__filter">
|
||||
<el-select
|
||||
v-model="typeFilter"
|
||||
placeholder="筛选类型"
|
||||
size="default"
|
||||
style="width: 160px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="asset-list__count">共 {{ filteredAssets.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="assetStore.loading" v-loading="true" class="asset-list__loading"></div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="filteredAssets.length === 0"
|
||||
class="asset-list__empty"
|
||||
>
|
||||
<svg class="asset-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
<p class="asset-list__empty-text">暂无资产</p>
|
||||
<p class="asset-list__empty-hint">点击「登记资产」添加第一个资产</p>
|
||||
</div>
|
||||
|
||||
<!-- 资产网格 -->
|
||||
<div v-else class="asset-list__grid">
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
@edit="handleEdit"
|
||||
@transfer="handleTransfer"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑弹窗 -->
|
||||
<CreateAssetDialog
|
||||
v-model="showCreateDialog"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
:edit-asset="editingAsset"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<!-- 转移弹窗 -->
|
||||
<TransferAssetDialog
|
||||
v-model="showTransferDialog"
|
||||
:asset="transferringAsset"
|
||||
:members="groupStore.currentMembers"
|
||||
@transferred="handleTransferred"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.asset-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.asset-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.asset-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.asset-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 过滤栏 */
|
||||
.asset-list__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-list__count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.asset-list__loading {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.asset-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.asset-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.asset-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.asset-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.asset-list__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: string
|
||||
editAsset?: Asset
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
|
||||
const isEditing = computed(() => !!props.editAsset)
|
||||
|
||||
const dialogTitle = computed(() => isEditing.value ? '编辑资产' : '登记资产')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
type: 'other' as AssetType,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const selectedImage = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 资产类型选项
|
||||
const typeOptions = computed(() => {
|
||||
return (Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}))
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
type: 'other',
|
||||
description: '',
|
||||
}
|
||||
fileList.value = []
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
// 初始化编辑表单
|
||||
function initForm() {
|
||||
resetForm()
|
||||
if (props.editAsset) {
|
||||
form.value.name = props.editAsset.name
|
||||
form.value.type = props.editAsset.type
|
||||
form.value.description = props.editAsset.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 文件选择变更
|
||||
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
|
||||
fileList.value = uploadFileList
|
||||
if (_file.raw) {
|
||||
selectedImage.value = _file.raw
|
||||
}
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function handleFileRemove() {
|
||||
fileList.value = []
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
// 超出限制
|
||||
function handleExceed() {
|
||||
ElMessage.warning('只能上传一张图片,请先移除已选图片')
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入资产名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEditing.value && props.editAsset) {
|
||||
await assetStore.editAsset(
|
||||
props.editAsset.id,
|
||||
{
|
||||
name: form.value.name.trim(),
|
||||
type: form.value.type,
|
||||
description: form.value.description.trim() || undefined,
|
||||
},
|
||||
selectedImage.value || undefined
|
||||
)
|
||||
ElMessage.success('资产更新成功')
|
||||
} else {
|
||||
await assetStore.addAsset({
|
||||
group: props.groupId,
|
||||
name: form.value.name.trim(),
|
||||
type: form.value.type,
|
||||
description: form.value.description.trim() || undefined,
|
||||
image: selectedImage.value || undefined,
|
||||
})
|
||||
ElMessage.success('资产登记成功')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || (isEditing.value ? '更新资产失败' : '登记资产失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开时初始化
|
||||
function handleOpen() {
|
||||
initForm()
|
||||
}
|
||||
|
||||
// 关闭时重置
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 名称 -->
|
||||
<div class="form-field">
|
||||
<label>名称 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入资产名称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 类型 -->
|
||||
<div class="form-field">
|
||||
<label>类型</label>
|
||||
<el-select v-model="form.type" placeholder="选择资产类型" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="简单描述一下(可选)"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div class="form-field">
|
||||
<label>图片</label>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="fileList"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:on-exceed="handleExceed"
|
||||
accept="image/*"
|
||||
list-type="picture"
|
||||
>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div>点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '登记') }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { displayName } from '@/types'
|
||||
import type { Asset, User } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
asset: Asset | null
|
||||
members: User[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
transferred: []
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 资产名称
|
||||
const assetName = computed(() => props.asset?.name || '')
|
||||
|
||||
// 当前持有者
|
||||
const currentHolderId = computed(() => props.asset?.currentHolder || '')
|
||||
|
||||
// 成员列表(排除当前持有者)
|
||||
const availableMembers = computed(() => {
|
||||
if (!props.asset) return []
|
||||
return props.members.filter(m => m.id !== currentHolderId.value)
|
||||
})
|
||||
|
||||
// 重置
|
||||
function resetForm() {
|
||||
selectedUserId.value = null
|
||||
}
|
||||
|
||||
// 选择"放回在库"
|
||||
function selectInStock() {
|
||||
selectedUserId.value = null
|
||||
}
|
||||
|
||||
// 选择成员
|
||||
function selectMember(userId: string) {
|
||||
selectedUserId.value = userId
|
||||
}
|
||||
|
||||
// 提交转移
|
||||
async function handleSubmit() {
|
||||
if (!props.asset) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// null 表示放回在库,传空字符串让 PocketBase 清空
|
||||
const targetUserId = selectedUserId.value || ''
|
||||
await assetStore.transfer(props.asset.id, targetUserId)
|
||||
|
||||
visible.value = false
|
||||
ElMessage.success(selectedUserId.value ? '资产已转移' : '资产已放回在库')
|
||||
emit('transferred')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '转移资产失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开时重置
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="转移资产"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div v-if="asset" class="transfer-form">
|
||||
<!-- 资产名称 -->
|
||||
<div class="transfer-asset-name">
|
||||
<span class="transfer-label">资产</span>
|
||||
<span class="transfer-value">{{ assetName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 放回在库 -->
|
||||
<div class="member-selection">
|
||||
<div class="section-label">选择新持有者</div>
|
||||
|
||||
<div
|
||||
class="member-item member-item--stock"
|
||||
:class="{ 'member-item--selected': selectedUserId === null }"
|
||||
@click="selectInStock"
|
||||
>
|
||||
<div class="member-item__check">
|
||||
<span v-if="selectedUserId === null" class="check-dot"></span>
|
||||
</div>
|
||||
<div class="member-item__avatar member-item__avatar--stock">
|
||||
库
|
||||
</div>
|
||||
<span class="member-item__name">放回在库</span>
|
||||
<span class="member-item__tag">在库</span>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div
|
||||
v-for="member in availableMembers"
|
||||
:key="member.id"
|
||||
class="member-item"
|
||||
:class="{ 'member-item--selected': selectedUserId === member.id }"
|
||||
@click="selectMember(member.id)"
|
||||
>
|
||||
<div class="member-item__check">
|
||||
<span v-if="selectedUserId === member.id" class="check-dot"></span>
|
||||
</div>
|
||||
<div class="member-item__avatar">
|
||||
{{ displayName(member).charAt(0) }}
|
||||
</div>
|
||||
<span class="member-item__name">{{ displayName(member) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 无可用成员 -->
|
||||
<div v-if="availableMembers.length === 0 && currentHolderId" class="no-members">
|
||||
没有其他群成员可以转移
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '转移中...' : '确认转移' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.transfer-asset-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--gg-bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.transfer-label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transfer-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
background: rgba(5, 150, 105, 0.04);
|
||||
}
|
||||
|
||||
.member-item--selected {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.member-item__check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--gg-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.member-item--selected .member-item__check {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.check-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
}
|
||||
|
||||
.member-item__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-item__avatar--stock {
|
||||
background: var(--gg-success);
|
||||
}
|
||||
|
||||
.member-item__name {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-item__tag {
|
||||
font-size: 11px;
|
||||
color: var(--gg-success);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-members {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { displayName } from '@/types'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
bet: Bet
|
||||
options: BetOption[]
|
||||
entries: BetEntry[]
|
||||
currentUserEntry: BetEntry | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// 状态标签
|
||||
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||
open: { label: '进行中', cls: 'active' },
|
||||
closed: { label: '已关闭', cls: 'closed' },
|
||||
settled: { label: '已开奖', cls: 'settled' },
|
||||
}
|
||||
|
||||
const statusInfo = computed(() => statusMap[props.bet.status] || statusMap.open)
|
||||
|
||||
// 发起人名称
|
||||
const creatorName = computed(() => displayName(props.bet.expand?.creator))
|
||||
|
||||
// 奖池总额
|
||||
const totalPool = computed(() => props.entries.reduce((sum, e) => sum + e.stake, 0))
|
||||
|
||||
// 各选项的下注总额
|
||||
const optionStakes = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const e of props.entries) {
|
||||
map[e.option] = (map[e.option] || 0) + e.stake
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 截止时间倒计时
|
||||
const deadlineText = computed(() => {
|
||||
if (!props.bet.deadline) return null
|
||||
const deadline = new Date(props.bet.deadline).getTime()
|
||||
const now = Date.now()
|
||||
const diff = deadline - now
|
||||
|
||||
if (diff <= 0) return '已截止'
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天后截止`
|
||||
if (hours > 0) return `${hours}小时后截止`
|
||||
if (minutes > 0) return `${minutes}分钟后截止`
|
||||
return '即将截止'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-card" @click="emit('click')">
|
||||
<!-- 顶部标签行 -->
|
||||
<div class="bet-card__tags">
|
||||
<span class="bet-card__status-tag" :class="`bet-card__status-tag--${statusInfo.cls}`">
|
||||
{{ statusInfo.label }}
|
||||
</span>
|
||||
<span v-if="currentUserEntry" class="bet-card__voted-badge">
|
||||
已下注
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="bet-card__title">{{ bet.title }}</div>
|
||||
|
||||
<!-- 发起人 -->
|
||||
<div class="bet-card__creator">
|
||||
<svg class="bet-card__creator-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{{ creatorName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 奖池总额 -->
|
||||
<div class="bet-card__pool">
|
||||
<svg class="bet-card__pool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<span class="bet-card__pool-label">奖池</span>
|
||||
<span class="bet-card__pool-value">{{ totalPool }} 积分</span>
|
||||
</div>
|
||||
|
||||
<!-- 各选项进度条 -->
|
||||
<div v-if="options.length > 0 && totalPool > 0" class="bet-card__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="bet-card__option"
|
||||
>
|
||||
<div class="bet-card__option-header">
|
||||
<span class="bet-card__option-name">{{ option.content }}</span>
|
||||
<span class="bet-card__option-pct">
|
||||
{{ totalPool > 0 ? Math.round((optionStakes[option.id] || 0) / totalPool * 100) : 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="bet-card__option-bar">
|
||||
<div
|
||||
class="bet-card__option-fill"
|
||||
:style="{
|
||||
width: totalPool > 0
|
||||
? ((optionStakes[option.id] || 0) / totalPool * 100) + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:截止时间 -->
|
||||
<div v-if="deadlineText" class="bet-card__footer">
|
||||
<svg class="bet-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span :class="{ 'bet-card__deadline--urgent': deadlineText === '即将截止' }">
|
||||
{{ deadlineText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.bet-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.bet-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bet-card__status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.bet-card__status-tag--active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-card__status-tag--closed {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__status-tag--settled {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.bet-card__voted-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.bet-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 发起人 */
|
||||
.bet-card__creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-card__creator-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 奖池 */
|
||||
.bet-card__pool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bet-card__pool-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--gg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__pool-label {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__pool-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 选项进度 */
|
||||
.bet-card__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-card__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.bet-card__option-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bet-card__option-name {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bet-card__option-pct {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__option-bar {
|
||||
height: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bet-card__option-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bet-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__footer-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__deadline--urgent {
|
||||
color: var(--gg-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,650 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import { getBet, getBetOptions, getBetEntries, placeBet, closeBet, subscribeBets } from '@/api/bets'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
import BetEntryList from './BetEntryList.vue'
|
||||
import SettleBetDialog from './SettleBetDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
betId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
// 数据
|
||||
const bet = ref<Bet | null>(null)
|
||||
const options = ref<BetOption[]>([])
|
||||
const entries = ref<BetEntry[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 下注表单
|
||||
const selectedOption = ref('')
|
||||
const stakeAmount = ref(1)
|
||||
|
||||
// 结算弹窗
|
||||
const showSettle = ref(false)
|
||||
|
||||
// 实时订阅
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 计算属性
|
||||
const creatorName = computed(() => displayName(bet.value?.expand?.creator))
|
||||
|
||||
const totalPool = computed(() => entries.value.reduce((sum, e) => sum + e.stake, 0))
|
||||
|
||||
// 各选项的下注总额和人数
|
||||
const optionStats = computed(() => {
|
||||
const stats: Record<string, { totalStake: number; count: number }> = {}
|
||||
for (const opt of options.value) {
|
||||
stats[opt.id] = { totalStake: 0, count: 0 }
|
||||
}
|
||||
for (const e of entries.value) {
|
||||
if (!stats[e.option]) {
|
||||
stats[e.option] = { totalStake: 0, count: 0 }
|
||||
}
|
||||
stats[e.option].totalStake += e.stake
|
||||
stats[e.option].count += 1
|
||||
}
|
||||
return stats
|
||||
})
|
||||
|
||||
// 当前用户
|
||||
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||
const isCreator = computed(() => bet.value?.creator === currentUserId.value)
|
||||
|
||||
// 当前用户的下注记录
|
||||
const currentUserEntry = computed(() =>
|
||||
entries.value.find((e) => e.user === currentUserId.value) || null
|
||||
)
|
||||
|
||||
// 状态
|
||||
const isOpen = computed(() => bet.value?.status === 'open')
|
||||
const isClosed = computed(() => bet.value?.status === 'closed')
|
||||
const isSettled = computed(() => bet.value?.status === 'settled')
|
||||
|
||||
// 结果选项ID
|
||||
const resultOptionId = computed(() => bet.value?.resultOption || '')
|
||||
|
||||
// 加载数据
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [betData, optionsData, entriesData] = await Promise.all([
|
||||
getBet(props.betId),
|
||||
getBetOptions(props.betId),
|
||||
getBetEntries(props.betId),
|
||||
])
|
||||
bet.value = betData
|
||||
options.value = optionsData
|
||||
entries.value = entriesData
|
||||
} catch (error) {
|
||||
console.error('加载竞猜详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下注
|
||||
async function handlePlaceBet() {
|
||||
if (!selectedOption.value) {
|
||||
ElMessage.warning('请选择一个选项')
|
||||
return
|
||||
}
|
||||
if (!stakeAmount.value || stakeAmount.value < 1) {
|
||||
ElMessage.warning('请输入有效的积分数')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await placeBet(props.betId, selectedOption.value, stakeAmount.value)
|
||||
ElMessage.success('下注成功')
|
||||
await loadDetail()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '下注失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭竞猜
|
||||
async function handleClose() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要关闭竞猜吗?关闭后将不能再下注。', '关闭竞猜', {
|
||||
confirmButtonText: '确定关闭',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await closeBet(props.betId)
|
||||
ElMessage.success('竞猜已关闭')
|
||||
await loadDetail()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
if (!bet.value) return
|
||||
const groupId = bet.value.group
|
||||
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||
loadDetail()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail()
|
||||
startSubscription()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-detail">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bet-detail__header">
|
||||
<el-button text @click="emit('back')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<div class="bet-detail__actions">
|
||||
<button
|
||||
v-if="isCreator && isOpen"
|
||||
class="action-btn action-btn--close"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭竞猜
|
||||
</button>
|
||||
<button
|
||||
v-if="isCreator && isClosed"
|
||||
class="action-btn action-btn--settle"
|
||||
@click="showSettle = true"
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="bet-detail__loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 竞猜内容 -->
|
||||
<template v-else-if="bet">
|
||||
<!-- 标题区域 -->
|
||||
<div class="bet-detail__title-area">
|
||||
<h2 class="bet-detail__title">{{ bet.title }}</h2>
|
||||
<div class="bet-detail__meta">
|
||||
<span class="bet-detail__meta-item">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{{ creatorName }} 发起
|
||||
</span>
|
||||
<span class="bet-detail__meta-item">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
奖池 {{ totalPool }} 积分
|
||||
</span>
|
||||
<span class="bet-detail__meta-item">
|
||||
下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div v-if="bet.description" class="bet-detail__desc">
|
||||
{{ bet.description }}
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="bet-detail__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:class="[
|
||||
'bet-detail__option',
|
||||
{
|
||||
'bet-detail__option--winner': isSettled && resultOptionId === option.id,
|
||||
'bet-detail__option--loser': isSettled && resultOptionId !== option.id,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="bet-detail__option-header">
|
||||
<span class="bet-detail__option-name">
|
||||
{{ option.content }}
|
||||
<span v-if="isSettled && resultOptionId === option.id" class="winner-badge">
|
||||
正确答案
|
||||
</span>
|
||||
</span>
|
||||
<div class="bet-detail__option-stats">
|
||||
<span class="bet-detail__option-count">
|
||||
{{ (optionStats[option.id]?.count || 0) }} 人
|
||||
</span>
|
||||
<span class="bet-detail__option-stake">
|
||||
{{ optionStats[option.id]?.totalStake || 0 }} 积分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bet-detail__option-bar">
|
||||
<div
|
||||
class="bet-detail__option-fill"
|
||||
:class="{ 'bet-detail__option-fill--winner': isSettled && resultOptionId === option.id }"
|
||||
:style="{
|
||||
width: totalPool > 0
|
||||
? ((optionStats[option.id]?.totalStake || 0) / totalPool * 100) + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下注区域(状态 open 且未下注时) -->
|
||||
<div v-if="isOpen && !currentUserEntry" class="bet-detail__bet-area">
|
||||
<h4 class="bet-detail__bet-title">下注</h4>
|
||||
<div class="bet-detail__bet-form">
|
||||
<div class="bet-detail__bet-option">
|
||||
<label>选择选项</label>
|
||||
<div class="bet-detail__option-pick">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
:class="['bet-detail__pick-card', { 'bet-detail__pick-card--active': selectedOption === option.id }]"
|
||||
@click="selectedOption = option.id"
|
||||
>
|
||||
{{ option.content }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bet-detail__bet-stake">
|
||||
<label>下注积分</label>
|
||||
<el-input-number
|
||||
v-model="stakeAmount"
|
||||
:min="bet.minStake"
|
||||
:max="bet.maxStake"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="bet-detail__bet-btn"
|
||||
:disabled="!selectedOption"
|
||||
@click="handlePlaceBet"
|
||||
>
|
||||
下注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已下注提示 -->
|
||||
<div v-if="isOpen && currentUserEntry" class="bet-detail__already-bet">
|
||||
<svg class="bet-detail__already-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>
|
||||
你已下注 <strong>{{ currentUserEntry.stake }}</strong> 积分,选择「{{ currentUserEntry.expand?.option?.content || '未知' }}」
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下注记录 -->
|
||||
<div class="bet-detail__entries">
|
||||
<h4 class="bet-detail__entries-title">下注记录</h4>
|
||||
<BetEntryList :entries="entries" :show-result="isSettled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 开奖弹窗 -->
|
||||
<SettleBetDialog
|
||||
v-if="bet"
|
||||
v-model="showSettle"
|
||||
:bet-id="betId"
|
||||
:options="options"
|
||||
@settled="loadDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bet-detail__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bet-detail__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.action-btn--close {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.action-btn--close:hover {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.action-btn--settle {
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn--settle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.bet-detail__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 0;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.bet-detail__title-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bet-detail__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.bet-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.bet-detail__desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 10px 14px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
}
|
||||
|
||||
/* 选项列表 */
|
||||
.bet-detail__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bet-detail__option {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bet-detail__option--winner {
|
||||
border-color: var(--gg-success);
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.bet-detail__option--loser {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bet-detail__option-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-detail__option-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.winner-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-detail__option-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-detail__option-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__option-stake {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__option-bar {
|
||||
height: 6px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bet-detail__option-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bet-detail__option-fill--winner {
|
||||
background: var(--gg-success);
|
||||
}
|
||||
|
||||
/* 下注区域 */
|
||||
.bet-detail__bet-area {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg-card);
|
||||
}
|
||||
|
||||
.bet-detail__bet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.bet-detail__bet-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__bet-option,
|
||||
.bet-detail__bet-stake {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bet-detail__bet-option label,
|
||||
.bet-detail__bet-stake label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__option-pick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__pick-card {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card);
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.bet-detail__pick-card:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__pick-card--active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
color: var(--gg-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn {
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 已下注提示 */
|
||||
.bet-detail__already-bet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__already-bet strong {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__already-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--gg-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 下注记录 */
|
||||
.bet-detail__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bet-detail__entries-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bet-detail__bet-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@/types'
|
||||
import type { BetEntry } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
entries: BetEntry[]
|
||||
showResult?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-list">
|
||||
<div v-if="entries.length === 0" class="entry-list__empty">
|
||||
暂无下注记录
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="entry-list__item"
|
||||
>
|
||||
<!-- 用户信息 -->
|
||||
<div class="entry-list__user">
|
||||
<div class="entry-list__avatar">
|
||||
{{ displayName(entry.expand?.user).charAt(0) }}
|
||||
</div>
|
||||
<span class="entry-list__name">{{ displayName(entry.expand?.user) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 选择 & 积分 -->
|
||||
<div class="entry-list__info">
|
||||
<span class="entry-list__option">
|
||||
{{ entry.expand?.option?.content || '未知选项' }}
|
||||
</span>
|
||||
<span class="entry-list__stake">{{ entry.stake }} 积分</span>
|
||||
</div>
|
||||
|
||||
<!-- 结果标签 -->
|
||||
<div v-if="showResult" class="entry-list__result">
|
||||
<span v-if="entry.won" class="entry-list__badge entry-list__badge--win">赢</span>
|
||||
<span v-else class="entry-list__badge entry-list__badge--lose">输</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-list__empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.entry-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.entry-list__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-list__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__option {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__stake {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__result {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-list__badge--win {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.entry-list__badge--lose {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { listBets, getBetOptions, getBetEntries, subscribeBets } from '@/api/bets'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import BetCard from './BetCard.vue'
|
||||
import CreateBetDialog from './CreateBetDialog.vue'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewBet: [betId: string]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const loading = ref(false)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 竞猜列表
|
||||
const allBets = ref<Bet[]>([])
|
||||
|
||||
// 缓存每个 bet 的 options / entries
|
||||
interface BetExtra {
|
||||
options: BetOption[]
|
||||
entries: BetEntry[]
|
||||
currentUserEntry: BetEntry | null
|
||||
}
|
||||
const betExtras = ref<Record<string, BetExtra>>({})
|
||||
|
||||
// 分栏
|
||||
const activeBets = computed(() =>
|
||||
allBets.value.filter((b) => b.status === 'open' || b.status === 'closed')
|
||||
)
|
||||
const settledBets = computed(() =>
|
||||
allBets.value.filter((b) => b.status === 'settled')
|
||||
)
|
||||
|
||||
// 加载所有竞猜及详情
|
||||
async function loadAll() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const bets = await listBets(groupId)
|
||||
allBets.value = bets
|
||||
|
||||
// 并行加载每个 bet 的 options / entries
|
||||
const currentUserId = pb.authStore.model?.id
|
||||
const results = await Promise.allSettled(
|
||||
bets.map(async (bet) => {
|
||||
const [options, entries] = await Promise.all([
|
||||
getBetOptions(bet.id),
|
||||
getBetEntries(bet.id),
|
||||
])
|
||||
const currentUserEntry = currentUserId
|
||||
? entries.find((e) => e.user === currentUserId) || null
|
||||
: null
|
||||
return { betId: bet.id, options, entries, currentUserEntry }
|
||||
})
|
||||
)
|
||||
|
||||
const extras: Record<string, BetExtra> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
extras[r.value.betId] = {
|
||||
options: r.value.options,
|
||||
entries: r.value.entries,
|
||||
currentUserEntry: r.value.currentUserEntry,
|
||||
}
|
||||
}
|
||||
}
|
||||
betExtras.value = extras
|
||||
} catch (error) {
|
||||
console.error('加载竞猜列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某个 bet 的缓存 extras
|
||||
function getExtra(betId: string): BetExtra {
|
||||
return betExtras.value[betId] || { options: [], entries: [], currentUserEntry: null }
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||
loadAll()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadAll()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadAll()
|
||||
if (!unsubscribeFn) startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bet-list__header">
|
||||
<h3 class="bet-list__title">竞猜</h3>
|
||||
<button class="bet-list__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bet-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
发起竞猜
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateBetDialog
|
||||
v-model="showCreate"
|
||||
@created="loadAll(); showCreate = false"
|
||||
/>
|
||||
|
||||
<!-- 进行中 -->
|
||||
<section v-if="activeBets.length > 0" class="bet-list__section">
|
||||
<div class="bet-list__section-header">
|
||||
<span class="bet-list__section-dot bet-list__section-dot--active"></span>
|
||||
<span class="bet-list__section-label">进行中</span>
|
||||
<span class="bet-list__section-count">{{ activeBets.length }}</span>
|
||||
</div>
|
||||
<div class="bet-list__grid">
|
||||
<BetCard
|
||||
v-for="bet in activeBets"
|
||||
:key="bet.id"
|
||||
:bet="bet"
|
||||
:options="getExtra(bet.id).options"
|
||||
:entries="getExtra(bet.id).entries"
|
||||
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||
@click="emit('viewBet', bet.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 已结束 -->
|
||||
<section v-if="settledBets.length > 0" class="bet-list__section">
|
||||
<div class="bet-list__section-header">
|
||||
<span class="bet-list__section-dot bet-list__section-dot--settled"></span>
|
||||
<span class="bet-list__section-label">已结束</span>
|
||||
<span class="bet-list__section-count">{{ settledBets.length }}</span>
|
||||
</div>
|
||||
<div class="bet-list__grid">
|
||||
<BetCard
|
||||
v-for="bet in settledBets"
|
||||
:key="bet.id"
|
||||
:bet="bet"
|
||||
:options="getExtra(bet.id).options"
|
||||
:entries="getExtra(bet.id).entries"
|
||||
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||
@click="emit('viewBet', bet.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && allBets.length === 0" class="bet-list__loading">
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="activeBets.length === 0 && settledBets.length === 0 && !loading"
|
||||
class="bet-list__empty"
|
||||
>
|
||||
<svg class="bet-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<p class="bet-list__empty-text">暂无竞猜</p>
|
||||
<p class="bet-list__empty-hint">群组内还没有发起过竞猜</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bet-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bet-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bet-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bet-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bet-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 分栏 */
|
||||
.bet-list__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bet-list__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bet-list__section-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bet-list__section-dot--active {
|
||||
background: var(--gg-success);
|
||||
box-shadow: 0 0 6px var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-list__section-dot--settled {
|
||||
background: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-list__section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-list__section-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.bet-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.bet-list__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.bet-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bet-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bet-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.bet-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bet-list__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createBet } from '@/api/bets'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
options: ['', ''],
|
||||
deadline: '' as string | Date,
|
||||
minStake: 1,
|
||||
maxStake: 10,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否可以添加选项
|
||||
const canAddOption = computed(() => form.value.options.length < 10)
|
||||
|
||||
function addOption() {
|
||||
if (canAddOption.value) {
|
||||
form.value.options.push('')
|
||||
}
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
form.value.options.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
options: ['', ''],
|
||||
deadline: '',
|
||||
minStake: 1,
|
||||
maxStake: 10,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// 标题必填
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入竞猜标题')
|
||||
return
|
||||
}
|
||||
|
||||
// 至少2个非空选项
|
||||
const nonEmpty = form.value.options.filter((o) => o.trim())
|
||||
if (nonEmpty.length < 2) {
|
||||
ElMessage.warning('至少需要2个非空选项')
|
||||
return
|
||||
}
|
||||
|
||||
// 截止时间必填
|
||||
if (!form.value.deadline) {
|
||||
ElMessage.warning('请选择截止时间')
|
||||
return
|
||||
}
|
||||
|
||||
// 积分范围校验
|
||||
if (form.value.minStake < 1 || form.value.maxStake < form.value.minStake) {
|
||||
ElMessage.warning('请设置正确的积分范围')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await createBet({
|
||||
group: groupId,
|
||||
title: form.value.title.trim(),
|
||||
description: form.value.description.trim() || undefined,
|
||||
options: nonEmpty,
|
||||
minStake: form.value.minStake,
|
||||
maxStake: form.value.maxStake,
|
||||
deadline: new Date(String(form.value.deadline)).toISOString(),
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
resetForm()
|
||||
ElMessage.success('竞猜创建成功')
|
||||
emit('created')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '创建竞猜失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="发起竞猜"
|
||||
width="520px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="今晚谁 MVP?"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="补充说明(可选)"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选项管理 -->
|
||||
<div class="form-field">
|
||||
<label>竞猜选项</label>
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="(_, index) in form.options"
|
||||
:key="index"
|
||||
class="option-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.options[index]"
|
||||
:placeholder="`选项 ${index + 1}`"
|
||||
maxlength="100"
|
||||
/>
|
||||
<el-button
|
||||
v-if="form.options.length > 2"
|
||||
type="danger"
|
||||
text
|
||||
@click="removeOption(index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canAddOption"
|
||||
class="add-option-btn"
|
||||
@click="addOption"
|
||||
>
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 截止时间 -->
|
||||
<div class="form-field">
|
||||
<label>截止时间 <span class="required">*</span></label>
|
||||
<el-date-picker
|
||||
v-model="form.deadline"
|
||||
type="datetime"
|
||||
placeholder="选择截止时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 积分范围 -->
|
||||
<div class="form-field">
|
||||
<label>下注积分范围</label>
|
||||
<div class="stake-range">
|
||||
<div class="stake-range__item">
|
||||
<span class="stake-range__label">最小</span>
|
||||
<el-input-number
|
||||
v-model="form.minStake"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span class="stake-range__separator">~</span>
|
||||
<div class="stake-range__item">
|
||||
<span class="stake-range__label">最大</span>
|
||||
<el-input-number
|
||||
v-model="form.maxStake"
|
||||
:min="form.minStake"
|
||||
:max="100"
|
||||
:step="1"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? '创建中...' : '发起竞猜' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.add-option-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stake-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stake-range__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stake-range__label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stake-range__separator {
|
||||
font-size: 16px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { settleBet } from '@/api/bets'
|
||||
import type { BetOption } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
betId: string
|
||||
options: BetOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
settled: []
|
||||
}>()
|
||||
|
||||
const selectedOption = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
function handleOpen() {
|
||||
selectedOption.value = ''
|
||||
}
|
||||
|
||||
async function handleSettle() {
|
||||
if (!selectedOption.value) {
|
||||
ElMessage.warning('请选择正确选项')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await settleBet(props.betId, selectedOption.value)
|
||||
visible.value = false
|
||||
ElMessage.success('开奖成功')
|
||||
emit('settled')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '开奖失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="开奖"
|
||||
width="420px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="settle-form">
|
||||
<p class="settle-form__hint">请选择正确的选项来结算竞猜:</p>
|
||||
|
||||
<el-radio-group v-model="selectedOption" class="settle-form__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="settle-form__option"
|
||||
>
|
||||
<el-radio :value="option.id">
|
||||
{{ option.content }}
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button
|
||||
class="settle-form__submit"
|
||||
:disabled="loading || !selectedOption"
|
||||
@click="handleSettle"
|
||||
>
|
||||
{{ loading ? '结算中...' : '确认开奖' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settle-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settle-form__hint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.settle-form__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settle-form__option {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.settle-form__option:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
}
|
||||
|
||||
.settle-form__submit {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.settle-form__submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.settle-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import InvitationCard from '@/components/team/InvitationCard.vue'
|
||||
import JoinRequestCard from '@/components/group/JoinRequestCard.vue'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
store.loadPendingInvitations()
|
||||
store.loadAppNotifications()
|
||||
})
|
||||
|
||||
function onInvitationResponded(id: string, _accepted: boolean) {
|
||||
@@ -17,6 +21,27 @@ function onInvitationResponded(id: string, _accepted: boolean) {
|
||||
function onJoinRequestResponded(id: string) {
|
||||
store.removeJoinRequest(id)
|
||||
}
|
||||
|
||||
async function handleNotificationClick(n: { id: string; read: boolean; relatedType?: string; relatedId?: string }) {
|
||||
if (!n.read) store.markRead(n.id)
|
||||
|
||||
if (n.relatedType === 'poll' && n.relatedId) {
|
||||
try {
|
||||
const poll = await pb.collection('polls').getOne(n.relatedId, { $autoCancel: false })
|
||||
store.showPanel = false
|
||||
router.push({ name: 'GroupView', params: { id: poll.group }, query: { tab: 'polls' } })
|
||||
} catch { /* ignore */ }
|
||||
} else if (n.relatedType === 'team' && n.relatedId) {
|
||||
try {
|
||||
const session = await pb.collection('team_sessions').getOne(n.relatedId, { $autoCancel: false })
|
||||
store.showPanel = false
|
||||
router.push({ name: 'GroupView', params: { id: session.sourceGroup } })
|
||||
} catch { /* ignore */ }
|
||||
} else if (n.relatedType === 'group' && n.relatedId) {
|
||||
store.showPanel = false
|
||||
router.push({ name: 'GroupView', params: { id: n.relatedId } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,6 +81,29 @@ function onJoinRequestResponded(id: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 站内通知 -->
|
||||
<div v-if="store.appNotifications.length > 0" class="section">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">消息通知</h4>
|
||||
<button v-if="store.appUnreadCount > 0" class="mark-all-btn" @click="store.markAllRead">
|
||||
全部已读
|
||||
</button>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="n in store.appNotifications"
|
||||
:key="n.id"
|
||||
class="app-notification"
|
||||
:class="{ unread: !n.read }"
|
||||
@click="handleNotificationClick(n)"
|
||||
>
|
||||
<div class="notif-title">{{ n.title }}</div>
|
||||
<div v-if="n.content" class="notif-content">{{ n.content }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created).toLocaleString('zh-CN') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@@ -76,11 +124,30 @@ function onJoinRequestResponded(id: string) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mark-all-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--gg-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark-all-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -88,4 +155,39 @@ function onJoinRequestResponded(id: string) {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-notification {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
border: 1px solid var(--gg-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.app-notification:hover {
|
||||
background: var(--gg-bg-hover);
|
||||
}
|
||||
|
||||
.app-notification.unread {
|
||||
border-left: 3px solid var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.04);
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.notif-content {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { deleteBlacklistEntry } from '@/api/gameBlacklist'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
entries: BlacklistEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleted: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 当前用户 ID
|
||||
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||
|
||||
// 是否为群主
|
||||
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||
|
||||
// 是否可以删除(举报人本人或群主)
|
||||
function canDelete(entry: BlacklistEntry): boolean {
|
||||
return entry.reporter === currentUserId.value || isOwner.value
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 30) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
} else if (diffDays > 0) {
|
||||
return `${diffDays}天前`
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}小时前`
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes}分钟前`
|
||||
}
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
// 获取举报人显示名称
|
||||
function reporterName(entry: BlacklistEntry): string {
|
||||
if (entry.expand?.reporter) {
|
||||
return displayName(entry.expand.reporter)
|
||||
}
|
||||
return '未知用户'
|
||||
}
|
||||
|
||||
// 严重程度 CSS class
|
||||
function severityClass(severity: BlacklistSeverity): string {
|
||||
return `entry__severity--${severity}`
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
async function handleDelete(entryId: string) {
|
||||
try {
|
||||
await deleteBlacklistEntry(entryId)
|
||||
ElMessage.success('已删除')
|
||||
emit('deleted')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="(entry, index) in entries"
|
||||
:key="entry.id"
|
||||
:class="['entry', { 'entry--bordered': index > 0 }]"
|
||||
>
|
||||
<!-- 顶部:举报人 + 时间 + 删除 -->
|
||||
<div class="entry__header">
|
||||
<div class="entry__reporter">
|
||||
<div class="entry__avatar">
|
||||
{{ reporterName(entry).charAt(0) }}
|
||||
</div>
|
||||
<span class="entry__name">{{ reporterName(entry) }}</span>
|
||||
</div>
|
||||
<div class="entry__header-right">
|
||||
<span class="entry__time">{{ formatTime(entry.created) }}</span>
|
||||
<el-popconfirm
|
||||
v-if="canDelete(entry)"
|
||||
title="确定删除这条黑名单记录?"
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete(entry.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="entry__delete-btn" title="删除">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="entry__tags">
|
||||
<span class="entry__reason-tag">
|
||||
{{ BlacklistReasonMap[entry.reason] }}
|
||||
</span>
|
||||
<span class="entry__severity" :class="severityClass(entry.severity)">
|
||||
{{ BlacklistSeverityMap[entry.severity] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="entry__desc">
|
||||
{{ entry.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entry {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.entry--bordered {
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
/* 顶部行 */
|
||||
.entry__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry__reporter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.entry__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.entry__delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.entry__delete-btn:hover {
|
||||
color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.entry__delete-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.entry__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry__reason-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.entry__severity {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.entry__severity--mild {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.entry__severity--medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.entry__severity--severe {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.entry__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string
|
||||
entries: BlacklistEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// 被标记次数
|
||||
const reportCount = computed(() => props.entries.length)
|
||||
|
||||
// 最新一条记录
|
||||
const latestEntry = computed(() => props.entries[0])
|
||||
|
||||
// 最近标记原因
|
||||
const latestReason = computed(() => {
|
||||
const entry = latestEntry.value
|
||||
if (!entry) return ''
|
||||
return BlacklistReasonMap[entry.reason]
|
||||
})
|
||||
|
||||
// 严重程度排序:severe > medium > mild
|
||||
const severityOrder: Record<BlacklistSeverity, number> = {
|
||||
mild: 1,
|
||||
medium: 2,
|
||||
severe: 3,
|
||||
}
|
||||
|
||||
// 严重程度最高的一条
|
||||
const maxSeverity = computed(() => {
|
||||
let max: BlacklistSeverity = 'mild'
|
||||
for (const entry of props.entries) {
|
||||
if (severityOrder[entry.severity] > severityOrder[max]) {
|
||||
max = entry.severity
|
||||
}
|
||||
}
|
||||
return max
|
||||
})
|
||||
|
||||
const maxSeverityLabel = computed(() => BlacklistSeverityMap[maxSeverity.value])
|
||||
|
||||
// 严重程度对应的 CSS class
|
||||
const severityClass = computed(() => `game-card__severity--${maxSeverity.value}`)
|
||||
|
||||
// 第一条记录描述预览
|
||||
const previewDesc = computed(() => {
|
||||
const entry = latestEntry.value
|
||||
if (!entry) return ''
|
||||
return entry.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-card" @click="emit('click')">
|
||||
<!-- 左侧:游戏信息 -->
|
||||
<div class="game-card__info">
|
||||
<div class="game-card__name-row">
|
||||
<span class="game-card__name">{{ gameName }}</span>
|
||||
<span class="game-card__badge">{{ reportCount }} 次标记</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="game-card__tags">
|
||||
<span class="game-card__reason-tag">
|
||||
{{ latestReason }}
|
||||
</span>
|
||||
<span class="game-card__severity" :class="severityClass">
|
||||
{{ maxSeverityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述预览 -->
|
||||
<div v-if="previewDesc" class="game-card__desc">
|
||||
{{ previewDesc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:展开指示 -->
|
||||
<div class="game-card__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="game-card__arrow-icon">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 左侧信息 */
|
||||
.game-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 游戏名 + 次数 */
|
||||
.game-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-card__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.game-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-card__reason-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.game-card__severity {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.game-card__severity--mild {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.game-card__severity--medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.game-card__severity--severe {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 描述预览 */
|
||||
.game-card__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 展开箭头 */
|
||||
.game-card__arrow {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-card__arrow-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--gg-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.game-card:hover .game-card__arrow-icon {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { listBlacklist, subscribeBlacklist } from '@/api/gameBlacklist'
|
||||
import BlacklistGameCard from './BlacklistGameCard.vue'
|
||||
import BlacklistEntryList from './BlacklistEntryList.vue'
|
||||
import CreateBlacklistDialog from './CreateBlacklistDialog.vue'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistReason, BlacklistSeverity } from '@/types'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const allEntries = ref<BlacklistEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const expandedGame = ref<string | null>(null)
|
||||
|
||||
// 筛选条件
|
||||
const filterReason = ref<BlacklistReason | ''>('')
|
||||
const filterSeverity = ref<BlacklistSeverity | ''>('')
|
||||
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 按游戏名聚合
|
||||
const groupedByGame = computed(() => {
|
||||
const map = new Map<string, BlacklistEntry[]>()
|
||||
for (const entry of allEntries.value) {
|
||||
const key = entry.gameName
|
||||
if (!map.has(key)) {
|
||||
map.set(key, [])
|
||||
}
|
||||
map.get(key)!.push(entry)
|
||||
}
|
||||
// 每个游戏内的条目按时间倒序
|
||||
for (const entries of map.values()) {
|
||||
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 游戏列表(按被标记次数降序)
|
||||
const gameGroups = computed(() => {
|
||||
const groups: { gameName: string; entries: BlacklistEntry[] }[] = []
|
||||
for (const [gameName, entries] of groupedByGame.value) {
|
||||
groups.push({ gameName, entries })
|
||||
}
|
||||
groups.sort((a, b) => b.entries.length - a.entries.length)
|
||||
return groups
|
||||
})
|
||||
|
||||
async function loadEntries() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const options: { reason?: string; severity?: string } = {}
|
||||
if (filterReason.value) options.reason = filterReason.value
|
||||
if (filterSeverity.value) options.severity = filterSeverity.value
|
||||
|
||||
allEntries.value = await listBlacklist(groupId, options)
|
||||
} catch (error) {
|
||||
console.error('加载黑名单失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBlacklist(groupId, () => {
|
||||
loadEntries()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
// 展开/收起某个游戏的详细记录
|
||||
function toggleGame(gameName: string) {
|
||||
expandedGame.value = expandedGame.value === gameName ? null : gameName
|
||||
}
|
||||
|
||||
function handleCreated() {
|
||||
showCreate.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
expandedGame.value = null
|
||||
stopSubscription()
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选条件变化时重新加载
|
||||
watch([filterReason, filterSeverity], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="blacklist-main">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="blacklist-main__header">
|
||||
<h3 class="blacklist-main__title">游戏黑名单</h3>
|
||||
<div class="blacklist-main__actions">
|
||||
<div class="blacklist-main__filters">
|
||||
<el-select
|
||||
v-model="filterReason"
|
||||
placeholder="原因筛选"
|
||||
clearable
|
||||
size="small"
|
||||
class="blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistReasonMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterSeverity"
|
||||
placeholder="严重程度"
|
||||
clearable
|
||||
size="small"
|
||||
class="blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="blacklist-main__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-main__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
标记游戏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateBlacklistDialog
|
||||
v-model="showCreate"
|
||||
@created="handleCreated"
|
||||
/>
|
||||
|
||||
<!-- 游戏卡片列表 -->
|
||||
<div v-if="gameGroups.length > 0" class="blacklist-main__content">
|
||||
<div v-for="group in gameGroups" :key="group.gameName" class="blacklist-main__game-group">
|
||||
<BlacklistGameCard
|
||||
:game-name="group.gameName"
|
||||
:entries="group.entries"
|
||||
@click="toggleGame(group.gameName)"
|
||||
/>
|
||||
<!-- 展开的详细记录 -->
|
||||
<div v-if="expandedGame === group.gameName" class="blacklist-main__expanded">
|
||||
<BlacklistEntryList :entries="group.entries" @deleted="loadEntries" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="loading" class="blacklist-main__loading">
|
||||
<p class="blacklist-main__loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="blacklist-main__empty">
|
||||
<svg class="blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
<p class="blacklist-main__empty-text">暂无黑名单记录</p>
|
||||
<p class="blacklist-main__empty-hint">标记体验差的游戏,提醒队友避开</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blacklist-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.blacklist-main__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blacklist-main__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.blacklist-main__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blacklist-main__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.blacklist-main__filter-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.blacklist-main__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.blacklist-main__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.blacklist-main__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.blacklist-main__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blacklist-main__game-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.blacklist-main__expanded {
|
||||
padding: 0 16px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.blacklist-main__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.blacklist-main__loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.blacklist-main__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.blacklist-main__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.blacklist-main__filter-select {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createBlacklistEntry } from '@/api/gameBlacklist'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistReason, BlacklistSeverity } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const form = ref({
|
||||
gameName: '',
|
||||
reason: '' as BlacklistReason | '',
|
||||
severity: '' as BlacklistSeverity | '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
gameName: '',
|
||||
reason: '',
|
||||
severity: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// 校验必填字段
|
||||
if (!form.value.gameName.trim()) {
|
||||
ElMessage.warning('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.reason) {
|
||||
ElMessage.warning('请选择原因')
|
||||
return
|
||||
}
|
||||
if (!form.value.severity) {
|
||||
ElMessage.warning('请选择严重程度')
|
||||
return
|
||||
}
|
||||
if (!form.value.description.trim()) {
|
||||
ElMessage.warning('请填写描述')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await createBlacklistEntry({
|
||||
group: groupId,
|
||||
gameName: form.value.gameName.trim(),
|
||||
reason: form.value.reason,
|
||||
severity: form.value.severity,
|
||||
description: form.value.description.trim(),
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
resetForm()
|
||||
ElMessage.success('标记成功')
|
||||
emit('created')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '标记失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="标记坑游戏"
|
||||
width="460px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 游戏名称 -->
|
||||
<div class="form-field">
|
||||
<label>游戏名称 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.gameName"
|
||||
placeholder="输入游戏名称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 原因选择 -->
|
||||
<div class="form-field">
|
||||
<label>原因 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.reason"
|
||||
placeholder="选择原因"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistReasonMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 严重程度 -->
|
||||
<div class="form-field">
|
||||
<label>严重程度 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.severity"
|
||||
placeholder="选择严重程度"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="描述一下为什么这游戏坑"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { Ledger, LedgerType, LedgerCategory } from '@/types'
|
||||
import { LedgerCategoryMap } from '@/types'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: string
|
||||
editLedger?: Ledger
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
type: 'expense' as LedgerType,
|
||||
amount: 0,
|
||||
category: 'other' as LedgerCategory,
|
||||
description: '',
|
||||
relatedMembers: [] as string[],
|
||||
occurredAt: '' as string | Date,
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.editLedger)
|
||||
const dialogTitle = computed(() => (isEditing.value ? '编辑账目' : '新建账目'))
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = Object.entries(LedgerCategoryMap).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))
|
||||
|
||||
// 群组成员选项
|
||||
const memberOptions = computed(() => {
|
||||
return groupStore.currentMembers.map((m) => ({
|
||||
label: displayName(m),
|
||||
value: m.id,
|
||||
}))
|
||||
})
|
||||
|
||||
// 格式化今天日期
|
||||
function getTodayStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
type: 'expense',
|
||||
amount: 0,
|
||||
category: 'other',
|
||||
description: '',
|
||||
relatedMembers: [],
|
||||
occurredAt: getTodayStr(),
|
||||
}
|
||||
}
|
||||
|
||||
// 填充编辑数据
|
||||
function fillForm(ledger: Ledger) {
|
||||
form.value = {
|
||||
type: ledger.type,
|
||||
amount: ledger.amount,
|
||||
category: ledger.category,
|
||||
description: ledger.description || '',
|
||||
relatedMembers: [...(ledger.relatedMembers || [])],
|
||||
occurredAt: ledger.occurredAt?.slice(0, 10) || getTodayStr(),
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框打开
|
||||
function handleOpen() {
|
||||
if (props.editLedger) {
|
||||
fillForm(props.editLedger)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
async function handleSubmit() {
|
||||
if (!props.groupId) {
|
||||
ElMessage.error('缺少群组信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.amount <= 0) {
|
||||
ElMessage.warning('金额必须大于0')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const occurredAt = form.value.occurredAt
|
||||
? new Date(String(form.value.occurredAt)).toISOString()
|
||||
: new Date().toISOString()
|
||||
|
||||
const submitData = {
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
category: form.value.category,
|
||||
description: form.value.description || '',
|
||||
relatedMembers: form.value.relatedMembers,
|
||||
occurredAt,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.editLedger) {
|
||||
await ledgerStore.editLedger(props.editLedger.id, submitData)
|
||||
ElMessage.success('账目更新成功')
|
||||
} else {
|
||||
await ledgerStore.addLedger({
|
||||
group: props.groupId,
|
||||
...submitData,
|
||||
})
|
||||
ElMessage.success('账目创建成功')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 类型 -->
|
||||
<div class="form-field">
|
||||
<label>类型 <span class="required">*</span></label>
|
||||
<div class="type-switch">
|
||||
<button
|
||||
:class="['type-btn', { active: form.type === 'income' }]"
|
||||
@click="form.type = 'income'"
|
||||
>
|
||||
收入
|
||||
</button>
|
||||
<button
|
||||
:class="['type-btn', { active: form.type === 'expense' }]"
|
||||
@click="form.type = 'expense'"
|
||||
>
|
||||
支出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金额 -->
|
||||
<div class="form-field">
|
||||
<label>金额 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
:min="0.01"
|
||||
:step="0.01"
|
||||
placeholder="请输入金额"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="amount-prefix">¥</span>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 分类 -->
|
||||
<div class="form-field">
|
||||
<label>分类 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.category"
|
||||
placeholder="请选择分类"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in categoryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入账目描述(可选)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 关联成员 -->
|
||||
<div class="form-field">
|
||||
<label>关联成员</label>
|
||||
<el-select
|
||||
v-model="form.relatedMembers"
|
||||
multiple
|
||||
placeholder="选择关联成员(可选)"
|
||||
style="width: 100%"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in memberOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 发生日期 -->
|
||||
<div class="form-field">
|
||||
<label>发生日期</label>
|
||||
<el-date-picker
|
||||
v-model="form.occurredAt"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.type-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
background: var(--gg-bg, #fff);
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: var(--gg-primary, #67c23a);
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: var(--gg-primary, #67c23a);
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.amount-prefix {
|
||||
color: var(--gg-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Edit, Delete } from '@element-plus/icons-vue'
|
||||
import type { Ledger } from '@/types'
|
||||
import { LedgerTypeMap, LedgerCategoryMap, displayName } from '@/types'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const props = defineProps<{
|
||||
ledger: Ledger
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [ledgerId: string]
|
||||
delete: [ledgerId: string]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const isCreator = computed(() => {
|
||||
return props.ledger.creator === pb.authStore.model?.id
|
||||
})
|
||||
|
||||
const isGroupOwner = computed(() => {
|
||||
return pb.authStore.model?.id === groupStore.currentGroup?.owner
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => LedgerTypeMap[props.ledger.type])
|
||||
const categoryLabel = computed(() => LedgerCategoryMap[props.ledger.category])
|
||||
const isIncome = computed(() => props.ledger.type === 'income')
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
const prefix = isIncome.value ? '+' : '-'
|
||||
return `${prefix}¥${props.ledger.amount.toFixed(2)}`
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.ledger.occurredAt) return ''
|
||||
return props.ledger.occurredAt.slice(0, 10)
|
||||
})
|
||||
|
||||
const creatorName = computed(() => {
|
||||
return displayName(props.ledger.expand?.creator)
|
||||
})
|
||||
|
||||
const relatedMemberNames = computed(() => {
|
||||
const members = props.ledger.expand?.relatedMembers
|
||||
if (!members || members.length === 0) return []
|
||||
return members.map((m) => displayName(m))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-card">
|
||||
<div class="ledger-card__main">
|
||||
<!-- 左侧:类型标签 + 金额 -->
|
||||
<div class="ledger-card__left">
|
||||
<span
|
||||
class="ledger-card__type-tag"
|
||||
:class="isIncome ? 'ledger-card__type-tag--income' : 'ledger-card__type-tag--expense'"
|
||||
>
|
||||
{{ typeLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="ledger-card__amount"
|
||||
:class="isIncome ? 'ledger-card__amount--income' : 'ledger-card__amount--expense'"
|
||||
>
|
||||
{{ formattedAmount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:描述、分类、日期 -->
|
||||
<div class="ledger-card__center">
|
||||
<div class="ledger-card__desc">{{ ledger.description || '无备注' }}</div>
|
||||
<div class="ledger-card__meta">
|
||||
<span class="ledger-card__category-tag">{{ categoryLabel }}</span>
|
||||
<span class="ledger-card__date">{{ formattedDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:关联成员 + 创建者 -->
|
||||
<div class="ledger-card__right">
|
||||
<div v-if="relatedMemberNames.length > 0" class="ledger-card__members">
|
||||
<span
|
||||
v-for="(name, index) in relatedMemberNames.slice(0, 3)"
|
||||
:key="index"
|
||||
class="ledger-card__member"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
<span v-if="relatedMemberNames.length > 3" class="ledger-card__member ledger-card__member--more">
|
||||
+{{ relatedMemberNames.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-card__creator">
|
||||
{{ creatorName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="isCreator || isGroupOwner" class="ledger-card__actions">
|
||||
<button v-if="isCreator" class="ledger-card__action-btn" @click.stop="emit('edit', ledger.id)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</button>
|
||||
<button class="ledger-card__action-btn ledger-card__action-btn--danger" @click.stop="emit('delete', ledger.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md, 8px);
|
||||
padding: 14px 18px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ledger-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.ledger-card__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.ledger-card__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ledger-card__type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ledger-card__type-tag--income {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-card__type-tag--expense {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.ledger-card__amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__amount--income {
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-card__amount--expense {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 中间 */
|
||||
.ledger-card__center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ledger-card__desc {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ledger-card__category-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ledger-card__date {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.ledger-card__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-card__members {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ledger-card__member {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-secondary);
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__member--more {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ledger-card__creator {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.ledger-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ledger-card__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.ledger-card__action-btn:hover {
|
||||
color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.ledger-card__action-btn--danger:hover {
|
||||
color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-card__main {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ledger-card__left {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ledger-card__right {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { LedgerType, LedgerCategory } from '@/types'
|
||||
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
||||
import LedgerSummary from './LedgerSummary.vue'
|
||||
import LedgerCard from './LedgerCard.vue'
|
||||
import CreateLedgerDialog from './CreateLedgerDialog.vue'
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const editingLedger = ref<any>(undefined)
|
||||
const filterType = ref<LedgerType | ''>('')
|
||||
const filterCategory = ref<LedgerCategory | ''>('')
|
||||
|
||||
// 月份选择器值
|
||||
const selectedMonth = ref(new Date())
|
||||
|
||||
// 类型筛选选项
|
||||
const typeOptions = computed(() => [
|
||||
{ label: '全部', value: '' },
|
||||
...Object.entries(LedgerTypeMap).map(([value, label]) => ({ label, value }))
|
||||
])
|
||||
|
||||
// 分类筛选选项
|
||||
const categoryOptions = computed(() => [
|
||||
{ label: '全部', value: '' },
|
||||
...Object.entries(LedgerCategoryMap).map(([value, label]) => ({ label, value }))
|
||||
])
|
||||
|
||||
// 获取月份字符串
|
||||
function getMonthString(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
const month = getMonthString(selectedMonth.value)
|
||||
await ledgerStore.loadLedgers(groupId, month)
|
||||
}
|
||||
|
||||
// 筛选后的账目列表
|
||||
const filteredLedgers = computed(() => {
|
||||
let list = [...ledgerStore.ledgers]
|
||||
if (filterType.value) {
|
||||
list = list.filter((l) => l.type === filterType.value)
|
||||
}
|
||||
if (filterCategory.value) {
|
||||
list = list.filter((l) => l.category === filterCategory.value)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// 按日期分组
|
||||
const groupedByDate = computed(() => {
|
||||
const groups: Record<string, typeof filteredLedgers.value> = {}
|
||||
for (const ledger of filteredLedgers.value) {
|
||||
const date = ledger.occurredAt?.slice(0, 10) || '未知日期'
|
||||
if (!groups[date]) {
|
||||
groups[date] = []
|
||||
}
|
||||
groups[date].push(ledger)
|
||||
}
|
||||
// 按日期降序排列
|
||||
const sortedKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a))
|
||||
return sortedKeys.map((date) => ({ date, items: groups[date] }))
|
||||
})
|
||||
|
||||
// 月份变更
|
||||
function handleMonthChange(val: Date) {
|
||||
selectedMonth.value = val
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 筛选变更
|
||||
function handleFilterChange() {
|
||||
// 筛选在前端完成,无需重新加载
|
||||
}
|
||||
|
||||
// 新建
|
||||
function handleCreate() {
|
||||
editingLedger.value = undefined
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(ledgerId: string) {
|
||||
const ledger = ledgerStore.ledgers.find((l) => l.id === ledgerId)
|
||||
if (ledger) {
|
||||
editingLedger.value = ledger
|
||||
showCreate.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(ledgerId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条账目记录吗?删除后不可恢复。', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await ledgerStore.removeLedger(ledgerId)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 保存成功
|
||||
function handleSaved() {
|
||||
showCreate.value = false
|
||||
editingLedger.value = undefined
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 实时订阅(store 内部管理生命周期)
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await ledgerStore.startSubscription(groupId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadData()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ledgerStore.stopSubscription()
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadData()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="ledger-list__header">
|
||||
<h3 class="ledger-list__title">账本</h3>
|
||||
<button class="ledger-list__create-btn" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建账目
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="ledger-list__filters">
|
||||
<el-date-picker
|
||||
:model-value="selectedMonth"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
format="YYYY年MM月"
|
||||
value-format="YYYY-MM"
|
||||
@update:model-value="handleMonthChange"
|
||||
class="ledger-list__month-picker"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="类型"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="ledger-list__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterCategory"
|
||||
placeholder="分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="ledger-list__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in categoryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 汇总 -->
|
||||
<LedgerSummary />
|
||||
|
||||
<!-- 账目列表 -->
|
||||
<div v-loading="ledgerStore.loading" class="ledger-list__content">
|
||||
<section
|
||||
v-for="group in groupedByDate"
|
||||
:key="group.date"
|
||||
class="ledger-list__date-group"
|
||||
>
|
||||
<div class="ledger-list__date-header">
|
||||
<span class="ledger-list__date-dot"></span>
|
||||
<span class="ledger-list__date-label">{{ group.date }}</span>
|
||||
<span class="ledger-list__date-count">{{ group.items.length }} 条</span>
|
||||
</div>
|
||||
<div class="ledger-list__items">
|
||||
<LedgerCard
|
||||
v-for="ledger in group.items"
|
||||
:key="ledger.id"
|
||||
:ledger="ledger"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="groupedByDate.length === 0 && !ledgerStore.loading"
|
||||
class="ledger-list__empty"
|
||||
>
|
||||
<svg class="ledger-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z" />
|
||||
</svg>
|
||||
<p class="ledger-list__empty-text">暂无账目</p>
|
||||
<p class="ledger-list__empty-hint">该月份还没有账目记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<CreateLedgerDialog
|
||||
v-model="showCreate"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
:edit-ledger="editingLedger"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.ledger-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ledger-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ledger-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ledger-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.ledger-list__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ledger-list__month-picker {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.ledger-list__filter-select {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.ledger-list__content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 日期分组 */
|
||||
.ledger-list__date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ledger-list__date-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ledger-list__date-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ledger-list__date-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.ledger-list__date-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ledger-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.ledger-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ledger-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ledger-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.ledger-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-list__filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ledger-list__month-picker,
|
||||
.ledger-list__filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
|
||||
const totalIncome = computed(() => ledgerStore.summary.totalIncome)
|
||||
const totalExpense = computed(() => ledgerStore.summary.totalExpense)
|
||||
const balance = computed(() => ledgerStore.summary.balance)
|
||||
|
||||
function formatAmount(value: number): string {
|
||||
return '¥' + value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-summary__stat ledger-summary__stat--income">
|
||||
<span class="ledger-summary__label">总收入</span>
|
||||
<span class="ledger-summary__amount ledger-summary__amount--income">
|
||||
{{ formatAmount(totalIncome) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-summary__stat ledger-summary__stat--expense">
|
||||
<span class="ledger-summary__label">总支出</span>
|
||||
<span class="ledger-summary__amount ledger-summary__amount--expense">
|
||||
{{ formatAmount(totalExpense) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-summary__stat ledger-summary__stat--balance">
|
||||
<span class="ledger-summary__label">余额</span>
|
||||
<span
|
||||
class="ledger-summary__amount"
|
||||
:class="balance >= 0 ? 'ledger-summary__amount--income' : 'ledger-summary__amount--expense'"
|
||||
>
|
||||
{{ formatAmount(balance) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ledger-summary__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg, 12px);
|
||||
}
|
||||
|
||||
.ledger-summary__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.ledger-summary__amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ledger-summary__amount--income {
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-summary__amount--expense {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-summary {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ledger-summary__stat {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,531 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useMemoryStore } from '@/stores/memory'
|
||||
import { subscribeMemories } from '@/api/memories'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import { Plus, PictureFilled, Document, VideoCamera, Headset, Loading, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import MemoryUploadDialog from './MemoryUploadDialog.vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const memoryStore = useMemoryStore()
|
||||
|
||||
const showUploadDialog = ref(false)
|
||||
const activeFilter = ref<string>('')
|
||||
let unsubscribeFn: (() => Promise<void>) | null = null
|
||||
|
||||
// 存储容量信息
|
||||
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
|
||||
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
|
||||
const storagePercent = computed(() => memoryStore.storagePercent)
|
||||
const isStorageWarning = computed(() => storagePercent.value >= 80)
|
||||
|
||||
// 过滤后的记忆列表
|
||||
const filteredMemories = computed(() => {
|
||||
if (!activeFilter.value) return memoryStore.memories
|
||||
return memoryStore.memories.filter((m: any) => m.fileType === activeFilter.value)
|
||||
})
|
||||
|
||||
// 获取文件访问 URL(支持缩略图)
|
||||
function getFileUrl(memory: any, thumb?: string): string {
|
||||
if (!memory?.file) return ''
|
||||
return pb.files.getUrl(memory, memory.file, thumb ? { thumb } : {})
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
|
||||
return `${size} ${units[i]}`
|
||||
}
|
||||
|
||||
// 判断当前用户是否为上传者或群管理员
|
||||
function isOwnerOrAdmin(memory: any): boolean {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
if (memory.uploader === userId) return true
|
||||
// 群管理员 = 群主
|
||||
if (groupStore.currentGroup?.owner === userId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取文件类型图标
|
||||
function getFileIcon(fileType: string) {
|
||||
switch (fileType) {
|
||||
case 'image': return PictureFilled
|
||||
case 'video': return VideoCamera
|
||||
case 'audio': return Headset
|
||||
default: return Document
|
||||
}
|
||||
}
|
||||
|
||||
// 点击文件卡片(打开预览弹窗)
|
||||
const showPreview = ref(false)
|
||||
const previewMemory = ref<any>(null)
|
||||
|
||||
function handleCardClick(memory: any) {
|
||||
if (memory.fileType === 'image' || memory.fileType === 'video') {
|
||||
previewMemory.value = memory
|
||||
showPreview.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除记忆
|
||||
async function handleDelete(memory: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条记忆吗?', '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await memoryStore.remove(memory.id, groupStore.currentGroupId)
|
||||
ElMessage.success('删除成功')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHour = Math.floor(diffMs / 3600000)
|
||||
const diffDay = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`
|
||||
if (diffHour < 24) return `${diffHour} 小时前`
|
||||
if (diffDay < 7) return `${diffDay} 天前`
|
||||
return d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await memoryStore.loadMemories(groupId, activeFilter.value || undefined)
|
||||
}
|
||||
|
||||
// 订阅变更
|
||||
async function setupSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
try {
|
||||
unsubscribeFn = await subscribeMemories(groupId, () => {
|
||||
loadData()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('订阅记忆变更失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupStore.currentGroupId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
await loadData()
|
||||
if (!unsubscribeFn) await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (unsubscribeFn) {
|
||||
try {
|
||||
await unsubscribeFn()
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
unsubscribeFn = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-grid-container">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="memory-header">
|
||||
<h3 class="memory-title">记忆相册</h3>
|
||||
<el-button type="primary" :icon="Plus" @click="showUploadDialog = true">
|
||||
上传
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 存储容量条 -->
|
||||
<div class="storage-section" :class="{ warning: isStorageWarning }">
|
||||
<div class="storage-bar">
|
||||
<div
|
||||
class="storage-bar-fill"
|
||||
:class="{ 'bar-warning': isStorageWarning }"
|
||||
:style="{ width: Math.min(storagePercent, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="storage-label">
|
||||
{{ storageUsedGB }} GB / {{ storageLimitGB }} GB
|
||||
<span v-if="isStorageWarning" class="storage-warn-text">空间不足</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 类型过滤 -->
|
||||
<div class="filter-bar">
|
||||
<el-button
|
||||
:type="activeFilter === '' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="activeFilter = ''"
|
||||
>全部</el-button>
|
||||
<el-button
|
||||
:type="activeFilter === 'image' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="activeFilter = 'image'"
|
||||
>图片</el-button>
|
||||
<el-button
|
||||
:type="activeFilter === 'video' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="activeFilter = 'video'"
|
||||
>视频</el-button>
|
||||
<el-button
|
||||
:type="activeFilter === 'audio' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="activeFilter = 'audio'"
|
||||
>音频</el-button>
|
||||
<el-button
|
||||
:type="activeFilter === 'document' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="activeFilter = 'document'"
|
||||
>文档</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="memoryStore.loading" class="loading-state">
|
||||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredMemories.length === 0" class="empty-state">
|
||||
<el-icon :size="48" class="empty-icon"><PictureFilled /></el-icon>
|
||||
<p>还没有记忆,快来上传第一个吧</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件网格 -->
|
||||
<div v-else class="memory-grid">
|
||||
<div
|
||||
v-for="memory in filteredMemories"
|
||||
:key="memory.id"
|
||||
class="memory-card"
|
||||
@click="handleCardClick(memory)"
|
||||
>
|
||||
<!-- 文件预览区 -->
|
||||
<div class="card-preview">
|
||||
<!-- 图片缩略图 -->
|
||||
<img
|
||||
v-if="memory.fileType === 'image'"
|
||||
:src="getFileUrl(memory, '300x300')"
|
||||
:alt="memory.title"
|
||||
class="preview-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- 视频缩略图(取第一帧) -->
|
||||
<video
|
||||
v-else-if="memory.fileType === 'video'"
|
||||
:src="getFileUrl(memory)"
|
||||
class="preview-video"
|
||||
preload="metadata"
|
||||
muted
|
||||
/>
|
||||
<!-- 其他文件图标 -->
|
||||
<div v-else class="preview-icon">
|
||||
<el-icon :size="36"><component :is="getFileIcon(memory.fileType)" /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 视频播放按钮遮罩 -->
|
||||
<div v-if="memory.fileType === 'video'" class="video-play-overlay">
|
||||
<svg viewBox="0 0 24 24" fill="white" width="40" height="40">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" />
|
||||
<polygon points="10,8 16,12 10,16" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<el-button
|
||||
v-if="isOwnerOrAdmin(memory)"
|
||||
class="delete-btn"
|
||||
:icon="Delete"
|
||||
circle
|
||||
size="small"
|
||||
type="danger"
|
||||
@click.stop="handleDelete(memory)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="card-info">
|
||||
<div class="card-title" :title="memory.title">{{ memory.title }}</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-uploader">{{ displayName(memory.expand?.uploader) }}</span>
|
||||
<span class="card-size">{{ formatSize(memory.size) }}</span>
|
||||
</div>
|
||||
<div class="card-time">{{ formatTime(memory.created) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片/视频预览弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showPreview"
|
||||
:title="previewMemory?.title"
|
||||
width="80%"
|
||||
class="preview-dialog"
|
||||
@close="previewMemory = null"
|
||||
>
|
||||
<img
|
||||
v-if="previewMemory?.fileType === 'image'"
|
||||
:src="getFileUrl(previewMemory)"
|
||||
class="preview-fullscreen"
|
||||
/>
|
||||
<video
|
||||
v-else-if="previewMemory?.fileType === 'video'"
|
||||
:src="getFileUrl(previewMemory)"
|
||||
controls
|
||||
autoplay
|
||||
class="preview-fullscreen"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 上传弹窗 -->
|
||||
<MemoryUploadDialog
|
||||
v-model="showUploadDialog"
|
||||
@uploaded="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-grid-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.memory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.memory-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 存储条 */
|
||||
.storage-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.storage-bar {
|
||||
height: 6px;
|
||||
background: var(--gg-bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.storage-bar-fill.bar-warning {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.storage-label {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.storage-warn-text {
|
||||
color: var(--el-color-warning);
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.storage-section.warning .storage-bar {
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
/* 过滤栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 加载 & 空状态 */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
color: var(--gg-text-hint);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--gg-text-hint);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 文件网格 */
|
||||
.memory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.memory-card {
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--gg-bg-primary);
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 预览区 */
|
||||
.card-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: var(--gg-bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-icon {
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.memory-card:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 信息区 */
|
||||
.card-info {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.card-uploader {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
/* 视频预览 */
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.memory-card:hover .video-play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 预览弹窗 */
|
||||
.preview-fullscreen {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.memory-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useMemoryStore } from '@/stores/memory'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ uploaded: [] }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const memoryStore = useMemoryStore()
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 存储容量信息
|
||||
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
|
||||
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
|
||||
const storagePercent = computed(() => memoryStore.storagePercent)
|
||||
const isStorageWarning = computed(() => storagePercent.value >= 80)
|
||||
|
||||
// 文件选择变更
|
||||
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
|
||||
fileList.value = uploadFileList
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function handleFileRemove(_file: UploadFile, uploadFileList: UploadFile[]) {
|
||||
fileList.value = uploadFileList
|
||||
}
|
||||
|
||||
// 超出限制
|
||||
function handleExceed() {
|
||||
ElMessage.warning('只能上传一个文件,请先移除已选文件')
|
||||
}
|
||||
|
||||
// 提交上传
|
||||
async function handleSubmit() {
|
||||
if (!title.value.trim()) {
|
||||
ElMessage.warning('请输入标题')
|
||||
return
|
||||
}
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const rawFile = fileList.value[0].raw
|
||||
if (!rawFile) {
|
||||
ElMessage.error('文件读取失败,请重新选择')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('未选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await memoryStore.upload(groupId, rawFile, {
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim() || undefined
|
||||
})
|
||||
ElMessage.success('上传成功')
|
||||
resetForm()
|
||||
emit('uploaded')
|
||||
visible.value = false
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '上传失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
title.value = ''
|
||||
description.value = ''
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
// 关闭时重置
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传记忆"
|
||||
width="480px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="upload-form">
|
||||
<div class="field">
|
||||
<label>标题 *</label>
|
||||
<el-input v-model="title" placeholder="给这段记忆起个名字" maxlength="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="description"
|
||||
type="textarea"
|
||||
placeholder="简单描述一下(可选)"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>文件 *</label>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="fileList"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:on-exceed="handleExceed"
|
||||
drag
|
||||
>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div>将文件拖到此处,或<em>点击上传</em></div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<!-- 存储信息 -->
|
||||
<div class="storage-info" :class="{ warning: isStorageWarning }">
|
||||
<div class="storage-bar">
|
||||
<div
|
||||
class="storage-bar-fill"
|
||||
:class="{ 'bar-warning': isStorageWarning }"
|
||||
:style="{ width: Math.min(storagePercent, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="storage-text">
|
||||
已用 {{ storageUsedGB }} GB / {{ storageLimitGB }} GB
|
||||
<span v-if="isStorageWarning" class="warning-text">(存储空间不足)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
export default { name: 'MemoryUploadDialog' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-trigger em {
|
||||
color: var(--gg-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
/* 存储信息 */
|
||||
.storage-info {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
.storage-bar {
|
||||
height: 6px;
|
||||
background: var(--gg-bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.storage-bar-fill.bar-warning {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.storage-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--el-color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.storage-info.warning .storage-bar {
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createPlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { PlayerTag, BlacklistSeverity } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const form = ref({
|
||||
playerId: '',
|
||||
platform: '',
|
||||
tags: [] as PlayerTag[],
|
||||
customTag: '',
|
||||
severity: '' as BlacklistSeverity | '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
playerId: '',
|
||||
platform: '',
|
||||
tags: [],
|
||||
customTag: '',
|
||||
severity: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.playerId.trim()) {
|
||||
ElMessage.warning('请输入玩家ID')
|
||||
return
|
||||
}
|
||||
if (!form.value.platform.trim()) {
|
||||
ElMessage.warning('请输入游戏/平台')
|
||||
return
|
||||
}
|
||||
if (form.value.tags.length === 0) {
|
||||
ElMessage.warning('请至少选择一个标签')
|
||||
return
|
||||
}
|
||||
if (!form.value.severity) {
|
||||
ElMessage.warning('请选择严重程度')
|
||||
return
|
||||
}
|
||||
if (!form.value.description.trim()) {
|
||||
ElMessage.warning('请填写描述')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await createPlayerBlacklistEntry({
|
||||
group: groupId,
|
||||
playerId: form.value.playerId.trim(),
|
||||
platform: form.value.platform.trim(),
|
||||
tags: form.value.tags,
|
||||
customTag: form.value.customTag.trim() || undefined,
|
||||
description: form.value.description.trim(),
|
||||
severity: form.value.severity,
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
resetForm()
|
||||
ElMessage.success('标记成功')
|
||||
emit('created')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '标记失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="标记坑玩家"
|
||||
width="460px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<div class="form-field">
|
||||
<label>玩家ID <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.playerId"
|
||||
placeholder="输入玩家的游戏内ID或昵称"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>游戏/平台 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.platform"
|
||||
placeholder="如:英雄联盟、Steam、绝地求生"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>标签 <span class="required">*</span></label>
|
||||
<el-checkbox-group v-model="form.tags">
|
||||
<el-checkbox
|
||||
v-for="(label, key) in PlayerTagMap"
|
||||
:key="key"
|
||||
:value="key"
|
||||
:label="label"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>自定义标签</label>
|
||||
<el-input
|
||||
v-model="form.customTag"
|
||||
placeholder="补充标签(选填)"
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>严重程度 <span class="required">*</span></label>
|
||||
<el-select v-model="form.severity" placeholder="选择严重程度" style="width: 100%">
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>描述 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="描述一下遇到的情况"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,694 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { listPlayerBlacklist, subscribePlayerBlacklist } from '@/api/playerBlacklist'
|
||||
import { deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||
import { ElMessage, ElPopconfirm } from 'element-plus'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { PlayerBlacklistEntry, PlayerTag, BlacklistSeverity } from '@/types'
|
||||
import CreatePlayerBlacklistDialog from './CreatePlayerBlacklistDialog.vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const allEntries = ref<PlayerBlacklistEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
|
||||
// 筛选
|
||||
const filterTag = ref<PlayerTag | ''>('')
|
||||
const filterSeverity = ref<BlacklistSeverity | ''>('')
|
||||
|
||||
// 搜索
|
||||
const searchPlayerId = ref('')
|
||||
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 按玩家ID聚合
|
||||
const groupedByPlayer = computed(() => {
|
||||
let filtered = allEntries.value
|
||||
if (searchPlayerId.value.trim()) {
|
||||
const q = searchPlayerId.value.trim().toLowerCase()
|
||||
filtered = filtered.filter(e => e.playerId.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
const map = new Map<string, PlayerBlacklistEntry[]>()
|
||||
for (const entry of filtered) {
|
||||
const key = `${entry.playerId}|${entry.platform}`
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(entry)
|
||||
}
|
||||
for (const entries of map.values()) {
|
||||
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 玩家列表(按被标记次数降序)
|
||||
const playerGroups = computed(() => {
|
||||
const groups: { playerId: string; platform: string; entries: PlayerBlacklistEntry[] }[] = []
|
||||
for (const [key, entries] of groupedByPlayer.value) {
|
||||
const [playerId, platform] = key.split('|')
|
||||
groups.push({ playerId, platform, entries })
|
||||
}
|
||||
groups.sort((a, b) => b.entries.length - a.entries.length)
|
||||
return groups
|
||||
})
|
||||
|
||||
// 展开的玩家
|
||||
const expandedPlayer = ref<string | null>(null)
|
||||
|
||||
async function loadEntries() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const options: { tag?: string; severity?: string } = {}
|
||||
if (filterTag.value) options.tag = filterTag.value
|
||||
if (filterSeverity.value) options.severity = filterSeverity.value
|
||||
allEntries.value = await listPlayerBlacklist(groupId, options)
|
||||
} catch (error) {
|
||||
console.error('加载玩家黑名单失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
unsubscribeFn = await subscribePlayerBlacklist(groupId, () => loadEntries())
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayer(key: string) {
|
||||
expandedPlayer.value = expandedPlayer.value === key ? null : key
|
||||
}
|
||||
|
||||
function handleCreated() {
|
||||
showCreate.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||
|
||||
function canDelete(entry: PlayerBlacklistEntry): boolean {
|
||||
return entry.reporter === currentUserId.value || isOwner.value
|
||||
}
|
||||
|
||||
async function handleDelete(entryId: string) {
|
||||
try {
|
||||
await deletePlayerBlacklistEntry(entryId)
|
||||
ElMessage.success('已删除')
|
||||
loadEntries()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays > 30) return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
if (diffDays > 0) return `${diffDays}天前`
|
||||
if (diffHours > 0) return `${diffHours}小时前`
|
||||
if (diffMinutes > 0) return `${diffMinutes}分钟前`
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
function reporterName(entry: PlayerBlacklistEntry): string {
|
||||
return entry.expand?.reporter ? displayName(entry.expand.reporter) : '未知用户'
|
||||
}
|
||||
|
||||
function severityClass(severity: BlacklistSeverity): string {
|
||||
return `severity--${severity}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
expandedPlayer.value = null
|
||||
stopSubscription()
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch([filterTag, filterSeverity], () => loadEntries())
|
||||
|
||||
onUnmounted(() => stopSubscription())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="player-blacklist-main">
|
||||
<div class="player-blacklist-main__header">
|
||||
<h3 class="player-blacklist-main__title">玩家黑名单</h3>
|
||||
<div class="player-blacklist-main__actions">
|
||||
<div class="player-blacklist-main__filters">
|
||||
<el-input
|
||||
v-model="searchPlayerId"
|
||||
placeholder="搜索玩家ID"
|
||||
clearable
|
||||
size="small"
|
||||
class="player-blacklist-main__search"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterTag"
|
||||
placeholder="标签筛选"
|
||||
clearable
|
||||
size="small"
|
||||
class="player-blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in PlayerTagMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterSeverity"
|
||||
placeholder="严重程度"
|
||||
clearable
|
||||
size="small"
|
||||
class="player-blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="player-blacklist-main__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-blacklist-main__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
标记玩家
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreatePlayerBlacklistDialog v-model="showCreate" @created="handleCreated" />
|
||||
|
||||
<!-- 玩家卡片列表 -->
|
||||
<div v-if="playerGroups.length > 0" class="player-blacklist-main__content">
|
||||
<div v-for="group in playerGroups" :key="group.playerId + group.platform" class="player-blacklist-main__player-group">
|
||||
<!-- 玩家卡片 -->
|
||||
<div class="player-card" @click="togglePlayer(group.playerId + group.platform)">
|
||||
<div class="player-card__info">
|
||||
<div class="player-card__name-row">
|
||||
<span class="player-card__name">{{ group.playerId }}</span>
|
||||
<span class="player-card__platform">{{ group.platform }}</span>
|
||||
<span class="player-card__badge">{{ group.entries.length }} 次标记</span>
|
||||
</div>
|
||||
<div class="player-card__tags">
|
||||
<span
|
||||
v-for="tag in [...new Set(group.entries.flatMap(e => e.tags))].slice(0, 4)"
|
||||
:key="tag"
|
||||
class="player-card__tag"
|
||||
>
|
||||
{{ PlayerTagMap[tag] }}
|
||||
</span>
|
||||
<span
|
||||
v-for="entry in [...new Set(group.entries.map(e => e.customTag).filter(Boolean))].slice(0, 2)"
|
||||
:key="entry"
|
||||
class="player-card__tag player-card__tag--custom"
|
||||
>
|
||||
{{ entry }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="group.entries[0]?.description" class="player-card__desc">
|
||||
{{ group.entries[0].description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-card__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-card__arrow-icon">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开的详细记录 -->
|
||||
<div v-if="expandedPlayer === group.playerId + group.platform" class="player-blacklist-main__expanded">
|
||||
<div
|
||||
v-for="(entry, index) in group.entries"
|
||||
:key="entry.id"
|
||||
:class="['entry', { 'entry--bordered': index > 0 }]"
|
||||
>
|
||||
<div class="entry__header">
|
||||
<div class="entry__reporter">
|
||||
<div class="entry__avatar">{{ reporterName(entry).charAt(0) }}</div>
|
||||
<span class="entry__name">{{ reporterName(entry) }}</span>
|
||||
</div>
|
||||
<div class="entry__header-right">
|
||||
<span class="entry__time">{{ formatTime(entry.created) }}</span>
|
||||
<el-popconfirm
|
||||
v-if="canDelete(entry)"
|
||||
title="确定删除?"
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete(entry.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="entry__delete-btn" title="删除" @click.stop>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry__tags">
|
||||
<span v-for="tag in entry.tags" :key="tag" class="entry__tag">{{ PlayerTagMap[tag] }}</span>
|
||||
<span v-if="entry.customTag" class="entry__tag entry__tag--custom">{{ entry.customTag }}</span>
|
||||
<span class="entry__severity" :class="severityClass(entry.severity)">{{ BlacklistSeverityMap[entry.severity] }}</span>
|
||||
</div>
|
||||
<div class="entry__desc">{{ entry.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="player-blacklist-main__loading">
|
||||
<p class="player-blacklist-main__loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="player-blacklist-main__empty">
|
||||
<svg class="player-blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<p class="player-blacklist-main__empty-text">暂无玩家黑名单记录</p>
|
||||
<p class="player-blacklist-main__empty-hint">标记坑人的玩家,下次遇到有准备</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.player-blacklist-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.player-blacklist-main__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.player-blacklist-main__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__search {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__filter-select {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.player-blacklist-main__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.player-blacklist-main__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__player-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.player-blacklist-main__expanded {
|
||||
padding: 0 16px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||
}
|
||||
|
||||
/* 玩家卡片 */
|
||||
.player-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.player-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.player-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.player-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.player-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-card__platform {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.player-card__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.player-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.player-card__tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.player-card__tag--custom {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.player-card__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.player-card__arrow {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-card__arrow-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--gg-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.player-card:hover .player-card__arrow-icon {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 记录条目 */
|
||||
.entry {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.entry--bordered {
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.entry__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry__reporter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.entry__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.entry__delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.entry__delete-btn:hover {
|
||||
color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.entry__delete-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.entry__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entry__tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.entry__tag--custom {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.entry__severity {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.severity--mild {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.severity--medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.severity--severe {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.entry__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 加载/空状态 */
|
||||
.player-blacklist-main__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.player-blacklist-main__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-blacklist-main__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.player-blacklist-main__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.player-blacklist-main__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.player-blacklist-main__search {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.player-blacklist-main__filter-select {
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createPoll } from '@/api/polls'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 投票类型: option=选项投票, rollcall=接龙报名
|
||||
const pollType = ref<'option' | 'rollcall'>('option')
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
options: ['', ''],
|
||||
maxParticipants: 10,
|
||||
deadline: '' as string | Date,
|
||||
anonymous: false,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否可以添加选项
|
||||
const canAddOption = computed(() => form.value.options.length < 10)
|
||||
|
||||
function addOption() {
|
||||
if (canAddOption.value) {
|
||||
form.value.options.push('')
|
||||
}
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
form.value.options.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
pollType.value = 'option'
|
||||
form.value = {
|
||||
title: '',
|
||||
options: ['', ''],
|
||||
maxParticipants: 10,
|
||||
deadline: '',
|
||||
anonymous: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// 标题必填
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入投票标题')
|
||||
return
|
||||
}
|
||||
|
||||
// 选项投票模式: 至少2个非空选项
|
||||
if (pollType.value === 'option') {
|
||||
const nonEmpty = form.value.options.filter((o) => o.trim())
|
||||
if (nonEmpty.length < 2) {
|
||||
ElMessage.warning('选项投票至少需要2个非空选项')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 接龙模式自动创建一个"报名参加"选项
|
||||
const options =
|
||||
pollType.value === 'rollcall'
|
||||
? ['报名参加']
|
||||
: form.value.options.filter((o) => o.trim())
|
||||
|
||||
await createPoll({
|
||||
group: groupId,
|
||||
title: form.value.title.trim(),
|
||||
type: pollType.value,
|
||||
anonymous: form.value.anonymous,
|
||||
deadline: form.value.deadline ? new Date(String(form.value.deadline)).toISOString() : undefined,
|
||||
maxParticipants: pollType.value === 'rollcall' ? form.value.maxParticipants : undefined,
|
||||
options,
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
resetForm()
|
||||
ElMessage.success('投票创建成功')
|
||||
emit('created')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '创建投票失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="发起投票"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 投票类型切换 -->
|
||||
<div class="form-field">
|
||||
<label>投票类型</label>
|
||||
<div class="type-switch">
|
||||
<button
|
||||
:class="['type-btn', { active: pollType === 'option' }]"
|
||||
@click="pollType = 'option'"
|
||||
>
|
||||
选项投票
|
||||
</button>
|
||||
<button
|
||||
:class="['type-btn', { active: pollType === 'rollcall' }]"
|
||||
@click="pollType = 'rollcall'"
|
||||
>
|
||||
接龙报名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入投票标题"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表(仅选项投票模式) -->
|
||||
<div v-if="pollType === 'option'" class="form-field">
|
||||
<label>投票选项</label>
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="(_, index) in form.options"
|
||||
:key="index"
|
||||
class="option-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.options[index]"
|
||||
:placeholder="`选项 ${index + 1}`"
|
||||
maxlength="100"
|
||||
/>
|
||||
<el-button
|
||||
v-if="form.options.length > 2"
|
||||
type="danger"
|
||||
text
|
||||
@click="removeOption(index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canAddOption"
|
||||
class="add-option-btn"
|
||||
@click="addOption"
|
||||
>
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 人数上限(仅接龙模式) -->
|
||||
<div v-if="pollType === 'rollcall'" class="form-field">
|
||||
<label>人数上限</label>
|
||||
<el-input-number
|
||||
v-model="form.maxParticipants"
|
||||
:min="2"
|
||||
:max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 截止时间 -->
|
||||
<div class="form-field">
|
||||
<label>截止时间</label>
|
||||
<el-date-picker
|
||||
v-model="form.deadline"
|
||||
type="datetime"
|
||||
placeholder="选择截止时间(可选)"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 匿名开关 -->
|
||||
<div class="form-field">
|
||||
<label>匿名投票</label>
|
||||
<el-switch v-model="form.anonymous" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.type-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
background: var(--gg-bg, #fff);
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: var(--gg-primary, #67c23a);
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: var(--gg-primary, #67c23a);
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.add-option-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { usePollStore } from '@/stores/poll'
|
||||
import type { PollOption, PollVote } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
pollId: string
|
||||
pollType: 'option' | 'rollcall'
|
||||
pollAnonymous: boolean
|
||||
options: PollOption[]
|
||||
votes: PollVote[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const pollStore = usePollStore()
|
||||
|
||||
interface EditOption {
|
||||
id?: string
|
||||
content: string
|
||||
hasVotes: boolean
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
options: [] as EditOption[],
|
||||
maxParticipants: 10 as number | null,
|
||||
deadline: '' as string | Date,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 哪些选项被标记删除
|
||||
const deletedIds = ref<Set<string>>(new Set())
|
||||
|
||||
const activeOptions = computed(() =>
|
||||
form.value.options.filter(o => !deletedIds.value.has(o.id || ''))
|
||||
)
|
||||
|
||||
const canAddOption = computed(() => activeOptions.value.length < 10)
|
||||
|
||||
function initForm() {
|
||||
deletedIds.value = new Set()
|
||||
|
||||
// 从 store 读取当前投票数据
|
||||
const poll = pollStore.currentPoll
|
||||
if (!poll) return
|
||||
|
||||
form.value.title = poll.title
|
||||
form.value.maxParticipants = poll.maxParticipants || null
|
||||
form.value.deadline = poll.deadline ? new Date(poll.deadline) : ''
|
||||
|
||||
// 获取每个选项是否有投票
|
||||
const voteCounts: Record<string, number> = {}
|
||||
for (const v of pollStore.currentVotes) {
|
||||
voteCounts[v.option] = (voteCounts[v.option] || 0) + 1
|
||||
}
|
||||
|
||||
form.value.options = pollStore.currentOptions.map(o => ({
|
||||
id: o.id,
|
||||
content: o.content,
|
||||
hasVotes: (voteCounts[o.id] || 0) > 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (!canAddOption.value) return
|
||||
form.value.options.push({ content: '', hasVotes: false })
|
||||
}
|
||||
|
||||
function removeOption(opt: EditOption) {
|
||||
if (opt.id) {
|
||||
deletedIds.value.add(opt.id)
|
||||
} else {
|
||||
const idx = form.value.options.indexOf(opt)
|
||||
if (idx !== -1) form.value.options.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入投票标题')
|
||||
return
|
||||
}
|
||||
|
||||
const kept = activeOptions.value.filter(o => o.content.trim())
|
||||
if (props.pollType === 'option' && kept.length < 2) {
|
||||
ElMessage.warning('选项投票至少需要2个非空选项')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await pollStore.edit(props.pollId, {
|
||||
title: form.value.title.trim(),
|
||||
deadline: form.value.deadline ? new Date(form.value.deadline).toISOString() : '',
|
||||
maxParticipants: props.pollType === 'rollcall' ? form.value.maxParticipants : null,
|
||||
options: activeOptions.value.map(o => ({
|
||||
id: o.id,
|
||||
content: o.content.trim(),
|
||||
hasVotes: o.hasVotes,
|
||||
})),
|
||||
deletedOptionIds: Array.from(deletedIds.value),
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
ElMessage.success('投票已更新')
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '更新投票失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="编辑投票"
|
||||
width="480px"
|
||||
@open="initForm"
|
||||
>
|
||||
<div class="edit-form">
|
||||
<!-- 投票类型(只读) -->
|
||||
<div class="form-field">
|
||||
<label>投票类型</label>
|
||||
<div class="type-readonly">
|
||||
{{ pollType === 'option' ? '选项投票' : '接龙报名' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入投票标题"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表(仅选项投票模式) -->
|
||||
<div v-if="pollType === 'option'" class="form-field">
|
||||
<label>投票选项</label>
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="opt in form.options"
|
||||
:key="opt.id || opt.content"
|
||||
v-show="!deletedIds.has(opt.id || '')"
|
||||
class="option-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="opt.content"
|
||||
placeholder="选项内容"
|
||||
maxlength="100"
|
||||
/>
|
||||
<el-button
|
||||
v-if="opt.hasVotes"
|
||||
type="info"
|
||||
text
|
||||
disabled
|
||||
title="已有投票,不可删除"
|
||||
>
|
||||
已投票
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="activeOptions.length > 2"
|
||||
type="danger"
|
||||
text
|
||||
@click="removeOption(opt)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canAddOption"
|
||||
class="add-option-btn"
|
||||
@click="addOption"
|
||||
>
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 人数上限(仅接龙模式) -->
|
||||
<div v-if="pollType === 'rollcall'" class="form-field">
|
||||
<label>人数上限</label>
|
||||
<el-input-number
|
||||
v-model="form.maxParticipants"
|
||||
:min="2"
|
||||
:max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 截止时间 -->
|
||||
<div class="form-field">
|
||||
<label>截止时间</label>
|
||||
<el-date-picker
|
||||
v-model="form.deadline"
|
||||
type="datetime"
|
||||
placeholder="选择截止时间(可选)"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 匿名(只读) -->
|
||||
<div class="form-field">
|
||||
<label>匿名投票</label>
|
||||
<div class="type-readonly">{{ pollAnonymous ? '是' : '否' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="save-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.type-readonly {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 6px;
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.add-option-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Poll, PollOption, PollVote } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
poll: Poll
|
||||
options: PollOption[]
|
||||
votes: PollVote[]
|
||||
currentUserVote: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// 投票类型标签
|
||||
const typeLabel = computed(() => {
|
||||
return props.poll.type === 'option' ? '选项投票' : '接龙报名'
|
||||
})
|
||||
|
||||
// 状态标签
|
||||
const statusLabel = computed(() => {
|
||||
return props.poll.status === 'active' ? '进行中' : '已结束'
|
||||
})
|
||||
|
||||
// 是否进行中
|
||||
const isActive = computed(() => props.poll.status === 'active')
|
||||
|
||||
// 总投票人数(去重)
|
||||
const totalVotes = computed(() => {
|
||||
const uniqueUsers = new Set(props.votes.map(v => v.user))
|
||||
return uniqueUsers.size
|
||||
})
|
||||
|
||||
// 各选项得票数
|
||||
const optionVoteCounts = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const vote of props.votes) {
|
||||
counts[vote.option] = (counts[vote.option] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
// 领先选项(最高票)
|
||||
const topOption = computed(() => {
|
||||
if (props.options.length === 0 || props.votes.length === 0) return null
|
||||
|
||||
let topId = ''
|
||||
let topCount = 0
|
||||
for (const [optionId, count] of Object.entries(optionVoteCounts.value)) {
|
||||
if (count > topCount) {
|
||||
topCount = count
|
||||
topId = optionId
|
||||
}
|
||||
}
|
||||
|
||||
const option = props.options.find(o => o.id === topId)
|
||||
return option ? { content: option.content, count: topCount } : null
|
||||
})
|
||||
|
||||
// 截止时间倒计时
|
||||
const deadlineText = computed(() => {
|
||||
if (!props.poll.deadline) return null
|
||||
|
||||
const deadline = new Date(props.poll.deadline).getTime()
|
||||
const now = Date.now()
|
||||
const diff = deadline - now
|
||||
|
||||
if (diff <= 0) return '已截止'
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天后截止`
|
||||
if (hours > 0) return `${hours}小时后截止`
|
||||
if (minutes > 0) return `${minutes}分钟后截止`
|
||||
return '即将截止'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poll-card" @click="emit('click')">
|
||||
<!-- 顶部标签行 -->
|
||||
<div class="poll-card__tags">
|
||||
<span class="poll-card__type-tag" :class="`poll-card__type-tag--${poll.type}`">
|
||||
{{ typeLabel }}
|
||||
</span>
|
||||
<span class="poll-card__status-tag" :class="isActive ? 'poll-card__status-tag--active' : 'poll-card__status-tag--settled'">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<span v-if="poll.anonymous" class="poll-card__anon-tag">
|
||||
匿名
|
||||
</span>
|
||||
<span v-if="currentUserVote" class="poll-card__voted-badge">
|
||||
已参与
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="poll-card__title">
|
||||
{{ poll.title }}
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="poll-card__stats">
|
||||
<span class="poll-card__stat">
|
||||
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{{ totalVotes }} 人参与
|
||||
</span>
|
||||
|
||||
<span v-if="poll.maxParticipants" class="poll-card__stat">
|
||||
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
</svg>
|
||||
限 {{ poll.maxParticipants }} 人
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 领先选项 -->
|
||||
<div v-if="topOption" class="poll-card__top-option">
|
||||
<span class="poll-card__top-label">领先</span>
|
||||
<span class="poll-card__top-content">{{ topOption.content }}</span>
|
||||
<span class="poll-card__top-count">{{ topOption.count }} 票</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部:截止时间 -->
|
||||
<div v-if="deadlineText" class="poll-card__footer">
|
||||
<svg class="poll-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span :class="{ 'poll-card__deadline--urgent': deadlineText === '即将截止' }">
|
||||
{{ deadlineText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poll-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.poll-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.poll-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.poll-card__type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.poll-card__type-tag--option {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.poll-card__type-tag--rollcall {
|
||||
background: rgba(13, 148, 136, 0.1);
|
||||
color: var(--gg-accent);
|
||||
}
|
||||
|
||||
.poll-card__status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.poll-card__status-tag--active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.poll-card__status-tag--settled {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.poll-card__anon-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.poll-card__voted-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.poll-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 统计信息 */
|
||||
.poll-card__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.poll-card__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.poll-card__stat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 领先选项 */
|
||||
.poll-card__top-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.poll-card__top-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.poll-card__top-content {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poll-card__top-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.poll-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.poll-card__footer-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.poll-card__deadline--urgent {
|
||||
color: var(--gg-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Edit } from '@element-plus/icons-vue'
|
||||
import { usePollStore } from '@/stores/poll'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { subscribePollVotes } from '@/api/polls'
|
||||
import { displayName } from '@/types'
|
||||
import EditPollDialog from './EditPollDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
pollId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const pollStore = usePollStore()
|
||||
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
const showEdit = ref(false)
|
||||
|
||||
// 每选项的投票数
|
||||
const optionVoteCounts = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const vote of pollStore.currentVotes) {
|
||||
counts[vote.option] = (counts[vote.option] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
// 总投票人数(去重)
|
||||
const totalVotes = computed(() => {
|
||||
const users = new Set(pollStore.currentVotes.map((v) => v.user))
|
||||
return users.size
|
||||
})
|
||||
|
||||
// 接龙是否满员
|
||||
const isRollcallFull = computed(() => {
|
||||
const poll = pollStore.currentPoll
|
||||
if (!poll || poll.type !== 'rollcall') return false
|
||||
if (!poll.maxParticipants) return false
|
||||
return totalVotes.value >= poll.maxParticipants
|
||||
})
|
||||
|
||||
// 当前用户是否是发起人
|
||||
const isCreator = computed(() => {
|
||||
return pollStore.currentPoll?.creator === pb.authStore.model?.id
|
||||
})
|
||||
|
||||
// 当前用户已投的选项ID集合
|
||||
const votedOptionIds = computed(() => {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return new Set<string>()
|
||||
return new Set(
|
||||
pollStore.currentVotes
|
||||
.filter((v) => v.user === user.id)
|
||||
.map((v) => v.option)
|
||||
)
|
||||
})
|
||||
|
||||
// 当前用户是否已投票
|
||||
const hasVoted = computed(() => votedOptionIds.value.size > 0)
|
||||
|
||||
// 投票是否已结束
|
||||
const isSettled = computed(() => pollStore.currentPoll?.status === 'settled')
|
||||
|
||||
// 获取某个选项的投票人列表
|
||||
function getVotersForOption(optionId: string) {
|
||||
return pollStore.currentVotes.filter((v) => v.option === optionId)
|
||||
}
|
||||
|
||||
// 判断选项是否可点击
|
||||
function canVote(_optionId: string) {
|
||||
if (isSettled.value) return false
|
||||
if (hasVoted.value) return false
|
||||
if (isRollcallFull.value) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 点击选项投票
|
||||
async function handleVote(optionId: string) {
|
||||
if (!canVote(optionId)) return
|
||||
try {
|
||||
await pollStore.vote(props.pollId, optionId)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '投票失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 取消投票
|
||||
async function handleCancelVote() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要取消投票吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await pollStore.unvote(props.pollId)
|
||||
ElMessage.success('已取消投票')
|
||||
} catch {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 结束投票
|
||||
async function handleSettle() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要结束投票吗?结束后将无法再投票。', '结束投票', {
|
||||
confirmButtonText: '确定结束',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await pollStore.settle(props.pollId)
|
||||
ElMessage.success('投票已结束')
|
||||
} catch {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await pollStore.loadPollDetail(props.pollId)
|
||||
// 订阅投票记录变更
|
||||
unsubscribeFn = await subscribePollVotes(props.pollId, () => {
|
||||
pollStore.loadPollDetail(props.pollId)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载投票详情失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
pollStore.clearCurrent()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poll-detail">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="detail-header">
|
||||
<el-button text @click="emit('back')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<div class="detail-header-actions">
|
||||
<button
|
||||
v-if="isCreator && !isSettled"
|
||||
class="action-btn action-btn--edit"
|
||||
@click="showEdit = true"
|
||||
>
|
||||
<el-icon><Edit /></el-icon> 编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="isCreator && !isSettled"
|
||||
class="action-btn action-btn--settle"
|
||||
@click="handleSettle"
|
||||
>
|
||||
结束投票
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="pollStore.loading" class="loading-state">
|
||||
<el-icon class="is-loading"><ArrowLeft /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 投票内容 -->
|
||||
<template v-else-if="pollStore.currentPoll">
|
||||
<!-- 标题区域 -->
|
||||
<div class="poll-title-area">
|
||||
<h2 class="poll-title">{{ pollStore.currentPoll.title }}</h2>
|
||||
<div class="poll-meta">
|
||||
<span v-if="pollStore.currentPoll.type === 'rollcall'" class="poll-type-tag rollcall">
|
||||
接龙报名
|
||||
</span>
|
||||
<span v-else class="poll-type-tag option">选项投票</span>
|
||||
<span v-if="pollStore.currentPoll.anonymous" class="poll-type-tag anonymous">
|
||||
匿名
|
||||
</span>
|
||||
<span class="poll-info">
|
||||
{{ totalVotes }} 人参与
|
||||
</span>
|
||||
<span
|
||||
v-if="pollStore.currentPoll.type === 'rollcall' && pollStore.currentPoll.maxParticipants"
|
||||
class="poll-info"
|
||||
>
|
||||
{{ totalVotes }} / {{ pollStore.currentPoll.maxParticipants }} 人
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="options-area">
|
||||
<div
|
||||
v-for="option in pollStore.currentOptions"
|
||||
:key="option.id"
|
||||
:class="[
|
||||
'option-card',
|
||||
{
|
||||
voted: votedOptionIds.has(option.id),
|
||||
disabled: !canVote(option.id),
|
||||
},
|
||||
]"
|
||||
@click="handleVote(option.id)"
|
||||
>
|
||||
<div class="option-header">
|
||||
<span class="option-text">{{ option.content }}</span>
|
||||
<span class="option-count">{{ optionVoteCounts[option.id] || 0 }} 票</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="option-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{
|
||||
width: totalVotes > 0
|
||||
? ((optionVoteCounts[option.id] || 0) / totalVotes * 100) + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 投票人列表(非匿名时) -->
|
||||
<div
|
||||
v-if="!pollStore.currentPoll.anonymous && getVotersForOption(option.id).length > 0"
|
||||
class="voter-list"
|
||||
>
|
||||
<span
|
||||
v-for="vote in getVotersForOption(option.id)"
|
||||
:key="vote.id"
|
||||
class="voter-name"
|
||||
>
|
||||
{{ displayName(vote.expand?.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div v-if="hasVoted && !isSettled" class="detail-footer">
|
||||
<el-button type="warning" text @click="handleCancelVote">
|
||||
取消投票
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<EditPollDialog
|
||||
v-model="showEdit"
|
||||
:poll-id="pollId"
|
||||
:poll-type="pollStore.currentPoll?.type || 'option'"
|
||||
:poll-anonymous="pollStore.currentPoll?.anonymous || false"
|
||||
:options="pollStore.currentOptions"
|
||||
:votes="pollStore.currentVotes"
|
||||
@saved="pollStore.loadPollDetail(pollId)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poll-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn--edit {
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn--edit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.action-btn--settle {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.action-btn--settle:hover {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 0;
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
}
|
||||
|
||||
.poll-title-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.poll-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.poll-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.poll-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.poll-type-tag.option {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
color: var(--gg-primary, #67c23a);
|
||||
}
|
||||
|
||||
.poll-type-tag.rollcall {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.poll-type-tag.anonymous {
|
||||
background: rgba(144, 147, 153, 0.1);
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
}
|
||||
|
||||
.poll-info {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
}
|
||||
|
||||
.options-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--gg-border, #ebeef5);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.option-card:hover:not(.disabled) {
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
background: rgba(103, 194, 58, 0.03);
|
||||
}
|
||||
|
||||
.option-card.voted {
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
background: rgba(103, 194, 58, 0.06);
|
||||
}
|
||||
|
||||
.option-card.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.option-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
}
|
||||
|
||||
.option-progress {
|
||||
height: 4px;
|
||||
background: var(--gg-bg-page, #f5f7fa);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--gg-primary, #67c23a);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.voted .progress-bar {
|
||||
background: var(--gg-primary, #67c23a);
|
||||
}
|
||||
|
||||
.voter-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.voter-name {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--gg-bg-page, #f5f7fa);
|
||||
border-radius: 4px;
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { usePollStore } from '@/stores/poll'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { subscribePolls, getPollOptions, getPollVotes, getUserVote } from '@/api/polls'
|
||||
import PollCard from './PollCard.vue'
|
||||
import CreatePollDialog from './CreatePollDialog.vue'
|
||||
import type { PollOption, PollVote } from '@/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewPoll: [pollId: string]
|
||||
}>()
|
||||
|
||||
const pollStore = usePollStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 缓存每个 poll 的 options / votes / userVote
|
||||
interface PollExtra {
|
||||
options: PollOption[]
|
||||
votes: PollVote[]
|
||||
userVote: string | null
|
||||
}
|
||||
const pollExtras = ref<Record<string, PollExtra>>({})
|
||||
|
||||
// 加载所有投票及其详情
|
||||
async function loadAll() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
await pollStore.loadPolls(groupId)
|
||||
|
||||
// 并行加载每条 poll 的 options / votes / userVote
|
||||
const allPolls = [...pollStore.activePolls, ...pollStore.settledPolls]
|
||||
const results = await Promise.allSettled(
|
||||
allPolls.map(async (poll) => {
|
||||
const [options, votes, userVote] = await Promise.all([
|
||||
getPollOptions(poll.id),
|
||||
getPollVotes(poll.id),
|
||||
getUserVote(poll.id),
|
||||
])
|
||||
return {
|
||||
pollId: poll.id,
|
||||
options,
|
||||
votes,
|
||||
userVote: userVote?.option ?? null,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const extras: Record<string, PollExtra> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
extras[r.value.pollId] = {
|
||||
options: r.value.options,
|
||||
votes: r.value.votes,
|
||||
userVote: r.value.userVote,
|
||||
}
|
||||
}
|
||||
}
|
||||
pollExtras.value = extras
|
||||
}
|
||||
|
||||
// 实时订阅投票变更
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribePolls(groupId, () => {
|
||||
loadAll()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某个 poll 的缓存 extras
|
||||
function getExtra(pollId: string): PollExtra {
|
||||
return pollExtras.value[pollId] || { options: [], votes: [], userVote: null }
|
||||
}
|
||||
|
||||
// 分栏数据
|
||||
const activePolls = computed(() => pollStore.activePolls)
|
||||
const settledPolls = computed(() => pollStore.settledPolls)
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadAll()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadAll()
|
||||
if (!unsubscribeFn) startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poll-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="poll-list__header">
|
||||
<h3 class="poll-list__title">投票</h3>
|
||||
<button class="poll-list__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="poll-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
发起投票
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreatePollDialog
|
||||
v-model="showCreate"
|
||||
@created="loadAll(); showCreate = false"
|
||||
/>
|
||||
|
||||
<!-- 进行中 -->
|
||||
<section v-if="activePolls.length > 0" class="poll-list__section">
|
||||
<div class="poll-list__section-header">
|
||||
<span class="poll-list__section-dot poll-list__section-dot--active"></span>
|
||||
<span class="poll-list__section-label">进行中</span>
|
||||
<span class="poll-list__section-count">{{ activePolls.length }}</span>
|
||||
</div>
|
||||
<div class="poll-list__grid">
|
||||
<PollCard
|
||||
v-for="poll in activePolls"
|
||||
:key="poll.id"
|
||||
:poll="poll"
|
||||
:options="getExtra(poll.id).options"
|
||||
:votes="getExtra(poll.id).votes"
|
||||
:current-user-vote="getExtra(poll.id).userVote"
|
||||
@click="emit('viewPoll', poll.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 已结束 -->
|
||||
<section v-if="settledPolls.length > 0" class="poll-list__section">
|
||||
<div class="poll-list__section-header">
|
||||
<span class="poll-list__section-dot poll-list__section-dot--settled"></span>
|
||||
<span class="poll-list__section-label">已结束</span>
|
||||
<span class="poll-list__section-count">{{ settledPolls.length }}</span>
|
||||
</div>
|
||||
<div class="poll-list__grid">
|
||||
<PollCard
|
||||
v-for="poll in settledPolls"
|
||||
:key="poll.id"
|
||||
:poll="poll"
|
||||
:options="getExtra(poll.id).options"
|
||||
:votes="getExtra(poll.id).votes"
|
||||
:current-user-vote="getExtra(poll.id).userVote"
|
||||
@click="emit('viewPoll', poll.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="activePolls.length === 0 && settledPolls.length === 0 && !pollStore.loading"
|
||||
class="poll-list__empty"
|
||||
>
|
||||
<svg class="poll-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="9" x2="15" y2="9" />
|
||||
<line x1="9" y1="13" x2="13" y2="13" />
|
||||
</svg>
|
||||
<p class="poll-list__empty-text">暂无投票</p>
|
||||
<p class="poll-list__empty-hint">群组内还没有发起过投票</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poll-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.poll-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.poll-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.poll-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.poll-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.poll-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 分栏 */
|
||||
.poll-list__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.poll-list__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.poll-list__section-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.poll-list__section-dot--active {
|
||||
background: var(--gg-success);
|
||||
box-shadow: 0 0 6px var(--gg-success);
|
||||
}
|
||||
|
||||
.poll-list__section-dot--settled {
|
||||
background: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.poll-list__section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.poll-list__section-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.poll-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.poll-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.poll-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.poll-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.poll-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.poll-list__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import { TrendCharts, Trophy } from '@element-plus/icons-vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const weekSessionCount = ref(0)
|
||||
const pollParticipationRate = ref('0%')
|
||||
const topMembers = ref<{ name: string; points: number }[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
async function loadStats() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 查询本周组队次数
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
const sessionResult = await pb.collection('team_sessions').getList(1, 1, {
|
||||
filter: `sourceGroup="${groupId}" && created>="${sevenDaysAgo.toISOString()}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
weekSessionCount.value = sessionResult.totalItems
|
||||
|
||||
// 查询投票参与率(已投票人数 / 群成员数)
|
||||
const memberCount = groupStore.currentMembers.length || 1
|
||||
const memberIds = groupStore.currentMembers.map(m => m.id)
|
||||
const pollResult = await pb.collection('polls').getFullList({
|
||||
filter: `group="${groupId}" && status="active"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
let votedUserCount = 0
|
||||
if (pollResult.length > 0 && memberIds.length > 0) {
|
||||
const pollIds = (pollResult as any[]).map(p => p.id)
|
||||
const pollFilter = pollIds.map(id => `poll="${id}"`).join(' || ')
|
||||
const votes = await pb.collection('poll_votes').getFullList({
|
||||
filter: `(${pollFilter})`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
const votedUsers = new Set((votes as any[]).map(v => v.user))
|
||||
votedUserCount = votedUsers.size
|
||||
}
|
||||
const rate = memberCount > 0 ? Math.round((votedUserCount * 100) / memberCount) : 0
|
||||
pollParticipationRate.value = `${rate}%`
|
||||
|
||||
// 群成员积分排行 top 10
|
||||
const sorted = [...groupStore.currentMembers]
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
.slice(0, 10)
|
||||
topMembers.value = sorted.map(m => ({
|
||||
name: displayName(m),
|
||||
points: m.points || 0,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) loadStats()
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-panel">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="stats-loading">
|
||||
<el-icon class="is-loading" :size="20"><TrendCharts /></el-icon>
|
||||
<span>加载统计数据中...</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<!-- 本周组队 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-icon">
|
||||
<el-icon :size="24"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-card-body">
|
||||
<div class="stat-card-value">{{ weekSessionCount }}</div>
|
||||
<div class="stat-card-label">本周组队</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投票参与率 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-icon icon-poll">
|
||||
<el-icon :size="24"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-card-body">
|
||||
<div class="stat-card-value">{{ pollParticipationRate }}</div>
|
||||
<div class="stat-card-label">投票参与率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群成员 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-icon icon-members">
|
||||
<el-icon :size="24"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-card-body">
|
||||
<div class="stat-card-value">{{ groupStore.currentMembers.length }}</div>
|
||||
<div class="stat-card-label">群成员</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活跃排行 -->
|
||||
<div class="ranking-section">
|
||||
<div class="ranking-header">
|
||||
<el-icon><Trophy /></el-icon>
|
||||
<span>积分排行</span>
|
||||
</div>
|
||||
<div v-if="topMembers.length === 0" class="ranking-empty">暂无排行数据</div>
|
||||
<div v-else class="ranking-list">
|
||||
<div
|
||||
v-for="(member, index) in topMembers"
|
||||
:key="index"
|
||||
class="ranking-item"
|
||||
>
|
||||
<span class="ranking-index" :class="{ 'top-three': index < 3 }">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="ranking-name">{{ member.name }}</span>
|
||||
<span class="ranking-points">{{ member.points }} 分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stats-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 0;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(5, 150, 105, 0.12);
|
||||
color: var(--gg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-card-icon.icon-poll {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-card-icon.icon-members {
|
||||
background: rgba(230, 162, 60, 0.12);
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.stat-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 排行 */
|
||||
.ranking-section {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ranking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ranking-empty {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ranking-item:hover {
|
||||
background: var(--gg-bg-elevated);
|
||||
}
|
||||
|
||||
.ranking-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ranking-index.top-three {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ranking-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ranking-points {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary-light);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- src/components/team/TeamSessionPanel.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -15,6 +16,7 @@ const userStore = useUserStore()
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
||||
const showGameSelect = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const memberDetails = computed(() => {
|
||||
if (!session.value) return []
|
||||
@@ -114,6 +116,13 @@ async function handleGameSelected(gameName: string) {
|
||||
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
|
||||
游戏结束
|
||||
</button>
|
||||
<router-link
|
||||
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||
class="voice-btn"
|
||||
>
|
||||
语音房间
|
||||
</router-link>
|
||||
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
|
||||
解散小组
|
||||
</button>
|
||||
@@ -315,4 +324,25 @@ async function handleGameSelected(gameName: string) {
|
||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="'/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-status muted">静音</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-status active">说话中</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mic-status.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-status.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
// src/composables/useVoiceRoom.ts
|
||||
import { ref } from 'vue'
|
||||
import { Room, RoomEvent, Participant, RemoteTrackPublication } from 'livekit-client'
|
||||
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string
|
||||
name: string
|
||||
isSpeaking: boolean
|
||||
isMuted: boolean
|
||||
}
|
||||
|
||||
export function useVoiceRoom() {
|
||||
const room = ref<Room | null>(null)
|
||||
const connected = ref(false)
|
||||
const participants = ref<Map<string, VoiceParticipant>>(new Map())
|
||||
const micEnabled = ref(true)
|
||||
const speakerEnabled = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function syncParticipant(participant: Participant) {
|
||||
const audioPub = participant.audioTrackPublications.values().next().value
|
||||
participants.value.set(participant.identity, {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
isMuted: audioPub?.isMuted ?? true,
|
||||
})
|
||||
participants.value = new Map(participants.value)
|
||||
}
|
||||
|
||||
async function connect(sessionId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
const isElectron = /Electron/.test(navigator.userAgent)
|
||||
if (isElectron) {
|
||||
throw new Error('麦克风权限未获取,请检查 Electron 是否已正确配置安全源和权限处理器。')
|
||||
}
|
||||
throw new Error('浏览器不允许在 HTTP 下使用麦克风。请在 Chrome 地址栏输入 chrome://flags/#unsafely-treat-insecure-origin-as-secure,启用后将 http://192.168.1.14:7033 加入白名单,然后重启浏览器。')
|
||||
}
|
||||
|
||||
const token = await fetchVoiceToken(sessionId)
|
||||
const livekitUrl = getLiveKitUrl()
|
||||
|
||||
const newRoom = new Room()
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, syncParticipant)
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, (p) => {
|
||||
participants.value.delete(p.identity)
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
const ids = new Set(speakers.map(s => s.identity))
|
||||
for (const [id, p] of participants.value) {
|
||||
p.isSpeaking = ids.has(id)
|
||||
}
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
newRoom.on(RoomEvent.TrackMuted, (_pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackUnmuted, (_pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||
|
||||
await newRoom.connect(livekitUrl, token)
|
||||
|
||||
syncParticipant(newRoom.localParticipant as unknown as Participant)
|
||||
for (const p of newRoom.remoteParticipants.values()) {
|
||||
syncParticipant(p as unknown as Participant)
|
||||
}
|
||||
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
room.value = newRoom
|
||||
connected.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '连接语音房间失败'
|
||||
console.error('Voice room connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!room.value) return
|
||||
const enabled = !micEnabled.value
|
||||
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
syncParticipant(room.value.localParticipant as unknown as Participant)
|
||||
}
|
||||
|
||||
async function toggleSpeaker() {
|
||||
if (!room.value) return
|
||||
speakerEnabled.value = !speakerEnabled.value
|
||||
for (const p of room.value.remoteParticipants.values()) {
|
||||
for (const pub of p.audioTrackPublications.values()) {
|
||||
if (pub instanceof RemoteTrackPublication) {
|
||||
pub.setEnabled(speakerEnabled.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room.value) {
|
||||
await room.value.disconnect()
|
||||
room.value = null
|
||||
}
|
||||
connected.value = false
|
||||
participants.value = new Map()
|
||||
micEnabled.value = true
|
||||
speakerEnabled.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import './assets/design.css'
|
||||
import './assets/mobile.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
@@ -19,5 +22,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.use(Vant)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<!-- src/mobile/MobileLayout.vue -->
|
||||
<!-- 手机端主布局:顶部栏 + 底部 Tab + 内容区 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 与桌面 Layout 相同的初始化
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.initUser()
|
||||
await groupStore.loadGroups()
|
||||
await teamStore.loadActiveSession()
|
||||
await notificationStore.loadPendingInvitations()
|
||||
await notificationStore.startListening()
|
||||
|
||||
refreshTimer = setInterval(async () => {
|
||||
await groupStore.loadGroups()
|
||||
await teamStore.loadActiveSession()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
notificationStore.stopListening()
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 当前激活的底部 Tab(根据路由匹配)
|
||||
const activeTab = computed(() => {
|
||||
if (route.path.startsWith('/mobile-groups')) return 'groups'
|
||||
if (route.path.startsWith('/games')) return 'games'
|
||||
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
|
||||
if (route.path.startsWith('/profile')) return 'profile'
|
||||
return 'home'
|
||||
})
|
||||
|
||||
// 顶部栏标题
|
||||
const pageTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
home: 'Game Group',
|
||||
groups: '我的群组',
|
||||
games: '游戏库',
|
||||
notifications: '通知',
|
||||
profile: '我的'
|
||||
}
|
||||
// 群组详情页显示群组名
|
||||
if (route.name === 'GroupView' && groupStore.currentGroup) {
|
||||
return groupStore.currentGroup.name
|
||||
}
|
||||
return titles[activeTab.value] || 'Game Group'
|
||||
})
|
||||
|
||||
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
|
||||
const showBack = computed(() => {
|
||||
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
|
||||
})
|
||||
|
||||
// 底部 Tab 切换
|
||||
function onTabChange(name: string | number) {
|
||||
const routes: Record<string, string> = {
|
||||
home: '/',
|
||||
groups: '/mobile-groups',
|
||||
games: '/games',
|
||||
notifications: '/mobile-notifications',
|
||||
profile: '/profile'
|
||||
}
|
||||
const target = routes[String(name)]
|
||||
if (target && route.path !== target) {
|
||||
router.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
function onBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 通知未读数
|
||||
const unreadCount = computed(() => notificationStore.unreadCount)
|
||||
|
||||
function goNotifications() {
|
||||
router.push('/mobile-notifications')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-app">
|
||||
<!-- 顶部栏 -->
|
||||
<van-nav-bar
|
||||
:title="pageTitle"
|
||||
:left-arrow="showBack"
|
||||
fixed
|
||||
placeholder
|
||||
@click-left="onBack"
|
||||
>
|
||||
<template #right>
|
||||
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
|
||||
<van-icon name="bell" size="22" @click="goNotifications" />
|
||||
</van-badge>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="mobile-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- 底部 Tab -->
|
||||
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
|
||||
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
|
||||
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
|
||||
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
|
||||
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
|
||||
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-app {
|
||||
min-height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
flex: 1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
// src/mobile/useDevice.ts
|
||||
// 设备检测:判断当前是否移动端,结果存 localStorage 避免重复检测
|
||||
|
||||
const STORAGE_KEY = 'device_mode'
|
||||
|
||||
/** UA 关键字匹配移动设备 */
|
||||
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
|
||||
|
||||
/** 屏幕宽度阈值(含平板竖屏) */
|
||||
const MOBILE_WIDTH = 768
|
||||
|
||||
/**
|
||||
* 原始检测:综合 UA 和屏幕宽度判断
|
||||
* - UA 命中移动设备关键字 → 手机
|
||||
* - 屏宽 <= 768 → 手机
|
||||
* - 否则 → 桌面
|
||||
*/
|
||||
function detectRaw(): 'mobile' | 'desktop' {
|
||||
if (typeof navigator === 'undefined') return 'desktop'
|
||||
if (MOBILE_UA.test(navigator.userAgent)) return 'mobile'
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_WIDTH) return 'mobile'
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
/** 当前设备模式(读取 localStorage,无则检测并存入) */
|
||||
export function getDeviceMode(): 'mobile' | 'desktop' {
|
||||
if (typeof localStorage === 'undefined') return detectRaw()
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'mobile' || stored === 'desktop') return stored
|
||||
const detected = detectRaw()
|
||||
localStorage.setItem(STORAGE_KEY, detected)
|
||||
return detected
|
||||
}
|
||||
|
||||
/** 是否移动端(路由分流用,同步函数) */
|
||||
export function isMobile(): boolean {
|
||||
return getDeviceMode() === 'mobile'
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动切换设备模式(设置页"切换到桌面版/手机版"用)
|
||||
* 切换后需整页刷新以重新走路由解析
|
||||
*/
|
||||
export function setDeviceMode(mode: 'mobile' | 'desktop') {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
}
|
||||
|
||||
/** 重置为自动检测(登出时调用,避免下个用户沿用上个用户的偏好) */
|
||||
export function resetDeviceMode() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -2,56 +2,144 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { isAuthenticated } from '@/api/pocketbase'
|
||||
import { isMobile } from '@/mobile/useDevice'
|
||||
|
||||
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
|
||||
const LayoutComponent = () =>
|
||||
isMobile()
|
||||
? import('@/mobile/MobileLayout.vue')
|
||||
: import('@/views/Layout.vue')
|
||||
|
||||
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
|
||||
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
|
||||
return isMobile() ? mobile : desktop
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
component: view(
|
||||
() => import('@/views/Login.vue'),
|
||||
() => import('@/views-mobile/LoginMobile.vue')
|
||||
),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
component: view(
|
||||
() => import('@/views/Register.vue'),
|
||||
() => import('@/views-mobile/RegisterMobile.vue')
|
||||
),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/Layout.vue'),
|
||||
component: LayoutComponent,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue')
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'),
|
||||
() => import('@/views-mobile/HomeMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'mobile-groups',
|
||||
name: 'MobileGroups',
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
|
||||
() => import('@/views-mobile/GroupsMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'mobile-notifications',
|
||||
name: 'MobileNotifications',
|
||||
component: view(
|
||||
() => import('@/views/Home.vue'),
|
||||
() => import('@/views-mobile/NotificationsMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'group/:id',
|
||||
name: 'GroupView',
|
||||
component: () => import('@/views/GroupView.vue'),
|
||||
component: view(
|
||||
() => import('@/views/GroupView.vue'),
|
||||
() => import('@/views-mobile/GroupViewMobile.vue')
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: view(
|
||||
() => import('@/views/LedgerView.vue'),
|
||||
() => import('@/views-mobile/LedgerMobile.vue')
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: view(
|
||||
() => import('@/views/AssetView.vue'),
|
||||
() => import('@/views-mobile/AssetMobile.vue')
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/blacklist',
|
||||
name: 'BlacklistView',
|
||||
component: view(
|
||||
() => import('@/views/BlacklistView.vue'),
|
||||
() => import('@/views-mobile/BlacklistMobile.vue')
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: view(
|
||||
() => import('@/views/VoiceRoom.vue'),
|
||||
() => import('@/views-mobile/VoiceRoomMobile.vue')
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
component: () => import('@/views/GamesLibrary.vue')
|
||||
component: view(
|
||||
() => import('@/views/GamesLibrary.vue'),
|
||||
() => import('@/views-mobile/GamesLibraryMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue')
|
||||
component: view(
|
||||
() => import('@/views/Profile.vue'),
|
||||
() => import('@/views-mobile/ProfileMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue')
|
||||
component: view(
|
||||
() => import('@/views/Settings.vue'),
|
||||
() => import('@/views-mobile/SettingsMobile.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'Changelog',
|
||||
component: () => import('@/views/Changelog.vue')
|
||||
component: view(
|
||||
() => import('@/views/Changelog.vue'),
|
||||
() => import('@/views-mobile/ChangelogMobile.vue')
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -68,17 +156,15 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
// 路由守卫(与原逻辑一致)
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authenticated = isAuthenticated()
|
||||
|
||||
// 需要登录的页面
|
||||
if (to.meta.requiresAuth && !authenticated) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录用户访问登录/注册页
|
||||
if (to.meta.requiresGuest && authenticated) {
|
||||
next({ name: 'Home' })
|
||||
return
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Asset } from '@/types'
|
||||
import {
|
||||
listAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
transferAsset,
|
||||
deleteAsset,
|
||||
subscribeAssets
|
||||
} from '@/api/assets'
|
||||
import type { CreateAssetData, UpdateAssetData } from '@/api/assets'
|
||||
|
||||
export const useAssetStore = defineStore('asset', () => {
|
||||
const assets = ref<Asset[]>([])
|
||||
const loading = ref(false)
|
||||
let unsubFn: (() => Promise<void> | void) | null = null
|
||||
|
||||
async function loadAssets(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
assets.value = await listAssets(groupId)
|
||||
} catch (error) {
|
||||
console.error('加载资产列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addAsset(data: CreateAssetData) {
|
||||
try {
|
||||
loading.value = true
|
||||
await createAsset(data)
|
||||
await loadAssets(data.group)
|
||||
} catch (error) {
|
||||
console.error('创建资产失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function editAsset(assetId: string, data: UpdateAssetData, image?: File) {
|
||||
try {
|
||||
loading.value = true
|
||||
await updateAsset(assetId, data, image)
|
||||
// 找到当前资产的 groupId 以刷新列表
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
await loadAssets(asset.group)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新资产失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function transfer(assetId: string, userId: string) {
|
||||
try {
|
||||
await transferAsset(assetId, userId)
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
await loadAssets(asset.group)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转移资产失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAsset(assetId: string) {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
assets.value = assets.value.filter(a => a.id !== assetId)
|
||||
// 返回 groupId 以便调用方需要时使用
|
||||
return asset?.group
|
||||
} catch (error) {
|
||||
console.error('删除资产失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
stopSubscription()
|
||||
unsubFn = await subscribeAssets(groupId, () => {
|
||||
loadAssets(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubFn) {
|
||||
unsubFn()
|
||||
unsubFn = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assets,
|
||||
loading,
|
||||
loadAssets,
|
||||
addAsset,
|
||||
editAsset,
|
||||
transfer,
|
||||
removeAsset,
|
||||
startSubscription,
|
||||
stopSubscription
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user