Compare commits
34 Commits
4c7152ff50
..
uat
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ef615bc0 | |||
| 7cd53ac420 | |||
| f79bdac889 | |||
| 3d1d325203 | |||
| 80dc45f528 | |||
| 2af0025c3e | |||
| 062c044295 | |||
| 4b54f71902 | |||
| 13e87110ae | |||
| 6ba671d2c3 | |||
| 1cc23a0836 | |||
| 0ec868c949 | |||
| 446dbf8ae0 | |||
| a303415857 | |||
| d4dbb1a10f | |||
| 38c13ec50e | |||
| 0b999eebb0 | |||
| d76ecb15d6 | |||
| f3fe7d4f2d | |||
| ceafc873c7 | |||
| 625645dad4 | |||
| a762a5bb4c | |||
| a062889a11 | |||
| 2fec2108ca | |||
| 0cde794c85 | |||
| 7f17dc826e | |||
| 01412a0a94 | |||
| 671933d960 | |||
| 6b3cd288b1 | |||
| d3ef22f06e | |||
| 068708bbd7 | |||
| f99c44f716 | |||
| 860ad7cbd8 | |||
| 3c2b68bbc3 |
+6
-1
@@ -1,6 +1,5 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
@@ -43,3 +42,9 @@ backend/pb_migrations.bak/
|
|||||||
|
|
||||||
# PocketBase UAT data
|
# PocketBase UAT data
|
||||||
backend/pb_data_uat/
|
backend/pb_data_uat/
|
||||||
|
|
||||||
|
# Electron update build artifacts
|
||||||
|
electron-update/
|
||||||
|
|
||||||
|
# Docs plans (working drafts)
|
||||||
|
docs/plans/
|
||||||
|
|||||||
@@ -9,22 +9,35 @@ Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队
|
|||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建前端
|
# 构建前端(先 vue-tsc 类型检查,再 vite build。类型错误会阻断构建)
|
||||||
|
# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理
|
||||||
|
# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
# 本地开发(一般不用,用 Docker 部署代替)
|
# 本地 vite dev server(一般不用,用 Docker 部署代替)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
|
|
||||||
# 部署脚本(根目录)
|
# 部署脚本(根目录)
|
||||||
./deploy-backend.sh # 部署 PocketBase 后端
|
./deploy-dev.sh # 构建 + 部署 Dev 全套 (PB + LiveKit + 前端, 端口 7033/8090)
|
||||||
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
|
./deploy-uat.sh # 构建 + 部署 UAT 全套 (PB + LiveKit + 前端, 端口 7034/8712)
|
||||||
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
|
|
||||||
./stop-all.sh # 停止所有服务
|
./stop-all.sh # 停止所有服务
|
||||||
|
|
||||||
|
# Electron 桌面端
|
||||||
|
cd electron && npm install
|
||||||
|
cd electron && npm run start # 启动桌面端(默认连接 Dev)
|
||||||
|
cd electron && npm run start:dev # 同上,显式指定 dev 环境
|
||||||
|
cd electron && npm run start:uat # 连接 UAT 环境
|
||||||
|
cd electron && npm run build # 打包 Windows 可执行文件
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
docker logs -f gamegroup-pb # 后端
|
docker logs -f gamegroup-pb # Dev PocketBase
|
||||||
docker logs -f gamegroup-frontend-dev # Dev
|
docker logs -f gamegroup-pb-uat # UAT PocketBase
|
||||||
docker logs -f gamegroup-frontend-uat # UAT
|
docker logs -f gamegroup-livekit-dev # Dev LiveKit
|
||||||
|
docker logs -f gamegroup-livekit-uat # UAT LiveKit
|
||||||
|
docker logs -f gamegroup-voice-token-dev # Dev Voice Token
|
||||||
|
docker logs -f gamegroup-voice-token-uat # UAT Voice Token
|
||||||
|
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 前必须等用户确认。
|
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
|
||||||
@@ -36,6 +49,8 @@ docker logs -f gamegroup-frontend-uat # UAT
|
|||||||
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
||||||
- **实时通信**: PocketBase realtime subscriptions
|
- **实时通信**: PocketBase realtime subscriptions
|
||||||
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
||||||
|
- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务
|
||||||
|
- **桌面端**: Electron 35 + `electron-store` + `electron-builder`
|
||||||
|
|
||||||
## 环境与端口
|
## 环境与端口
|
||||||
|
|
||||||
@@ -43,8 +58,12 @@ docker logs -f gamegroup-frontend-uat # UAT
|
|||||||
|------|-----|-----|
|
|------|-----|-----|
|
||||||
| 前端 (nginx) | 7033 | 7034 |
|
| 前端 (nginx) | 7033 | 7034 |
|
||||||
| PocketBase | 8090 | 8712 |
|
| PocketBase | 8090 | 8712 |
|
||||||
|
| LiveKit | 7880/7881/7882(udp) | 7890/7891/7892(udp) |
|
||||||
|
| Voice Token | 7883 | 7893 |
|
||||||
|
|
||||||
Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
|
Docker Compose 文件:`docker-compose.dev.yml`(Dev 全套)、`docker-compose.uat.yml`(UAT 全套),共享 `gamegroup-net` 网络。每个环境独立运行完整的 PB + LiveKit + Voice Token + 前端服务。
|
||||||
|
|
||||||
|
前端 `.env` 文件:`frontend/.env.dev` 和 `frontend/.env.uat`。每个 env 包含四个变量:`VITE_PB_URL`(PocketBase 地址)、`VITE_PORT`(dev server 端口)、`VITE_LIVEKIT_URL`(LiveKit WebSocket 地址)、`VITE_VOICE_TOKEN_URL`(Voice Token 服务地址)。Docker 构建时 `VITE_PB_URL` 被覆盖为空,由 nginx 反向代理处理。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
@@ -59,10 +78,12 @@ pocketbase.ts (PB 客户端初始化)
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
- **`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`)
|
- **`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`, `voice.ts`, `bulletins.ts`, `events.ts`)
|
||||||
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)
|
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)。共 11 个 store(user/group/team/event/poll/ledger/asset/memory/notification/bulletin),`index.ts` 仅导出前 3 个,其余直接从文件导入
|
||||||
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||||
|
- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制
|
||||||
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
||||||
|
- **路径别名**: `@/` 映射到 `src/`,在 `vite.config.ts` 和 `tsconfig.json` 中配置
|
||||||
|
|
||||||
### 认证流程
|
### 认证流程
|
||||||
|
|
||||||
@@ -72,17 +93,41 @@ pocketbase.ts (PB 客户端初始化)
|
|||||||
|
|
||||||
### 路由结构
|
### 路由结构
|
||||||
|
|
||||||
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
|
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), VoiceRoom (`/group/:groupId/voice/:sessionId`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。JoinGroup (`/join/group/:groupId`) 和 JoinTeam (`/join/team/:sessionId`) 为无需认证的独立页面,用于外部邀请链接。
|
||||||
|
|
||||||
### Vite 代理 vs Nginx
|
### Vite 代理 vs Nginx
|
||||||
|
|
||||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
|
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 voice-token 端口(Dev: 7883, UAT: 7893)。
|
||||||
|
|
||||||
|
前端 nginx 配置有两份:`nginx.conf`(dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。
|
||||||
|
|
||||||
|
### 语音通话架构
|
||||||
|
|
||||||
|
组队会话 (`team_sessions`) 支持语音房间。语音流程:
|
||||||
|
1. 用户点击语音按钮 → 路由跳转到 `VoiceRoom.vue`
|
||||||
|
2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts` 的 `fetchVoiceToken`
|
||||||
|
3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token
|
||||||
|
4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT
|
||||||
|
5. 前端用 token 连接 LiveKit server(Dev: `ws://192.168.1.14:7880`, UAT: `ws://192.168.1.14:7890`)
|
||||||
|
|
||||||
|
HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。
|
||||||
|
|
||||||
|
### Electron 桌面端
|
||||||
|
|
||||||
|
- `electron/main.js` — 主进程,加载远程 URL(dev/uat 可切换),窗口大小 1280x800
|
||||||
|
- `electron/preload.js` — 预加载脚本(当前为空壳,保留扩展点)
|
||||||
|
- `electron/package.json` — 独立包,依赖 `electron-store`(用于本地配置持久化)
|
||||||
|
- 构建产物为 Windows portable 可执行文件
|
||||||
|
|
||||||
|
### 实时订阅管理
|
||||||
|
|
||||||
|
所有 PocketBase 实时订阅统一通过 `useRealtime.ts` composable 管理。组件使用时在 `onMounted` 中调用订阅方法,`onUnmounted` 时自动调用 `unsubscribeAll` 清理。不要直接在组件中创建孤立的 `pb.collection().subscribe()` 而不做清理。
|
||||||
|
|
||||||
### 数据模型(PocketBase Collections)
|
### 数据模型(PocketBase Collections)
|
||||||
|
|
||||||
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
||||||
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
||||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
|
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom` 和 `voiceActive` 字段
|
||||||
- **invitations** — 组队邀请,pending/accepted/rejected
|
- **invitations** — 组队邀请,pending/accepted/rejected
|
||||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||||
- **game_comments** / **game_favorites** — 评论和收藏
|
- **game_comments** / **game_favorites** — 评论和收藏
|
||||||
@@ -90,12 +135,13 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`),
|
|||||||
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
||||||
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
||||||
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
||||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
|
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group。nginx 配置 `client_max_body_size 500m` 支持大文件上传
|
||||||
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
||||||
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
||||||
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
||||||
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
||||||
- **notifications** — 站内通知(投票/组队/入群等事件)
|
- **notifications** — 站内通知(投票/组队/入群等事件)
|
||||||
|
- **bulletin_posts** / **bulletin_reads** — 群组信息公示板,支持置顶/优先级/已读追踪/过期时间
|
||||||
|
|
||||||
### PocketBase 注意事项
|
### PocketBase 注意事项
|
||||||
|
|
||||||
@@ -103,5 +149,10 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`),
|
|||||||
- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代
|
- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代
|
||||||
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
||||||
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
||||||
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
|
|
||||||
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
||||||
|
|
||||||
|
## 编码约束
|
||||||
|
|
||||||
|
- **TypeScript 严格模式**: `strict: true` + `noUnusedLocals` + `noUnusedParameters`,未使用的变量/参数会导致构建失败
|
||||||
|
- **无测试框架**: 项目当前没有测试文件和测试配置,不要尝试运行测试
|
||||||
|
- **无 Linter 配置**: 没有 ESLint/Prettier 配置文件
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("gblacklist_col")
|
||||||
|
|
||||||
|
collection.updateRule = "reporter = @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("gblacklist_col")
|
||||||
|
|
||||||
|
collection.updateRule = null
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||||
|
|
||||||
|
// add admins field
|
||||||
|
collection.schema.addField(new SchemaField({
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_admins",
|
||||||
|
"name": "admins",
|
||||||
|
"type": "relation",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": null,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add allowMemberManage field
|
||||||
|
collection.schema.addField(new SchemaField({
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_allowmm",
|
||||||
|
"name": "allowMemberManage",
|
||||||
|
"type": "bool",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||||
|
|
||||||
|
collection.schema.removeField("sf_admins")
|
||||||
|
collection.schema.removeField("sf_allowmm")
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"id": "sf_events_001",
|
||||||
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
|
"name": "events",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_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": "sf_e_creator",
|
||||||
|
"name": "creator",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_title",
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 200,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_desc",
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 2000,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_location",
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 200,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_start",
|
||||||
|
"name": "startTime",
|
||||||
|
"type": "date",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": "",
|
||||||
|
"max": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_end",
|
||||||
|
"name": "endTime",
|
||||||
|
"type": "date",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": "",
|
||||||
|
"max": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_status",
|
||||||
|
"name": "status",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"maxSelect": 1,
|
||||||
|
"values": [
|
||||||
|
"upcoming",
|
||||||
|
"ongoing",
|
||||||
|
"completed",
|
||||||
|
"cancelled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_max",
|
||||||
|
"name": "maxParticipants",
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": null,
|
||||||
|
"noDecimal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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("sf_events_001");
|
||||||
|
return dao.deleteCollection(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"id": "sf_ec_001",
|
||||||
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
|
"name": "event_comments",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_ec_event",
|
||||||
|
"name": "event",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "sf_events_001",
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_ec_user",
|
||||||
|
"name": "user",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_ec_content",
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 500,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"updateRule": "user = @request.auth.id",
|
||||||
|
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||||
|
"options": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Dao(db).saveCollection(collection);
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db);
|
||||||
|
const collection = dao.findCollectionByNameOrId("sf_ec_001");
|
||||||
|
return dao.deleteCollection(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"id": "sf_er_001",
|
||||||
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
|
"name": "event_rsvps",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_er_event",
|
||||||
|
"name": "event",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "sf_events_001",
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_er_user",
|
||||||
|
"name": "user",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_er_type",
|
||||||
|
"name": "type",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"maxSelect": 1,
|
||||||
|
"values": [
|
||||||
|
"going",
|
||||||
|
"interested",
|
||||||
|
"maybe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_er_comment",
|
||||||
|
"name": "comment",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 200,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX idx_event_user ON event_rsvps (event, user)"
|
||||||
|
],
|
||||||
|
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
|
"updateRule": "user = @request.auth.id",
|
||||||
|
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||||
|
"options": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Dao(db).saveCollection(collection);
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db);
|
||||||
|
const collection = dao.findCollectionByNameOrId("sf_er_001");
|
||||||
|
return dao.deleteCollection(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
// remove old url cover field
|
||||||
|
collection.schema.removeField("cover_field")
|
||||||
|
|
||||||
|
// add new file cover field
|
||||||
|
collection.schema.addField(new SchemaField({
|
||||||
|
"system": false,
|
||||||
|
"id": "cover_file",
|
||||||
|
"name": "cover",
|
||||||
|
"type": "file",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 5242880,
|
||||||
|
"mimeTypes": ["image/jpeg","image/png","image/gif","image/webp","image/svg+xml"],
|
||||||
|
"thumbs": null,
|
||||||
|
"protected": false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
// remove file cover field
|
||||||
|
collection.schema.removeField("cover_file")
|
||||||
|
|
||||||
|
// restore url cover field
|
||||||
|
collection.schema.addField(new SchemaField({
|
||||||
|
"system": false,
|
||||||
|
"id": "cover_field",
|
||||||
|
"name": "cover",
|
||||||
|
"type": "url",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"exceptDomains": null,
|
||||||
|
"onlyDomains": null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
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("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
collection.listRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||||
|
collection.viewRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
collection.listRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||||
|
collection.viewRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
// add aliases field (json array of strings)
|
||||||
|
collection.schema.addField(new SchemaField({
|
||||||
|
"system": false,
|
||||||
|
"id": "aliases_field",
|
||||||
|
"name": "aliases",
|
||||||
|
"type": "json",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"maxSize": 5000
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update rules: allow group admins to update/delete
|
||||||
|
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||||
|
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||||
|
|
||||||
|
// remove aliases field
|
||||||
|
collection.schema.removeField("aliases_field")
|
||||||
|
|
||||||
|
// restore original rules
|
||||||
|
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||||
|
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -13,19 +13,32 @@ app.post('/api/voice-token/:sessionId', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { sessionId } = req.params
|
const { sessionId } = req.params
|
||||||
const authHeader = req.headers.authorization
|
const authHeader = req.headers.authorization
|
||||||
|
console.log('Voice token request:', { sessionId, authHeader: authHeader ? authHeader.slice(0, 20) + '...' : null })
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
|
console.log('Missing auth header')
|
||||||
return res.status(401).json({ error: '未登录' })
|
return res.status(401).json({ error: '未登录' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证用户 token — 调用 PocketBase
|
// 验证用户 token — 调用 PocketBase
|
||||||
const pbRes = await fetch(`${PB_URL}/api/collections/users/auth-refresh`, {
|
const pbRefreshUrl = `${PB_URL}/api/collections/users/auth-refresh`
|
||||||
|
console.log('Calling PB auth-refresh:', pbRefreshUrl)
|
||||||
|
const pbRes = await fetch(pbRefreshUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: authHeader },
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
})
|
})
|
||||||
|
console.log('PB auth-refresh status:', pbRes.status)
|
||||||
if (!pbRes.ok) {
|
if (!pbRes.ok) {
|
||||||
return res.status(401).json({ error: '认证失败' })
|
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()
|
const userData = await pbRes.json()
|
||||||
|
console.log('PB auth-refresh success, userId:', userData.record?.id)
|
||||||
const userId = userData.record?.id
|
const userId = userData.record?.id
|
||||||
const userName = userData.record?.name || userData.record?.username || userId
|
const userName = userData.record?.name || userData.record?.username || userId
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 部署 PocketBase 后端
|
|
||||||
|
|
||||||
echo "🚀 部署 PocketBase 后端..."
|
|
||||||
|
|
||||||
docker compose -f docker-compose.backend.yml up -d --force-recreate
|
|
||||||
|
|
||||||
echo "✅ PocketBase 已启动"
|
|
||||||
echo "📡 API 地址: http://192.168.1.14:8711/api/"
|
|
||||||
echo "🔧 管理面板: http://192.168.1.14:8711/_/"
|
|
||||||
+11
-3
@@ -1,12 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 部署 Dev 前端
|
# 部署 Dev 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||||
|
|
||||||
echo "🚀 部署 Dev 前端..."
|
echo "🚀 部署 Dev 环境..."
|
||||||
|
|
||||||
# 确保网络存在
|
# 确保网络存在
|
||||||
docker network create gamegroup-net 2>/dev/null || true
|
docker network create gamegroup-net 2>/dev/null || true
|
||||||
|
|
||||||
|
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||||
|
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||||
|
|
||||||
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "✅ Dev 环境已启动"
|
echo "✅ Dev 环境已启动"
|
||||||
echo "🌐 访问地址: http://192.168.1.14:7033"
|
echo "🌐 前端: http://192.168.1.14:7033"
|
||||||
|
echo "📡 PB API: http://192.168.1.14:8090/api/"
|
||||||
|
echo "🔧 PB 管理面板: http://192.168.1.14:8090/_/"
|
||||||
|
echo "🎙️ LiveKit: ws://192.168.1.14:7880"
|
||||||
|
echo "🔑 Token: http://192.168.1.14:7883"
|
||||||
|
|||||||
+11
-3
@@ -1,12 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 部署 UAT 前端
|
# 部署 UAT 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||||
|
|
||||||
echo "🚀 部署 UAT 前端..."
|
echo "🚀 部署 UAT 环境..."
|
||||||
|
|
||||||
# 确保网络存在
|
# 确保网络存在
|
||||||
docker network create gamegroup-net 2>/dev/null || true
|
docker network create gamegroup-net 2>/dev/null || true
|
||||||
|
|
||||||
|
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||||
|
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||||
|
|
||||||
docker compose -f docker-compose.uat.yml up -d --build --force-recreate
|
docker compose -f docker-compose.uat.yml up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "✅ UAT 环境已启动"
|
echo "✅ UAT 环境已启动"
|
||||||
echo "🌐 访问地址: http://192.168.1.14:7034"
|
echo "🌐 前端: http://192.168.1.14:7034"
|
||||||
|
echo "📡 PB API: http://192.168.1.14:8712/api/"
|
||||||
|
echo "🔧 PB 管理面板: http://192.168.1.14:8712/_/"
|
||||||
|
echo "🎙️ LiveKit: ws://192.168.1.14:7890"
|
||||||
|
echo "🔑 Token: http://192.168.1.14:7893"
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
services:
|
|
||||||
pocketbase:
|
|
||||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
|
||||||
container_name: gamegroup-pb
|
|
||||||
ports:
|
|
||||||
- "8090:8090"
|
|
||||||
volumes:
|
|
||||||
- ./backend/pb_data:/pb_data
|
|
||||||
- ./backend/pb_migrations:/pb_migrations
|
|
||||||
- ./backend/pb_hooks:/pb_hooks
|
|
||||||
environment:
|
|
||||||
- GO_ENV=production
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
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
|
|
||||||
@@ -1,4 +1,55 @@
|
|||||||
services:
|
services:
|
||||||
|
pocketbase-dev:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||||
|
container_name: gamegroup-pb
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
volumes:
|
||||||
|
- ./backend/pb_data:/pb_data
|
||||||
|
- ./backend/pb_migrations:/pb_migrations
|
||||||
|
- ./backend/pb_hooks:/pb_hooks
|
||||||
|
environment:
|
||||||
|
- GO_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- gamegroup-net
|
||||||
|
|
||||||
|
livekit-dev:
|
||||||
|
image: livekit/livekit-server:v1.10
|
||||||
|
container_name: gamegroup-livekit-dev
|
||||||
|
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-dev:
|
||||||
|
build:
|
||||||
|
context: ./backend/voice-token-service
|
||||||
|
container_name: gamegroup-voice-token-dev
|
||||||
|
ports:
|
||||||
|
- "7883:7882"
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||||
|
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||||
|
- PB_URL=http://gamegroup-pb:8090
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- pocketbase-dev
|
||||||
|
networks:
|
||||||
|
- gamegroup-net
|
||||||
|
|
||||||
frontend-dev:
|
frontend-dev:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -6,9 +57,13 @@ services:
|
|||||||
container_name: gamegroup-frontend-dev
|
container_name: gamegroup-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "7033:80"
|
- "7033:80"
|
||||||
|
volumes:
|
||||||
|
- ./electron-update/dev:/usr/share/nginx/html/electron-update
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- pocketbase-dev
|
||||||
networks:
|
networks:
|
||||||
- gamegroup-net
|
- gamegroup-net
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -20,13 +20,13 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- gamegroup-net
|
- gamegroup-net
|
||||||
|
|
||||||
livekit:
|
livekit-uat:
|
||||||
image: livekit/livekit-server:v1.10
|
image: livekit/livekit-server:v1.10
|
||||||
container_name: gamegroup-livekit
|
container_name: gamegroup-livekit-uat
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7890:7880"
|
||||||
- "7881:7881/udp"
|
- "7891:7881/udp"
|
||||||
- "7882:7882/udp"
|
- "7892:7882/udp"
|
||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||||
command: --dev --node-ip 192.168.1.14
|
command: --dev --node-ip 192.168.1.14
|
||||||
@@ -34,12 +34,12 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- gamegroup-net
|
- gamegroup-net
|
||||||
|
|
||||||
voice-token:
|
voice-token-uat:
|
||||||
build:
|
build:
|
||||||
context: ./backend/voice-token-service
|
context: ./backend/voice-token-service
|
||||||
container_name: gamegroup-voice-token
|
container_name: gamegroup-voice-token-uat
|
||||||
ports:
|
ports:
|
||||||
- "7882:7882"
|
- "7893:7882"
|
||||||
environment:
|
environment:
|
||||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||||
@@ -59,6 +59,8 @@ services:
|
|||||||
container_name: gamegroup-frontend-uat
|
container_name: gamegroup-frontend-uat
|
||||||
ports:
|
ports:
|
||||||
- "7034:80"
|
- "7034:80"
|
||||||
|
volumes:
|
||||||
|
- ./electron-update/uat:/usr/share/nginx/html/electron-update
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 手机端前端(uat 重做)设计文档
|
||||||
|
|
||||||
|
> 日期:2026-06-18
|
||||||
|
> 仓库:gamegroup2
|
||||||
|
> 分支:`uat`(基于 `origin/uat`)
|
||||||
|
> 状态:已确认,待实施
|
||||||
|
> 背景:master 上已有 8 个移动端 commit(基于 6-15 设计),但基于**旧 master**,不知 uat 后续新增的 PC 端功能。本次在 uat 上**重新做**移动端:迁移 master 已写好的实现 + 适配 uat 的 PC 端变化 + 补全 uat 新增功能的移动端页面。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
**目标**:在 uat 分支上产出完整、可用的手机端前端,覆盖 uat PC 端的全部功能(含信息公示板、事件/活动、游戏库增强、邀请落地页等 uat 独有功能),手机浏览器访问自动进入移动版。
|
||||||
|
|
||||||
|
**做法**(用户已定):
|
||||||
|
- **选项 A**:在 uat 上重新做整套移动端(不是直接 cherry-pick 8 个 commit)
|
||||||
|
- **"重新做"含义**:迁移 master 上已写好的 22 个移动端文件 + 基础设施,然后适配 uat 的 API/stores/types/router 变化,并补全 uat 独有功能的移动端页面。不从零写。
|
||||||
|
- **新功能覆盖**:全部覆盖(公示板浏览+发帖、事件浏览+创建、游戏库详情+评论+收藏+添加+导入、邀请落地页 2 个)
|
||||||
|
- **设备分流**:路由层分流(沿用 master 方案)
|
||||||
|
- **实施节奏**:分阶段提交,每阶段可验证
|
||||||
|
|
||||||
|
## 2. 与 master 移动端的差异(迁移适配点)
|
||||||
|
|
||||||
|
### 2.1 uat 相对 master 新增的 PC 端功能域(移动端需补)
|
||||||
|
|
||||||
|
| 功能域 | PC 端位置 | 移动端处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 信息公示板 (bulletin) | `components/bulletin/*` (4) + `api/bulletins.ts` | 新增 `components-mobile/bulletin/*` + 群组详情新增"公示板" tab |
|
||||||
|
| 事件/活动 (event) | `components/event/*` (3) + `api/events.ts` | 新增 `components-mobile/event/*` + 群组详情新增"活动" tab + 首页接入事件板 |
|
||||||
|
| 游戏库增强 | `components/game/*` (AddGame/GameDetail/GameForm/ImportGames) | GamesLibraryMobile 增加添加/导入入口;游戏详情面板增加评论+收藏 |
|
||||||
|
| 邀请落地页 | `views/JoinGroupPage.vue`、`JoinTeamPage.vue` | 新增 `views-mobile/JoinGroupPageMobile.vue`、`JoinTeamPageMobile.vue`,路由层分流 |
|
||||||
|
|
||||||
|
### 2.2 uat 相对 master 的基础设施变化(迁移要适配)
|
||||||
|
|
||||||
|
| 项 | master 移动端写法 | uat 现状 | 适配方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| router/index.ts | 旧结构(无 JoinGroup/JoinTeam 路由) | 有 `JoinGroup`/`JoinTeam` 顶层路由 + Layout children 嵌套 | **在 uat router 基础上叠加**:每个路由 component 用 `isMobile()` 三元分流,保留 JoinGroup/JoinTeam |
|
||||||
|
| package.json deps | 仅 vant | uat 已有 `livekit-client`、`tailwindcss`、`vue-tsc` 等 | 迁移时仅追加 `vant@^4` + `@vant/auto-import-resolver`,不动其他 |
|
||||||
|
| vite.config.ts | master 已加 auto-import-resolver + 手动分包 | uat 是基础配置 | 迁移 master 的 vant 插件配置 + 在 uat 基础上追加 manualChunks(保留 uat 现有 proxy/alias) |
|
||||||
|
| index.html viewport | master 改为含 `maximum-scale=1, viewport-fit=cover` | uat 基础版 | 采用 master 版本(适配键盘弹起、刘海屏) |
|
||||||
|
| types/index.ts | 移动端引用的 Event/Bulletin 等类型 | uat 已新增这些类型 | 迁移代码直接复用 uat 的 types,无需改动 |
|
||||||
|
|
||||||
|
### 2.3 pocketbase 导出兼容性(已确认)
|
||||||
|
|
||||||
|
uat 的 `api/pocketbase.ts` 同时提供 **命名导出 `{ pb }`** 和 **默认导出 `pb`**。master 移动端代码用默认导入 `import pb from './pocketbase'` 兼容;bulletins.ts 用命名导入也兼容。**无需改动**。
|
||||||
|
|
||||||
|
## 3. 目录结构(uat 上最终形态)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├─ api/ # 共享(uat 已有 events.ts/bulletins.ts/invitations.ts)
|
||||||
|
├─ stores/ # 共享
|
||||||
|
├─ types/ # 共享(uat 已含 Event/BulletinPost 等类型)
|
||||||
|
├─ composables/ # 共享
|
||||||
|
├─ assets/
|
||||||
|
│ ├─ design.css # 共享
|
||||||
|
│ └─ mobile.css # 迁移:Vant 主题映射
|
||||||
|
├─ views/ # uat PC 端(不动)
|
||||||
|
├─ components/ # uat PC 端(不动)
|
||||||
|
├─ views-mobile/ # 迁移 + 新增
|
||||||
|
│ ├─ LoginMobile.vue / RegisterMobile.vue / HomeMobile.vue / GroupsMobile.vue
|
||||||
|
│ ├─ GroupViewMobile.vue / VoiceRoomMobile.vue
|
||||||
|
│ ├─ GamesLibraryMobile.vue(增强:添加/导入入口)
|
||||||
|
│ ├─ LedgerMobile.vue / AssetMobile.vue / BlacklistMobile.vue
|
||||||
|
│ ├─ NotificationsMobile.vue / ProfileMobile.vue / SettingsMobile.vue / ChangelogMobile.vue
|
||||||
|
│ ├─ Placeholder.vue
|
||||||
|
│ └─ JoinGroupPageMobile.vue / JoinTeamPageMobile.vue # 新增
|
||||||
|
├─ components-mobile/ # 迁移 + 新增
|
||||||
|
│ ├─ bet/BetListMobile.vue
|
||||||
|
│ ├─ poll/PollListMobile.vue
|
||||||
|
│ ├─ memory/MemoryGridMobile.vue
|
||||||
|
│ ├─ stats/StatsPanelMobile.vue
|
||||||
|
│ ├─ group/ActivityFeedMobile.vue / MemberListMobile.vue
|
||||||
|
│ ├─ game/GameDetailSheetMobile.vue # 新增(详情底部面板)
|
||||||
|
│ ├─ game/AddGameSheetMobile.vue # 新增(添加游戏)
|
||||||
|
│ ├─ game/ImportGamesSheetMobile.vue # 新增(导入游戏)
|
||||||
|
│ ├─ bulletin/BulletinListMobile.vue # 新增(公示板列表)
|
||||||
|
│ ├─ bulletin/BulletinPostSheetMobile.vue # 新增(发帖/详情)
|
||||||
|
│ ├─ event/EventListMobile.vue # 新增(事件列表)
|
||||||
|
│ └─ event/CreateEventSheetMobile.vue # 新增(创建事件)
|
||||||
|
├─ mobile/
|
||||||
|
│ ├─ MobileLayout.vue # 迁移
|
||||||
|
│ ├─ useDevice.ts # 迁移
|
||||||
|
│ └─ DeviceGuard.ts # 迁移(若 master 有)
|
||||||
|
├─ router/index.ts # 在 uat 基础上叠加 isMobile() 分流
|
||||||
|
└─ main.ts # 追加 vant 按需注册 + mobile.css 引入
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 实施阶段(11 个阶段,每阶段一次提交,可验证)
|
||||||
|
|
||||||
|
每个阶段完成后在 uat 提交,提交信息前缀 `feat(mobile): `。每阶段验证标准见各阶段说明。
|
||||||
|
|
||||||
|
### 阶段 1:基础设施迁移
|
||||||
|
**迁移内容**:
|
||||||
|
- `package.json`:追加 `vant@^4` + `@vant/auto-import-resolver`
|
||||||
|
- `vite.config.ts`:在 uat 配置基础上追加 Vant 自动导入 resolver + manualChunks 分包
|
||||||
|
- `src/assets/mobile.css`:迁移(Vant 主题变量映射到 `--gg-*`)
|
||||||
|
- `src/mobile/useDevice.ts`:迁移
|
||||||
|
- `src/mobile/MobileLayout.vue`:迁移(已去除 logout handler 的版本)
|
||||||
|
- `index.html`:viewport 改为含 `maximum-scale=1, viewport-fit=cover`
|
||||||
|
- `src/main.ts`:追加 `import 'vant/lib/index.css'` + `import './assets/mobile.css'` + vant 按需注册
|
||||||
|
- `src/router/index.ts`:在 uat 现有 router 上叠加——每个业务路由 component 改为 `isMobile() ? mobile : desktop`,保留 JoinGroup/JoinTeam 路由(也加分流)
|
||||||
|
- `src/views-mobile/Placeholder.vue`:迁移(占位页,让分流能跑起来)
|
||||||
|
|
||||||
|
**验证**:`npm install` + `npm run build` 通过;PC 浏览器访问仍是桌面版;移动 UA 访问看到 Placeholder。
|
||||||
|
|
||||||
|
### 阶段 2:认证
|
||||||
|
迁移 `LoginMobile.vue` + `RegisterMobile.vue`,适配 uat 的 user store / pocketbase API(已确认兼容)。
|
||||||
|
**验证**:手机 UA 访问 `/login`、`/register` 正常,登录注册全流程通。
|
||||||
|
|
||||||
|
### 阶段 3:首页 + 群组列表
|
||||||
|
迁移 `HomeMobile.vue` + `GroupsMobile.vue`。
|
||||||
|
**适配**:首页接入事件板(listEvents 调用 + 事件卡片展示),适配 uat 的 group store 字段变化。
|
||||||
|
**验证**:首页显示状态/群组/热门游戏/事件板;群组列表创建/加入正常。
|
||||||
|
|
||||||
|
### 阶段 4:群组详情核心
|
||||||
|
迁移 `GroupViewMobile.vue` + `components-mobile/group/ActivityFeedMobile.vue` + `MemberListMobile.vue`。
|
||||||
|
**适配**:标签栏结构对照 uat PC GroupView 的 tab 列表,预留"活动"和"公示板"两个新 tab 位置(阶段 10/11 填充)。
|
||||||
|
**验证**:群组详情 9 个原有标签可访问。
|
||||||
|
|
||||||
|
### 阶段 5:组队 + 语音房
|
||||||
|
迁移 `VoiceRoomMobile.vue` + 适配 uat 的 team store / TeamSessionPanel 相关逻辑(GameSelectDialog/InviteButton 等的移动端交互通过 ActionSheet 实现)。
|
||||||
|
**验证**:发起组队/邀请/接受/状态切换正常;语音房 UI 显示占位提示。
|
||||||
|
|
||||||
|
### 阶段 6:投票 + 竞猜
|
||||||
|
迁移 `components-mobile/poll/PollListMobile.vue` + `bet/BetListMobile.vue`,适配 uat 的 poll/bet store。
|
||||||
|
**验证**:创建/参与/结算正常。
|
||||||
|
|
||||||
|
### 阶段 7:游戏库增强
|
||||||
|
迁移 `GamesLibraryMobile.vue` + 新增 `components-mobile/game/GameDetailSheetMobile.vue`(详情底部面板:封面/平台/标签/评论/收藏/发起组队)、`AddGameSheetMobile.vue`、`ImportGamesSheetMobile.vue`。
|
||||||
|
**适配**:GamesLibraryMobile 增加添加/导入入口按钮,接 `api/games.ts` 的 create/import。
|
||||||
|
**验证**:浏览/搜索/详情/评论/收藏/添加/导入正常。
|
||||||
|
|
||||||
|
### 阶段 8:账本 + 资产 + 黑名单
|
||||||
|
迁移 `LedgerMobile.vue` + `AssetMobile.vue` + `BlacklistMobile.vue`。
|
||||||
|
**验证**:增删改查正常,资产转移正常。
|
||||||
|
|
||||||
|
### 阶段 9:回忆 + 统计 + 个人/设置/更新日志
|
||||||
|
迁移 `MemoryGridMobile.vue` + `StatsPanelMobile.vue` + `ProfileMobile.vue` + `SettingsMobile.vue` + `ChangelogMobile.vue`。
|
||||||
|
**适配**:Profile/Changelog 对照 uat v0.3.5/0.3.6 新数据。
|
||||||
|
**验证**:回忆上传/浏览正常;统计只读展示;个人/设置/日志正常。
|
||||||
|
|
||||||
|
### 阶段 10:信息公示板
|
||||||
|
新增 `components-mobile/bulletin/BulletinListMobile.vue`(置顶帖+普通列表)+ `BulletinPostSheetMobile.vue`(发帖/查看详情)。
|
||||||
|
集成:群组详情标签栏新增"公示板" tab(接 `api/bulletins.ts`)。
|
||||||
|
**验证**:群内发帖/置顶/浏览正常。
|
||||||
|
|
||||||
|
### 阶段 11:事件 + 邀请落地页
|
||||||
|
新增 `components-mobile/event/EventListMobile.vue` + `CreateEventSheetMobile.vue`;群组详情新增"活动" tab;首页事件板(阶段 3 已接)的事件卡片点击进事件详情。
|
||||||
|
新增 `views-mobile/JoinGroupPageMobile.vue` + `JoinTeamPageMobile.vue`,路由层 `JoinGroup`/`JoinTeam` 加 `isMobile()` 分流。
|
||||||
|
**验证**:事件创建/RSVP/浏览正常;邀请链接手机端访问显示移动版落地页。
|
||||||
|
|
||||||
|
## 5. 验收标准
|
||||||
|
|
||||||
|
- [ ] PC 浏览器访问 uat 仍是桌面版,行为不变
|
||||||
|
- [ ] 手机浏览器访问 uat 自动进入移动版
|
||||||
|
- [ ] 登录/注册/退出全流程正常
|
||||||
|
- [ ] 首页:状态切换、群组入口、热门游戏、事件板正常
|
||||||
|
- [ ] 群组详情:9 个原有标签 + 活动 + 公示板 = 11 个标签可访问
|
||||||
|
- [ ] 组队/语音房占位 UI 正常
|
||||||
|
- [ ] 投票/竞猜:创建/参与/结算正常
|
||||||
|
- [ ] 游戏库:浏览/搜索/详情/评论/收藏/添加/导入正常
|
||||||
|
- [ ] 账本/资产/黑名单:增删改查 + 资产转移正常
|
||||||
|
- [ ] 回忆上传/浏览、统计只读正常
|
||||||
|
- [ ] 信息公示板:发帖/置顶/浏览正常
|
||||||
|
- [ ] 事件:创建/RSVP/浏览正常
|
||||||
|
- [ ] 邀请落地页:手机端访问 `/join/group/:id` 和 `/join/team/:id` 显示移动版
|
||||||
|
- [ ] `npm run build` 通过,无 TS 错误
|
||||||
|
|
||||||
|
## 6. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| uat 的 stores/types 与 master 移动端代码假设不符 | 阶段 1-9 迁移时逐阶段核对 `git diff master..HEAD` 的 stores/types 改动,按报错适配 |
|
||||||
|
| Vant + Element Plus 样式冲突 | 路由分流隔离,不同时挂载;mobile.css 已映射主题变量 |
|
||||||
|
| 邀请落地页在未登录态下访问,移动版守卫逻辑 | JoinGroup/JoinTeam 路由 meta 不加 requiresAuth(沿用 uat),分流只换组件 |
|
||||||
|
| 构建体积 | Vant 按需引入 + manualChunks 分包(master 已验证) |
|
||||||
|
|
||||||
|
## 7. 不在本次范围内
|
||||||
|
|
||||||
|
- 语音 WebRTC 实际接入(移动端仅 UI + 占位)
|
||||||
|
- PWA / 离线支持
|
||||||
|
- 原生推送通知
|
||||||
|
- 桌面端代码改动
|
||||||
+150
-2
@@ -1,5 +1,22 @@
|
|||||||
const { app, BrowserWindow } = require('electron')
|
const { app, BrowserWindow, session, dialog, ipcMain } = require('electron')
|
||||||
|
const { autoUpdater } = require('electron-updater')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const Store = require('electron-store')
|
||||||
|
|
||||||
|
const store = new Store()
|
||||||
|
|
||||||
|
// 同步 IPC:供 preload 读写持久化数据
|
||||||
|
ipcMain.on('store-get-sync', (event, key) => {
|
||||||
|
event.returnValue = store.get(key)
|
||||||
|
})
|
||||||
|
ipcMain.on('store-set-sync', (event, key, value) => {
|
||||||
|
store.set(key, value)
|
||||||
|
event.returnValue = true
|
||||||
|
})
|
||||||
|
ipcMain.on('store-delete-sync', (event, key) => {
|
||||||
|
store.delete(key)
|
||||||
|
event.returnValue = true
|
||||||
|
})
|
||||||
|
|
||||||
const ENV_URLS = {
|
const ENV_URLS = {
|
||||||
dev: 'http://192.168.1.14:7033',
|
dev: 'http://192.168.1.14:7033',
|
||||||
@@ -15,6 +32,19 @@ function getWindowUrl() {
|
|||||||
return ENV_URLS.dev
|
return ENV_URLS.dev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEnvName() {
|
||||||
|
const url = getWindowUrl()
|
||||||
|
if (url.includes('7034')) return 'uat'
|
||||||
|
return 'dev'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
|
||||||
|
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
|
||||||
|
const insecureOrigins = Object.values(ENV_URLS).join(',')
|
||||||
|
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
@@ -27,9 +57,12 @@ function createWindow() {
|
|||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: false,
|
webSecurity: false,
|
||||||
|
allowRunningInsecureContent: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindow = win
|
||||||
|
|
||||||
const url = getWindowUrl()
|
const url = getWindowUrl()
|
||||||
win.loadURL(url)
|
win.loadURL(url)
|
||||||
|
|
||||||
@@ -38,9 +71,124 @@ function createWindow() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
win.setTitle(`Game Group - ${title}`)
|
win.setTitle(`Game Group - ${title}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(createWindow)
|
// 自动更新配置
|
||||||
|
function setupAutoUpdater(win) {
|
||||||
|
const env = getEnvName()
|
||||||
|
const feedUrl = `${ENV_URLS[env]}/electron-update/`
|
||||||
|
|
||||||
|
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl })
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
autoUpdater.autoInstallOnAppQuit = false
|
||||||
|
|
||||||
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
console.log('[updater] Checking for update at', feedUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
console.log('[updater] Update available:', info.version)
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('update-available', info)
|
||||||
|
dialog.showMessageBox(win, {
|
||||||
|
type: 'info',
|
||||||
|
title: '发现新版本',
|
||||||
|
message: `发现新版本 ${info.version},是否立即下载更新?`,
|
||||||
|
buttons: ['立即下载', '稍后再说'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
}).then(({ response }) => {
|
||||||
|
if (response === 0) {
|
||||||
|
autoUpdater.downloadUpdate().catch((err) => {
|
||||||
|
console.error('[updater] Download failed:', err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
console.log('[updater] No update available')
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on('error', (err) => {
|
||||||
|
console.error('[updater] Error:', err.message)
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('update-error', err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (progress) => {
|
||||||
|
console.log(`[updater] Download progress: ${progress.percent.toFixed(1)}%`)
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('download-progress', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
console.log('[updater] Update downloaded:', info.version)
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('update-downloaded', info)
|
||||||
|
dialog.showMessageBox(win, {
|
||||||
|
type: 'info',
|
||||||
|
title: '更新就绪',
|
||||||
|
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
|
||||||
|
buttons: ['立即重启', '稍后'],
|
||||||
|
defaultId: 0,
|
||||||
|
}).then(({ response }) => {
|
||||||
|
if (response === 0) {
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动触发检查更新(供前端调用)
|
||||||
|
ipcMain.on('check-for-updates', () => {
|
||||||
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
|
console.error('[updater] Manual check failed:', err.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('quit-and-install', () => {
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 启动后延迟 5 秒检查更新(等窗口稳定后再检查)
|
||||||
|
setTimeout(() => {
|
||||||
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
|
console.error('[updater] Initial check failed:', err.message)
|
||||||
|
})
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
const win = createWindow()
|
||||||
|
setupAutoUpdater(win)
|
||||||
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|||||||
+28
-6
@@ -1,17 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "gamegroup-electron",
|
"name": "gamegroup-electron",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"description": "Game Group V2 桌面客户端",
|
"description": "Game Group V2 桌面客户端",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"start:dev": "electron . --env=dev",
|
"start:dev": "electron . --env=dev",
|
||||||
"start:uat": "electron . --env=uat",
|
"start:uat": "electron . --env=uat",
|
||||||
"build": "electron-builder --win",
|
"bump": "node scripts/bump-version.js",
|
||||||
"build:portable": "electron-builder --win portable"
|
"bump:minor": "node scripts/bump-version.js minor",
|
||||||
|
"bump:major": "node scripts/bump-version.js major",
|
||||||
|
"build:dev": "node scripts/bump-version.js && electron-builder --win --config scripts/build.dev.json && node scripts/copy-to-nas.js dev",
|
||||||
|
"build:uat": "node scripts/bump-version.js && electron-builder --win && node scripts/copy-to-nas.js uat",
|
||||||
|
"build:portable": "node scripts/bump-version.js && electron-builder --win portable && node scripts/copy-to-nas.js uat",
|
||||||
|
"copy-to-nas": "node scripts/copy-to-nas.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-store": "^8.2"
|
"electron-store": "^8.2",
|
||||||
|
"electron-updater": "^6.8.3",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^35.0",
|
"electron": "^35.0",
|
||||||
@@ -26,12 +34,26 @@
|
|||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "portable",
|
"target": "nsis",
|
||||||
"arch": ["x64"]
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "build/icon.ico"
|
"icon": "build/icon.ico"
|
||||||
},
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "GameGroup"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "http://nas.wjl-work.top:7034/electron-update/",
|
||||||
|
"channel": "latest"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
|
|||||||
+15
-2
@@ -1,6 +1,19 @@
|
|||||||
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
const { contextBridge } = require('electron')
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|
||||||
|
// 自动更新相关 IPC
|
||||||
|
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (_event, info) => callback(info)),
|
||||||
|
onUpdateError: (callback) => ipcRenderer.on('update-error', (_event, message) => callback(message)),
|
||||||
|
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, progress) => callback(progress)),
|
||||||
|
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (_event, info) => callback(info)),
|
||||||
|
|
||||||
|
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||||
|
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
||||||
|
|
||||||
|
// 持久化存储(用于记住登录状态)
|
||||||
|
storeGet: (key) => ipcRenderer.sendSync('store-get-sync', key),
|
||||||
|
storeSet: (key, value) => ipcRenderer.sendSync('store-set-sync', key, value),
|
||||||
|
storeDelete: (key) => ipcRenderer.sendSync('store-delete-sync', key),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"publish": {
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "http://192.168.1.14:7033/electron-update/",
|
||||||
|
"channel": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const type = process.argv[2] || 'patch' // patch | minor | major
|
||||||
|
const pkgPath = path.join(__dirname, '..', 'package.json')
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
||||||
|
|
||||||
|
let [major, minor, patch] = pkg.version.split('.').map(Number)
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'major':
|
||||||
|
major++
|
||||||
|
minor = 0
|
||||||
|
patch = 0
|
||||||
|
break
|
||||||
|
case 'minor':
|
||||||
|
minor++
|
||||||
|
patch = 0
|
||||||
|
break
|
||||||
|
case 'patch':
|
||||||
|
default:
|
||||||
|
patch++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg.version = `${major}.${minor}.${patch}`
|
||||||
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
console.log(`[bump-version] ${type} -> ${pkg.version}`)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const env = process.argv[2] || 'uat'
|
||||||
|
const srcDir = path.join(__dirname, '..', 'dist')
|
||||||
|
// 项目根目录下的 electron-update/{env},用于 docker volume 挂载给 nginx
|
||||||
|
const localUpdateDir = path.join(__dirname, '..', '..', 'electron-update', env)
|
||||||
|
// NAS 共享路径
|
||||||
|
const nasDir = path.join('\\\\JIULUGNAS\\personal_folder\\CodeSpace\\GameGroup2\\electron-update', env)
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[copy-to-nas] failed to create dir ${dir}:`, err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFilesTo(destDir, label) {
|
||||||
|
if (!fs.existsSync(srcDir)) {
|
||||||
|
console.log(`[copy-to-nas] dist not found, skipping ${label}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(srcDir).filter((f) => {
|
||||||
|
const ext = path.extname(f).toLowerCase()
|
||||||
|
return (
|
||||||
|
ext === '.exe' ||
|
||||||
|
ext === '.yml' ||
|
||||||
|
ext === '.blockmap' ||
|
||||||
|
ext === '.nupkg' ||
|
||||||
|
ext === '.yaml'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log(`[copy-to-nas] no update files found in dist, skipping ${label}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureDir(destDir)) return
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const src = path.join(srcDir, file)
|
||||||
|
const dest = path.join(destDir, file)
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(src, dest)
|
||||||
|
console.log(`[copy-to-nas] copied ${file} -> ${destDir}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[copy-to-nas] failed to copy ${file} to ${label}:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制到本地目录(供 docker volume 挂载)
|
||||||
|
copyFilesTo(localUpdateDir, 'local')
|
||||||
|
|
||||||
|
// 复制到 NAS
|
||||||
|
copyFilesTo(nasDir, 'NAS')
|
||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
VITE_PB_URL=http://192.168.1.14:8711
|
VITE_PB_URL=http://192.168.1.14:8711
|
||||||
VITE_PORT=7033
|
VITE_PORT=7033
|
||||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7883
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# UAT Environment
|
# UAT Environment
|
||||||
VITE_PB_URL=http://192.168.1.14:8711
|
VITE_PB_URL=http://192.168.1.14:8711
|
||||||
VITE_PORT=7034
|
VITE_PORT=7034
|
||||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
VITE_LIVEKIT_URL=ws://192.168.1.14:7890
|
||||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7893
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<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>">
|
<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>
|
</head>
|
||||||
|
|||||||
+9
-1
@@ -32,13 +32,21 @@ server {
|
|||||||
|
|
||||||
# Voice token service proxy
|
# Voice token service proxy
|
||||||
location /voice-api/ {
|
location /voice-api/ {
|
||||||
proxy_pass http://192.168.1.14:7882/api/;
|
proxy_pass http://192.168.1.14:7883/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Electron 自动更新文件托管
|
||||||
|
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||||
|
location /electron-update/ {
|
||||||
|
alias /usr/share/nginx/html/electron-update/;
|
||||||
|
autoindex on;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# API 代理到局域网 PocketBase
|
# API 代理到局域网 PocketBase
|
||||||
location /api/ {
|
location /api/ {
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
|
|||||||
+11
-3
@@ -32,13 +32,21 @@ server {
|
|||||||
|
|
||||||
# Voice token service proxy
|
# Voice token service proxy
|
||||||
location /voice-api/ {
|
location /voice-api/ {
|
||||||
proxy_pass http://192.168.1.14:7882/api/;
|
proxy_pass http://192.168.1.14:7893/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Electron 自动更新文件托管
|
||||||
|
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||||
|
location /electron-update/ {
|
||||||
|
alias /usr/share/nginx/html/electron-update/;
|
||||||
|
autoindex on;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# API 代理到局域网 PocketBase
|
# API 代理到局域网 PocketBase
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://192.168.1.14:8712;
|
proxy_pass http://192.168.1.14:8712;
|
||||||
@@ -52,8 +60,8 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存(排除 /api/ 路径,避免拦截 PB 文件请求)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/(?!api/|voice-api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2709
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
"livekit-client": "^2.18.3",
|
"livekit-client": "^2.18.3",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pocketbase": "^0.21.1",
|
"pocketbase": "^0.21.1",
|
||||||
|
"vant": "^4.9.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
|
<defs>
|
||||||
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
|
<linearGradient id="avBg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ecfdf5"/>
|
||||||
|
<stop offset="100%" stop-color="#d1fae5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect fill="url(#avBg)" width="64" height="64" rx="32"/>
|
||||||
|
<circle cx="32" cy="25" r="10" fill="none" stroke="#059669" stroke-width="2.5"/>
|
||||||
|
<path d="M14 54c0-9 8-15 18-15s18 6 18 15" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 547 B |
@@ -1,5 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
|
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
|
||||||
<rect fill="#e8f5e9" width="200" height="260"/>
|
<defs>
|
||||||
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
|
<linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1">
|
||||||
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
|
<stop offset="0%" stop-color="#f0fdf4"/>
|
||||||
|
<stop offset="100%" stop-color="#dcfce7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect fill="url(#gBg)" width="200" height="260"/>
|
||||||
|
<!-- 手柄主体 -->
|
||||||
|
<rect x="44" y="108" width="112" height="58" rx="20" fill="none" stroke="#059669" stroke-width="3.5"/>
|
||||||
|
<!-- 左侧十字方向 -->
|
||||||
|
<path d="M70 137v-10M65 132h10" stroke="#059669" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
<!-- 右侧按钮 -->
|
||||||
|
<circle cx="125" cy="128" r="3.2" fill="#10b981"/>
|
||||||
|
<circle cx="138" cy="142" r="3.2" fill="#10b981"/>
|
||||||
|
<text x="100" y="200" text-anchor="middle" fill="#059669" font-size="13" font-family="system-ui,sans-serif" font-weight="500">暂无封面</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 874 B |
@@ -0,0 +1,113 @@
|
|||||||
|
import { pb } from './pocketbase'
|
||||||
|
import type { BulletinPost, BulletinRead } from '@/types'
|
||||||
|
|
||||||
|
export async function createBulletin(data: {
|
||||||
|
group: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned?: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}): Promise<BulletinPost> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const post = await pb.collection('bulletin_posts').create({
|
||||||
|
group: data.group,
|
||||||
|
creator: user.id,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
priority: data.priority || 'normal',
|
||||||
|
pinned: data.pinned || false,
|
||||||
|
expiresAt: data.expiresAt || '',
|
||||||
|
})
|
||||||
|
return post as unknown as BulletinPost
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBulletins(groupId: string): Promise<BulletinPost[]> {
|
||||||
|
const result = await pb.collection('bulletin_posts').getFullList({
|
||||||
|
filter: `group="${groupId}"`,
|
||||||
|
sort: '-pinned,-created',
|
||||||
|
expand: 'creator',
|
||||||
|
$autoCancel: false,
|
||||||
|
})
|
||||||
|
return result as unknown as BulletinPost[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBulletin(postId: string): Promise<BulletinPost> {
|
||||||
|
const result = await pb.collection('bulletin_posts').getOne(postId, {
|
||||||
|
expand: 'creator',
|
||||||
|
})
|
||||||
|
return result as unknown as BulletinPost
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBulletin(
|
||||||
|
postId: string,
|
||||||
|
data: {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned?: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
): Promise<BulletinPost> {
|
||||||
|
const payload: Record<string, any> = {}
|
||||||
|
if (data.title !== undefined) payload.title = data.title
|
||||||
|
if (data.content !== undefined) payload.content = data.content
|
||||||
|
if (data.priority !== undefined) payload.priority = data.priority
|
||||||
|
if (data.pinned !== undefined) payload.pinned = data.pinned
|
||||||
|
if (data.expiresAt !== undefined) payload.expiresAt = data.expiresAt
|
||||||
|
|
||||||
|
const result = await pb.collection('bulletin_posts').update(postId, payload)
|
||||||
|
return result as unknown as BulletinPost
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBulletin(postId: string): Promise<void> {
|
||||||
|
await pb.collection('bulletin_posts').delete(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已读追踪
|
||||||
|
export async function markBulletinAsRead(postId: string): Promise<BulletinRead | null> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = await pb.collection('bulletin_reads').getList(1, 1, {
|
||||||
|
filter: `post="${postId}" && user="${user.id}"`,
|
||||||
|
$autoCancel: false,
|
||||||
|
})
|
||||||
|
if (existing.items.length > 0) return existing.items[0] as unknown as BulletinRead
|
||||||
|
|
||||||
|
const result = await pb.collection('bulletin_reads').create({
|
||||||
|
post: postId,
|
||||||
|
user: user.id,
|
||||||
|
})
|
||||||
|
return result as unknown as BulletinRead
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMyReads(): Promise<BulletinRead[]> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) return []
|
||||||
|
|
||||||
|
const result = await pb.collection('bulletin_reads').getFullList({
|
||||||
|
filter: `user="${user.id}"`,
|
||||||
|
$autoCancel: false,
|
||||||
|
})
|
||||||
|
return result as unknown as BulletinRead[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅
|
||||||
|
export async function subscribeBulletins(
|
||||||
|
groupId: string,
|
||||||
|
callback: (data: any) => void
|
||||||
|
): Promise<() => void> {
|
||||||
|
await pb.collection('bulletin_posts').subscribe('*', (data) => {
|
||||||
|
if (data.record?.group === groupId) {
|
||||||
|
callback(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pb.collection('bulletin_posts').unsubscribe('*')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import pb from './pocketbase'
|
||||||
|
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
|
||||||
|
|
||||||
|
// 获取群组的活动列表
|
||||||
|
export async function listEvents(groupId: string): Promise<Event[]> {
|
||||||
|
const result = await pb.collection('events').getFullList({
|
||||||
|
filter: `group="${groupId}"`,
|
||||||
|
sort: '-startTime',
|
||||||
|
expand: 'creator',
|
||||||
|
$autoCancel: false
|
||||||
|
})
|
||||||
|
return result as unknown as Event[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取活动详情
|
||||||
|
export async function getEvent(eventId: string): Promise<Event> {
|
||||||
|
return pb.collection('events').getOne(eventId, {
|
||||||
|
expand: 'creator,group',
|
||||||
|
$autoCancel: false
|
||||||
|
}) as unknown as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建活动
|
||||||
|
export async function createEvent(data: {
|
||||||
|
group: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
location?: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
maxParticipants?: number
|
||||||
|
}): Promise<Event> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
return pb.collection('events').create({
|
||||||
|
...data,
|
||||||
|
creator: user.id,
|
||||||
|
status: 'upcoming'
|
||||||
|
}) as unknown as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新活动
|
||||||
|
export async function updateEvent(eventId: string, data: Partial<Event>): Promise<Event> {
|
||||||
|
return pb.collection('events').update(eventId, data) as unknown as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除活动
|
||||||
|
export async function deleteEvent(eventId: string) {
|
||||||
|
return pb.collection('events').delete(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取活动的评论
|
||||||
|
export async function listEventComments(eventId: string): Promise<EventComment[]> {
|
||||||
|
const result = await pb.collection('event_comments').getFullList({
|
||||||
|
filter: `event="${eventId}"`,
|
||||||
|
sort: '-created',
|
||||||
|
expand: 'user',
|
||||||
|
$autoCancel: false
|
||||||
|
})
|
||||||
|
return result as unknown as EventComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建评论
|
||||||
|
export async function createEventComment(eventId: string, content: string): Promise<EventComment> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
return pb.collection('event_comments').create({
|
||||||
|
event: eventId,
|
||||||
|
user: user.id,
|
||||||
|
content
|
||||||
|
}) as unknown as EventComment
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除评论
|
||||||
|
export async function deleteEventComment(commentId: string) {
|
||||||
|
return pb.collection('event_comments').delete(commentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取活动的 RSVP 列表
|
||||||
|
export async function listEventRSVPs(eventId: string): Promise<EventRSVP[]> {
|
||||||
|
const result = await pb.collection('event_rsvps').getFullList({
|
||||||
|
filter: `event="${eventId}"`,
|
||||||
|
sort: '-created',
|
||||||
|
expand: 'user',
|
||||||
|
$autoCancel: false
|
||||||
|
})
|
||||||
|
return result as unknown as EventRSVP[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建/更新 RSVP
|
||||||
|
export async function setEventRSVP(
|
||||||
|
eventId: string,
|
||||||
|
type: RSVPType,
|
||||||
|
comment?: string
|
||||||
|
): Promise<EventRSVP> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
// 检查是否已有 RSVP
|
||||||
|
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||||
|
filter: `event="${eventId}" && user="${user.id}"`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.items.length > 0) {
|
||||||
|
return pb.collection('event_rsvps').update(existing.items[0].id, {
|
||||||
|
type,
|
||||||
|
comment
|
||||||
|
}) as unknown as EventRSVP
|
||||||
|
}
|
||||||
|
|
||||||
|
return pb.collection('event_rsvps').create({
|
||||||
|
event: eventId,
|
||||||
|
user: user.id,
|
||||||
|
type,
|
||||||
|
comment
|
||||||
|
}) as unknown as EventRSVP
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消 RSVP
|
||||||
|
export async function cancelEventRSVP(eventId: string) {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||||
|
filter: `event="${eventId}" && user="${user.id}"`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.items.length > 0) {
|
||||||
|
return pb.collection('event_rsvps').delete(existing.items[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅活动变更
|
||||||
|
export function subscribeEvents(groupId: string, callback: () => void) {
|
||||||
|
return pb.collection('events').subscribe('*', (payload) => {
|
||||||
|
const record = payload.record as any
|
||||||
|
if (record.group === groupId) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅活动评论变更
|
||||||
|
export function subscribeEventComments(eventId: string, callback: () => void) {
|
||||||
|
return pb.collection('event_comments').subscribe('*', (payload) => {
|
||||||
|
const record = payload.record as any
|
||||||
|
if (record.event === eventId) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅 RSVP 变更
|
||||||
|
export function subscribeEventRSVPs(eventId: string, callback: () => void) {
|
||||||
|
return pb.collection('event_rsvps').subscribe('*', (payload) => {
|
||||||
|
const record = payload.record as any
|
||||||
|
if (record.event === eventId) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+48
-11
@@ -1,6 +1,12 @@
|
|||||||
import pb from './pocketbase'
|
import pb from './pocketbase'
|
||||||
import type { Game, GamePlatform, GameComment } from '@/types'
|
import type { Game, GamePlatform, GameComment } from '@/types'
|
||||||
|
|
||||||
|
// 生成游戏封面 URL
|
||||||
|
export function getGameCoverUrl(game: Game, thumb?: string): string {
|
||||||
|
if (!game.cover) return ''
|
||||||
|
return pb.files.getUrl(game, game.cover, thumb ? { thumb } : undefined) as string
|
||||||
|
}
|
||||||
|
|
||||||
// 获取群组的游戏列表
|
// 获取群组的游戏列表
|
||||||
export async function getGroupGames(groupId: string, options?: {
|
export async function getGroupGames(groupId: string, options?: {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -11,7 +17,7 @@ export async function getGroupGames(groupId: string, options?: {
|
|||||||
const { page = 1, limit = 50, platform, search } = options || {}
|
const { page = 1, limit = 50, platform, search } = options || {}
|
||||||
let filter = `group="${groupId}"`
|
let filter = `group="${groupId}"`
|
||||||
if (platform) filter += ` && platform="${platform}"`
|
if (platform) filter += ` && platform="${platform}"`
|
||||||
if (search) filter += ` && name ~ "${search}"`
|
if (search) filter += ` && (name ~ "${search}" || aliases ~ "${search}")`
|
||||||
|
|
||||||
const result = await pb.collection('games').getList(page, limit, {
|
const result = await pb.collection('games').getList(page, limit, {
|
||||||
filter,
|
filter,
|
||||||
@@ -24,17 +30,49 @@ export async function getGroupGames(groupId: string, options?: {
|
|||||||
// 添加游戏到群组
|
// 添加游戏到群组
|
||||||
export async function addGame(groupId: string, data: {
|
export async function addGame(groupId: string, data: {
|
||||||
name: string
|
name: string
|
||||||
|
aliases?: string[]
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
coverFile?: File
|
||||||
}) {
|
}) {
|
||||||
const user = pb.authStore.model
|
const user = pb.authStore.model
|
||||||
return pb.collection('games').create({
|
const formData = new FormData()
|
||||||
...data,
|
formData.append('name', data.name)
|
||||||
group: groupId,
|
formData.append('group', groupId)
|
||||||
addedBy: user?.id,
|
formData.append('addedBy', user?.id || '')
|
||||||
popularCount: 0
|
formData.append('popularCount', '0')
|
||||||
}, { $autoCancel: false })
|
if (data.platform) formData.append('platform', data.platform)
|
||||||
|
if (data.aliases && data.aliases.length > 0) formData.append('aliases', JSON.stringify(data.aliases))
|
||||||
|
if (data.tags && data.tags.length > 0) formData.append('tags', JSON.stringify(data.tags))
|
||||||
|
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||||
|
|
||||||
|
return pb.collection('games').create(formData, { $autoCancel: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新游戏
|
||||||
|
export async function updateGame(gameId: string, data: {
|
||||||
|
name?: string
|
||||||
|
aliases?: string[]
|
||||||
|
platform?: GamePlatform | ''
|
||||||
|
tags?: string[]
|
||||||
|
coverFile?: File
|
||||||
|
}) {
|
||||||
|
const formData = new FormData()
|
||||||
|
if (data.name !== undefined) formData.append('name', data.name)
|
||||||
|
if (data.aliases && data.aliases.length > 0) {
|
||||||
|
formData.append('aliases', JSON.stringify(data.aliases))
|
||||||
|
} else {
|
||||||
|
formData.append('aliases', '[]')
|
||||||
|
}
|
||||||
|
if (data.platform !== undefined) formData.append('platform', data.platform || '')
|
||||||
|
if (data.tags && data.tags.length > 0) {
|
||||||
|
formData.append('tags', JSON.stringify(data.tags))
|
||||||
|
} else {
|
||||||
|
formData.append('tags', '[]')
|
||||||
|
}
|
||||||
|
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||||
|
|
||||||
|
return pb.collection('games').update(gameId, formData, { $autoCancel: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除游戏
|
// 删除游戏
|
||||||
@@ -42,12 +80,11 @@ export async function deleteGame(gameId: string) {
|
|||||||
return pb.collection('games').delete(gameId, { $autoCancel: false })
|
return pb.collection('games').delete(gameId, { $autoCancel: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入游戏(批量添加)
|
// 导入游戏(批量添加,无封面文件)
|
||||||
export async function importGames(groupId: string, games: Array<{
|
export async function importGames(groupId: string, games: Array<{
|
||||||
name: string
|
name: string
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
|
||||||
}>) {
|
}>) {
|
||||||
const results = []
|
const results = []
|
||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
@@ -137,7 +174,7 @@ export function getAllPlatforms(): GamePlatform[] {
|
|||||||
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
||||||
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
||||||
if (!query.trim()) return []
|
if (!query.trim()) return []
|
||||||
let filter = `name ~ "${query}"`
|
let filter = `(name ~ "${query}" || aliases ~ "${query}")`
|
||||||
if (groupId) filter += ` && group="${groupId}"`
|
if (groupId) filter += ` && group="${groupId}"`
|
||||||
|
|
||||||
const result = await pb.collection('games').getList(1, limit, {
|
const result = await pb.collection('games').getList(1, limit, {
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ export async function getMyGroupsJoinRequests(): Promise<JoinRequest[]> {
|
|||||||
return result.items as unknown as JoinRequest[]
|
return result.items as unknown as JoinRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前用户的所有入群申请(包括所有状态)
|
||||||
|
export async function getMyJoinRequests(): Promise<JoinRequest[]> {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) return []
|
||||||
|
|
||||||
|
const result = await pb.collection('join_requests').getList(1, 50, {
|
||||||
|
filter: `user="${user.id}"`,
|
||||||
|
sort: '-created',
|
||||||
|
expand: 'group',
|
||||||
|
$autoCancel: false
|
||||||
|
})
|
||||||
|
return result.items as unknown as JoinRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
// 审批加入申请
|
// 审批加入申请
|
||||||
export async function respondJoinRequest(
|
export async function respondJoinRequest(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -189,6 +203,78 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转让群主
|
||||||
|
// 安全提示:PB groups updateRule 允许认证用户更新,前端校验是唯一防线。
|
||||||
|
// 如需服务端强制保护,需添加 PocketBase JS hooks 或改用更严格的 updateRule。
|
||||||
|
export async function transferGroupOwnership(groupId: string, newOwnerId: string) {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const group = await pb.collection('groups').getOne(groupId)
|
||||||
|
if (group.owner !== user.id) {
|
||||||
|
throw new Error('只有群主可以转让群组')
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = (group.admins as string[]) || []
|
||||||
|
|
||||||
|
// 将原群主加入 admins,新群主从 admins 中移除
|
||||||
|
const newAdmins = [...new Set([...admins, user.id])].filter(id => id !== newOwnerId)
|
||||||
|
|
||||||
|
return pb.collection('groups').update(groupId, {
|
||||||
|
owner: newOwnerId,
|
||||||
|
admins: newAdmins
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加管理员
|
||||||
|
export async function addGroupAdmin(groupId: string, userId: string) {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const group = await pb.collection('groups').getOne(groupId)
|
||||||
|
if (group.owner !== user.id) {
|
||||||
|
throw new Error('只有群主可以设置管理员')
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = (group.admins as string[]) || []
|
||||||
|
if (admins.includes(userId)) {
|
||||||
|
throw new Error('该用户已是管理员')
|
||||||
|
}
|
||||||
|
|
||||||
|
return pb.collection('groups').update(groupId, {
|
||||||
|
admins: [...admins, userId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除管理员
|
||||||
|
export async function removeGroupAdmin(groupId: string, userId: string) {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const group = await pb.collection('groups').getOne(groupId)
|
||||||
|
if (group.owner !== user.id) {
|
||||||
|
throw new Error('只有群主可以移除管理员')
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = (group.admins as string[]) || []
|
||||||
|
return pb.collection('groups').update(groupId, {
|
||||||
|
admins: admins.filter(id => id !== userId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新全员管理设置
|
||||||
|
export async function updateGroupMemberManage(groupId: string, allow: boolean) {
|
||||||
|
const user = pb.authStore.model
|
||||||
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
|
const group = await pb.collection('groups').getOne(groupId)
|
||||||
|
if (group.owner !== user.id) {
|
||||||
|
throw new Error('只有群主可以修改此设置')
|
||||||
|
}
|
||||||
|
|
||||||
|
return pb.collection('groups').update(groupId, { allowMemberManage: allow })
|
||||||
|
}
|
||||||
|
|
||||||
// 订阅加入申请变更
|
// 订阅加入申请变更
|
||||||
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
|
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
|
||||||
return pb.collection('join_requests').subscribe('*', (payload) => {
|
return pb.collection('join_requests').subscribe('*', (payload) => {
|
||||||
|
|||||||
@@ -7,6 +7,27 @@ export const pb = new PocketBase(pbUrl)
|
|||||||
|
|
||||||
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
||||||
|
|
||||||
|
// Electron 环境下使用 electron-store 做更持久的备份
|
||||||
|
const eAPI = (window as any).electronAPI
|
||||||
|
if (eAPI?.storeGet) {
|
||||||
|
// 若 localStorage 无有效认证,尝试从 electron-store 恢复
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
const saved = eAPI.storeGet('pb_auth')
|
||||||
|
if (saved?.token && saved?.model) {
|
||||||
|
pb.authStore.save(saved.token, saved.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证状态变化时同步到 electron-store
|
||||||
|
pb.authStore.onChange((token: string, model: any) => {
|
||||||
|
if (token && model) {
|
||||||
|
eAPI.storeSet('pb_auth', { token, model })
|
||||||
|
} else {
|
||||||
|
eAPI.storeDelete('pb_auth')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前用户
|
// 获取当前用户
|
||||||
export function getCurrentUser() {
|
export function getCurrentUser() {
|
||||||
return pb.authStore.model
|
return pb.authStore.model
|
||||||
@@ -20,6 +41,10 @@ export function isAuthenticated(): boolean {
|
|||||||
// 登出
|
// 登出
|
||||||
export function logout() {
|
export function logout() {
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
|
const eAPI = (window as any).electronAPI
|
||||||
|
if (eAPI?.storeDelete) {
|
||||||
|
eAPI.storeDelete('pb_auth')
|
||||||
|
}
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise<TeamSession
|
|||||||
return result.items as unknown as TeamSession[]
|
return result.items as unknown as TeamSession[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取单个临时小组详情
|
||||||
|
export async function getTeamSession(sessionId: string): Promise<TeamSession> {
|
||||||
|
return pb.collection('team_sessions').getOne(sessionId, {
|
||||||
|
expand: 'sourceGroup',
|
||||||
|
$autoCancel: false
|
||||||
|
}) as unknown as TeamSession
|
||||||
|
}
|
||||||
|
|
||||||
// 更新临时小组状态
|
// 更新临时小组状态
|
||||||
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
|
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
|
||||||
const updateData: Partial<TeamSession> = { status }
|
const updateData: Partial<TeamSession> = { status }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
|||||||
if (!user) throw new Error('未登录')
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
const token = pb.authStore.token
|
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}`, {
|
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -21,9 +22,11 @@ export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('[voice] token service response status:', res.status)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
||||||
throw new Error(data.error || '语音服务暂不可用')
|
console.log('[voice] token service error:', data)
|
||||||
|
throw new Error(data.error || data.detail || '语音服务暂不可用')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 细节体验优化(全局) ============ */
|
||||||
|
|
||||||
|
/* 1. 按钮点击反馈:统一按压缩放 */
|
||||||
|
.mobile-app .van-button:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.mobile-app .van-button {
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 可点击卡片按压反馈(约定:凡 .clickable 或带 @click 的卡片建议加此类) */
|
||||||
|
.mobile-app .clickable {
|
||||||
|
transition: transform 0.12s, box-shadow 0.12s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mobile-app .clickable:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. van-empty 空状态:图标加品牌色淡背景,提升存在感 */
|
||||||
|
.mobile-app .van-empty__image {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.mobile-app .van-empty__description {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. 列表项分隔线统一为更柔和的颜色 */
|
||||||
|
.mobile-app .van-cell {
|
||||||
|
border-color: var(--gg-border);
|
||||||
|
}
|
||||||
|
.mobile-app .van-cell::after {
|
||||||
|
border-color: var(--gg-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. 全局卡片阴影统一(覆盖各页面自定义的过深/过浅阴影) */
|
||||||
|
.mobile-app .van-popup,
|
||||||
|
.mobile-app .van-action-sheet {
|
||||||
|
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6. Tab 栏激活态下划线更柔和 */
|
||||||
|
.mobile-app .van-tabs__line {
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 7. 输入框聚焦态:边框过渡更顺滑 */
|
||||||
|
.mobile-app .van-field {
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 8. 骨架屏占位(list 加载时,配合 van-skeleton 或 .skeleton 类使用) */
|
||||||
|
.mobile-app .skeleton-block {
|
||||||
|
background: linear-gradient(90deg, var(--gg-bg-elevated) 25%, var(--gg-bg) 37%, var(--gg-bg-elevated) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: skeleton-loading 1.4s ease infinite;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
}
|
||||||
|
@keyframes skeleton-loading {
|
||||||
|
0% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 9. 滚动容器:隐藏滚动条但保留滚动能力(移动端原生体验) */
|
||||||
|
.mobile-app .scroll-hide {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.mobile-app .scroll-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 10. 文字溢出省略工具类(列表标题常用) */
|
||||||
|
.mobile-app .ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mobile-app .ellipsis-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 11. 页面切换淡入动画(router-view 配合) */
|
||||||
|
.mobile-app .mobile-content {
|
||||||
|
animation: page-fade-in 0.25s ease;
|
||||||
|
}
|
||||||
|
@keyframes page-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 12. Toast 在刘海屏顶部不被遮挡 */
|
||||||
|
.mobile-app .van-toast {
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 10%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!--
|
||||||
|
MobileIcon.vue
|
||||||
|
手机端统一 SVG 图标组件 —— 线性描边风格,2px 线宽,24×24 viewBox
|
||||||
|
颜色继承 currentColor,通过 color/style/class 控制
|
||||||
|
用法:<MobileIcon name="game" /> <MobileIcon name="bell" :size="22" color="var(--gg-primary)" />
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
name: string
|
||||||
|
size?: number | string
|
||||||
|
color?: string
|
||||||
|
}>(), {
|
||||||
|
size: 20,
|
||||||
|
color: 'currentColor'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 所有图标的 path 数据(线性描边,stroke 风格统一)
|
||||||
|
// 每个 name 对应一组 SVG 内部元素(path/circle 等)
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
// ---- 品牌/导航 ----
|
||||||
|
logo: '<path d="M6 8h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z"/><path d="M7 13h2"/><path d="M15 13h2"/><path d="M9 16h6"/>',
|
||||||
|
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5"/><path d="M9.5 21v-6h5v6"/>',
|
||||||
|
users: '<circle cx="9" cy="8" r="3"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 6.5a3 3 0 0 1 0 5.8"/><path d="M17 14.5a5.5 5.5 0 0 1 3.5 5.1"/>',
|
||||||
|
game: '<rect x="2.5" y="7" width="19" height="10" rx="3"/><path d="M7 11v3"/><path d="M5.5 12.5h3"/><circle cx="16" cy="11.5" r="0.8" fill="currentColor" stroke="none"/><circle cx="18.5" cy="14" r="0.8" fill="currentColor" stroke="none"/>',
|
||||||
|
bell: '<path d="M6 9a6 6 0 0 1 12 0c0 4 1.2 5.5 2 6.5H4c.8-1 2-2.5 2-6.5Z"/><path d="M10 19a2 2 0 0 0 4 0"/>',
|
||||||
|
user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>',
|
||||||
|
|
||||||
|
// ---- 功能/操作 ----
|
||||||
|
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-3.6-3.6"/>',
|
||||||
|
edit: '<path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m14 6 4 4"/>',
|
||||||
|
trash: '<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||||||
|
close: '<path d="M6 6l12 12"/><path d="M18 6 6 18"/>',
|
||||||
|
check: '<path d="m5 12.5 4.5 4.5L19 7"/>',
|
||||||
|
arrowDown: '<path d="M12 5v14"/><path d="m6 13 6 6 6-6"/>',
|
||||||
|
arrowLeft: '<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>',
|
||||||
|
arrowRight: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
|
||||||
|
arrowUp: '<path d="M12 19V5"/><path d="m6 11 6-6 6 6"/>',
|
||||||
|
refresh: '<path d="M4 12a8 8 0 0 1 13.5-5.8L20 8"/><path d="M20 4v4h-4"/><path d="M20 12a8 8 0 0 1-13.5 5.8L4 16"/><path d="M4 20v-4h4"/>',
|
||||||
|
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
|
||||||
|
|
||||||
|
// ---- 状态/反馈 ----
|
||||||
|
star: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z"/>',
|
||||||
|
starFilled: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z" fill="currentColor" stroke="none"/>',
|
||||||
|
fire: '<path d="M12 3c1 3-1 4-1 6 0 1 1 2 2 2s2-1 2-3c2 2 3 4 3 7a6 6 0 0 1-12 0c0-3 2-5 3-6 1 1 1 2 2 2 1 0 1-2 1-5 0-1 0-2 0-3Z"/>',
|
||||||
|
bookmark: '<path d="M6 4h12v16l-6-4-6 4V4Z"/>',
|
||||||
|
warning: '<path d="M12 4 2.5 20h19L12 4Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r="0.8" fill="currentColor" stroke="none"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.5" r="0.8" fill="currentColor" stroke="none"/>',
|
||||||
|
|
||||||
|
// ---- 时间/地点/类型 ----
|
||||||
|
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3.5 2"/>',
|
||||||
|
location: '<path d="M12 21s7-6 7-11a7 7 0 0 0-14 0c0 5 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/>',
|
||||||
|
calendar: '<rect x="4" y="5" width="16" height="16" rx="2"/><path d="M4 9h16"/><path d="M8 3v4"/><path d="M16 3v4"/>',
|
||||||
|
trophy: '<path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 5H5v2a3 3 0 0 0 3 3"/><path d="M16 5h3v2a3 3 0 0 1-3 3"/><path d="M12 12v4"/><path d="M9 20h6"/><path d="M10 16h4l.5 4h-5l.5-4Z"/>',
|
||||||
|
tag: '<path d="M3 5h7l9 9-7 7-9-9V5Z"/><circle cx="7.5" cy="9.5" r="1.2" fill="currentColor" stroke="none"/>',
|
||||||
|
chart: '<path d="M4 20V4"/><path d="M4 20h16"/><path d="M8 16v-4"/><path d="M13 16V8"/><path d="M18 16v-7"/>',
|
||||||
|
|
||||||
|
// ---- 组队/语音 ----
|
||||||
|
volume: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 9a3 3 0 0 1 0 6"/><path d="M18.5 7a6 6 0 0 1 0 10"/>',
|
||||||
|
volumeOff: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="m16 9 5 6"/><path d="m21 9-5 6"/>',
|
||||||
|
speaker: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 8a5 5 0 0 1 0 8"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>',
|
||||||
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
|
||||||
|
micOff: '<path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 9v2a3 3 0 0 0 5 2"/><path d="M5 11a7 7 0 0 0 11 4"/><path d="M12 18v3"/><path d="m4 4 16 16"/>',
|
||||||
|
|
||||||
|
// ---- 群组/资产相关 ----
|
||||||
|
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 9h18"/><circle cx="16.5" cy="13" r="1.2" fill="currentColor" stroke="none"/>',
|
||||||
|
gift: '<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M4 12h16"/><path d="M12 8v12"/><path d="M12 8C12 5 10 4 8.5 4S6 6 8 8"/><path d="M12 8c0-3 2-4 3.5-4S18 6 16 8"/>',
|
||||||
|
photo: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="m3 17 5-4 4 3 3-2 6 4"/>',
|
||||||
|
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.5 1.5"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.5-1.5"/>',
|
||||||
|
download: '<path d="M12 4v11"/><path d="m7 11 5 5 5-5"/><path d="M5 20h14"/>',
|
||||||
|
upload: '<path d="M12 16V5"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/>',
|
||||||
|
music: '<circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="16" r="2.5"/><path d="M8.5 18V5l12-2v13"/>',
|
||||||
|
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
|
||||||
|
document: '<path d="M6 3h8l4 4v14H6V3Z"/><path d="M14 3v4h4"/><path d="M9 13h6"/><path d="M9 17h6"/>',
|
||||||
|
setting: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
||||||
|
logout: '<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M16 16l4-4-4-4"/><path d="M20 12H9"/>',
|
||||||
|
|
||||||
|
// ---- 状态点 ----
|
||||||
|
dotIdle: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerSvg = computed(() => ICONS[props.name] || ICONS.info)
|
||||||
|
|
||||||
|
const wrapperStyle = computed(() => ({
|
||||||
|
width: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||||
|
height: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||||
|
color: props.color
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
:style="wrapperStyle"
|
||||||
|
class="mobile-icon"
|
||||||
|
v-html="innerSvg"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
<!-- 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'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
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"><MobileIcon name="trophy" :size="13" color="var(--gg-warning)" /> 胜出</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 { display: inline-flex; align-items: center; gap: 2px; 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,285 @@
|
|||||||
|
<!-- src/components-mobile/bulletin/BulletinListMobile.vue -->
|
||||||
|
<!-- 手机端信息公示板:置顶区 + 普通列表 + 发布 + 已读标记 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { subscribeBulletins } from '@/api/bulletins'
|
||||||
|
import { BulletinPriorityMap } from '@/types'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import BulletinPostSheetMobile from './BulletinPostSheetMobile.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId: string }>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
|
||||||
|
const posts = computed(() => bulletinStore.posts)
|
||||||
|
const pinnedPosts = computed(() => bulletinStore.pinnedPosts)
|
||||||
|
const normalPosts = computed(() => bulletinStore.normalPosts)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// 详情/编辑面板
|
||||||
|
const showSheet = ref(false)
|
||||||
|
const viewingPost = ref<BulletinPost | null>(null)
|
||||||
|
const editing = ref(false)
|
||||||
|
|
||||||
|
// 发布面板
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await bulletinStore.loadPosts(props.groupId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAll()
|
||||||
|
try {
|
||||||
|
unsubscribeFn = await subscribeBulletins(props.groupId, () => loadAll())
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openDetail(post: BulletinPost) {
|
||||||
|
viewingPost.value = post
|
||||||
|
editing.value = false
|
||||||
|
showSheet.value = true
|
||||||
|
// 标记已读
|
||||||
|
if (!bulletinStore.isRead(post.id)) {
|
||||||
|
bulletinStore.markAsRead(post.id).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(post: BulletinPost) {
|
||||||
|
viewingPost.value = post
|
||||||
|
editing.value = true
|
||||||
|
showSheet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(post: BulletinPost) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除公告',
|
||||||
|
message: `确定要删除「${post.title}」吗?`
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await bulletinStore.remove(post.id)
|
||||||
|
showSuccessToast('删除成功')
|
||||||
|
showSheet.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
try {
|
||||||
|
await bulletinStore.markAllAsRead(props.groupId)
|
||||||
|
showSuccessToast('全部已读')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityType(p: string): any {
|
||||||
|
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
|
||||||
|
return map[p] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||||
|
if (min < 1) return '刚刚'
|
||||||
|
if (min < 60) return `${min}分钟前`
|
||||||
|
const hour = Math.floor(min / 60)
|
||||||
|
if (hour < 24) return `${hour}小时前`
|
||||||
|
const day = Math.floor(hour / 24)
|
||||||
|
if (day < 30) return `${day}天前`
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板关闭后处理(编辑/发布后刷新)
|
||||||
|
async function onSheetSaved() {
|
||||||
|
showSheet.value = false
|
||||||
|
showCreate.value = false
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bulletin-mobile">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<van-button size="small" round icon="success" plain @click="handleMarkAllRead">全部已读</van-button>
|
||||||
|
<van-button type="primary" size="small" round icon="edit" @click="showCreate = true">发布公告</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && posts.length === 0" class="loading-box">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="posts.length === 0" class="empty">
|
||||||
|
<van-empty description="暂无公告" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="post-list">
|
||||||
|
<!-- 置顶区 -->
|
||||||
|
<div v-if="pinnedPosts.length > 0" class="section-block">
|
||||||
|
<div class="block-title">
|
||||||
|
<van-icon name="bookmark-o" /> 置顶
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="post in pinnedPosts"
|
||||||
|
:key="post.id"
|
||||||
|
class="post-card pinned"
|
||||||
|
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||||
|
@click="openDetail(post)"
|
||||||
|
>
|
||||||
|
<div class="post-header">
|
||||||
|
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||||
|
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-title">{{ post.title }}</div>
|
||||||
|
<div class="post-content-preview">{{ post.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 普通列表 -->
|
||||||
|
<div v-if="normalPosts.length > 0" class="section-block">
|
||||||
|
<div v-if="pinnedPosts.length > 0" class="block-title">
|
||||||
|
<van-icon name="notes-o" /> 公告
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="post in normalPosts"
|
||||||
|
:key="post.id"
|
||||||
|
class="post-card"
|
||||||
|
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||||
|
@click="openDetail(post)"
|
||||||
|
>
|
||||||
|
<div class="post-header">
|
||||||
|
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||||
|
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-title">{{ post.title }}</div>
|
||||||
|
<div class="post-content-preview">{{ post.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情/编辑面板 -->
|
||||||
|
<BulletinPostSheetMobile
|
||||||
|
v-model:show="showSheet"
|
||||||
|
:post="viewingPost"
|
||||||
|
:editing="editing"
|
||||||
|
:group-id="props.groupId"
|
||||||
|
@edit="openEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@saved="onSheetSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 发布面板(复用 PostSheet,post 为 null 表示新建) -->
|
||||||
|
<BulletinPostSheetMobile
|
||||||
|
v-model:show="showCreate"
|
||||||
|
:post="null"
|
||||||
|
:editing="true"
|
||||||
|
:group-id="props.groupId"
|
||||||
|
@saved="onSheetSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-mobile { padding: 12px; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||||
|
.empty { padding: 20px 0; }
|
||||||
|
|
||||||
|
.section-block { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:active { opacity: 0.85; }
|
||||||
|
|
||||||
|
.post-card.pinned {
|
||||||
|
border-left: 3px solid var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card.unread::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content-preview {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<!-- src/components-mobile/bulletin/BulletinPostSheetMobile.vue -->
|
||||||
|
<!-- 公告详情/编辑/创建 三合一底部面板 -->
|
||||||
|
<!-- - post=null & editing=true → 创建 -->
|
||||||
|
<!-- - post=非null & editing=false → 详情(含编辑/删除) -->
|
||||||
|
<!-- - post=非null & editing=true → 编辑 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { BulletinPriorityMap } from '@/types'
|
||||||
|
import type { BulletinPost, BulletinPriority } from '@/types'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
post: BulletinPost | null
|
||||||
|
editing: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'edit': [post: BulletinPost]
|
||||||
|
'delete': [post: BulletinPost]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'normal' as BulletinPriority,
|
||||||
|
pinned: false,
|
||||||
|
expiresAt: ''
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 是否创建模式
|
||||||
|
const isCreate = computed(() => props.editing && !props.post)
|
||||||
|
|
||||||
|
// 权限:群主/管理员可编辑删除
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||||
|
const canManage = computed(() => {
|
||||||
|
if (!props.post) return false
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (props.post.creator === currentUserId.value) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开时同步表单
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
if (props.editing && props.post) {
|
||||||
|
// 编辑模式:填充现有数据
|
||||||
|
form.value = {
|
||||||
|
title: props.post.title,
|
||||||
|
content: props.post.content,
|
||||||
|
priority: props.post.priority,
|
||||||
|
pinned: props.post.pinned,
|
||||||
|
expiresAt: props.post.expiresAt || ''
|
||||||
|
}
|
||||||
|
} else if (isCreate.value) {
|
||||||
|
// 创建模式:清空
|
||||||
|
form.value = { title: '', content: '', priority: 'normal', pinned: false, expiresAt: '' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
showFailToast('请输入标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.content.trim()) {
|
||||||
|
showFailToast('请输入内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
content: form.value.content.trim(),
|
||||||
|
priority: form.value.priority,
|
||||||
|
pinned: form.value.pinned,
|
||||||
|
expiresAt: form.value.expiresAt || undefined
|
||||||
|
}
|
||||||
|
if (isCreate.value) {
|
||||||
|
await bulletinStore.create({ group: props.groupId, ...payload })
|
||||||
|
showSuccessToast('发布成功')
|
||||||
|
} else if (props.post) {
|
||||||
|
await bulletinStore.update(props.post.id, payload)
|
||||||
|
showSuccessToast('修改成功')
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityKeys = Object.keys(BulletinPriorityMap) as BulletinPriority[]
|
||||||
|
|
||||||
|
function priorityType(p: string): any {
|
||||||
|
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
|
||||||
|
return map[p] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: editing ? '80%' : '70%' }"
|
||||||
|
>
|
||||||
|
<div class="post-sheet">
|
||||||
|
<!-- 编辑/创建模式 -->
|
||||||
|
<template v-if="editing">
|
||||||
|
<div class="sheet-title">{{ isCreate ? '发布公告' : '编辑公告' }}</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="form.title" label="标题" placeholder="公告标题" required maxlength="100" />
|
||||||
|
<van-field
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
label="内容"
|
||||||
|
placeholder="公告正文(支持换行)"
|
||||||
|
rows="5"
|
||||||
|
autosize
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<van-field name="priority" label="优先级">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="form.priority" class="native-select">
|
||||||
|
<option v-for="p in priorityKeys" :key="p" :value="p">{{ BulletinPriorityMap[p] }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-cell title="置顶" center>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-switch v-model="form.pinned" size="22px" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-field v-model="form.expiresAt" label="截止时间" placeholder="可选,如 2026-12-31" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||||
|
{{ isCreate ? '发布' : '保存' }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 详情模式 -->
|
||||||
|
<template v-else-if="post">
|
||||||
|
<div class="detail-header">
|
||||||
|
<van-tag :type="priorityType(post.priority)" size="large">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||||
|
<van-tag v-if="post.pinned" type="warning" size="large">置顶</van-tag>
|
||||||
|
<span class="detail-time">{{ formatDate(post.created) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="detail-title">{{ post.title }}</h2>
|
||||||
|
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span>发布人:{{ displayName(post.expand?.creator) }}</span>
|
||||||
|
<span v-if="post.expiresAt" class="detail-expire">截止:{{ formatDate(post.expiresAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-content">{{ post.content }}</div>
|
||||||
|
|
||||||
|
<div v-if="canManage" class="detail-actions">
|
||||||
|
<van-button size="small" round icon="edit" @click="emit('edit', post)">编辑</van-button>
|
||||||
|
<van-button size="small" round plain type="danger" icon="delete-o" @click="emit('delete', post)">删除</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-select {
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情 */
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expire {
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 0 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- src/components-mobile/event/CreateEventSheetMobile.vue -->
|
||||||
|
<!-- 创建活动底部面板:标题/描述/地点/开始/结束/人数上限 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'created': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
maxParticipants: 0
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
// 默认开始时间为当前+1小时(取整点)
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
startTime: toLocalInput(now),
|
||||||
|
endTime: '',
|
||||||
|
maxParticipants: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toLocalInput(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
showFailToast('请输入活动标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.startTime) {
|
||||||
|
showFailToast('请设置开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const startTime = new Date(form.value.startTime).toISOString()
|
||||||
|
const endTime = form.value.endTime ? new Date(form.value.endTime).toISOString() : undefined
|
||||||
|
if (endTime && new Date(endTime) <= new Date(startTime)) {
|
||||||
|
showFailToast('结束时间必须晚于开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await eventStore.addEvent({
|
||||||
|
group: props.groupId,
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
description: form.value.description.trim() || undefined,
|
||||||
|
location: form.value.location.trim() || undefined,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
maxParticipants: form.value.maxParticipants > 0 ? form.value.maxParticipants : undefined
|
||||||
|
})
|
||||||
|
showSuccessToast('创建成功')
|
||||||
|
emit('created')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '80%' }"
|
||||||
|
>
|
||||||
|
<div class="create-sheet">
|
||||||
|
<div class="sheet-title">发起活动</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="form.title" label="标题" placeholder="活动名称" required maxlength="100" />
|
||||||
|
<van-field
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
label="描述"
|
||||||
|
placeholder="活动详情(可选)"
|
||||||
|
rows="3"
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
<van-field v-model="form.location" label="地点" placeholder="如:语音房 / 线下某处" />
|
||||||
|
<van-field
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="datetime-local"
|
||||||
|
label="开始时间"
|
||||||
|
placeholder="选择时间"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="form.endTime"
|
||||||
|
type="datetime-local"
|
||||||
|
label="结束时间"
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
<van-field name="stepper" label="人数上限">
|
||||||
|
<template #input>
|
||||||
|
<van-stepper v-model="form.maxParticipants" min="0" max="200" />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="hint">人数上限为 0 表示不限制</div>
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||||
|
创建活动
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<!-- src/components-mobile/event/EventListMobile.vue -->
|
||||||
|
<!-- 手机端事件列表 + 展开详情(RSVP + 评论)+ 创建入口 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
|
||||||
|
import { EventStatusMap, RSVPTypeMap } from '@/types'
|
||||||
|
import type { Event, RSVPType } from '@/types'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
import CreateEventSheetMobile from './CreateEventSheetMobile.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId: string }>()
|
||||||
|
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const upcomingEvents = computed(() => eventStore.upcomingEvents)
|
||||||
|
const pastEvents = computed(() => eventStore.pastEvents)
|
||||||
|
const expandedId = ref<string | null>(null)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
let unsubEvents: (() => void) | null = null
|
||||||
|
let unsubComments: (() => void) | null = null
|
||||||
|
let unsubRSVPs: (() => void) | null = null
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await eventStore.loadEvents(props.groupId)
|
||||||
|
try {
|
||||||
|
unsubEvents = await subscribeEvents(props.groupId, async () => {
|
||||||
|
await eventStore.loadEvents(props.groupId)
|
||||||
|
if (expandedId.value) await loadDetail(expandedId.value)
|
||||||
|
})
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubEvents) unsubEvents()
|
||||||
|
if (unsubComments) unsubComments()
|
||||||
|
if (unsubRSVPs) unsubRSVPs()
|
||||||
|
eventStore.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDetail(eventId: string) {
|
||||||
|
await eventStore.loadCommentsAndRSVPs(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleExpand(event: Event) {
|
||||||
|
if (expandedId.value === event.id) {
|
||||||
|
expandedId.value = null
|
||||||
|
if (unsubComments) { unsubComments(); unsubComments = null }
|
||||||
|
if (unsubRSVPs) { unsubRSVPs(); unsubRSVPs = null }
|
||||||
|
} else {
|
||||||
|
expandedId.value = event.id
|
||||||
|
await loadDetail(event.id)
|
||||||
|
if (unsubComments) unsubComments()
|
||||||
|
if (unsubRSVPs) unsubRSVPs()
|
||||||
|
try {
|
||||||
|
unsubComments = await subscribeEventComments(event.id, () => loadDetail(event.id))
|
||||||
|
unsubRSVPs = await subscribeEventRSVPs(event.id, () => loadDetail(event.id))
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvps = computed(() => eventStore.rsvps)
|
||||||
|
const comments = computed(() => eventStore.comments)
|
||||||
|
|
||||||
|
function myRsvp(eventId: string) {
|
||||||
|
if (expandedId.value !== eventId) return null
|
||||||
|
return rsvps.value.find(r => r.user === currentUserId.value) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function goingCount(eventId: string): number {
|
||||||
|
if (expandedId.value !== eventId) return 0
|
||||||
|
return rsvps.value.filter(r => r.type === 'going').length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRsvp(event: Event, type: RSVPType) {
|
||||||
|
try {
|
||||||
|
await eventStore.rsvp(event.id, type)
|
||||||
|
showSuccessToast(`已${RSVPTypeMap[type]}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRsvp(event: Event) {
|
||||||
|
try {
|
||||||
|
await eventStore.cancelRSVP(event.id)
|
||||||
|
showSuccessToast('已取消')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评论
|
||||||
|
const newComment = ref('')
|
||||||
|
const commentSubmitting = ref(false)
|
||||||
|
|
||||||
|
async function submitComment(eventId: string) {
|
||||||
|
if (!newComment.value.trim()) {
|
||||||
|
showFailToast('请输入评论')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await eventStore.addComment(eventId, newComment.value.trim())
|
||||||
|
newComment.value = ''
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '评论失败')
|
||||||
|
} finally {
|
||||||
|
commentSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(event: Event) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除事件',
|
||||||
|
message: `确定要删除「${event.title}」吗?`
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await eventStore.removeEvent(event.id)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
expandedId.value = null
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManage(event: Event): boolean {
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (event.creator === currentUserId.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(s: string): any {
|
||||||
|
return s === 'upcoming' ? 'primary' : s === 'ongoing' ? 'success' : 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreated() {
|
||||||
|
showCreate.value = false
|
||||||
|
await eventStore.loadEvents(props.groupId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-mobile">
|
||||||
|
<div class="toolbar">
|
||||||
|
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
|
||||||
|
发起活动
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="upcomingEvents.length === 0 && pastEvents.length === 0" class="empty">
|
||||||
|
<van-empty description="暂无活动" image-size="100">
|
||||||
|
<van-button type="primary" size="small" round @click="showCreate = true">发起第一个活动</van-button>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 即将开始 -->
|
||||||
|
<div v-if="upcomingEvents.length > 0" class="section-block">
|
||||||
|
<div class="block-title">即将开始</div>
|
||||||
|
<div v-for="event in upcomingEvents" :key="event.id" class="event-card-wrap">
|
||||||
|
<div class="event-card" @click="toggleExpand(event)">
|
||||||
|
<div class="event-card-header">
|
||||||
|
<van-tag :type="statusType(event.status)" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
|
||||||
|
<span v-if="expandedId !== event.id && goingCount(event.id) > 0" class="event-going">
|
||||||
|
{{ goingCount(event.id) }} 人参加
|
||||||
|
</span>
|
||||||
|
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="event-title">{{ event.title }}</div>
|
||||||
|
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
|
||||||
|
<div v-if="event.location" class="event-loc"><MobileIcon name="location" :size="13" /> {{ event.location }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开详情 -->
|
||||||
|
<div v-if="expandedId === event.id" class="event-detail">
|
||||||
|
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
|
||||||
|
<div v-if="event.endTime" class="detail-meta">结束:{{ formatDateTime(event.endTime) }}</div>
|
||||||
|
<div v-if="event.maxParticipants" class="detail-meta">人数上限:{{ event.maxParticipants }}</div>
|
||||||
|
|
||||||
|
<!-- RSVP -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">参加报名({{ goingCount(event.id) }} 人)</div>
|
||||||
|
<div class="rsvp-row">
|
||||||
|
<template v-if="myRsvp(event.id)">
|
||||||
|
<van-tag :type="myRsvp(event.id)!.type === 'going' ? 'success' : 'primary'" size="large">
|
||||||
|
{{ RSVPTypeMap[myRsvp(event.id)!.type] }}
|
||||||
|
</van-tag>
|
||||||
|
<van-button size="mini" plain round @click="cancelRsvp(event)">取消报名</van-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<van-button size="small" type="primary" round @click="doRsvp(event, 'going')">参加</van-button>
|
||||||
|
<van-button size="small" round @click="doRsvp(event, 'interested')">感兴趣</van-button>
|
||||||
|
<van-button size="small" round @click="doRsvp(event, 'maybe')">可能参加</van-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评论 -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">评论 ({{ comments.length }})</div>
|
||||||
|
<div class="comment-input-row">
|
||||||
|
<van-field v-model="newComment" placeholder="写评论..." class="comment-input" />
|
||||||
|
<van-button size="small" type="primary" :loading="commentSubmitting" @click="submitComment(event.id)">发送</van-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="comments.length === 0" class="comment-empty">暂无评论</div>
|
||||||
|
<div v-else class="comment-list">
|
||||||
|
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||||
|
<img :src="c.expand?.user?.avatar || '/default-avatar.svg'" class="comment-avatar" alt="" />
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-author">{{ displayName(c.expand?.user) }}</div>
|
||||||
|
<div class="comment-content">{{ c.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 管理操作 -->
|
||||||
|
<div v-if="canManage(event)" class="detail-actions">
|
||||||
|
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已结束 -->
|
||||||
|
<div v-if="pastEvents.length > 0" class="section-block">
|
||||||
|
<div class="block-title">已结束</div>
|
||||||
|
<div v-for="event in pastEvents" :key="event.id" class="event-card-wrap">
|
||||||
|
<div class="event-card ended" @click="toggleExpand(event)">
|
||||||
|
<div class="event-card-header">
|
||||||
|
<van-tag type="default" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
|
||||||
|
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="event-title">{{ event.title }}</div>
|
||||||
|
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedId === event.id" class="event-detail">
|
||||||
|
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
|
||||||
|
<div v-if="canManage(event)" class="detail-actions">
|
||||||
|
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateEventSheetMobile v-model:show="showCreate" :group-id="props.groupId" @created="onCreated" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-mobile { padding: 12px; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
|
||||||
|
.empty { padding: 20px 0; }
|
||||||
|
|
||||||
|
.section-block { margin-bottom: 16px; }
|
||||||
|
.block-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-wrap { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
.event-card:active { opacity: 0.85; }
|
||||||
|
.event-card.ended { opacity: 0.7; }
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.event-going { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
|
||||||
|
.expand-icon { margin-left: auto; color: var(--gg-text-muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.event-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
|
||||||
|
.event-time { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
|
||||||
|
.event-loc { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
|
||||||
|
|
||||||
|
.event-detail {
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-desc { font-size: 14px; color: var(--gg-text); line-height: 1.6; margin: 0 0 12px; white-space: pre-wrap; }
|
||||||
|
.detail-meta { font-size: 12px; color: var(--gg-text-muted); margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.detail-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
|
||||||
|
.detail-section-title { font-size: 13px; font-weight: 600; color: var(--gg-text-secondary); margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.rsvp-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.comment-input-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.comment-input { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 4px 10px; }
|
||||||
|
.comment-empty { font-size: 12px; color: var(--gg-text-muted); padding: 12px 0; text-align: center; }
|
||||||
|
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||||
|
.comment-item { display: flex; gap: 8px; }
|
||||||
|
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||||
|
.comment-body { flex: 1; min-width: 0; }
|
||||||
|
.comment-author { font-size: 12px; font-weight: 600; color: var(--gg-text); }
|
||||||
|
.comment-content { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; word-break: break-word; }
|
||||||
|
|
||||||
|
.detail-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!-- src/components-mobile/game/AddGameSheetMobile.vue -->
|
||||||
|
<!-- 添加游戏表单(绑定到当前群组):名称/别名/平台/标签/封面 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { addGame, getAllPlatforms } from '@/api/games'
|
||||||
|
import type { GamePlatform } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
aliases: '',
|
||||||
|
platform: '' as GamePlatform | '',
|
||||||
|
tags: ''
|
||||||
|
})
|
||||||
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
form.value = { name: '', aliases: '', platform: '', tags: '' }
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
if (input.files?.[0]) {
|
||||||
|
coverFile.value = input.files[0]
|
||||||
|
coverPreview.value = URL.createObjectURL(input.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCover() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.name.trim()) {
|
||||||
|
showFailToast('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
const aliases = form.value.aliases.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = form.value.tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
await addGame(props.groupId, {
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: form.value.platform || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
showSuccessToast('添加成功')
|
||||||
|
emit('saved')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '80%' }"
|
||||||
|
>
|
||||||
|
<div class="add-sheet">
|
||||||
|
<div class="sheet-title">添加游戏</div>
|
||||||
|
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="form.name" label="名称" placeholder="游戏名称" required />
|
||||||
|
<van-field v-model="form.aliases" label="别名" placeholder="逗号分隔,如 LOL,英雄联盟" />
|
||||||
|
<van-field name="platform" label="平台">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="form.platform" class="native-select">
|
||||||
|
<option value="">不指定</option>
|
||||||
|
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field v-model="form.tags" label="标签" placeholder="逗号分隔,如 MOBA,竞技" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 封面上传 -->
|
||||||
|
<div class="cover-field">
|
||||||
|
<div class="field-label">封面图</div>
|
||||||
|
<div class="cover-upload">
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="" />
|
||||||
|
<button class="cover-clear" @click="clearCover">
|
||||||
|
<van-icon name="cross" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-else class="cover-picker">
|
||||||
|
<van-icon name="photograph" size="28" />
|
||||||
|
<span>上传封面</span>
|
||||||
|
<input type="file" accept="image/*" class="file-input" @change="onFileChange" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||||
|
添加游戏
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.add-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-select {
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-field {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 90px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker {
|
||||||
|
width: 90px;
|
||||||
|
height: 120px;
|
||||||
|
border: 1px dashed var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<!-- src/components-mobile/game/GameDetailSheetMobile.vue -->
|
||||||
|
<!-- 游戏详情底部面板:封面/名称/别名/标签/收藏/编辑/删除/快速组队 + 评论评分 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { Game, GamePlatform, GameComment } from '@/types'
|
||||||
|
import {
|
||||||
|
toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms,
|
||||||
|
getGameComments, addComment
|
||||||
|
} from '@/api/games'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { createTeamSession } from '@/api/sessions'
|
||||||
|
import { useTeamStore } from '@/stores/team'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
game: Game | null
|
||||||
|
groupId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'deleted': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const teamStore = useTeamStore()
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const favorited = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const editName = ref('')
|
||||||
|
const editAliases = ref('')
|
||||||
|
const editPlatform = ref<GamePlatform | ''>('')
|
||||||
|
const editTags = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 评论
|
||||||
|
const comments = ref<GameComment[]>([])
|
||||||
|
const newContent = ref('')
|
||||||
|
const newRating = ref(0)
|
||||||
|
const commentLoading = ref(false)
|
||||||
|
const commentSubmitting = ref(false)
|
||||||
|
|
||||||
|
const canModify = computed(() => {
|
||||||
|
if (!props.game) return false
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId) return false
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (props.game.addedBy === userId) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开时初始化
|
||||||
|
watch(() => props.show, async (val) => {
|
||||||
|
if (val && props.game) {
|
||||||
|
editing.value = false
|
||||||
|
try {
|
||||||
|
favorited.value = await isFavorite(props.game.id)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
if (!props.game) return
|
||||||
|
try {
|
||||||
|
commentLoading.value = true
|
||||||
|
comments.value = await getGameComments(props.game.id)
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
commentLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!props.game) return
|
||||||
|
editName.value = props.game.name
|
||||||
|
editAliases.value = (props.game.aliases || []).join(', ')
|
||||||
|
editPlatform.value = props.game.platform || ''
|
||||||
|
editTags.value = (props.game.tags || []).join(', ')
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!props.game || !editName.value.trim()) {
|
||||||
|
showFailToast('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
await updateGame(props.game.id, {
|
||||||
|
name: editName.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: editPlatform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined
|
||||||
|
})
|
||||||
|
showSuccessToast('修改成功')
|
||||||
|
editing.value = false
|
||||||
|
emit('deleted') // 触发父组件重新加载
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '修改失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFavorite() {
|
||||||
|
if (!props.game) return
|
||||||
|
try {
|
||||||
|
favorited.value = await toggleFavorite(props.game.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!props.game) return
|
||||||
|
const game = props.game
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除游戏',
|
||||||
|
message: `确定要删除「${game.name}」吗?`
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteGame(game.id)
|
||||||
|
showSuccessToast('删除成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('deleted')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateTeam() {
|
||||||
|
if (!props.game || !props.groupId) return
|
||||||
|
try {
|
||||||
|
const me = pb.authStore.model?.id
|
||||||
|
if (!me) return
|
||||||
|
const session = await createTeamSession({
|
||||||
|
sourceGroup: props.groupId,
|
||||||
|
name: `${props.game.name}小队`,
|
||||||
|
gameName: props.game.name,
|
||||||
|
members: [me]
|
||||||
|
})
|
||||||
|
await teamStore.loadActiveSession()
|
||||||
|
showSuccessToast('已发起组队')
|
||||||
|
visible.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'VoiceRoom',
|
||||||
|
params: { groupId: props.groupId, sessionId: session.id }
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '组队失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitComment() {
|
||||||
|
if (!props.game) return
|
||||||
|
if (!newContent.value.trim()) {
|
||||||
|
showFailToast('请输入评论内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
commentSubmitting.value = true
|
||||||
|
await addComment(props.game.id, newContent.value.trim(), newRating.value || undefined)
|
||||||
|
newContent.value = ''
|
||||||
|
newRating.value = 0
|
||||||
|
await loadComments()
|
||||||
|
showSuccessToast('评论成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '评论失败')
|
||||||
|
} finally {
|
||||||
|
commentSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '85%' }"
|
||||||
|
>
|
||||||
|
<div v-if="game" class="detail-sheet">
|
||||||
|
<!-- 编辑模式 -->
|
||||||
|
<template v-if="editing">
|
||||||
|
<div class="sheet-title">编辑游戏</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="editName" label="名称" placeholder="游戏名称" required />
|
||||||
|
<van-field v-model="editAliases" label="别名" placeholder="逗号分隔" />
|
||||||
|
<van-field name="platform" label="平台">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="editPlatform" class="native-select">
|
||||||
|
<option value="">不指定</option>
|
||||||
|
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field v-model="editTags" label="标签" placeholder="逗号分隔" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<van-button block round @click="editing = false">取消</van-button>
|
||||||
|
<van-button type="primary" block round :loading="saving" @click="handleSave">保存</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 详情模式 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="detail-cover-wrap">
|
||||||
|
<img
|
||||||
|
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||||
|
:alt="game.name"
|
||||||
|
class="detail-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="detail-name">{{ game.name }}</h2>
|
||||||
|
|
||||||
|
<div v-if="game.aliases?.length" class="detail-aliases">
|
||||||
|
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-meta">
|
||||||
|
<van-tag v-if="game.platform" type="primary" size="medium">{{ game.platform }}</van-tag>
|
||||||
|
<span class="popularity"><MobileIcon name="fire" :size="13" color="var(--gg-warning)" /> {{ game.popularCount }} 人游玩</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="game.tags?.length" class="detail-tags">
|
||||||
|
<van-tag v-for="tag in game.tags" :key="tag" plain size="medium">{{ tag }}</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="detail-actions">
|
||||||
|
<van-button
|
||||||
|
:type="favorited ? 'warning' : 'default'"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:icon="favorited ? 'star' : 'star-o'"
|
||||||
|
@click="handleFavorite"
|
||||||
|
>{{ favorited ? '已收藏' : '收藏' }}</van-button>
|
||||||
|
<van-button v-if="canModify" size="small" round icon="edit" @click="startEdit">编辑</van-button>
|
||||||
|
<van-button v-if="canModify" size="small" round plain type="danger" icon="delete-o" @click="handleDelete">删除</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-button type="primary" block round class="team-btn" @click="handleCreateTeam">
|
||||||
|
快速组队
|
||||||
|
</van-button>
|
||||||
|
|
||||||
|
<!-- 评论区 -->
|
||||||
|
<div class="comments-section">
|
||||||
|
<div class="comments-title">评论 ({{ comments.length }})</div>
|
||||||
|
|
||||||
|
<!-- 评论输入 -->
|
||||||
|
<div class="comment-form">
|
||||||
|
<div class="rating-row">
|
||||||
|
<span class="rating-label">评分</span>
|
||||||
|
<div class="stars">
|
||||||
|
<van-icon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
name="star"
|
||||||
|
:class="{ active: star <= newRating }"
|
||||||
|
size="18"
|
||||||
|
@click="newRating = star"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-field
|
||||||
|
v-model="newContent"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="写下你的评价..."
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
class="comment-input"
|
||||||
|
/>
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:loading="commentSubmitting"
|
||||||
|
@click="handleSubmitComment"
|
||||||
|
>发表</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评论列表 -->
|
||||||
|
<div v-if="commentLoading" class="comment-loading">
|
||||||
|
<van-loading size="20px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="comments.length === 0" class="comment-empty">
|
||||||
|
暂无评论,快来抢沙发
|
||||||
|
</div>
|
||||||
|
<div v-else class="comment-list">
|
||||||
|
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||||
|
<img
|
||||||
|
:src="c.expand?.author?.avatar || '/default-avatar.svg'"
|
||||||
|
class="comment-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-head">
|
||||||
|
<span class="comment-author">{{ c.expand?.author?.name || c.expand?.author?.username || '匿名' }}</span>
|
||||||
|
<span v-if="c.rating" class="comment-rating">
|
||||||
|
<van-icon v-for="s in c.rating" :key="s" name="star" class="star-filled" size="12" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">{{ c.content }}</div>
|
||||||
|
<div class="comment-time">{{ formatDate(c.created) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-select {
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cover-wrap {
|
||||||
|
width: 160px;
|
||||||
|
height: 210px;
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-aliases {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-tag {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-btn {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论区 */
|
||||||
|
.comments-section {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
border-top: 8px solid var(--gg-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars .van-icon {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars .van-icon.active {
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form .van-button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-loading, .comment-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-rating .star-filled {
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!-- src/components-mobile/game/ImportGamesSheetMobile.vue -->
|
||||||
|
<!-- 批量导入游戏:每行一个游戏名(可含平台/标签),绑定到当前群组 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { importGames } from '@/api/games'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'imported': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gamesText = ref('')
|
||||||
|
const importing = ref(false)
|
||||||
|
const result = ref<{ success: number; failed: number } | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
gamesText.value = ''
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
const lines = gamesText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (lines.length === 0) {
|
||||||
|
showFailToast('请输入游戏名称(每行一个)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
importing.value = true
|
||||||
|
result.value = null
|
||||||
|
// 每行解析:支持 "游戏名" 或 "游戏名 | 平台" 或 "游戏名 | 平台 | 标签1,标签2"
|
||||||
|
const games = lines.map(line => {
|
||||||
|
const parts = line.split('|').map(s => s.trim())
|
||||||
|
const name = parts[0]
|
||||||
|
const platform = parts[1] || undefined
|
||||||
|
const tags = parts[2] ? parts[2].split(',').map(t => t.trim()).filter(Boolean) : undefined
|
||||||
|
return { name, platform: platform as any, tags }
|
||||||
|
})
|
||||||
|
const results = await importGames(props.groupId, games)
|
||||||
|
const success = results.filter(r => r.success).length
|
||||||
|
const failed = results.length - success
|
||||||
|
result.value = { success, failed }
|
||||||
|
if (failed === 0) {
|
||||||
|
showSuccessToast(`成功导入 ${success} 个游戏`)
|
||||||
|
emit('imported')
|
||||||
|
} else {
|
||||||
|
showFailToast(`成功 ${success},失败 ${failed}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '导入失败')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
>
|
||||||
|
<div class="import-sheet">
|
||||||
|
<div class="sheet-title">批量导入游戏</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
每行输入一个游戏,支持以下格式:
|
||||||
|
<code>游戏名</code> 或
|
||||||
|
<code>游戏名 | 平台</code> 或
|
||||||
|
<code>游戏名 | 平台 | 标签1,标签2</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field
|
||||||
|
v-model="gamesText"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="每行一个游戏,可用 | 分隔平台和标签"
|
||||||
|
rows="8"
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div v-if="result" class="result-bar">
|
||||||
|
<van-tag type="success">成功 {{ result.success }}</van-tag>
|
||||||
|
<van-tag v-if="result.failed > 0" type="danger">失败 {{ result.failed }}</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="importing" @click="handleImport">
|
||||||
|
开始导入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
<!-- 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, showConfirmDialog } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前会话状态(用于显示招募中/游戏中)
|
||||||
|
const sessionStatus = computed(() => teamStore.currentSession?.status || 'recruiting')
|
||||||
|
const isRecruiting = computed(() => sessionStatus.value === 'recruiting')
|
||||||
|
const isPlaying = computed(() => sessionStatus.value === 'playing')
|
||||||
|
|
||||||
|
// 解散/结束组队(与 PC 端一致:session 存在且处于招募/游戏中时,群组成员可操作)
|
||||||
|
const ending = ref(false)
|
||||||
|
async function handleEndSession() {
|
||||||
|
const s = teamStore.currentSession
|
||||||
|
if (!s) return
|
||||||
|
const isRecruit = s.status === 'recruiting'
|
||||||
|
showConfirmDialog({
|
||||||
|
title: isRecruit ? '解散小队' : '结束游戏',
|
||||||
|
message: isRecruit
|
||||||
|
? '确定要解散当前小队吗?'
|
||||||
|
: '确定要结束当前游戏吗?小队将被解散。'
|
||||||
|
}).then(async () => {
|
||||||
|
ending.value = true
|
||||||
|
try {
|
||||||
|
await teamStore.finishGame()
|
||||||
|
showSuccessToast(isRecruit ? '已解散' : '已结束')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
ending.value = false
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="activity-feed">
|
||||||
|
<!-- 当前组队卡片 -->
|
||||||
|
<div v-if="hasSession && sessionInThisGroup" class="current-team-card">
|
||||||
|
<div class="team-card-body" @click="goVoiceRoom">
|
||||||
|
<div class="team-header">
|
||||||
|
<MobileIcon name="volume" :size="16" color="#fff" />
|
||||||
|
<span class="team-label">{{ isPlaying ? '游戏中' : '招募中' }}</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">
|
||||||
|
<MobileIcon name="arrowRight" :size="14" color="#fff" />
|
||||||
|
进入语音房
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 解散/结束按钮(招募中或游戏中时显示) -->
|
||||||
|
<button
|
||||||
|
v-if="isRecruiting || isPlaying"
|
||||||
|
class="team-end-btn"
|
||||||
|
:loading="ending"
|
||||||
|
@click.stop="handleEndSession"
|
||||||
|
>
|
||||||
|
<MobileIcon name="close" :size="14" color="#fff" />
|
||||||
|
{{ isRecruiting ? '解散' : '结束' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速组队按钮 -->
|
||||||
|
<van-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
@click="showCreateTeam = true"
|
||||||
|
>
|
||||||
|
<MobileIcon name="plus" :size="16" color="#fff" />
|
||||||
|
<span style="margin-left: 4px;">发起组队</span>
|
||||||
|
</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);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-body:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解散/结束按钮 */
|
||||||
|
.team-end-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-end-btn:active {
|
||||||
|
background: rgba(239, 68, 68, 0.5);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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, uploadMemory } 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 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,488 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { subscribeBulletins } from '@/api/bulletins'
|
||||||
|
import BulletinPostCard from './BulletinPostCard.vue'
|
||||||
|
import CreateBulletinDialog from './CreateBulletinDialog.vue'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const editingPost = ref<BulletinPost | null>(null)
|
||||||
|
const detailPost = ref<BulletinPost | null>(null)
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
await bulletinStore.loadPosts(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
unsubscribeFn = await subscribeBulletins(groupId, () => {
|
||||||
|
loadAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(post: BulletinPost) {
|
||||||
|
detailPost.value = post
|
||||||
|
bulletinStore.markAsRead(post.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(post: BulletinPost) {
|
||||||
|
editingPost.value = post
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(post: BulletinPost) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除公告 "${post.title}" 吗?`,
|
||||||
|
'确认删除',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
await bulletinStore.remove(post.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogClose() {
|
||||||
|
editingPost.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailVisible = computed({
|
||||||
|
get: () => !!detailPost.value,
|
||||||
|
set: (v: boolean) => { if (!v) detailPost.value = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
function priorityLabel(post: BulletinPost) { return BulletinPriorityMap[post.priority] }
|
||||||
|
function priorityColor(post: BulletinPost) { return BulletinPriorityColor[post.priority] }
|
||||||
|
function creatorName(post: BulletinPost) { return displayName(post.expand?.creator) }
|
||||||
|
function isExpired(post: BulletinPost) {
|
||||||
|
return post.expiresAt ? new Date(post.expiresAt) <= new Date() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
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 '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteFromDetail(post: BulletinPost) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除公告 "${post.title}" 吗?`,
|
||||||
|
'确认删除',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
await bulletinStore.remove(post.id)
|
||||||
|
detailPost.value = null
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="bulletin-board">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="bulletin-board__header">
|
||||||
|
<h3 class="bulletin-board__title">公告</h3>
|
||||||
|
<button class="bulletin-board__create-btn" @click="showCreate = true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bulletin-board__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
发布公告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateBulletinDialog
|
||||||
|
v-model="showCreate"
|
||||||
|
:edit-post="editingPost"
|
||||||
|
@created="loadAll(); showCreate = false; handleDialogClose()"
|
||||||
|
@updated="loadAll(); showCreate = false; handleDialogClose()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="null"
|
||||||
|
width="580px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
class="bulletin-detail-dialog"
|
||||||
|
@closed="detailPost = null"
|
||||||
|
>
|
||||||
|
<template v-if="detailPost">
|
||||||
|
<div class="bulletin-detail__tags">
|
||||||
|
<span
|
||||||
|
class="bulletin-detail__priority"
|
||||||
|
:style="{ background: priorityColor(detailPost) + '18', color: priorityColor(detailPost) }"
|
||||||
|
>
|
||||||
|
{{ priorityLabel(detailPost) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="detailPost.pinned" class="bulletin-detail__pinned">置顶</span>
|
||||||
|
<span v-if="isExpired(detailPost)" class="bulletin-detail__expired">已过期</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="bulletin-detail__title">{{ detailPost.title }}</h2>
|
||||||
|
<div class="bulletin-detail__meta">
|
||||||
|
<span class="bulletin-detail__author">{{ creatorName(detailPost) }}</span>
|
||||||
|
<span class="bulletin-detail__sep">·</span>
|
||||||
|
<span class="bulletin-detail__time">{{ formatTime(detailPost.created) }}</span>
|
||||||
|
<span v-if="detailPost.expiresAt" class="bulletin-detail__expires">
|
||||||
|
· 截止 {{ formatDate(detailPost.expiresAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-detail__content">{{ detailPost.content }}</div>
|
||||||
|
<div class="bulletin-detail__footer">
|
||||||
|
<button class="bulletin-detail__btn" @click="handleEdit(detailPost!); detailPost = null">编辑</button>
|
||||||
|
<button class="bulletin-detail__btn bulletin-detail__btn--danger" @click="handleDeleteFromDetail(detailPost!)">删除</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 置顶公告 -->
|
||||||
|
<section v-if="bulletinStore.pinnedPosts.length > 0" class="bulletin-board__section">
|
||||||
|
<div class="bulletin-board__section-header">
|
||||||
|
<span class="bulletin-board__section-dot bulletin-board__section-dot--pinned"></span>
|
||||||
|
<span class="bulletin-board__section-label">置顶公告</span>
|
||||||
|
<span class="bulletin-board__section-count">{{ bulletinStore.pinnedPosts.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-board__grid">
|
||||||
|
<BulletinPostCard
|
||||||
|
v-for="post in bulletinStore.pinnedPosts"
|
||||||
|
:key="post.id"
|
||||||
|
:post="post"
|
||||||
|
:is-read="bulletinStore.isRead(post.id)"
|
||||||
|
@click="handleCardClick(post)"
|
||||||
|
@edit="handleEdit(post)"
|
||||||
|
@delete="handleDelete(post)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 普通公告 -->
|
||||||
|
<section v-if="bulletinStore.normalPosts.length > 0" class="bulletin-board__section">
|
||||||
|
<div class="bulletin-board__section-header">
|
||||||
|
<span class="bulletin-board__section-dot bulletin-board__section-dot--normal"></span>
|
||||||
|
<span class="bulletin-board__section-label">普通公告</span>
|
||||||
|
<span class="bulletin-board__section-count">{{ bulletinStore.normalPosts.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-board__grid">
|
||||||
|
<BulletinPostCard
|
||||||
|
v-for="post in bulletinStore.normalPosts"
|
||||||
|
:key="post.id"
|
||||||
|
:post="post"
|
||||||
|
:is-read="bulletinStore.isRead(post.id)"
|
||||||
|
@click="handleCardClick(post)"
|
||||||
|
@edit="handleEdit(post)"
|
||||||
|
@delete="handleDelete(post)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="bulletinStore.pinnedPosts.length === 0 && bulletinStore.normalPosts.length === 0 && !bulletinStore.loading"
|
||||||
|
class="bulletin-board__empty"
|
||||||
|
>
|
||||||
|
<svg class="bulletin-board__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
<p class="bulletin-board__empty-text">暂无公告</p>
|
||||||
|
<p class="bulletin-board__empty-hint">群组内还没有发布过公告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.bulletin-board__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分栏 */
|
||||||
|
.bulletin-board__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot--pinned {
|
||||||
|
background: var(--gg-primary);
|
||||||
|
box-shadow: 0 0 6px var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot--normal {
|
||||||
|
background: var(--gg-info);
|
||||||
|
box-shadow: 0 0 6px var(--gg-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格布局 */
|
||||||
|
.bulletin-board__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.bulletin-board__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bulletin-board__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bulletin-detail-dialog .el-dialog__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.bulletin-detail-dialog .el-dialog__body {
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-detail__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__priority,
|
||||||
|
.bulletin-detail__pinned,
|
||||||
|
.bulletin-detail__expired {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__pinned {
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__expired {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__sep {
|
||||||
|
color: var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn--danger:hover {
|
||||||
|
border-color: var(--gg-danger);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { BulletinPriorityMap } from '@/types'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
viewAll: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
|
||||||
|
const hasPinned = computed(() => bulletinStore.pinnedPosts.length > 0)
|
||||||
|
|
||||||
|
function handleClick(post: BulletinPost) {
|
||||||
|
bulletinStore.markAsRead(post.id)
|
||||||
|
emit('viewAll')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasPinned" class="bulletin-pinned">
|
||||||
|
<div
|
||||||
|
v-for="post in bulletinStore.pinnedPosts"
|
||||||
|
:key="post.id"
|
||||||
|
class="pinned-item"
|
||||||
|
:class="{ 'pinned-item--urgent': post.priority === 'urgent' }"
|
||||||
|
@click="handleClick(post)"
|
||||||
|
>
|
||||||
|
<span class="pinned-item__dot" />
|
||||||
|
<span class="pinned-item__priority">{{ BulletinPriorityMap[post.priority] }}</span>
|
||||||
|
<span class="pinned-item__title">{{ post.title }}</span>
|
||||||
|
<span v-if="!bulletinStore.isRead(post.id)" class="pinned-item__unread" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-pinned {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-left: 3px solid var(--gg-primary);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent {
|
||||||
|
border-left-color: var(--gg-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent:hover {
|
||||||
|
box-shadow: 0 0 16px rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent .pinned-item__dot {
|
||||||
|
background: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__priority {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent .pinned-item__priority {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__unread {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
box-shadow: 0 0 6px var(--gg-danger);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
post: BulletinPost
|
||||||
|
isRead: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const priorityLabel = computed(() => BulletinPriorityMap[props.post.priority])
|
||||||
|
const priorityColor = computed(() => BulletinPriorityColor[props.post.priority])
|
||||||
|
|
||||||
|
const creatorName = computed(() =>
|
||||||
|
displayName(props.post.expand?.creator)
|
||||||
|
)
|
||||||
|
|
||||||
|
const creatorAvatar = computed(() =>
|
||||||
|
props.post.expand?.creator?.avatar || '/default-avatar.svg'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内容摘要(最多3行)
|
||||||
|
const contentSummary = computed(() => {
|
||||||
|
const text = props.post.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
return text.length > 120 ? text.slice(0, 120) + '...' : text
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const timeText = computed(() => {
|
||||||
|
const d = new Date(props.post.created)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
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 '刚刚'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (!props.post.expiresAt) return false
|
||||||
|
return new Date(props.post.expiresAt) <= new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleEdit(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bulletin-card"
|
||||||
|
:class="{
|
||||||
|
'bulletin-card--pinned': post.pinned,
|
||||||
|
'bulletin-card--urgent': post.priority === 'urgent',
|
||||||
|
'bulletin-card--expired': isExpired,
|
||||||
|
'bulletin-card--unread': !isRead,
|
||||||
|
}"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<!-- 未读标记 -->
|
||||||
|
<span v-if="!isRead" class="bulletin-card__unread-dot" />
|
||||||
|
|
||||||
|
<!-- 顶部标签 -->
|
||||||
|
<div class="bulletin-card__tags">
|
||||||
|
<span
|
||||||
|
class="bulletin-card__priority-tag"
|
||||||
|
:style="{ background: priorityColor + '18', color: priorityColor }"
|
||||||
|
>
|
||||||
|
{{ priorityLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="post.pinned" class="bulletin-card__pinned-tag">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||||
|
<path d="M12 2l3 7h7l-5.5 4 2 7-6.5-5-6.5 5 2-7L2 9h7z" />
|
||||||
|
</svg>
|
||||||
|
置顶
|
||||||
|
</span>
|
||||||
|
<span v-if="isExpired" class="bulletin-card__expired-tag">已过期</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="bulletin-card__title">{{ post.title }}</div>
|
||||||
|
|
||||||
|
<!-- 内容摘要 -->
|
||||||
|
<div class="bulletin-card__content" v-html="contentSummary" />
|
||||||
|
|
||||||
|
<!-- 底部信息 -->
|
||||||
|
<div class="bulletin-card__footer">
|
||||||
|
<div class="bulletin-card__author">
|
||||||
|
<img :src="creatorAvatar" :alt="creatorName" class="bulletin-card__avatar" />
|
||||||
|
<span class="bulletin-card__author-name">{{ creatorName }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="bulletin-card__time">{{ timeText }}</span>
|
||||||
|
<div class="bulletin-card__actions" @click.stop>
|
||||||
|
<button class="bulletin-card__action-btn" @click="handleEdit">编辑</button>
|
||||||
|
<button class="bulletin-card__action-btn bulletin-card__action-btn--danger" @click="handleDelete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-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;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--pinned {
|
||||||
|
border-left: 3px solid var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--urgent {
|
||||||
|
border-left: 3px solid var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--urgent:hover {
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--expired {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未读标记 */
|
||||||
|
.bulletin-card__unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
box-shadow: 0 0 6px var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签 */
|
||||||
|
.bulletin-card__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__priority-tag,
|
||||||
|
.bulletin-card__pinned-tag,
|
||||||
|
.bulletin-card__expired-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__pinned-tag {
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__expired-tag {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
.bulletin-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容 */
|
||||||
|
.bulletin-card__content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.bulletin-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__author-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card:hover .bulletin-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn:hover {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn--danger:hover {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bulletin-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import type { BulletinPriority } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editPost?: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority: BulletinPriority
|
||||||
|
pinned: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
} | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.editPost)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'normal' as BulletinPriority,
|
||||||
|
pinned: false,
|
||||||
|
expiresAt: '' as string | Date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const priorityOptions: { label: string; value: BulletinPriority }[] = [
|
||||||
|
{ label: '低', value: 'low' },
|
||||||
|
{ label: '普通', value: 'normal' },
|
||||||
|
{ label: '高', value: 'high' },
|
||||||
|
{ label: '紧急', value: 'urgent' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (props.editPost) {
|
||||||
|
form.value = {
|
||||||
|
title: props.editPost.title,
|
||||||
|
content: props.editPost.content,
|
||||||
|
priority: props.editPost.priority,
|
||||||
|
pinned: props.editPost.pinned,
|
||||||
|
expiresAt: props.editPost.expiresAt || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'normal',
|
||||||
|
pinned: false,
|
||||||
|
expiresAt: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
ElMessage.warning('请输入公告标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.content.trim()) {
|
||||||
|
ElMessage.warning('请输入公告内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) {
|
||||||
|
ElMessage.error('请先选择群组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
content: form.value.content.trim(),
|
||||||
|
priority: form.value.priority,
|
||||||
|
pinned: form.value.pinned,
|
||||||
|
expiresAt: form.value.expiresAt
|
||||||
|
? new Date(String(form.value.expiresAt)).toISOString()
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && props.editPost) {
|
||||||
|
await bulletinStore.update(props.editPost.id, data)
|
||||||
|
ElMessage.success('公告更新成功')
|
||||||
|
emit('updated')
|
||||||
|
} else {
|
||||||
|
await bulletinStore.create({ group: groupId, ...data })
|
||||||
|
ElMessage.success('公告发布成功')
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑公告' : '发布公告'"
|
||||||
|
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="请输入公告标题"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>内容 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="支持 HTML 标签(如 <b>加粗</b>、<i>斜体</i>、<a href='...'>链接</a>)"
|
||||||
|
maxlength="5000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 优先级 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>优先级</label>
|
||||||
|
<el-select v-model="form.priority" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in priorityOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 置顶开关 -->
|
||||||
|
<div class="form-field form-field--inline">
|
||||||
|
<label>置顶显示</label>
|
||||||
|
<el-switch v-model="form.pinned" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期时间 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>过期时间(可选)</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.expiresAt"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择过期时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '提交中...' : (isEdit ? '保存' : '发布') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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,213 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
groupId: string
|
||||||
|
editEvent?: { id: string; title: string; description?: string; location?: string; startTime: string; endTime?: string; maxParticipants?: number } | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
created: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const location = ref('')
|
||||||
|
const startTime = ref('')
|
||||||
|
const endTime = ref('')
|
||||||
|
const maxParticipants = ref<number | undefined>(undefined)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.editEvent)
|
||||||
|
|
||||||
|
watch(() => props.editEvent, (evt) => {
|
||||||
|
if (evt) {
|
||||||
|
title.value = evt.title
|
||||||
|
description.value = evt.description || ''
|
||||||
|
location.value = evt.location || ''
|
||||||
|
startTime.value = formatForInput(evt.startTime)
|
||||||
|
endTime.value = evt.endTime ? formatForInput(evt.endTime) : ''
|
||||||
|
maxParticipants.value = evt.maxParticipants
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function formatForInput(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
title.value = ''
|
||||||
|
description.value = ''
|
||||||
|
location.value = ''
|
||||||
|
startTime.value = ''
|
||||||
|
endTime.value = ''
|
||||||
|
maxParticipants.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
ElMessage.warning('请输入活动标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!startTime.value) {
|
||||||
|
ElMessage.warning('请选择开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endTime.value && new Date(endTime.value) <= new Date(startTime.value)) {
|
||||||
|
ElMessage.warning('结束时间必须晚于开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
group: props.groupId,
|
||||||
|
title: title.value.trim(),
|
||||||
|
description: description.value.trim() || undefined,
|
||||||
|
location: location.value.trim() || undefined,
|
||||||
|
startTime: new Date(startTime.value).toISOString(),
|
||||||
|
endTime: endTime.value ? new Date(endTime.value).toISOString() : undefined,
|
||||||
|
maxParticipants: maxParticipants.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && props.editEvent) {
|
||||||
|
await eventStore.editEvent(props.editEvent.id, data)
|
||||||
|
ElMessage.success('活动已更新')
|
||||||
|
emit('updated')
|
||||||
|
} else {
|
||||||
|
await eventStore.addEvent(data)
|
||||||
|
ElMessage.success('活动创建成功')
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
reset()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (!isEdit.value) reset()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑活动' : '发起活动'"
|
||||||
|
width="520px"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<div class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>活动标题 <span class="required">*</span></label>
|
||||||
|
<el-input v-model="title" placeholder="例如:周末面基开黑" maxlength="200" show-word-limit />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>活动地点</label>
|
||||||
|
<el-input v-model="location" placeholder="例如:XX 网咖 / XX 餐厅" maxlength="200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field field--half">
|
||||||
|
<label>开始时间 <span class="required">*</span></label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="startTime"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择开始时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field field--half">
|
||||||
|
<label>结束时间</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="endTime"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择结束时间(可选)"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>人数上限</label>
|
||||||
|
<el-input-number v-model="maxParticipants" :min="1" :max="999" placeholder="不限" style="width: 100%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>活动详情</label>
|
||||||
|
<el-input
|
||||||
|
v-model="description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="描述一下活动内容..."
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--half {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { EventStatusMap, EventStatusColor, RSVPTypeMap, displayName } from '@/types'
|
||||||
|
import type { Event, RSVPType } from '@/types'
|
||||||
|
import { Calendar, Location, User, ChatDotRound } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: Event
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [event: Event]
|
||||||
|
rsvp: [eventId: string, type: RSVPType]
|
||||||
|
comment: [eventId: string, content: string]
|
||||||
|
delete: [event: Event]
|
||||||
|
edit: [event: Event]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const commentInput = ref('')
|
||||||
|
|
||||||
|
const canManage = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return groupStore.isGroupOwner ||
|
||||||
|
(props.event.creator === userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDelete = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return groupStore.isGroupOwner ||
|
||||||
|
(props.event.creator === userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(event: Event): string {
|
||||||
|
if (event.status === 'completed') return '已结束'
|
||||||
|
if (event.status === 'cancelled') return '已取消'
|
||||||
|
if (event.status === 'ongoing') return '进行中'
|
||||||
|
const diff = new Date(event.startTime).getTime() - Date.now()
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
|
if (days <= 0) return '今天'
|
||||||
|
if (days === 1) return '明天'
|
||||||
|
return `${days} 天后`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRSVPCounts(eventId: string) {
|
||||||
|
return {
|
||||||
|
going: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'going').length,
|
||||||
|
interested: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'interested').length,
|
||||||
|
maybe: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'maybe').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMyRSVP(eventId: string) {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return eventStore.rsvps.find(r => r.event === eventId && r.user === userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = computed(() => getRSVPCounts(props.event.id))
|
||||||
|
const myRSVP = computed(() => getMyRSVP(props.event.id))
|
||||||
|
const comments = computed(() =>
|
||||||
|
eventStore.comments.filter(c => c.event === props.event.id)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-card">
|
||||||
|
<div class="event-card-header" @click="emit('toggle', event)">
|
||||||
|
<div class="event-main">
|
||||||
|
<h3 class="event-title">{{ event.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="event-status-tag"
|
||||||
|
:style="{ background: EventStatusColor[event.status] + '20', color: EventStatusColor[event.status] }"
|
||||||
|
>
|
||||||
|
{{ EventStatusMap[event.status] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="event-relative">{{ formatRelative(event) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-info">
|
||||||
|
<span class="info-item">
|
||||||
|
<el-icon><Calendar /></el-icon>
|
||||||
|
{{ formatDateTime(event.startTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.location" class="info-item">
|
||||||
|
<el-icon><Location /></el-icon>
|
||||||
|
{{ event.location }}
|
||||||
|
</span>
|
||||||
|
<span class="info-item">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ counts.going }} 参加 · {{ counts.interested }} 感兴趣
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expanded" class="event-expanded">
|
||||||
|
<p v-if="event.description" class="event-desc">{{ event.description }}</p>
|
||||||
|
|
||||||
|
<div class="rsvp-section">
|
||||||
|
<span class="rsvp-label">我要参加:</span>
|
||||||
|
<div class="rsvp-buttons">
|
||||||
|
<button
|
||||||
|
v-for="type in (['going', 'interested', 'maybe'] as RSVPType[])"
|
||||||
|
:key="type"
|
||||||
|
class="rsvp-btn"
|
||||||
|
:class="{ 'rsvp-btn--active': myRSVP?.type === type }"
|
||||||
|
@click.stop="emit('rsvp', event.id, type)"
|
||||||
|
>
|
||||||
|
{{ RSVPTypeMap[type] }} {{ counts[type] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<div class="comments-header">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
评论 ({{ comments.length }})
|
||||||
|
</div>
|
||||||
|
<p v-if="comments.length === 0" class="no-comments">暂无评论,来说两句吧</p>
|
||||||
|
<div v-else class="comments-list">
|
||||||
|
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||||
|
<img :src="c.expand?.user?.avatar ? `${pb.baseUrl}/api/files/users/${c.user}/${c.expand.user.avatar}` : '/default-avatar.svg'" class="comment-avatar" />
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">{{ displayName(c.expand?.user) }}</span>
|
||||||
|
<span class="comment-time">{{ formatDateTime(c.created) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="comment-text">{{ c.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-input-row">
|
||||||
|
<input
|
||||||
|
v-model="commentInput"
|
||||||
|
class="comment-input"
|
||||||
|
placeholder="写个评论..."
|
||||||
|
@keyup.enter="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="comment-send-btn"
|
||||||
|
@click.stop="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canManage || canDelete" class="event-actions">
|
||||||
|
<button v-if="canManage" class="action-link" @click.stop="emit('edit', event)">编辑</button>
|
||||||
|
<button v-if="canDelete" class="action-link action-link--danger" @click.stop="emit('delete', event)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-status-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-relative {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-expanded {
|
||||||
|
padding: 0 18px 16px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn--active {
|
||||||
|
background: var(--gg-primary);
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg);
|
||||||
|
color: var(--gg-text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input:focus {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-send-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-send-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-link--danger {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { Event } from '@/types'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import CreateEventDialog from './CreateEventDialog.vue'
|
||||||
|
import EventCard from './EventCard.vue'
|
||||||
|
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupId = route.params.id as string
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const editingEvent = ref<Event | null>(null)
|
||||||
|
const expandedEventId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isMember = computed(() => !!groupStore.currentGroup)
|
||||||
|
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
let unsubscribeCommentsFn: (() => void) | null = null
|
||||||
|
let unsubscribeRSVPsFn: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await eventStore.loadEvents(groupId)
|
||||||
|
unsubscribeFn = await subscribeEvents(groupId, () => {
|
||||||
|
eventStore.loadEvents(groupId)
|
||||||
|
if (expandedEventId.value) {
|
||||||
|
eventStore.loadCommentsAndRSVPs(expandedEventId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribeFn) unsubscribeFn()
|
||||||
|
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||||
|
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
unsubscribeCommentsFn = null
|
||||||
|
unsubscribeRSVPsFn = null
|
||||||
|
eventStore.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggleExpand(event: Event) {
|
||||||
|
if (expandedEventId.value === event.id) {
|
||||||
|
expandedEventId.value = null
|
||||||
|
if (unsubscribeCommentsFn) { unsubscribeCommentsFn(); unsubscribeCommentsFn = null }
|
||||||
|
if (unsubscribeRSVPsFn) { unsubscribeRSVPsFn(); unsubscribeRSVPsFn = null }
|
||||||
|
} else {
|
||||||
|
expandedEventId.value = event.id
|
||||||
|
await eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||||
|
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||||
|
unsubscribeCommentsFn = await subscribeEventComments(event.id, () => {
|
||||||
|
eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
})
|
||||||
|
unsubscribeRSVPsFn = await subscribeEventRSVPs(event.id, () => {
|
||||||
|
eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRSVP(eventId: string, type: 'going' | 'interested' | 'maybe') {
|
||||||
|
const current = eventStore.rsvps.find(r => r.event === eventId && r.user === pb.authStore.model?.id)
|
||||||
|
if (current?.type === type) {
|
||||||
|
await eventStore.cancelRSVP(eventId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await eventStore.rsvp(eventId, type)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComment(eventId: string, content: string) {
|
||||||
|
const text = content.trim()
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await eventStore.addComment(eventId, text)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '评论失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(event: Event) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个活动吗?', '确认删除', { type: 'warning' })
|
||||||
|
await eventStore.removeEvent(event.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch {
|
||||||
|
// 取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(event: Event) {
|
||||||
|
editingEvent.value = event
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialogClosed() {
|
||||||
|
editingEvent.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-list">
|
||||||
|
<div class="list-header">
|
||||||
|
<h2 class="list-title">群组活动</h2>
|
||||||
|
<button v-if="isMember" class="create-btn" @click="showCreateDialog = true">
|
||||||
|
<el-icon><Plus /></el-icon> 发起活动
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="eventStore.loading && eventStore.events.length === 0" class="loading-state">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="eventStore.events.length === 0" class="empty-state">
|
||||||
|
<p>暂无活动</p>
|
||||||
|
<p v-if="isMember" class="empty-hint">点击右上角发起一个活动吧</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="event-sections">
|
||||||
|
<div v-if="eventStore.upcomingEvents.length > 0" class="section">
|
||||||
|
<h3 class="section-label">即将开始</h3>
|
||||||
|
<div class="event-cards">
|
||||||
|
<EventCard
|
||||||
|
v-for="event in eventStore.upcomingEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:event="event"
|
||||||
|
:expanded="expandedEventId === event.id"
|
||||||
|
@toggle="toggleExpand"
|
||||||
|
@rsvp="handleRSVP"
|
||||||
|
@comment="handleComment"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@edit="handleEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="eventStore.pastEvents.length > 0" class="section">
|
||||||
|
<h3 class="section-label">历史活动</h3>
|
||||||
|
<div class="event-cards">
|
||||||
|
<EventCard
|
||||||
|
v-for="event in eventStore.pastEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:event="event"
|
||||||
|
:expanded="expandedEventId === event.id"
|
||||||
|
@toggle="toggleExpand"
|
||||||
|
@rsvp="handleRSVP"
|
||||||
|
@comment="handleComment"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@edit="handleEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateEventDialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
:group-id="groupId"
|
||||||
|
:edit-event="editingEvent"
|
||||||
|
@created="eventStore.loadEvents(groupId)"
|
||||||
|
@updated="eventStore.loadEvents(groupId)"
|
||||||
|
@update:model-value="onDialogClosed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--gg-border);
|
||||||
|
border-top-color: var(--gg-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,8 @@ import { addGame } from '@/api/games'
|
|||||||
import type { GamePlatform } from '@/types'
|
import type { GamePlatform } from '@/types'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getAllPlatforms } from '@/api/games'
|
import { getAllPlatforms } from '@/api/games'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -23,10 +25,24 @@ const visible = computed({
|
|||||||
const name = ref('')
|
const name = ref('')
|
||||||
const platform = ref<GamePlatform | ''>('')
|
const platform = ref<GamePlatform | ''>('')
|
||||||
const tagsInput = ref('')
|
const tagsInput = ref('')
|
||||||
const cover = ref('')
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const platforms = getAllPlatforms()
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
function handleFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
coverFile.value = uploadFile.raw
|
||||||
|
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileRemove() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!name.value.trim()) {
|
if (!name.value.trim()) {
|
||||||
ElMessage.warning('请输入游戏名称')
|
ElMessage.warning('请输入游戏名称')
|
||||||
@@ -39,13 +55,15 @@ async function handleSubmit() {
|
|||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
platform: platform.value || undefined,
|
platform: platform.value || undefined,
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
cover: cover.value.trim() || undefined
|
coverFile: coverFile.value || undefined
|
||||||
})
|
})
|
||||||
ElMessage.success('添加成功')
|
ElMessage.success('添加成功')
|
||||||
name.value = ''
|
name.value = ''
|
||||||
platform.value = ''
|
platform.value = ''
|
||||||
tagsInput.value = ''
|
tagsInput.value = ''
|
||||||
cover.value = ''
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
emit('created')
|
emit('created')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -74,8 +92,24 @@ async function handleSubmit() {
|
|||||||
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>封面图 URL</label>
|
<label>封面图</label>
|
||||||
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -89,4 +123,19 @@ async function handleSubmit() {
|
|||||||
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import type { Game } from '@/types'
|
import type { Game, GamePlatform } from '@/types'
|
||||||
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
|
import { toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms } from '@/api/games'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
|
import { TrendCharts, StarFilled, Star, Delete, Edit, Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
import GameComments from './GameComments.vue'
|
import GameComments from './GameComments.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,12 +21,40 @@ const emit = defineEmits<{
|
|||||||
'deleted': []
|
'deleted': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (val) => emit('update:modelValue', val)
|
set: (val) => emit('update:modelValue', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
const favorited = ref(false)
|
const favorited = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const editName = ref('')
|
||||||
|
const editAliases = ref('')
|
||||||
|
const editPlatform = ref<GamePlatform | ''>('')
|
||||||
|
const editTags = ref('')
|
||||||
|
const editCoverFile = ref<File | null>(null)
|
||||||
|
const editCoverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const canModify = computed(() => {
|
||||||
|
if (!props.game) return false
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId) return false
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (props.game.addedBy === userId) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.game) {
|
if (props.game) {
|
||||||
@@ -31,6 +62,59 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!props.game) return
|
||||||
|
editName.value = props.game.name
|
||||||
|
editAliases.value = (props.game.aliases || []).join(', ')
|
||||||
|
editPlatform.value = props.game.platform || ''
|
||||||
|
editTags.value = (props.game.tags || []).join(', ')
|
||||||
|
editCoverFile.value = null
|
||||||
|
editCoverPreview.value = getGameCoverUrl(props.game) || ''
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
editCoverFile.value = uploadFile.raw
|
||||||
|
editCoverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditFileRemove() {
|
||||||
|
editCoverFile.value = null
|
||||||
|
editCoverPreview.value = getGameCoverUrl(props.game!) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!props.game || !editName.value.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
await updateGame(props.game.id, {
|
||||||
|
name: editName.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: editPlatform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: editCoverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
editing.value = false
|
||||||
|
emit('deleted') // trigger reload
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '修改失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFavorite() {
|
async function handleFavorite() {
|
||||||
if (!props.game) return
|
if (!props.game) return
|
||||||
favorited.value = await toggleFavorite(props.game.id)
|
favorited.value = await toggleFavorite(props.game.id)
|
||||||
@@ -60,13 +144,66 @@ function handleCreateTeam() {
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" width="480px" :show-close="true">
|
<el-dialog v-model="visible" width="480px" :show-close="true">
|
||||||
<template v-if="game">
|
<template v-if="game">
|
||||||
<div class="game-detail">
|
<!-- 编辑模式 -->
|
||||||
|
<div v-if="editing" class="game-detail">
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>游戏名称 *</label>
|
||||||
|
<el-input v-model="editName" placeholder="输入游戏名称" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>别名</label>
|
||||||
|
<el-input v-model="editAliases" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>平台</label>
|
||||||
|
<el-select v-model="editPlatform" placeholder="选择平台" clearable style="width: 100%">
|
||||||
|
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签</label>
|
||||||
|
<el-input v-model="editTags" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>封面图</label>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleEditFileChange"
|
||||||
|
:on-remove="handleEditFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="editCoverPreview" class="cover-preview">
|
||||||
|
<img :src="editCoverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<el-button @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情模式 -->
|
||||||
|
<div v-else class="game-detail">
|
||||||
<div class="detail-cover-wrap">
|
<div class="detail-cover-wrap">
|
||||||
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="detail-name">{{ game.name }}</h2>
|
<h2 class="detail-name">{{ game.name }}</h2>
|
||||||
|
|
||||||
|
<div v-if="game.aliases?.length" class="detail-aliases">
|
||||||
|
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-meta">
|
<div class="detail-meta">
|
||||||
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
|
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
|
||||||
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
|
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
|
||||||
@@ -81,7 +218,8 @@ function handleCreateTeam() {
|
|||||||
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
|
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
|
||||||
{{ favorited ? '已收藏' : '收藏' }}
|
{{ favorited ? '已收藏' : '收藏' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
<button v-if="canModify" class="edit-btn" @click="startEdit"><el-icon><Edit /></el-icon> 编辑</button>
|
||||||
|
<button v-if="canModify" class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
||||||
@@ -102,6 +240,9 @@ function handleCreateTeam() {
|
|||||||
|
|
||||||
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
||||||
|
|
||||||
|
.detail-aliases { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||||
|
.alias-tag { padding: 3px 10px; background: rgba(59, 130, 246, 0.1); color: #3b82f6; border-radius: 6px; font-size: 12px; font-weight: 500; }
|
||||||
|
|
||||||
.detail-meta { display: flex; align-items: center; gap: 12px; }
|
.detail-meta { display: flex; align-items: center; gap: 12px; }
|
||||||
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||||
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
||||||
@@ -117,6 +258,12 @@ function handleCreateTeam() {
|
|||||||
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
||||||
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
|
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.edit-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
|
||||||
|
|
||||||
.delete-detail-btn {
|
.delete-detail-btn {
|
||||||
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||||
@@ -124,4 +271,15 @@ function handleCreateTeam() {
|
|||||||
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
|
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
|
||||||
|
|
||||||
.team-btn { width: 100%; margin-top: 4px; }
|
.team-btn { width: 100%; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* 编辑表单 */
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 16px; width: 100%; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||||
|
.cover-preview img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 6px; }
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px;
|
||||||
|
color: var(--gg-text-muted); font-size: 13px;
|
||||||
|
}
|
||||||
|
.edit-actions { display: flex; gap: 12px; width: 100%; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { addGame, updateGame, getGameCoverUrl } from '@/api/games'
|
||||||
|
import type { Game, GamePlatform } from '@/types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAllPlatforms } from '@/api/games'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
groupId: string
|
||||||
|
game?: Game | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.game)
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const aliasesInput = ref('')
|
||||||
|
const platform = ref<GamePlatform | ''>('')
|
||||||
|
const tagsInput = ref('')
|
||||||
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val && props.game) {
|
||||||
|
name.value = props.game.name
|
||||||
|
aliasesInput.value = (props.game.aliases || []).join(', ')
|
||||||
|
platform.value = props.game.platform || ''
|
||||||
|
tagsInput.value = (props.game.tags || []).join(', ')
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = getGameCoverUrl(props.game) || ''
|
||||||
|
} else if (val) {
|
||||||
|
name.value = ''
|
||||||
|
aliasesInput.value = ''
|
||||||
|
platform.value = ''
|
||||||
|
tagsInput.value = ''
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
coverFile.value = uploadFile.raw
|
||||||
|
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileRemove() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const aliases = aliasesInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
if (isEdit.value && props.game) {
|
||||||
|
await updateGame(props.game.id, {
|
||||||
|
name: name.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: platform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
} else {
|
||||||
|
await addGame(props.groupId, {
|
||||||
|
name: name.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: platform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
name.value = ''
|
||||||
|
aliasesInput.value = ''
|
||||||
|
platform.value = ''
|
||||||
|
tagsInput.value = ''
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
emit('saved')
|
||||||
|
visible.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || (isEdit.value ? '修改失败' : '添加失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="isEdit ? '编辑游戏' : '添加游戏'" width="440px">
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>游戏名称 *</label>
|
||||||
|
<el-input v-model="name" placeholder="输入游戏名称" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>别名</label>
|
||||||
|
<el-input v-model="aliasesInput" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>平台</label>
|
||||||
|
<el-select v-model="platform" placeholder="选择平台" clearable style="width: 100%">
|
||||||
|
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签</label>
|
||||||
|
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>封面图</label>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleSubmit">{{ isEdit ? '保存' : '添加' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-fields { 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); }
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -52,7 +52,7 @@ function parseCSV(text: string) {
|
|||||||
const values = lines[i].split(',').map(v => v.trim())
|
const values = lines[i].split(',').map(v => v.trim())
|
||||||
const obj: any = {}
|
const obj: any = {}
|
||||||
headers.forEach((h, idx) => {
|
headers.forEach((h, idx) => {
|
||||||
if (h === 'tags' && values[idx]) {
|
if ((h === 'tags' || h === 'aliases') && values[idx]) {
|
||||||
obj[h] = values[idx].split(';').map((t: string) => t.trim())
|
obj[h] = values[idx].split(';').map((t: string) => t.trim())
|
||||||
} else {
|
} else {
|
||||||
obj[h] = values[idx] || undefined
|
obj[h] = values[idx] || undefined
|
||||||
@@ -97,8 +97,8 @@ function reset() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="format-hint">
|
<div class="format-hint">
|
||||||
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
|
<p><strong>JSON 格式:</strong> [{"name":"英雄联盟","aliases":["LOL","撸啊撸"],"platform":"PC","tags":["MOBA"]}]</p>
|
||||||
<p><strong>CSV 格式:</strong> name,platform,tags,cover(tags 用分号分隔)</p>
|
<p><strong>CSV 格式:</strong> name,aliases,platform,tags,cover(aliases/tags 用分号分隔)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="previewData.length > 0" class="preview">
|
<div v-if="previewData.length > 0" class="preview">
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
|
import {
|
||||||
|
getGroupJoinRequests,
|
||||||
|
updateGroupApproval,
|
||||||
|
subscribeJoinRequests,
|
||||||
|
transferGroupOwnership,
|
||||||
|
addGroupAdmin,
|
||||||
|
removeGroupAdmin,
|
||||||
|
updateGroupMemberManage
|
||||||
|
} from '@/api/groups'
|
||||||
import { ElSwitch } from 'element-plus'
|
import { ElSwitch } from 'element-plus'
|
||||||
import type { JoinRequest } from '@/types'
|
import type { JoinRequest } from '@/types'
|
||||||
import JoinRequestCard from './JoinRequestCard.vue'
|
import JoinRequestCard from './JoinRequestCard.vue'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const emit = defineEmits<{ requestHandled: [] }>()
|
||||||
|
|
||||||
const group = computed(() => groupStore.currentGroup)
|
const group = computed(() => groupStore.currentGroup)
|
||||||
const members = computed(() => groupStore.currentMembers)
|
const members = computed(() => groupStore.currentMembers)
|
||||||
const isOwner = computed(() => group.value?.owner === userStore.userId)
|
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||||
|
const isAdmin = computed(() => groupStore.isGroupAdmin)
|
||||||
|
const canManage = computed(() => groupStore.canManageGroup)
|
||||||
|
|
||||||
const joinRequests = ref<JoinRequest[]>([])
|
const joinRequests = ref<JoinRequest[]>([])
|
||||||
const approvalLoading = ref(false)
|
const approvalLoading = ref(false)
|
||||||
|
const memberManageLoading = ref(false)
|
||||||
|
const inviteLink = computed(() => {
|
||||||
|
if (!group.value?.id) return ''
|
||||||
|
return `${window.location.origin}/join/group/${group.value.id}`
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
let subscribedGroupId = ''
|
||||||
if (group.value && isOwner.value) {
|
|
||||||
|
watch(group, async (g) => {
|
||||||
|
if (g && canManage.value && g.id !== subscribedGroupId) {
|
||||||
|
subscribedGroupId = g.id
|
||||||
await loadJoinRequests()
|
await loadJoinRequests()
|
||||||
subscribeJoinRequests(group.value.id, () => {
|
subscribeJoinRequests(g.id, () => {
|
||||||
loadJoinRequests()
|
loadJoinRequests()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// cleanup handled by PocketBase
|
// cleanup handled by PocketBase
|
||||||
@@ -38,22 +57,38 @@ async function loadJoinRequests() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyGroupId() {
|
function copyInviteLink() {
|
||||||
if (group.value?.id) {
|
if (!group.value?.id) return
|
||||||
navigator.clipboard.writeText(group.value.id)
|
const text = inviteLink.value
|
||||||
ElMessage.success('群组 ID 已复制,分享给好友即可加入')
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(userId: string, username: string) {
|
async function removeMember(userId: string, username: string) {
|
||||||
if (!isOwner.value) return
|
if (!canManage.value) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
||||||
const grp = groupStore.currentGroup
|
const grp = groupStore.currentGroup
|
||||||
if (!grp) return
|
if (!grp) return
|
||||||
const { pb } = await import('@/api/pocketbase')
|
const { pb } = await import('@/api/pocketbase')
|
||||||
const newMembers = grp.members.filter(id => id !== userId)
|
const newMembers = grp.members.filter(id => id !== userId)
|
||||||
await pb.collection('groups').update(grp.id, { members: newMembers })
|
const newAdmins = (grp.admins || []).filter(id => id !== userId)
|
||||||
|
await pb.collection('groups').update(grp.id, { members: newMembers, admins: newAdmins })
|
||||||
await groupStore.setCurrentGroup(grp.id)
|
await groupStore.setCurrentGroup(grp.id)
|
||||||
ElMessage.success('已移除成员')
|
ElMessage.success('已移除成员')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,11 +110,64 @@ async function handleApprovalChange(val: string | number | boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMemberManageChange(val: string | number | boolean) {
|
||||||
|
if (!group.value || !isOwner.value) return
|
||||||
|
memberManageLoading.value = true
|
||||||
|
try {
|
||||||
|
await updateGroupMemberManage(group.value.id, !!val)
|
||||||
|
await groupStore.setCurrentGroup(group.value.id)
|
||||||
|
ElMessage.success(val ? '已开启全员管理' : '已关闭全员管理')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('更新设置失败')
|
||||||
|
} finally {
|
||||||
|
memberManageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onJoinRequestResponded(requestId: string) {
|
async function onJoinRequestResponded(requestId: string) {
|
||||||
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||||
if (group.value) {
|
if (group.value) {
|
||||||
await groupStore.setCurrentGroup(group.value.id)
|
await groupStore.setCurrentGroup(group.value.id)
|
||||||
}
|
}
|
||||||
|
emit('requestHandled')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransferOwnership(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要将群主转让给 ${username} 吗?转让后你将成为管理员。`,
|
||||||
|
'确认转让群主',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await transferGroupOwnership(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success('群主转让成功')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message) ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAdmin(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await addGroupAdmin(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success(`${username} 已成为管理员`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAdmin(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await removeGroupAdmin(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success(`${username} 已取消管理员身份`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -89,17 +177,17 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
<h3>群组信息</h3>
|
<h3>群组信息</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分享群组 ID -->
|
<!-- 分享邀请链接 -->
|
||||||
<div class="share-section">
|
<div class="share-section">
|
||||||
<label>群组 ID(分享给好友)</label>
|
<label>邀请链接(分享给好友)</label>
|
||||||
<div class="share-row">
|
<div class="share-row">
|
||||||
<code class="group-id">{{ group.id }}</code>
|
<code class="group-id">{{ inviteLink }}</code>
|
||||||
<button class="copy-btn" @click="copyGroupId">复制</button>
|
<button class="copy-btn" @click="copyInviteLink">复制</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 审核开关(仅群主可见) -->
|
<!-- 审核开关(管理员可见) -->
|
||||||
<div v-if="isOwner" class="approval-row">
|
<div v-if="canManage" class="approval-row">
|
||||||
<span class="info-label">加入需审核</span>
|
<span class="info-label">加入需审核</span>
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="group.requireApproval"
|
:model-value="group.requireApproval"
|
||||||
@@ -108,13 +196,23 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全员管理开关(仅群主可见) -->
|
||||||
|
<div v-if="isOwner" class="approval-row">
|
||||||
|
<span class="info-label">全员管理</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="group.allowMemberManage"
|
||||||
|
:loading="memberManageLoading"
|
||||||
|
@change="handleMemberManageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">成员</span>
|
<span class="info-label">成员</span>
|
||||||
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 待审核申请(仅群主可见) -->
|
<!-- 待审核申请(管理员可见) -->
|
||||||
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
|
<div v-if="canManage && joinRequests.length > 0" class="requests-section">
|
||||||
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
|
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
|
||||||
<div class="requests-list">
|
<div class="requests-list">
|
||||||
<JoinRequestCard
|
<JoinRequestCard
|
||||||
@@ -132,9 +230,40 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
<div class="member-info">
|
<div class="member-info">
|
||||||
<span class="member-name">{{ member.name || member.username }}</span>
|
<span class="member-name">{{ member.name || member.username }}</span>
|
||||||
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
||||||
|
<span v-else-if="(group.admins || []).includes(member.id)" class="admin-badge">管理</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 管理操作菜单 -->
|
||||||
|
<div v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId" class="member-actions">
|
||||||
<button
|
<button
|
||||||
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
|
v-if="member.id !== group.owner && !(group.admins || []).includes(member.id)"
|
||||||
|
class="action-btn action-btn--admin"
|
||||||
|
@click="handleAddAdmin(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
设管
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="(group.admins || []).includes(member.id)"
|
||||||
|
class="action-btn action-btn--admin"
|
||||||
|
@click="handleRemoveAdmin(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
取消管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--transfer"
|
||||||
|
@click="handleTransferOwnership(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
转让
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--remove"
|
||||||
|
@click="removeMember(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 管理员移除成员 -->
|
||||||
|
<button
|
||||||
|
v-else-if="isAdmin && member.id !== group.owner && member.id !== userStore.userId"
|
||||||
class="remove-btn"
|
class="remove-btn"
|
||||||
@click="removeMember(member.id, member.name || member.username)"
|
@click="removeMember(member.id, member.name || member.username)"
|
||||||
>
|
>
|
||||||
@@ -253,6 +382,16 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requests-section--highlight {
|
||||||
|
animation: highlight-pulse 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-pulse {
|
||||||
|
0% { background: rgba(245, 158, 11, 0.06); }
|
||||||
|
30% { background: rgba(245, 158, 11, 0.3); }
|
||||||
|
100% { background: rgba(245, 158, 11, 0.06); }
|
||||||
|
}
|
||||||
|
|
||||||
.members-list {
|
.members-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -284,10 +423,14 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-badge {
|
.owner-badge {
|
||||||
@@ -296,6 +439,63 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
background: var(--gg-gradient);
|
background: var(--gg-gradient);
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--admin {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--admin:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--transfer {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--transfer:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--remove {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--gg-danger);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--remove:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn {
|
.remove-btn {
|
||||||
@@ -308,6 +508,7 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn:hover {
|
.remove-btn:hover {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { searchGames } from '@/api/games'
|
import { searchGames, getGameCoverUrl } from '@/api/games'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
groupId?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -37,7 +38,7 @@ async function handleSearch() {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
games.value = await searchGames(searchQuery.value)
|
games.value = await searchGames(searchQuery.value, props.groupId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索游戏失败:', error)
|
console.error('搜索游戏失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,7 +83,7 @@ function confirmCustomGame() {
|
|||||||
@click="selectGame(game)"
|
@click="selectGame(game)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="game.cover || '/game-placeholder.svg'"
|
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||||
:alt="game.name"
|
:alt="game.name"
|
||||||
class="game-cover"
|
class="game-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async function sendInvite(teamSessionId: string) {
|
|||||||
|
|
||||||
<GameSelectDialog
|
<GameSelectDialog
|
||||||
v-model="showGameSelect"
|
v-model="showGameSelect"
|
||||||
|
:group-id="groupStore.currentGroupId"
|
||||||
@select="handleGameSelect"
|
@select="handleGameSelect"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -50,6 +50,33 @@ async function endGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteLink = computed(() => {
|
||||||
|
if (!session.value) return ''
|
||||||
|
return `${window.location.origin}/join/team/${session.value.id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyInviteLink() {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
const text = inviteLink.value
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
ElMessage.success('邀请链接已复制')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
ElMessage.success('邀请链接已复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGameSelected(gameName: string) {
|
async function handleGameSelected(gameName: string) {
|
||||||
let group = groupStore.currentGroup
|
let group = groupStore.currentGroup
|
||||||
if (!group) {
|
if (!group) {
|
||||||
@@ -92,6 +119,12 @@ async function handleGameSelected(gameName: string) {
|
|||||||
<span class="game-name">{{ session.gameName }}</span>
|
<span class="game-name">{{ session.gameName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="invite-link-row">
|
||||||
|
<span class="link-label">邀请:</span>
|
||||||
|
<code class="link-code">{{ inviteLink }}</code>
|
||||||
|
<button class="link-copy-btn" @click="copyInviteLink">复制</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="members-section">
|
<div class="members-section">
|
||||||
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
|
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
|
||||||
<div class="members-list">
|
<div class="members-list">
|
||||||
@@ -135,7 +168,7 @@ async function handleGameSelected(gameName: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
|
<GameSelectDialog v-model="showGameSelect" :group-id="groupStore.currentGroupId" @select="handleGameSelected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -181,6 +214,49 @@ async function handleGameSelected(gameName: string) {
|
|||||||
border: 1px solid var(--gg-border);
|
border: 1px solid var(--gg-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-label {
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-code {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.game-label {
|
.game-label {
|
||||||
color: var(--gg-text-secondary);
|
color: var(--gg-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<!-- src/components/voice/VoiceControls.vue -->
|
<!-- src/components/voice/VoiceControls.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Microphone, Mute, Headset, SwitchButton } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
micEnabled: boolean
|
micEnabled: boolean
|
||||||
speakerEnabled: boolean
|
speakerEnabled: boolean
|
||||||
@@ -20,7 +22,7 @@ const emit = defineEmits<{
|
|||||||
:class="{ active: micEnabled, off: !micEnabled }"
|
:class="{ active: micEnabled, off: !micEnabled }"
|
||||||
@click="emit('toggleMic')"
|
@click="emit('toggleMic')"
|
||||||
>
|
>
|
||||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
<el-icon class="ctrl-icon"><Microphone v-if="micEnabled" /><Mute v-else /></el-icon>
|
||||||
<span class="ctrl-label">麦克风</span>
|
<span class="ctrl-label">麦克风</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -29,12 +31,12 @@ const emit = defineEmits<{
|
|||||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||||
@click="emit('toggleSpeaker')"
|
@click="emit('toggleSpeaker')"
|
||||||
>
|
>
|
||||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
<el-icon class="ctrl-icon"><Headset /></el-icon>
|
||||||
<span class="ctrl-label">扬声器</span>
|
<span class="ctrl-label">扬声器</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||||
<span class="ctrl-icon">🚪</span>
|
<el-icon class="ctrl-icon"><SwitchButton /></el-icon>
|
||||||
<span class="ctrl-label">离开</span>
|
<span class="ctrl-label">离开</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,6 +82,9 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
.ctrl-icon {
|
.ctrl-icon {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctrl-label {
|
.ctrl-label {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function useVoiceRoom() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
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 加入白名单,然后重启浏览器。')
|
throw new Error('浏览器不允许在 HTTP 下使用麦克风。请在 Chrome 地址栏输入 chrome://flags/#unsafely-treat-insecure-origin-as-secure,启用后将 http://192.168.1.14:7033 加入白名单,然后重启浏览器。')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import Vant from 'vant'
|
||||||
|
import 'vant/lib/index.css'
|
||||||
import './assets/design.css'
|
import './assets/design.css'
|
||||||
|
import './assets/mobile.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
@@ -19,5 +22,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
|
app.use(Vant)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!-- src/mobile/MobileLayout.vue -->
|
||||||
|
<!-- 手机端主布局:顶部栏 + 底部 Tab + 内容区 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
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">
|
||||||
|
<MobileIcon 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">
|
||||||
|
<template #icon="{ active }">
|
||||||
|
<MobileIcon name="home" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
|
||||||
|
</template>
|
||||||
|
首页
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item name="groups">
|
||||||
|
<template #icon="{ active }">
|
||||||
|
<MobileIcon name="users" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
|
||||||
|
</template>
|
||||||
|
群组
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item name="games">
|
||||||
|
<template #icon="{ active }">
|
||||||
|
<MobileIcon name="game" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
|
||||||
|
</template>
|
||||||
|
游戏
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item name="notifications">
|
||||||
|
<template #icon="{ active }">
|
||||||
|
<MobileIcon name="bell" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
|
||||||
|
</template>
|
||||||
|
通知
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item name="profile">
|
||||||
|
<template #icon="{ active }">
|
||||||
|
<MobileIcon name="user" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
|
||||||
|
</template>
|
||||||
|
我的
|
||||||
|
</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,87 +2,169 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { isAuthenticated } from '@/api/pocketbase'
|
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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: () => import('@/views/Login.vue'),
|
component: view(
|
||||||
|
() => import('@/views/Login.vue'),
|
||||||
|
() => import('@/views-mobile/LoginMobile.vue')
|
||||||
|
),
|
||||||
meta: { requiresGuest: true }
|
meta: { requiresGuest: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'Register',
|
name: 'Register',
|
||||||
component: () => import('@/views/Register.vue'),
|
component: view(
|
||||||
|
() => import('@/views/Register.vue'),
|
||||||
|
() => import('@/views-mobile/RegisterMobile.vue')
|
||||||
|
),
|
||||||
meta: { requiresGuest: true }
|
meta: { requiresGuest: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/views/Layout.vue'),
|
component: LayoutComponent,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'Home',
|
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',
|
path: 'group/:id',
|
||||||
name: 'GroupView',
|
name: 'GroupView',
|
||||||
component: () => import('@/views/GroupView.vue'),
|
component: view(
|
||||||
|
() => import('@/views/GroupView.vue'),
|
||||||
|
() => import('@/views-mobile/GroupViewMobile.vue')
|
||||||
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'group/:groupId/ledger',
|
path: 'group/:groupId/ledger',
|
||||||
name: 'LedgerView',
|
name: 'LedgerView',
|
||||||
component: () => import('@/views/LedgerView.vue'),
|
component: view(
|
||||||
|
() => import('@/views/LedgerView.vue'),
|
||||||
|
() => import('@/views-mobile/LedgerMobile.vue')
|
||||||
|
),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'group/:groupId/assets',
|
path: 'group/:groupId/assets',
|
||||||
name: 'AssetView',
|
name: 'AssetView',
|
||||||
component: () => import('@/views/AssetView.vue'),
|
component: view(
|
||||||
|
() => import('@/views/AssetView.vue'),
|
||||||
|
() => import('@/views-mobile/AssetMobile.vue')
|
||||||
|
),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'group/:groupId/blacklist',
|
path: 'group/:groupId/blacklist',
|
||||||
name: 'BlacklistView',
|
name: 'BlacklistView',
|
||||||
component: () => import('@/views/BlacklistView.vue'),
|
component: view(
|
||||||
|
() => import('@/views/BlacklistView.vue'),
|
||||||
|
() => import('@/views-mobile/BlacklistMobile.vue')
|
||||||
|
),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'group/:groupId/voice/:sessionId',
|
path: 'group/:groupId/voice/:sessionId',
|
||||||
name: 'VoiceRoom',
|
name: 'VoiceRoom',
|
||||||
component: () => import('@/views/VoiceRoom.vue'),
|
component: view(
|
||||||
|
() => import('@/views/VoiceRoom.vue'),
|
||||||
|
() => import('@/views-mobile/VoiceRoomMobile.vue')
|
||||||
|
),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'games',
|
path: 'games',
|
||||||
name: 'GamesLibrary',
|
name: 'GamesLibrary',
|
||||||
component: () => import('@/views/GamesLibrary.vue')
|
component: view(
|
||||||
|
() => import('@/views/GamesLibrary.vue'),
|
||||||
|
() => import('@/views-mobile/GamesLibraryMobile.vue')
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: () => import('@/views/Profile.vue')
|
component: view(
|
||||||
|
() => import('@/views/Profile.vue'),
|
||||||
|
() => import('@/views-mobile/ProfileMobile.vue')
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
component: () => import('@/views/Settings.vue')
|
component: view(
|
||||||
|
() => import('@/views/Settings.vue'),
|
||||||
|
() => import('@/views-mobile/SettingsMobile.vue')
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
name: 'Changelog',
|
name: 'Changelog',
|
||||||
component: () => import('@/views/Changelog.vue')
|
component: view(
|
||||||
|
() => import('@/views/Changelog.vue'),
|
||||||
|
() => import('@/views-mobile/ChangelogMobile.vue')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/join/group/:groupId',
|
||||||
|
name: 'JoinGroup',
|
||||||
|
component: view(
|
||||||
|
() => import('@/views/JoinGroupPage.vue'),
|
||||||
|
() => import('@/views-mobile/JoinGroupPageMobile.vue')
|
||||||
|
),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/join/team/:sessionId',
|
||||||
|
name: 'JoinTeam',
|
||||||
|
component: view(
|
||||||
|
() => import('@/views/JoinTeamPage.vue'),
|
||||||
|
() => import('@/views-mobile/JoinTeamPageMobile.vue')
|
||||||
|
),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'NotFound',
|
name: 'NotFound',
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { BulletinPost, BulletinRead } from '@/types'
|
||||||
|
import {
|
||||||
|
listBulletins,
|
||||||
|
listMyReads,
|
||||||
|
markBulletinAsRead,
|
||||||
|
createBulletin,
|
||||||
|
updateBulletin,
|
||||||
|
deleteBulletin,
|
||||||
|
} from '@/api/bulletins'
|
||||||
|
|
||||||
|
export const useBulletinStore = defineStore('bulletin', () => {
|
||||||
|
const posts = ref<BulletinPost[]>([])
|
||||||
|
const reads = ref<BulletinRead[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const now = computed(() => new Date())
|
||||||
|
|
||||||
|
const activePosts = computed(() => {
|
||||||
|
return posts.value.filter((p) => {
|
||||||
|
if (!p.expiresAt) return true
|
||||||
|
return new Date(p.expiresAt) > now.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pinnedPosts = computed(() =>
|
||||||
|
activePosts.value
|
||||||
|
.filter((p) => p.pinned)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||||
|
return prioOrder[a.priority] - prioOrder[b.priority]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalPosts = computed(() =>
|
||||||
|
activePosts.value
|
||||||
|
.filter((p) => !p.pinned)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||||
|
const prioDiff = prioOrder[a.priority] - prioOrder[b.priority]
|
||||||
|
if (prioDiff !== 0) return prioDiff
|
||||||
|
return new Date(b.created).getTime() - new Date(a.created).getTime()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const readPostIds = computed(() => new Set(reads.value.map((r) => r.post)))
|
||||||
|
|
||||||
|
function isRead(postId: string): boolean {
|
||||||
|
return readPostIds.value.has(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unreadCount(groupId: string): number {
|
||||||
|
return activePosts.value.filter(
|
||||||
|
(p) => p.group === groupId && !isRead(p.id)
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPosts(groupId: string) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const [fetchedPosts, fetchedReads] = await Promise.all([
|
||||||
|
listBulletins(groupId),
|
||||||
|
listMyReads(),
|
||||||
|
])
|
||||||
|
posts.value = fetchedPosts
|
||||||
|
reads.value = fetchedReads
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载公告列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsRead(postId: string) {
|
||||||
|
if (isRead(postId)) return
|
||||||
|
try {
|
||||||
|
const record = await markBulletinAsRead(postId)
|
||||||
|
if (record) {
|
||||||
|
reads.value.push(record)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('标记已读失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsRead(groupId: string) {
|
||||||
|
const unread = activePosts.value.filter(
|
||||||
|
(p) => p.group === groupId && !isRead(p.id)
|
||||||
|
)
|
||||||
|
await Promise.all(unread.map((p) => markAsRead(p.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data: {
|
||||||
|
group: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const post = await createBulletin(data)
|
||||||
|
posts.value.unshift(post)
|
||||||
|
return post
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(
|
||||||
|
postId: string,
|
||||||
|
data: {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned?: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const post = await updateBulletin(postId, data)
|
||||||
|
const index = posts.value.findIndex((p) => p.id === postId)
|
||||||
|
if (index !== -1) {
|
||||||
|
posts.value[index] = { ...posts.value[index], ...post }
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(postId: string) {
|
||||||
|
try {
|
||||||
|
await deleteBulletin(postId)
|
||||||
|
posts.value = posts.value.filter((p) => p.id !== postId)
|
||||||
|
reads.value = reads.value.filter((r) => r.post !== postId)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
posts.value = []
|
||||||
|
reads.value = []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
reads,
|
||||||
|
loading,
|
||||||
|
activePosts,
|
||||||
|
pinnedPosts,
|
||||||
|
normalPosts,
|
||||||
|
readPostIds,
|
||||||
|
isRead,
|
||||||
|
unreadCount,
|
||||||
|
loadPosts,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import pb from '@/api/pocketbase'
|
||||||
|
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
|
||||||
|
import {
|
||||||
|
listEvents,
|
||||||
|
getEvent,
|
||||||
|
listEventComments,
|
||||||
|
listEventRSVPs,
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
createEventComment,
|
||||||
|
deleteEventComment,
|
||||||
|
setEventRSVP,
|
||||||
|
cancelEventRSVP
|
||||||
|
} from '@/api/events'
|
||||||
|
|
||||||
|
export const useEventStore = defineStore('event', () => {
|
||||||
|
const events = ref<Event[]>([])
|
||||||
|
const currentEvent = ref<Event | null>(null)
|
||||||
|
const comments = ref<EventComment[]>([])
|
||||||
|
const rsvps = ref<EventRSVP[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const upcomingEvents = computed(() =>
|
||||||
|
events.value.filter(e => e.status === 'upcoming' || e.status === 'ongoing')
|
||||||
|
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
|
||||||
|
)
|
||||||
|
|
||||||
|
const pastEvents = computed(() =>
|
||||||
|
events.value.filter(e => e.status === 'completed' || e.status === 'cancelled')
|
||||||
|
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||||
|
)
|
||||||
|
|
||||||
|
const goingCount = computed(() =>
|
||||||
|
rsvps.value.filter(r => r.type === 'going').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const interestedCount = computed(() =>
|
||||||
|
rsvps.value.filter(r => r.type === 'interested').length
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadEvents(groupId: string) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
events.value = await listEvents(groupId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载活动列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEventDetail(eventId: string) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const [eventData, commentData, rsvpData] = await Promise.all([
|
||||||
|
getEvent(eventId),
|
||||||
|
listEventComments(eventId),
|
||||||
|
listEventRSVPs(eventId)
|
||||||
|
])
|
||||||
|
currentEvent.value = eventData
|
||||||
|
comments.value = commentData
|
||||||
|
rsvps.value = rsvpData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载活动详情失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommentsAndRSVPs(eventId: string) {
|
||||||
|
try {
|
||||||
|
const [commentData, rsvpData] = await Promise.all([
|
||||||
|
listEventComments(eventId),
|
||||||
|
listEventRSVPs(eventId)
|
||||||
|
])
|
||||||
|
comments.value = commentData
|
||||||
|
rsvps.value = rsvpData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载评论和 RSVP 失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEvent(data: Parameters<typeof createEvent>[0]) {
|
||||||
|
try {
|
||||||
|
const event = await createEvent(data)
|
||||||
|
events.value.unshift(event)
|
||||||
|
return event
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建活动失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEvent(eventId: string) {
|
||||||
|
try {
|
||||||
|
await deleteEvent(eventId)
|
||||||
|
events.value = events.value.filter(e => e.id !== eventId)
|
||||||
|
if (currentEvent.value?.id === eventId) {
|
||||||
|
currentEvent.value = null
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除活动失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editEvent(eventId: string, data: Partial<Event>) {
|
||||||
|
try {
|
||||||
|
const updated = await updateEvent(eventId, data)
|
||||||
|
const index = events.value.findIndex(e => e.id === eventId)
|
||||||
|
if (index !== -1) {
|
||||||
|
events.value[index] = updated
|
||||||
|
}
|
||||||
|
if (currentEvent.value?.id === eventId) {
|
||||||
|
currentEvent.value = updated
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新活动失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment(eventId: string, content: string) {
|
||||||
|
try {
|
||||||
|
const comment = await createEventComment(eventId, content)
|
||||||
|
comments.value.unshift(comment)
|
||||||
|
return comment
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建评论失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeComment(commentId: string) {
|
||||||
|
try {
|
||||||
|
await deleteEventComment(commentId)
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除评论失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rsvp(eventId: string, type: RSVPType, comment?: string) {
|
||||||
|
try {
|
||||||
|
const rsvp = await setEventRSVP(eventId, type, comment)
|
||||||
|
const index = rsvps.value.findIndex(r => r.id === rsvp.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
rsvps.value[index] = rsvp
|
||||||
|
} else {
|
||||||
|
rsvps.value.push(rsvp)
|
||||||
|
}
|
||||||
|
return rsvp
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('RSVP 失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRSVP(eventId: string) {
|
||||||
|
try {
|
||||||
|
await cancelEventRSVP(eventId)
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
rsvps.value = rsvps.value.filter(r => !(r.event === eventId && r.user === userId))
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('取消 RSVP 失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
events.value = []
|
||||||
|
currentEvent.value = null
|
||||||
|
comments.value = []
|
||||||
|
rsvps.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
currentEvent,
|
||||||
|
comments,
|
||||||
|
rsvps,
|
||||||
|
loading,
|
||||||
|
upcomingEvents,
|
||||||
|
pastEvents,
|
||||||
|
goingCount,
|
||||||
|
interestedCount,
|
||||||
|
loadEvents,
|
||||||
|
loadEventDetail,
|
||||||
|
loadCommentsAndRSVPs,
|
||||||
|
addEvent,
|
||||||
|
removeEvent,
|
||||||
|
editEvent,
|
||||||
|
addComment,
|
||||||
|
removeComment,
|
||||||
|
rsvp,
|
||||||
|
cancelRSVP,
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -17,6 +17,15 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
const isGroupOwner = computed(() => {
|
const isGroupOwner = computed(() => {
|
||||||
return currentGroup.value?.owner === pb.authStore.model?.id
|
return currentGroup.value?.owner === pb.authStore.model?.id
|
||||||
})
|
})
|
||||||
|
const isGroupAdmin = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId || !currentGroup.value) return false
|
||||||
|
return (currentGroup.value.admins || []).includes(userId)
|
||||||
|
})
|
||||||
|
const canManageGroup = computed(() => {
|
||||||
|
if (!currentGroup.value) return false
|
||||||
|
return isGroupOwner.value || isGroupAdmin.value || currentGroup.value.allowMemberManage
|
||||||
|
})
|
||||||
|
|
||||||
// 加载用户的群组列表
|
// 加载用户的群组列表
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
@@ -78,6 +87,8 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
loading,
|
loading,
|
||||||
currentGroupId,
|
currentGroupId,
|
||||||
isGroupOwner,
|
isGroupOwner,
|
||||||
|
isGroupAdmin,
|
||||||
|
canManageGroup,
|
||||||
loadGroups,
|
loadGroups,
|
||||||
setCurrentGroup,
|
setCurrentGroup,
|
||||||
clearCurrentGroup,
|
clearCurrentGroup,
|
||||||
|
|||||||
@@ -66,13 +66,16 @@ export interface Group {
|
|||||||
description?: string
|
description?: string
|
||||||
owner: string
|
owner: string
|
||||||
members: string[]
|
members: string[]
|
||||||
|
admins: string[]
|
||||||
maxMembers: number
|
maxMembers: number
|
||||||
requireApproval: boolean
|
requireApproval: boolean
|
||||||
|
allowMemberManage: boolean
|
||||||
created: string
|
created: string
|
||||||
updated: string
|
updated: string
|
||||||
expand?: {
|
expand?: {
|
||||||
owner?: User
|
owner?: User
|
||||||
members?: User[]
|
members?: User[]
|
||||||
|
admins?: User[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ export interface Invitation {
|
|||||||
export interface Game {
|
export interface Game {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
aliases?: string[]
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
cover?: string
|
||||||
@@ -408,6 +412,123 @@ export interface PlayerBlacklistEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公告优先级
|
||||||
|
export type BulletinPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
|
||||||
|
export const BulletinPriorityMap: Record<BulletinPriority, string> = {
|
||||||
|
low: '低',
|
||||||
|
normal: '普通',
|
||||||
|
high: '高',
|
||||||
|
urgent: '紧急'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BulletinPriorityColor: Record<BulletinPriority, string> = {
|
||||||
|
low: 'var(--gg-info)',
|
||||||
|
normal: 'var(--gg-text-muted)',
|
||||||
|
high: 'var(--gg-warning)',
|
||||||
|
urgent: 'var(--gg-danger)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulletinPost {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
creator: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority: BulletinPriority
|
||||||
|
pinned: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
expand?: {
|
||||||
|
creator?: User
|
||||||
|
group?: Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulletinRead {
|
||||||
|
id: string
|
||||||
|
post: string
|
||||||
|
user: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动状态
|
||||||
|
export type EventStatus = 'upcoming' | 'ongoing' | 'completed' | 'cancelled'
|
||||||
|
|
||||||
|
export const EventStatusMap: Record<EventStatus, string> = {
|
||||||
|
upcoming: '即将开始',
|
||||||
|
ongoing: '进行中',
|
||||||
|
completed: '已结束',
|
||||||
|
cancelled: '已取消'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventStatusColor: Record<EventStatus, string> = {
|
||||||
|
upcoming: 'var(--gg-info)',
|
||||||
|
ongoing: 'var(--gg-primary)',
|
||||||
|
completed: 'var(--gg-text-muted)',
|
||||||
|
cancelled: 'var(--gg-danger)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSVP 类型
|
||||||
|
export type RSVPType = 'going' | 'interested' | 'maybe'
|
||||||
|
|
||||||
|
export const RSVPTypeMap: Record<RSVPType, string> = {
|
||||||
|
going: '参加',
|
||||||
|
interested: '感兴趣',
|
||||||
|
maybe: '也许'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动
|
||||||
|
export interface Event {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
creator: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
location?: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
status: EventStatus
|
||||||
|
maxParticipants?: number
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
expand?: {
|
||||||
|
creator?: User
|
||||||
|
group?: Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动评论
|
||||||
|
export interface EventComment {
|
||||||
|
id: string
|
||||||
|
event: string
|
||||||
|
user: string
|
||||||
|
content: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
expand?: {
|
||||||
|
user?: User
|
||||||
|
event?: Event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动 RSVP
|
||||||
|
export interface EventRSVP {
|
||||||
|
id: string
|
||||||
|
event: string
|
||||||
|
user: string
|
||||||
|
type: RSVPType
|
||||||
|
comment?: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
expand?: {
|
||||||
|
user?: User
|
||||||
|
event?: Event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 竞猜状态
|
// 竞猜状态
|
||||||
export type BetStatus = 'open' | 'closed' | 'settled'
|
export type BetStatus = 'open' | 'closed' | 'settled'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<!-- src/views-mobile/AssetMobile.vue -->
|
||||||
|
<!-- 手机端资产:列表 + 添加 + 转移 + 删除 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAssetStore } from '@/stores/asset'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { getAssetImageUrl } from '@/api/assets'
|
||||||
|
import { AssetTypeMap } from '@/types'
|
||||||
|
import type { AssetType } from '@/types'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const groupId = route.params.groupId as string
|
||||||
|
|
||||||
|
const assetStore = useAssetStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const assets = computed(() => assetStore.assets)
|
||||||
|
const members = computed(() => groupStore.currentMembers)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await groupStore.setCurrentGroup(groupId)
|
||||||
|
await assetStore.loadAssets(groupId)
|
||||||
|
await assetStore.startSubscription(groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
assetStore.stopSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
|
function imageUrl(assetId: string, filename: string): string {
|
||||||
|
if (!filename) return ''
|
||||||
|
return getAssetImageUrl(assetId, filename, '200x200')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加
|
||||||
|
const showAdd = ref(false)
|
||||||
|
const addForm = ref({ name: '', type: 'other' as AssetType, description: '' })
|
||||||
|
const addImage = ref<File | null>(null)
|
||||||
|
const addLoading = ref(false)
|
||||||
|
|
||||||
|
function onImageSelect(items: any) {
|
||||||
|
const arr = Array.isArray(items) ? items : [items]
|
||||||
|
addImage.value = arr[0]?.file || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!addForm.value.name.trim()) {
|
||||||
|
showFailToast('请输入名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addLoading.value = true
|
||||||
|
try {
|
||||||
|
await assetStore.addAsset({
|
||||||
|
group: groupId,
|
||||||
|
name: addForm.value.name.trim(),
|
||||||
|
type: addForm.value.type,
|
||||||
|
description: addForm.value.description.trim(),
|
||||||
|
image: addImage.value || undefined
|
||||||
|
})
|
||||||
|
showSuccessToast('添加成功')
|
||||||
|
showAdd.value = false
|
||||||
|
addForm.value = { name: '', type: 'other', description: '' }
|
||||||
|
addImage.value = null
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
} finally {
|
||||||
|
addLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转移
|
||||||
|
const showTransfer = ref(false)
|
||||||
|
const transferAssetId = ref('')
|
||||||
|
const transferUserId = ref('')
|
||||||
|
|
||||||
|
function openTransfer(assetId: string) {
|
||||||
|
transferAssetId.value = assetId
|
||||||
|
transferUserId.value = ''
|
||||||
|
showTransfer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransfer() {
|
||||||
|
if (!transferUserId.value) {
|
||||||
|
showFailToast('请选择成员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await assetStore.transfer(transferAssetId.value, transferUserId.value)
|
||||||
|
showSuccessToast('已转移')
|
||||||
|
showTransfer.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '转移失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(assetId: string) {
|
||||||
|
showConfirmDialog({ title: '删除', message: '确定删除这个资产吗?' })
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
await assetStore.removeAsset(assetId)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetTypes = Object.keys(AssetTypeMap) as AssetType[]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="asset-mobile">
|
||||||
|
<div v-if="assets.length === 0" class="empty">
|
||||||
|
<van-empty description="暂无资产" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="asset-list">
|
||||||
|
<div v-for="a in assets" :key="a.id" class="asset-card">
|
||||||
|
<div class="asset-main">
|
||||||
|
<img
|
||||||
|
v-if="a.image"
|
||||||
|
:src="imageUrl(a.id, a.image)"
|
||||||
|
class="asset-img"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div v-else class="asset-img-placeholder">
|
||||||
|
<van-icon name="gift-o" size="28" />
|
||||||
|
</div>
|
||||||
|
<div class="asset-info">
|
||||||
|
<div class="asset-name">{{ a.name }}</div>
|
||||||
|
<div class="asset-meta">
|
||||||
|
<van-tag plain size="medium">{{ AssetTypeMap[a.type] }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="a.expand?.currentHolder" class="asset-holder">
|
||||||
|
持有: {{ displayName(a.expand.currentHolder) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-actions">
|
||||||
|
<van-button size="mini" plain round @click="openTransfer(a.id)">转移</van-button>
|
||||||
|
<van-button size="mini" plain type="danger" round @click="handleDelete(a.id)">删除</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 浮动添加 -->
|
||||||
|
<div class="fab" @click="showAdd = true">
|
||||||
|
<van-icon name="plus" size="24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加弹层 -->
|
||||||
|
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">添加资产</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="addForm.name" label="名称" placeholder="资产名称" required />
|
||||||
|
<van-field name="select" label="类型">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="addForm.type" class="type-select">
|
||||||
|
<option v-for="t in assetTypes" :key="t" :value="t">{{ AssetTypeMap[t] }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field v-model="addForm.description" label="描述" placeholder="可选" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="upload-area">
|
||||||
|
<van-uploader :after-read="onImageSelect" :max-count="1" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 转移弹层 -->
|
||||||
|
<van-action-sheet v-model:show="showTransfer" title="转移给">
|
||||||
|
<div class="transfer-list">
|
||||||
|
<div
|
||||||
|
v-for="m in members"
|
||||||
|
:key="m.id"
|
||||||
|
class="transfer-item"
|
||||||
|
:class="{ active: transferUserId === m.id }"
|
||||||
|
@click="transferUserId = m.id"
|
||||||
|
>
|
||||||
|
<img :src="m.avatar || '/default-avatar.svg'" class="transfer-avatar" alt="" />
|
||||||
|
<span>{{ displayName(m) }}</span>
|
||||||
|
<van-icon v-if="transferUserId === m.id" name="success" class="transfer-check" />
|
||||||
|
</div>
|
||||||
|
<div class="transfer-actions">
|
||||||
|
<van-button type="primary" block round @click="handleTransfer">确认转移</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-action-sheet>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.asset-mobile { padding: 12px; min-height: 60vh; }
|
||||||
|
.empty { padding: 30px 0; }
|
||||||
|
|
||||||
|
.asset-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.asset-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
|
||||||
|
.asset-main { display: flex; gap: 12px; }
|
||||||
|
.asset-img { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||||
|
.asset-img-placeholder { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); background: var(--gg-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--gg-text-muted); flex-shrink: 0; }
|
||||||
|
.asset-info { flex: 1; min-width: 0; }
|
||||||
|
.asset-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
|
||||||
|
.asset-meta { margin-top: 6px; }
|
||||||
|
.asset-holder { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
|
||||||
|
.asset-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
|
||||||
|
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.type-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||||
|
.upload-area { padding: 16px; }
|
||||||
|
.popup-actions { padding: 16px; }
|
||||||
|
|
||||||
|
.transfer-list { padding: 8px 0; }
|
||||||
|
.transfer-item { display: flex; align-items: center; gap: 10px; padding: 12px 24px; }
|
||||||
|
.transfer-item.active { background: rgba(5,150,105,0.06); color: var(--gg-primary); }
|
||||||
|
.transfer-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.transfer-check { margin-left: auto; color: var(--gg-primary); }
|
||||||
|
.transfer-actions { padding: 16px 24px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<!-- src/views-mobile/BlacklistMobile.vue -->
|
||||||
|
<!-- 手机端黑名单:游戏/玩家双 Tab + 添加 + 删除 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { listBlacklist, createBlacklistEntry, deleteBlacklistEntry } from '@/api/gameBlacklist'
|
||||||
|
import { listPlayerBlacklist, createPlayerBlacklistEntry, deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||||
|
import { BlacklistReasonMap, BlacklistSeverityMap, PlayerTagMap } from '@/types'
|
||||||
|
import type { BlacklistReason, BlacklistSeverity, PlayerTag, BlacklistEntry, PlayerBlacklistEntry } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const groupId = route.params.groupId as string
|
||||||
|
|
||||||
|
const activeTab = ref<'game' | 'player'>('game')
|
||||||
|
|
||||||
|
const gameList = ref<BlacklistEntry[]>([])
|
||||||
|
const playerList = ref<PlayerBlacklistEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [games, players] = await Promise.all([
|
||||||
|
listBlacklist(groupId),
|
||||||
|
listPlayerBlacklist(groupId)
|
||||||
|
])
|
||||||
|
gameList.value = games
|
||||||
|
playerList.value = players
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
|
||||||
|
const showAddGame = ref(false)
|
||||||
|
const gameForm = ref({
|
||||||
|
gameName: '',
|
||||||
|
reason: 'behavior' as BlacklistReason,
|
||||||
|
severity: 'medium' as BlacklistSeverity,
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleAddGame() {
|
||||||
|
if (!gameForm.value.gameName.trim()) {
|
||||||
|
showFailToast('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createBlacklistEntry({
|
||||||
|
group: groupId,
|
||||||
|
gameName: gameForm.value.gameName.trim(),
|
||||||
|
reason: gameForm.value.reason,
|
||||||
|
severity: gameForm.value.severity,
|
||||||
|
description: gameForm.value.description.trim()
|
||||||
|
})
|
||||||
|
showSuccessToast('已添加')
|
||||||
|
showAddGame.value = false
|
||||||
|
gameForm.value = { gameName: '', reason: 'behavior', severity: 'medium', description: '' }
|
||||||
|
await loadAll()
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAddPlayer = ref(false)
|
||||||
|
const playerForm = ref({
|
||||||
|
playerId: '',
|
||||||
|
platform: '',
|
||||||
|
tags: [] as PlayerTag[],
|
||||||
|
severity: 'medium' as BlacklistSeverity,
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTag(tag: PlayerTag) {
|
||||||
|
const idx = playerForm.value.tags.indexOf(tag)
|
||||||
|
if (idx === -1) playerForm.value.tags.push(tag)
|
||||||
|
else playerForm.value.tags.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPlayer() {
|
||||||
|
if (!playerForm.value.playerId.trim()) {
|
||||||
|
showFailToast('请输入玩家 ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (playerForm.value.tags.length === 0) {
|
||||||
|
showFailToast('请至少选一个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createPlayerBlacklistEntry({
|
||||||
|
group: groupId,
|
||||||
|
playerId: playerForm.value.playerId.trim(),
|
||||||
|
platform: playerForm.value.platform.trim(),
|
||||||
|
tags: playerForm.value.tags,
|
||||||
|
severity: playerForm.value.severity,
|
||||||
|
description: playerForm.value.description.trim()
|
||||||
|
})
|
||||||
|
showSuccessToast('已添加')
|
||||||
|
showAddPlayer.value = false
|
||||||
|
playerForm.value = { playerId: '', platform: '', tags: [], severity: 'medium', description: '' }
|
||||||
|
await loadAll()
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGame(id: string) {
|
||||||
|
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteBlacklistEntry(id)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
await loadAll()
|
||||||
|
} catch { showFailToast('删除失败') }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlayer(id: string) {
|
||||||
|
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
await deletePlayerBlacklistEntry(id)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
await loadAll()
|
||||||
|
} catch { showFailToast('删除失败') }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonKeys = Object.keys(BlacklistReasonMap) as BlacklistReason[]
|
||||||
|
const severityKeys = Object.keys(BlacklistSeverityMap) as BlacklistSeverity[]
|
||||||
|
const tagKeys = Object.keys(PlayerTagMap) as PlayerTag[]
|
||||||
|
|
||||||
|
const severityType = (s: string): any => s === 'severe' ? 'danger' : s === 'medium' ? 'warning' : 'default'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="blacklist-mobile">
|
||||||
|
<van-tabs v-model:active="activeTab" sticky>
|
||||||
|
<van-tab title="游戏黑名单" name="game">
|
||||||
|
<div class="list-header">
|
||||||
|
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">添加</van-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="gameList.length === 0" class="empty">
|
||||||
|
<van-empty description="暂无游戏黑名单" image-size="100" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="entry-list">
|
||||||
|
<van-swipe-cell v-for="g in gameList" :key="g.id">
|
||||||
|
<div class="entry-card">
|
||||||
|
<div class="entry-top">
|
||||||
|
<span class="entry-name">{{ g.gameName }}</span>
|
||||||
|
<van-tag :type="severityType(g.severity)" size="medium">{{ BlacklistSeverityMap[g.severity] }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<van-tag plain size="medium">{{ BlacklistReasonMap[g.reason] }}</van-tag>
|
||||||
|
<p v-if="g.description" class="entry-desc">{{ g.description }}</p>
|
||||||
|
</div>
|
||||||
|
<template #right>
|
||||||
|
<van-button square type="danger" text="删除" class="delete-btn" @click="deleteGame(g.id)" />
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
<van-tab title="玩家黑名单" name="player">
|
||||||
|
<div class="list-header">
|
||||||
|
<van-button type="primary" size="small" round icon="plus" @click="showAddPlayer = true">添加</van-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="playerList.length === 0" class="empty">
|
||||||
|
<van-empty description="暂无玩家黑名单" image-size="100" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="entry-list">
|
||||||
|
<van-swipe-cell v-for="p in playerList" :key="p.id">
|
||||||
|
<div class="entry-card">
|
||||||
|
<div class="entry-top">
|
||||||
|
<span class="entry-name">{{ p.playerId }}</span>
|
||||||
|
<van-tag :type="severityType(p.severity)" size="medium">{{ BlacklistSeverityMap[p.severity] }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="tag-row">
|
||||||
|
<van-tag v-for="t in p.tags" :key="t" type="danger" plain size="medium">{{ PlayerTagMap[t] }}</van-tag>
|
||||||
|
<van-tag v-if="p.customTag" type="danger" plain size="medium">{{ p.customTag }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<p v-if="p.description" class="entry-desc">{{ p.description }}</p>
|
||||||
|
<div v-if="p.platform" class="entry-platform">平台: {{ p.platform }}</div>
|
||||||
|
</div>
|
||||||
|
<template #right>
|
||||||
|
<van-button square type="danger" text="删除" class="delete-btn" @click="deletePlayer(p.id)" />
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showAddGame" position="bottom" round closeable :style="{ height: '70%' }">
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">添加游戏黑名单</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="gameForm.gameName" label="游戏名" placeholder="游戏名称" required />
|
||||||
|
<van-field name="select" label="原因">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="gameForm.reason" class="sel">
|
||||||
|
<option v-for="r in reasonKeys" :key="r" :value="r">{{ BlacklistReasonMap[r] }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field name="select" label="严重度">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="gameForm.severity" class="sel">
|
||||||
|
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field v-model="gameForm.description" type="textarea" label="说明" placeholder="详细描述" rows="2" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<van-button type="primary" block round @click="handleAddGame">添加</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showAddPlayer" position="bottom" round closeable :style="{ height: '75%' }">
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">添加玩家黑名单</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="playerForm.playerId" label="玩家 ID" placeholder="游戏内 ID" required />
|
||||||
|
<van-field v-model="playerForm.platform" label="平台" placeholder="如 Steam/PSN" />
|
||||||
|
<van-field name="select" label="严重度">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="playerForm.severity" class="sel">
|
||||||
|
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="tag-select-title">标签(多选)</div>
|
||||||
|
<div class="tag-select-area">
|
||||||
|
<van-tag
|
||||||
|
v-for="t in tagKeys"
|
||||||
|
:key="t"
|
||||||
|
:type="playerForm.tags.includes(t) ? 'primary' : 'default'"
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
class="tag-opt"
|
||||||
|
@click="toggleTag(t)"
|
||||||
|
>{{ PlayerTagMap[t] }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="playerForm.description" type="textarea" label="说明" placeholder="可选" rows="2" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<van-button type="primary" block round @click="handleAddPlayer">添加</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blacklist-mobile { min-height: 60vh; }
|
||||||
|
.list-header { display: flex; justify-content: flex-end; padding: 12px; }
|
||||||
|
.empty { padding: 30px 0; }
|
||||||
|
.entry-list { padding: 0 12px; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.entry-card { background: var(--gg-bg-card); padding: 12px 14px; }
|
||||||
|
.entry-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.entry-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
|
||||||
|
.tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||||
|
.entry-desc { font-size: 13px; color: var(--gg-text-secondary); margin: 6px 0 0; line-height: 1.5; }
|
||||||
|
.entry-platform { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
|
||||||
|
.delete-btn { height: 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; }
|
||||||
|
.sel { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||||
|
.tag-select-title { padding: 16px 16px 8px; font-size: 14px; font-weight: 600; }
|
||||||
|
.tag-select-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 16px; }
|
||||||
|
.tag-opt { user-select: none; }
|
||||||
|
.popup-actions { padding: 20px 16px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!-- src/views-mobile/ChangelogMobile.vue -->
|
||||||
|
<!-- 手机端更新日志:时间线列表(数据对齐 uat PC Changelog.vue,含 v0.3.6 最新版本) -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
version: string
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
items: { type: 'feat' | 'fix' | 'refactor' | 'style'; text: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<LogEntry[]>([
|
||||||
|
{
|
||||||
|
version: 'v2.1.0',
|
||||||
|
date: '2026-06-18',
|
||||||
|
title: '手机端前端(uat 重做)',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '完整手机端界面:底部 Tab 导航、顶部栏、所有功能页面移动端适配' },
|
||||||
|
{ type: 'feat', text: '设备自动识别:手机访问自动进入手机版,桌面访问保持桌面版' },
|
||||||
|
{ type: 'feat', text: '游戏库移动端:按群组划分,支持详情/评论/收藏/添加/导入' },
|
||||||
|
{ type: 'feat', text: 'Vant 组件库集成:下拉刷新、ActionSheet、弹层等移动端交互' },
|
||||||
|
{ type: 'feat', text: '语音房手机端预览:成员网格 + 控制条 UI(实际语音待 App)' },
|
||||||
|
{ type: 'style', text: '代码分割优化:vendor 拆分,减少首屏加载体积' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.6',
|
||||||
|
date: '2026-04-24',
|
||||||
|
title: '游戏库别名与编辑权限',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
|
||||||
|
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
|
||||||
|
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
|
||||||
|
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
|
||||||
|
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
|
||||||
|
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.5',
|
||||||
|
date: '2026-04-23',
|
||||||
|
title: 'Bug 修复与体验优化',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '群组页面顶部新增待审核入群申请徽章(脉冲动画提示)' },
|
||||||
|
{ type: 'feat', text: '个人中心新增"我的入群申请"记录,展示申请状态、时间和拒绝原因' },
|
||||||
|
{ type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床' },
|
||||||
|
{ type: 'fix', text: '修复群组审核队列从首页进入时不显示的问题' },
|
||||||
|
{ type: 'fix', text: '修复拒绝入群申请后标题区待审核计数不同步更新' },
|
||||||
|
{ type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错,添加降级方案' },
|
||||||
|
{ type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' },
|
||||||
|
{ type: 'fix', text: '修复活动管理权限判断,拆分编辑权限和删除权限' },
|
||||||
|
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的创建/加入群组按钮' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.4',
|
||||||
|
date: '2026-04-21',
|
||||||
|
title: '公告详情弹窗',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '公告卡片点击打开详情弹窗,展示完整内容(保留换行格式)' },
|
||||||
|
{ type: 'feat', text: '详情弹窗显示优先级标签、作者、发布时间、截止时间等信息' },
|
||||||
|
{ type: 'feat', text: '详情弹窗内置编辑和删除操作按钮' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.3',
|
||||||
|
date: '2026-04-20',
|
||||||
|
title: 'Electron 桌面客户端',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: 'Electron 桌面端封装:将 Web 应用包装为独立桌面应用,支持 Windows/Linux/macOS' },
|
||||||
|
{ type: 'fix', text: '修复 HTTP 环境下 mediaDevices 不可用导致语音认证失败的问题' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.2',
|
||||||
|
date: '2026-04-19',
|
||||||
|
title: '实时语音房间',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC)' },
|
||||||
|
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
|
||||||
|
{ type: 'feat', text: '麦克风/扬声器开关:独立控制' },
|
||||||
|
{ type: 'feat', text: 'LiveKit 后端服务:Docker 部署 LiveKit SFU + Token 签发微服务' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.1',
|
||||||
|
date: '2026-04-19',
|
||||||
|
title: '玩家黑名单',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家,记录玩家ID和游戏平台' },
|
||||||
|
{ type: 'feat', text: '玩家卡片聚合:按玩家ID+平台聚合展示' },
|
||||||
|
{ type: 'feat', text: '自定义标签:除预定义标签外支持填写自定义标签' },
|
||||||
|
{ type: 'feat', text: '实时订阅:玩家黑名单变更实时更新' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.0',
|
||||||
|
date: '2026-04-19',
|
||||||
|
title: '积分竞猜、游戏黑名单',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '积分竞猜:发起竞猜、下注、开奖、奖池分配' },
|
||||||
|
{ type: 'feat', text: '游戏黑名单:标记体验差的游戏,提醒队友避坑' },
|
||||||
|
{ type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' },
|
||||||
|
{ type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const typeColor: Record<string, string> = {
|
||||||
|
feat: 'success',
|
||||||
|
fix: 'warning',
|
||||||
|
refactor: 'primary',
|
||||||
|
style: 'default'
|
||||||
|
}
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
feat: '新增', fix: '修复', refactor: '重构', style: '样式'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="changelog-mobile">
|
||||||
|
<div v-for="log in logs" :key="log.version" class="log-block">
|
||||||
|
<div class="log-header">
|
||||||
|
<van-tag type="primary" size="large">{{ log.version }}</van-tag>
|
||||||
|
<span class="log-date">{{ log.date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-title">{{ log.title }}</div>
|
||||||
|
<div class="log-items">
|
||||||
|
<div v-for="(item, idx) in log.items" :key="idx" class="log-item">
|
||||||
|
<van-tag :type="typeColor[item.type] as any" plain size="medium">{{ typeLabel[item.type] }}</van-tag>
|
||||||
|
<span class="item-text">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.changelog-mobile { padding: 16px 12px; }
|
||||||
|
|
||||||
|
.log-block { margin-bottom: 24px; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; box-shadow: var(--gg-shadow); }
|
||||||
|
|
||||||
|
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.log-date { font-size: 12px; color: var(--gg-text-muted); }
|
||||||
|
|
||||||
|
.log-title { font-size: 16px; font-weight: 600; color: var(--gg-text); margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.log-items { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.log-item { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.item-text { font-size: 13px; color: var(--gg-text-secondary); line-height: 1.5; flex: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<!-- src/views-mobile/GamesLibraryMobile.vue -->
|
||||||
|
<!-- 手机端游戏库:按群组划分 + 搜索 + 平台筛选 + 瀑布流 + 添加/导入/详情 -->
|
||||||
|
<!-- 对齐 uat PC 端 GamesLibrary(游戏绑定群组,非全局) -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { getGroupGames, deleteGame, getAllPlatforms, getGameCoverUrl } from '@/api/games'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { Game, GamePlatform } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import GameDetailSheetMobile from '@/components-mobile/game/GameDetailSheetMobile.vue'
|
||||||
|
import AddGameSheetMobile from '@/components-mobile/game/AddGameSheetMobile.vue'
|
||||||
|
import ImportGamesSheetMobile from '@/components-mobile/game/ImportGamesSheetMobile.vue'
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const selectedGroupId = ref('')
|
||||||
|
const games = ref<Game[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedPlatform = ref<GamePlatform | ''>('')
|
||||||
|
|
||||||
|
const showGroupPicker = ref(false)
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const selectedGame = ref<Game | null>(null)
|
||||||
|
const showAddGame = ref(false)
|
||||||
|
const showImport = ref(false)
|
||||||
|
|
||||||
|
const groupOptions = computed(() => groupStore.groups)
|
||||||
|
const hasGroups = computed(() => groupOptions.value.length > 0)
|
||||||
|
const currentGroupName = computed(() => {
|
||||||
|
const g = groupOptions.value.find(x => x.id === selectedGroupId.value)
|
||||||
|
return g?.name || '选择群组'
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||||
|
|
||||||
|
function canModifyGame(game: Game): boolean {
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (game.addedBy === currentUserId.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (groupStore.groups.length === 0) {
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
}
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
selectedGroupId.value = groupStore.currentGroupId
|
||||||
|
} else if (groupOptions.value.length > 0) {
|
||||||
|
selectedGroupId.value = groupOptions.value[0].id
|
||||||
|
}
|
||||||
|
if (selectedGroupId.value) {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedGroupId, () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedPlatform.value = ''
|
||||||
|
loadGames()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGames() {
|
||||||
|
if (!selectedGroupId.value) return
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const result = await getGroupGames(selectedGroupId.value, {
|
||||||
|
limit: 100,
|
||||||
|
search: searchQuery.value || undefined,
|
||||||
|
platform: selectedPlatform.value || undefined
|
||||||
|
})
|
||||||
|
games.value = result.items
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载游戏失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function onSearchInput() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => loadGames(), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGameDetail(game: Game) {
|
||||||
|
selectedGame.value = game
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteGame(game: Game) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除游戏',
|
||||||
|
message: `确定要删除「${game.name}」吗?`
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteGame(game.id)
|
||||||
|
showSuccessToast('删除成功')
|
||||||
|
await loadGames()
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupConfirm({ selectedValues }: { selectedValues: string[] }) {
|
||||||
|
if (selectedValues[0]) {
|
||||||
|
selectedGroupId.value = selectedValues[0]
|
||||||
|
// 同步到 store,供子组件(详情/添加)判断权限
|
||||||
|
groupStore.setCurrentGroup(selectedGroupId.value)
|
||||||
|
}
|
||||||
|
showGroupPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPickerColumns = computed(() =>
|
||||||
|
groupOptions.value.map(g => ({ text: g.name, value: g.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleDetailDeleted() {
|
||||||
|
showDetail.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameSaved() {
|
||||||
|
showAddGame.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportComplete() {
|
||||||
|
showImport.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="games-mobile">
|
||||||
|
<!-- 无群组引导 -->
|
||||||
|
<div v-if="!hasGroups" class="no-group">
|
||||||
|
<van-empty description="还没有群组" image-size="100">
|
||||||
|
<p class="no-group-hint">先创建或加入群组,再管理游戏库</p>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 顶部:群组选择 + 工具栏 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="group-selector" @click="showGroupPicker = true">
|
||||||
|
<van-icon name="friends-o" />
|
||||||
|
<span class="group-name">{{ currentGroupName }}</span>
|
||||||
|
<van-icon name="arrow-down" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-search
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索游戏"
|
||||||
|
shape="round"
|
||||||
|
@update:model-value="onSearchInput"
|
||||||
|
@search="loadGames"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 平台筛选横滑 -->
|
||||||
|
<div class="platform-bar">
|
||||||
|
<van-tag
|
||||||
|
:type="selectedPlatform === '' ? 'primary' : 'default'"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
class="platform-tag"
|
||||||
|
@click="selectedPlatform = ''; loadGames()"
|
||||||
|
>全部</van-tag>
|
||||||
|
<van-tag
|
||||||
|
v-for="p in platforms"
|
||||||
|
:key="p"
|
||||||
|
:type="selectedPlatform === p ? 'primary' : 'default'"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
class="platform-tag"
|
||||||
|
@click="selectedPlatform = p; loadGames()"
|
||||||
|
>{{ p }}</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="action-row">
|
||||||
|
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">
|
||||||
|
添加游戏
|
||||||
|
</van-button>
|
||||||
|
<van-button size="small" round icon="down" @click="showImport = true">
|
||||||
|
导入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 游戏列表 -->
|
||||||
|
<div v-if="loading" class="loading-box">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="games.length === 0" class="empty">
|
||||||
|
<van-empty :description="searchQuery ? '未找到游戏' : '暂无游戏'" image-size="100">
|
||||||
|
<van-button v-if="!searchQuery" type="primary" size="small" round @click="showAddGame = true">
|
||||||
|
添加第一个游戏
|
||||||
|
</van-button>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="games-grid">
|
||||||
|
<div
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
|
class="game-card"
|
||||||
|
@click="openGameDetail(game)"
|
||||||
|
>
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<img
|
||||||
|
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||||
|
:alt="game.name"
|
||||||
|
class="game-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="canModifyGame(game)"
|
||||||
|
class="delete-btn"
|
||||||
|
@click.stop="handleDeleteGame(game)"
|
||||||
|
>
|
||||||
|
<van-icon name="cross" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="game-info">
|
||||||
|
<div class="game-name">{{ game.name }}</div>
|
||||||
|
<div v-if="game.platform" class="game-platform">{{ game.platform }}</div>
|
||||||
|
<div v-if="game.tags?.length" class="game-tags">
|
||||||
|
<span v-for="tag in game.tags.slice(0, 2)" :key="tag" class="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 群组选择器 -->
|
||||||
|
<van-popup v-model:show="showGroupPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="groupPickerColumns"
|
||||||
|
:default-index="groupOptions.findIndex(g => g.id === selectedGroupId)"
|
||||||
|
@confirm="onGroupConfirm"
|
||||||
|
@cancel="showGroupPicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 详情面板 -->
|
||||||
|
<GameDetailSheetMobile
|
||||||
|
v-model:show="showDetail"
|
||||||
|
:game="selectedGame"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@deleted="handleDetailDeleted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 添加游戏 -->
|
||||||
|
<AddGameSheetMobile
|
||||||
|
v-model:show="showAddGame"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@saved="handleGameSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 导入游戏 -->
|
||||||
|
<ImportGamesSheetMobile
|
||||||
|
v-model:show="showImport"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@imported="handleImportComplete"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.games-mobile {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-group {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-group-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row .van-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info {
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-platform {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<!-- src/views-mobile/GroupViewMobile.vue -->
|
||||||
|
<!-- 手机端群组详情:群信息 + 可横滑标签栏 -->
|
||||||
|
<!-- 标签:动态/投票/竞猜/回忆/成员/账本/资产/黑名单/统计 + [阶段10]公示板 + [阶段11]活动 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import ActivityFeedMobile from '@/components-mobile/group/ActivityFeedMobile.vue'
|
||||||
|
import MemberListMobile from '@/components-mobile/group/MemberListMobile.vue'
|
||||||
|
import PollListMobile from '@/components-mobile/poll/PollListMobile.vue'
|
||||||
|
import BetListMobile from '@/components-mobile/bet/BetListMobile.vue'
|
||||||
|
import MemoryGridMobile from '@/components-mobile/memory/MemoryGridMobile.vue'
|
||||||
|
import StatsPanelMobile from '@/components-mobile/stats/StatsPanelMobile.vue'
|
||||||
|
import BulletinListMobile from '@/components-mobile/bulletin/BulletinListMobile.vue'
|
||||||
|
import EventListMobile from '@/components-mobile/event/EventListMobile.vue'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
import Placeholder from '@/views-mobile/Placeholder.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupId = route.params.id as string
|
||||||
|
|
||||||
|
const activeTab = ref(route.query.tab as string || 'activity')
|
||||||
|
|
||||||
|
const group = computed(() => groupStore.currentGroup)
|
||||||
|
const members = computed(() => groupStore.currentMembers)
|
||||||
|
|
||||||
|
const ownerName = computed(() => {
|
||||||
|
const owner = members.value.find(m => m.id === group.value?.owner)
|
||||||
|
return owner?.name || owner?.username || '未知'
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubFns: (() => Promise<void>)[] = []
|
||||||
|
|
||||||
|
async function loadGroup() {
|
||||||
|
await groupStore.setCurrentGroup(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadGroup()
|
||||||
|
// 订阅群组、成员、会话变更
|
||||||
|
try {
|
||||||
|
unsubFns.push(await pb.collection('users').subscribe('*', () => loadGroup()))
|
||||||
|
unsubFns.push(await pb.collection('groups').subscribe('*', (payload) => {
|
||||||
|
if (payload.record.id === groupId) loadGroup()
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('订阅失败:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(async () => {
|
||||||
|
for (const fn of unsubFns) {
|
||||||
|
try { await fn() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标签配置(全部 tab 已接入)
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'activity', label: '动态' },
|
||||||
|
{ name: 'bulletin', label: '公告' },
|
||||||
|
{ name: 'events', label: '活动' },
|
||||||
|
{ name: 'polls', label: '投票' },
|
||||||
|
{ name: 'bets', label: '竞猜' },
|
||||||
|
{ name: 'members', label: '成员' },
|
||||||
|
{ name: 'memories', label: '回忆' },
|
||||||
|
{ name: 'stats', label: '统计' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onTabChange(name: string) {
|
||||||
|
activeTab.value = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转群组级独立页面
|
||||||
|
function goLedger() {
|
||||||
|
router.push({ name: 'LedgerView', params: { groupId } })
|
||||||
|
}
|
||||||
|
function goAssets() {
|
||||||
|
router.push({ name: 'AssetView', params: { groupId } })
|
||||||
|
}
|
||||||
|
function goBlacklist() {
|
||||||
|
router.push({ name: 'BlacklistView', params: { groupId } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group-view-mobile">
|
||||||
|
<!-- 群信息条 -->
|
||||||
|
<div class="group-header">
|
||||||
|
<div class="header-row">
|
||||||
|
<h1 class="group-name">{{ group?.name || '加载中...' }}</h1>
|
||||||
|
<van-tag type="success" size="large">{{ members.length }} 人</van-tag>
|
||||||
|
</div>
|
||||||
|
<p class="group-desc">{{ group?.description || '暂无群组简介' }}</p>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="meta-text">群主: {{ ownerName }}</span>
|
||||||
|
<span class="meta-divider">·</span>
|
||||||
|
<span class="meta-text">{{ members.length }}/{{ group?.maxMembers || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 快捷入口 -->
|
||||||
|
<div class="quick-entry">
|
||||||
|
<div class="entry-item" @click="goLedger">
|
||||||
|
<MobileIcon name="wallet" :size="20" color="var(--gg-primary)" />
|
||||||
|
<span>账本</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-item" @click="goAssets">
|
||||||
|
<MobileIcon name="gift" :size="20" color="var(--gg-primary)" />
|
||||||
|
<span>资产</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-item" @click="goBlacklist">
|
||||||
|
<MobileIcon name="warning" :size="20" color="var(--gg-primary)" />
|
||||||
|
<span>黑名单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可横滑标签栏 -->
|
||||||
|
<van-tabs
|
||||||
|
v-model:active="activeTab"
|
||||||
|
class="group-tabs"
|
||||||
|
line-width="20"
|
||||||
|
@change="onTabChange"
|
||||||
|
>
|
||||||
|
<van-tab v-for="tab in tabs" :key="tab.name" :name="tab.name" :title="tab.label">
|
||||||
|
<div class="tab-content">
|
||||||
|
<ActivityFeedMobile v-if="activeTab === 'activity'" :group-id="groupId" />
|
||||||
|
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
|
||||||
|
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
|
||||||
|
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
|
||||||
|
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
|
||||||
|
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
|
||||||
|
<BulletinListMobile v-else-if="activeTab === 'bulletin'" :group-id="groupId" />
|
||||||
|
<EventListMobile v-else-if="activeTab === 'events'" :group-id="groupId" />
|
||||||
|
<Placeholder v-else />
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-view-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 群信息条 */
|
||||||
|
.group-header {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-divider {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-item:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-item .mobile-icon {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签内容 */
|
||||||
|
.tab-content {
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<!-- src/views-mobile/GroupsMobile.vue -->
|
||||||
|
<!-- 手机端群组列表 + 创建/加入群组 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { createGroup, searchGroups, joinGroup } from '@/api/groups'
|
||||||
|
import pb from '@/api/pocketbase'
|
||||||
|
import type { Group } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
// 创建群组
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createForm = ref({ name: '', description: '', maxMembers: 20 })
|
||||||
|
const createLoading = ref(false)
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.value.name.trim()) {
|
||||||
|
showFailToast('请输入群组名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
// 名称查重(与桌面端一致)
|
||||||
|
const existing = await pb.collection('groups').getList(1, 1, {
|
||||||
|
filter: `name="${createForm.value.name.trim()}"`
|
||||||
|
})
|
||||||
|
if (existing.items.length > 0) {
|
||||||
|
showFailToast('该群组名称已存在')
|
||||||
|
createLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const group = await createGroup({
|
||||||
|
name: createForm.value.name.trim(),
|
||||||
|
description: createForm.value.description.trim(),
|
||||||
|
maxMembers: createForm.value.maxMembers
|
||||||
|
})
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
showCreate.value = false
|
||||||
|
createForm.value = { name: '', description: '', maxMembers: 20 }
|
||||||
|
showSuccessToast('创建成功')
|
||||||
|
if (group?.id) {
|
||||||
|
router.push({ name: 'GroupView', params: { id: group.id } })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
showFailToast(error.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入群组(搜索)
|
||||||
|
const showJoin = ref(false)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchResults = ref<Group[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
if (!searchKeyword.value.trim()) {
|
||||||
|
searchResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
searchResults.value = await searchGroups(searchKeyword.value.trim())
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoin(groupId: string) {
|
||||||
|
try {
|
||||||
|
await joinGroup(groupId)
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
showSuccessToast('加入成功')
|
||||||
|
showJoin.value = false
|
||||||
|
searchKeyword.value = ''
|
||||||
|
searchResults.value = []
|
||||||
|
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||||
|
} catch (error: any) {
|
||||||
|
showFailToast(error.message || '加入失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(groupId: string) {
|
||||||
|
groupStore.setCurrentGroup(groupId)
|
||||||
|
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
async function onRefresh() {
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
showSuccessToast('刷新成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshing = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="groups-mobile">
|
||||||
|
<!-- 浮动操作按钮 -->
|
||||||
|
<div class="fab-row">
|
||||||
|
<van-button type="primary" round icon="plus" size="small" @click="showCreate = true">
|
||||||
|
创建
|
||||||
|
</van-button>
|
||||||
|
<van-button type="default" round icon="search" size="small" @click="showJoin = true">
|
||||||
|
加入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
|
<div v-if="groupStore.groups.length === 0" class="empty-state">
|
||||||
|
<van-empty description="还没有群组">
|
||||||
|
<van-button type="primary" round size="small" @click="showCreate = true">
|
||||||
|
创建第一个群组
|
||||||
|
</van-button>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="group-list">
|
||||||
|
<div
|
||||||
|
v-for="group in groupStore.groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="group-card"
|
||||||
|
@click="selectGroup(group.id)"
|
||||||
|
>
|
||||||
|
<div class="group-main">
|
||||||
|
<div class="group-name">{{ group.name }}</div>
|
||||||
|
<div class="group-desc">{{ group.description || '暂无简介' }}</div>
|
||||||
|
<div class="group-footer">
|
||||||
|
<van-tag type="success" size="medium">{{ group.members?.length || 0 }} 人</van-tag>
|
||||||
|
<span class="group-capacity">/{{ group.maxMembers || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-icon name="arrow" class="group-arrow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<!-- 创建群组弹层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="showCreate"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
>
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">创建群组</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field
|
||||||
|
v-model="createForm.name"
|
||||||
|
label="名称"
|
||||||
|
placeholder="给群组起个名字"
|
||||||
|
maxlength="50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="createForm.description"
|
||||||
|
type="textarea"
|
||||||
|
label="描述"
|
||||||
|
placeholder="简单介绍这个群组"
|
||||||
|
rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
<van-field name="stepper" label="最大成员数">
|
||||||
|
<template #input>
|
||||||
|
<van-stepper v-model="createForm.maxMembers" min="2" max="100" />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
|
||||||
|
创建
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 加入群组弹层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="showJoin"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
>
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">加入群组</div>
|
||||||
|
<van-search
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索群组名称"
|
||||||
|
shape="round"
|
||||||
|
@search="doSearch"
|
||||||
|
@clear="searchResults = []"
|
||||||
|
/>
|
||||||
|
<div v-if="searching" class="search-loading">
|
||||||
|
<van-loading size="24px">搜索中...</van-loading>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="searchResults.length === 0 && searchKeyword" class="search-empty">
|
||||||
|
<van-empty description="未找到群组" image-size="80" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-list">
|
||||||
|
<div
|
||||||
|
v-for="group in searchResults"
|
||||||
|
:key="group.id"
|
||||||
|
class="search-item"
|
||||||
|
>
|
||||||
|
<div class="search-info">
|
||||||
|
<div class="search-name">{{ group.name }}</div>
|
||||||
|
<div class="search-desc">{{ group.description || '暂无简介' }}</div>
|
||||||
|
<div class="search-meta">{{ group.members?.length || 0 }}/{{ group.maxMembers }} 人</div>
|
||||||
|
</div>
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
@click="handleJoin(group.id)"
|
||||||
|
>
|
||||||
|
加入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.groups-mobile {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list {
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-capacity {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-arrow {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
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;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-loading, .search-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-list {
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 2px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
<!-- src/views-mobile/HomeMobile.vue -->
|
||||||
|
<!-- 手机端首页:状态 + 当前组队 + 我的群组 + 热门游戏 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useTeamStore } from '@/stores/team'
|
||||||
|
import { getPopularGames } from '@/api/games'
|
||||||
|
import type { Game } from '@/types'
|
||||||
|
import { UserStatusMap } from '@/types'
|
||||||
|
import { showSuccessToast } from 'vant'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const teamStore = useTeamStore()
|
||||||
|
|
||||||
|
const popularGames = ref<Game[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
|
||||||
|
|
||||||
|
const hasNoGroup = computed(() => groupStore.groups.length === 0)
|
||||||
|
const hasSession = computed(() => !!teamStore.currentSession)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPopularGames()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPopularGames() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
popularGames.value = await getPopularGames(10)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载热门游戏失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(groupId: string) {
|
||||||
|
groupStore.setCurrentGroup(groupId)
|
||||||
|
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态切换选项
|
||||||
|
const statusActions = [
|
||||||
|
{ status: 'idle' as const, label: '空闲', color: 'var(--gg-success)' },
|
||||||
|
{ status: 'away' as const, label: '离开', color: 'var(--gg-text-muted)' },
|
||||||
|
{ status: 'working' as const, label: '工作中', color: 'var(--gg-danger)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showStatusSheet = ref(false)
|
||||||
|
|
||||||
|
async function onStatusSelect(action: { status: any; label: string }) {
|
||||||
|
showStatusSheet.value = false
|
||||||
|
try {
|
||||||
|
await userStore.setStatus(action.status)
|
||||||
|
showSuccessToast(`已切换为${action.label}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCreateGroup() {
|
||||||
|
router.push('/mobile-groups')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSession() {
|
||||||
|
const session = teamStore.currentSession
|
||||||
|
if (session?.voiceRoom) {
|
||||||
|
router.push({
|
||||||
|
name: 'VoiceRoom',
|
||||||
|
params: { groupId: session.sourceGroup, sessionId: session.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home-mobile">
|
||||||
|
<!-- 用户状态卡片 -->
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-info" @click="showStatusSheet = true">
|
||||||
|
<img
|
||||||
|
:src="userStore.user?.avatar || '/default-avatar.svg'"
|
||||||
|
class="avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="status-text">
|
||||||
|
<div class="user-name">{{ userStore.user?.name || userStore.user?.username }}</div>
|
||||||
|
<div class="user-status">
|
||||||
|
<span class="status-dot" :class="`dot-${userStore.userStatus}`" />
|
||||||
|
<span>{{ statusText }}</span>
|
||||||
|
<span v-if="userStore.user?.statusNote" class="status-note">
|
||||||
|
· {{ userStore.user.statusNote }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-icon name="arrow-down" class="status-arrow" />
|
||||||
|
</div>
|
||||||
|
<div class="quick-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-num">{{ userStore.user?.points ?? 0 }}</span>
|
||||||
|
<span class="stat-label">积分</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-num">{{ groupStore.groups.length }}</span>
|
||||||
|
<span class="stat-label">群组</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前组队卡片 -->
|
||||||
|
<div v-if="hasSession" class="session-card" @click="goSession">
|
||||||
|
<div class="session-header">
|
||||||
|
<van-icon name="volume-o" class="session-icon" />
|
||||||
|
<span class="session-title">当前组队</span>
|
||||||
|
<van-tag type="success" size="medium">{{ teamStore.teamStatus }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="session-body">
|
||||||
|
<div class="session-game">{{ teamStore.currentSession?.gameName }}</div>
|
||||||
|
<div class="session-members">
|
||||||
|
{{ teamStore.currentSession?.name }}
|
||||||
|
· {{ teamStore.currentSession?.members?.length || 0 }} 人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-action">
|
||||||
|
进入语音房 <van-icon name="arrow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我的群组 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">我的群组</span>
|
||||||
|
<span v-if="!hasNoGroup" class="section-more" @click="goCreateGroup">全部</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasNoGroup" class="empty-card">
|
||||||
|
<van-empty description="还没有群组" image-size="80">
|
||||||
|
<van-button type="primary" size="small" round @click="goCreateGroup">
|
||||||
|
创建或加入群组
|
||||||
|
</van-button>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="group-scroll">
|
||||||
|
<div
|
||||||
|
v-for="group in groupStore.groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="group-card"
|
||||||
|
@click="selectGroup(group.id)"
|
||||||
|
>
|
||||||
|
<div class="group-name">{{ group.name }}</div>
|
||||||
|
<div class="group-meta">{{ group.members?.length || 0 }} 人</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热门游戏 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">热门游戏</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-row">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="popularGames.length === 0" class="empty-row">
|
||||||
|
<span class="empty-text">暂无热门游戏</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="games-scroll">
|
||||||
|
<div
|
||||||
|
v-for="game in popularGames"
|
||||||
|
:key="game.id"
|
||||||
|
class="game-card"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="game.cover || '/game-placeholder.svg'"
|
||||||
|
:alt="game.name"
|
||||||
|
class="game-cover"
|
||||||
|
/>
|
||||||
|
<div class="game-name">{{ game.name }}</div>
|
||||||
|
<van-tag v-if="game.platform" plain size="medium">{{ game.platform }}</van-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态切换 ActionSheet -->
|
||||||
|
<van-action-sheet v-model:show="showStatusSheet" title="切换状态">
|
||||||
|
<div class="status-sheet">
|
||||||
|
<div
|
||||||
|
v-for="a in statusActions"
|
||||||
|
:key="a.status"
|
||||||
|
class="status-sheet-item"
|
||||||
|
@click="onStatusSelect(a)"
|
||||||
|
>
|
||||||
|
<span class="status-sheet-dot" :style="{ background: a.color }" />
|
||||||
|
<span class="status-sheet-label">{{ a.label }}</span>
|
||||||
|
<van-icon v-if="userStore.userStatus === a.status" name="success" class="status-sheet-check" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-action-sheet>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-mobile {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态卡片 */
|
||||||
|
.status-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-idle { background: var(--gg-success); }
|
||||||
|
.dot-in_team { background: var(--gg-info); }
|
||||||
|
.dot-working { background: var(--gg-danger); }
|
||||||
|
.dot-away { background: var(--gg-text-muted); }
|
||||||
|
|
||||||
|
/* 状态切换面板 */
|
||||||
|
.status-sheet { padding: 8px 0 calc(16px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.status-sheet-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
.status-sheet-item:active { background: var(--gg-bg-elevated); }
|
||||||
|
.status-sheet-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.status-sheet-label { flex: 1; }
|
||||||
|
.status-sheet-check { color: var(--gg-primary); font-size: 18px; }
|
||||||
|
|
||||||
|
.status-note {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-arrow {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组队卡片 */
|
||||||
|
.session-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-body {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-game {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-members {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用 section */
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-more {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row, .empty-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 群组横滑 */
|
||||||
|
.group-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 130px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 游戏横滑 */
|
||||||
|
.games-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover {
|
||||||
|
width: 100px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<!-- src/views-mobile/JoinGroupPageMobile.vue -->
|
||||||
|
<!-- 手机端群组邀请落地页 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb, isAuthenticated } from '@/api/pocketbase'
|
||||||
|
import type { Group } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const group = ref<Group | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const joining = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const groupId = route.params.groupId as string
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
group.value = await getGroup(groupId)
|
||||||
|
} catch {
|
||||||
|
error.value = '群组不存在或已解散'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => isAuthenticated())
|
||||||
|
|
||||||
|
const isMember = computed(() => {
|
||||||
|
if (!group.value) return false
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId) return false
|
||||||
|
return group.value.members?.includes(userId) || false
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToGroup() {
|
||||||
|
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
goToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joining.value = true
|
||||||
|
try {
|
||||||
|
if (group.value?.requireApproval) {
|
||||||
|
await createJoinRequest(groupId)
|
||||||
|
showSuccessToast('已提交加入申请,等待审核')
|
||||||
|
} else {
|
||||||
|
await joinGroup(groupId)
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
showSuccessToast('已成功加入群组')
|
||||||
|
goToGroup()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '加入失败')
|
||||||
|
} finally {
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="join-page">
|
||||||
|
<div class="join-card">
|
||||||
|
<div class="join-header">
|
||||||
|
<div class="header-icon"><MobileIcon name="link" :size="40" color="var(--gg-primary)" /></div>
|
||||||
|
<h1>群组邀请</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<van-icon name="warning-o" size="48" />
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<van-button block round @click="router.push('/')">返回首页</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="group" class="group-info">
|
||||||
|
<h2 class="group-name">{{ group.name }}</h2>
|
||||||
|
<p v-if="group.description" class="group-desc">{{ group.description }}</p>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-item">
|
||||||
|
<van-icon name="friends-o" /> {{ group.members?.length || 0 }} / {{ group.maxMembers }} 人
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<van-tag v-if="group.requireApproval" type="warning" size="large" round class="approval-tag">需审核</van-tag>
|
||||||
|
<van-tag v-else type="success" size="large" round class="approval-tag">可直接加入</van-tag>
|
||||||
|
|
||||||
|
<div v-if="isMember" class="action-block">
|
||||||
|
<p class="tip">你已经是该群组的成员</p>
|
||||||
|
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isLoggedIn" class="action-block">
|
||||||
|
<p class="tip">登录后即可加入群组</p>
|
||||||
|
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="action-block">
|
||||||
|
<p class="tip">{{ group.requireApproval ? '加入该群组需要群主审核' : '点击即可加入群组' }}</p>
|
||||||
|
<van-button type="primary" block round :loading="joining" @click="handleJoin">
|
||||||
|
{{ group.requireApproval ? '申请加入' : '立即加入' }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.join-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-lg);
|
||||||
|
padding: 32px 24px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
.error-state p { margin: 12px 0 16px; font-size: 14px; }
|
||||||
|
.error-state .van-button { max-width: 200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.group-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-tag { margin: 4px 0 12px; }
|
||||||
|
|
||||||
|
.action-block {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<!-- src/views-mobile/JoinTeamPageMobile.vue -->
|
||||||
|
<!-- 手机端组队邀请落地页 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { pb, isAuthenticated } from '@/api/pocketbase'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { joinTeamSession, getTeamSession } from '@/api/sessions'
|
||||||
|
import type { TeamSession } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const session = ref<TeamSession | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const joining = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const sessionId = route.params.sessionId as string
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
}
|
||||||
|
session.value = await getTeamSession(sessionId)
|
||||||
|
} catch {
|
||||||
|
error.value = '小队不存在或已解散'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => isAuthenticated())
|
||||||
|
|
||||||
|
const isInTeam = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId || !session.value) return false
|
||||||
|
return session.value.members?.includes(userId) || false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isInSourceGroup = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId || !session.value) return false
|
||||||
|
return groupStore.groups.some(g => g.id === session.value?.sourceGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToGroup() {
|
||||||
|
const gid = session.value?.sourceGroup
|
||||||
|
if (gid) router.push({ name: 'GroupView', params: { id: gid } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
goToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joining.value = true
|
||||||
|
try {
|
||||||
|
await joinTeamSession(sessionId)
|
||||||
|
showSuccessToast('已成功加入小队')
|
||||||
|
goToGroup()
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '加入失败')
|
||||||
|
} finally {
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="join-page">
|
||||||
|
<div class="join-card">
|
||||||
|
<div class="join-header">
|
||||||
|
<div class="header-icon"><MobileIcon name="game" :size="40" color="var(--gg-primary)" /></div>
|
||||||
|
<h1>组队邀请</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<van-icon name="warning-o" size="48" />
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<van-button block round @click="router.push('/')">返回首页</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="session" class="team-info">
|
||||||
|
<h2 class="team-game">{{ session.gameName }}</h2>
|
||||||
|
<p class="team-name">{{ session.name }}</p>
|
||||||
|
<div class="meta-row">
|
||||||
|
<van-icon name="friends-o" />
|
||||||
|
<span>{{ session.members?.length || 0 }} 人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已在队中 -->
|
||||||
|
<div v-if="isInTeam" class="action-block">
|
||||||
|
<p class="tip">你已在该小队中</p>
|
||||||
|
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未登录 -->
|
||||||
|
<div v-else-if="!isLoggedIn" class="action-block">
|
||||||
|
<p class="tip">登录后即可加入小队</p>
|
||||||
|
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未在源群组 -->
|
||||||
|
<div v-else-if="!isInSourceGroup" class="action-block">
|
||||||
|
<p class="tip warn">需要先加入该小队所在的群组</p>
|
||||||
|
<van-button type="primary" block round @click="goToGroup">前往群组</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可加入 -->
|
||||||
|
<div v-else class="action-block">
|
||||||
|
<p class="tip">点击加入小队,一起开黑</p>
|
||||||
|
<van-button type="primary" block round :loading="joining" @click="handleJoin">
|
||||||
|
立即加入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.join-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-lg);
|
||||||
|
padding: 32px 24px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
.error-state p { margin: 12px 0 16px; font-size: 14px; }
|
||||||
|
.error-state .van-button { max-width: 200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.team-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-game {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-block {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip.warn {
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!-- src/views-mobile/LedgerMobile.vue -->
|
||||||
|
<!-- 手机端账本:月度汇总 + 列表 + 添加 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useLedgerStore } from '@/stores/ledger'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
||||||
|
import type { LedgerType, LedgerCategory } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const groupId = route.params.groupId as string
|
||||||
|
|
||||||
|
const ledgerStore = useLedgerStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const ledgers = computed(() => ledgerStore.ledgers)
|
||||||
|
const summary = computed(() => ledgerStore.summary)
|
||||||
|
|
||||||
|
const showMonthPicker = ref(false)
|
||||||
|
const currentMonth = ref(ledgerStore.currentMonth || new Date().toISOString().slice(0, 7))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await groupStore.setCurrentGroup(groupId)
|
||||||
|
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||||
|
await ledgerStore.startSubscription(groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
ledgerStore.stopSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function changeMonth(month: string) {
|
||||||
|
currentMonth.value = month
|
||||||
|
showMonthPicker.value = false
|
||||||
|
await ledgerStore.loadLedgers(groupId, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 月份选择器
|
||||||
|
const months = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const list = []
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
list.push({
|
||||||
|
value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
|
||||||
|
label: `${d.getFullYear()}年${d.getMonth() + 1}月`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加
|
||||||
|
const showAdd = ref(false)
|
||||||
|
const addForm = ref({
|
||||||
|
type: 'expense' as LedgerType,
|
||||||
|
amount: 0,
|
||||||
|
category: 'gaming' as LedgerCategory,
|
||||||
|
description: '',
|
||||||
|
occurredAt: new Date().toISOString().slice(0, 10)
|
||||||
|
})
|
||||||
|
const addLoading = ref(false)
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (addForm.value.amount <= 0) {
|
||||||
|
showFailToast('请输入金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addLoading.value = true
|
||||||
|
try {
|
||||||
|
await ledgerStore.addLedger({
|
||||||
|
group: groupId,
|
||||||
|
type: addForm.value.type,
|
||||||
|
amount: addForm.value.amount,
|
||||||
|
category: addForm.value.category,
|
||||||
|
description: addForm.value.description.trim(),
|
||||||
|
occurredAt: addForm.value.occurredAt
|
||||||
|
})
|
||||||
|
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||||
|
showSuccessToast('添加成功')
|
||||||
|
showAdd.value = false
|
||||||
|
addForm.value = { type: 'expense', amount: 0, category: 'gaming', description: '', occurredAt: new Date().toISOString().slice(0, 10) }
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
} finally {
|
||||||
|
addLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
await ledgerStore.removeLedger(id)
|
||||||
|
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, 'success' | 'danger' | 'warning' | 'primary' | 'default'> = { income: 'success', expense: 'danger' }
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ledger-mobile">
|
||||||
|
<!-- 月度汇总 -->
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="month-bar" @click="showMonthPicker = true">
|
||||||
|
<span class="month-label">{{ months.find(m => m.value === currentMonth)?.label || currentMonth }}</span>
|
||||||
|
<van-icon name="arrow-down" />
|
||||||
|
</div>
|
||||||
|
<div class="balance-row">
|
||||||
|
<div class="balance-item">
|
||||||
|
<div class="balance-label">收入</div>
|
||||||
|
<div class="balance-num income">{{ summary.totalIncome.toFixed(2) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider" />
|
||||||
|
<div class="balance-item">
|
||||||
|
<div class="balance-label">支出</div>
|
||||||
|
<div class="balance-num expense">{{ summary.totalExpense.toFixed(2) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider" />
|
||||||
|
<div class="balance-item">
|
||||||
|
<div class="balance-label">结余</div>
|
||||||
|
<div class="balance-num" :class="summary.balance >= 0 ? 'income' : 'expense'">
|
||||||
|
{{ summary.balance.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div v-if="ledgers.length === 0" class="empty">
|
||||||
|
<van-empty description="本月暂无记录" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="ledger-list">
|
||||||
|
<van-swipe-cell v-for="l in ledgers" :key="l.id">
|
||||||
|
<div class="ledger-item">
|
||||||
|
<div class="ledger-left">
|
||||||
|
<van-tag :type="typeColors[l.type]" size="medium">{{ LedgerTypeMap[l.type] }}</van-tag>
|
||||||
|
<div class="ledger-info">
|
||||||
|
<div class="ledger-desc">{{ l.description || LedgerCategoryMap[l.category] }}</div>
|
||||||
|
<div class="ledger-meta">
|
||||||
|
{{ LedgerCategoryMap[l.category] }} · {{ formatDate(l.occurredAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-amount" :class="l.type">
|
||||||
|
{{ l.type === 'income' ? '+' : '-' }}{{ l.amount.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #right>
|
||||||
|
<van-button square type="danger" text="删除" class="delete-btn" @click="handleDelete(l.id)" />
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 浮动添加按钮 -->
|
||||||
|
<div class="fab" @click="showAdd = true">
|
||||||
|
<van-icon name="plus" size="24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 月份选择 -->
|
||||||
|
<van-action-sheet v-model:show="showMonthPicker" title="选择月份">
|
||||||
|
<div class="month-list">
|
||||||
|
<div
|
||||||
|
v-for="m in months"
|
||||||
|
:key="m.value"
|
||||||
|
class="month-option"
|
||||||
|
:class="{ active: m.value === currentMonth }"
|
||||||
|
@click="changeMonth(m.value)"
|
||||||
|
>
|
||||||
|
{{ m.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-action-sheet>
|
||||||
|
|
||||||
|
<!-- 添加弹层 -->
|
||||||
|
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="popup-title">添加账目</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field name="radio" label="类型">
|
||||||
|
<template #input>
|
||||||
|
<van-radio-group v-model="addForm.type" direction="horizontal">
|
||||||
|
<van-radio name="expense">支出</van-radio>
|
||||||
|
<van-radio name="income">收入</van-radio>
|
||||||
|
</van-radio-group>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field name="stepper" label="金额">
|
||||||
|
<template #input>
|
||||||
|
<van-stepper v-model="addForm.amount" min="0" step="0.01" allow-empty />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field name="radio" label="分类">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="addForm.category" class="category-select">
|
||||||
|
<option v-for="(label, key) in LedgerCategoryMap" :key="key" :value="key">{{ label }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field v-model="addForm.description" label="备注" placeholder="可选" />
|
||||||
|
<van-field v-model="addForm.occurredAt" label="日期" placeholder="YYYY-MM-DD" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-mobile { padding: 12px; min-height: 60vh; }
|
||||||
|
|
||||||
|
.summary-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 16px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
|
||||||
|
.month-bar { display: flex; align-items: center; gap: 4px; font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 14px; }
|
||||||
|
.balance-row { display: flex; align-items: center; }
|
||||||
|
.balance-item { flex: 1; text-align: center; }
|
||||||
|
.balance-label { font-size: 12px; color: var(--gg-text-muted); }
|
||||||
|
.balance-num { font-size: 18px; font-weight: 700; margin-top: 4px; }
|
||||||
|
.balance-num.income { color: var(--gg-success); }
|
||||||
|
.balance-num.expense { color: var(--gg-danger); }
|
||||||
|
.balance-divider { width: 1px; height: 32px; background: var(--gg-border); }
|
||||||
|
|
||||||
|
.empty { padding: 30px 0; }
|
||||||
|
|
||||||
|
.ledger-list { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.ledger-item { display: flex; align-items: center; justify-content: space-between; background: var(--gg-bg-card); padding: 12px 14px; }
|
||||||
|
.ledger-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||||
|
.ledger-info { min-width: 0; }
|
||||||
|
.ledger-desc { font-size: 14px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ledger-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
|
||||||
|
.ledger-amount { font-size: 15px; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.ledger-amount.income { color: var(--gg-success); }
|
||||||
|
.ledger-amount.expense { color: var(--gg-danger); }
|
||||||
|
.delete-btn { height: 100%; }
|
||||||
|
|
||||||
|
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
|
||||||
|
.fab:active { transform: scale(0.92); }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.category-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||||
|
.popup-actions { padding: 20px 16px 0; }
|
||||||
|
|
||||||
|
.month-list { padding: 8px 0; }
|
||||||
|
.month-option { padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
|
||||||
|
.month-option.active { color: var(--gg-primary); font-weight: 600; background: rgba(5,150,105,0.06); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!-- src/views-mobile/LoginMobile.vue -->
|
||||||
|
<!-- 手机端登录页 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { showFailToast } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const identity = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!identity.value || !password.value) {
|
||||||
|
showFailToast('请输入昵称/邮箱和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
let loginIdentity = identity.value.trim()
|
||||||
|
|
||||||
|
// 与桌面端一致:不含 @ 时按昵称或用户名查找对应 username
|
||||||
|
if (!loginIdentity.includes('@')) {
|
||||||
|
const result = await pb.collection('users').getList(1, 1, {
|
||||||
|
filter: `name="${loginIdentity}" || username="${loginIdentity}"`,
|
||||||
|
$autoCancel: false
|
||||||
|
})
|
||||||
|
if (result.items.length === 0) {
|
||||||
|
showFailToast('用户不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginIdentity = (result.items[0] as any).username
|
||||||
|
}
|
||||||
|
|
||||||
|
await userStore.login(loginIdentity, password.value)
|
||||||
|
const redirect = (route.query.redirect as string) || '/'
|
||||||
|
router.replace(redirect)
|
||||||
|
} catch (error: any) {
|
||||||
|
showFailToast(error.message || '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-mobile">
|
||||||
|
<div class="brand-area">
|
||||||
|
<div class="brand-icon"><MobileIcon name="logo" :size="56" color="var(--gg-primary)" /></div>
|
||||||
|
<h1 class="brand-title">Game Group</h1>
|
||||||
|
<p class="brand-subtitle">组队开黑,一起嗨</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-area">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field
|
||||||
|
v-model="identity"
|
||||||
|
label="账号"
|
||||||
|
placeholder="昵称 / 邮箱"
|
||||||
|
left-icon="contact"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
label="密码"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
left-icon="lock"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div class="submit-area">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
:loading="loading"
|
||||||
|
loading-text="登录中..."
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-links">
|
||||||
|
还没有账号?
|
||||||
|
<router-link :to="{ name: 'Register', query: route.query }" class="link">
|
||||||
|
立即注册
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-mobile {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: linear-gradient(160deg, #ecfdf5 0%, #f0fdf4 50%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-area {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-area {
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-area {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<!-- src/views-mobile/NotificationsMobile.vue -->
|
||||||
|
<!-- 手机端通知页:站内通知 + 邀请/入群申请 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
|
import { respondInvitation } from '@/api/invitations'
|
||||||
|
import { respondJoinRequest } from '@/api/groups'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import type { AppNotification } from '@/types'
|
||||||
|
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'notifications' | 'invitations'>('invitations')
|
||||||
|
|
||||||
|
const notifications = computed(() => notificationStore.appNotifications)
|
||||||
|
const invitations = computed(() => notificationStore.pendingInvitations)
|
||||||
|
const joinRequests = computed(() => notificationStore.pendingJoinRequests)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await notificationStore.loadAppNotifications()
|
||||||
|
await notificationStore.loadPendingInvitations()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 通知操作
|
||||||
|
async function onRead(n: AppNotification) {
|
||||||
|
try {
|
||||||
|
await notificationStore.markRead(n.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReadAll() {
|
||||||
|
try {
|
||||||
|
await notificationStore.markAllRead()
|
||||||
|
showSuccessToast('全部已读')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(n: AppNotification) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除通知',
|
||||||
|
message: '确定删除这条通知吗?'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await notificationStore.removeNotification(n.id)
|
||||||
|
showSuccessToast('已删除')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast('删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知类型图标/颜色
|
||||||
|
function notifyIcon(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
poll_new: 'chart-trending-o',
|
||||||
|
poll_deadline: 'clock-o',
|
||||||
|
poll_result: 'certificate',
|
||||||
|
team_invite: 'volume-o',
|
||||||
|
team_starting: 'fire-o',
|
||||||
|
join_request: 'add-o',
|
||||||
|
member_joined: 'user-o'
|
||||||
|
}
|
||||||
|
return map[type] || 'bell'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请操作
|
||||||
|
const invitationLoading = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleInvitation(invitationId: string, response: 'accepted' | 'rejected') {
|
||||||
|
invitationLoading.value = invitationId
|
||||||
|
try {
|
||||||
|
await respondInvitation(invitationId, response)
|
||||||
|
notificationStore.removeInvitation(invitationId)
|
||||||
|
showSuccessToast(response === 'accepted' ? '已接受' : '已拒绝')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
invitationLoading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 入群申请操作
|
||||||
|
const requestLoading = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleJoinRequest(requestId: string, status: 'approved' | 'rejected') {
|
||||||
|
requestLoading.value = requestId
|
||||||
|
try {
|
||||||
|
await respondJoinRequest(requestId, status)
|
||||||
|
notificationStore.removeJoinRequest(requestId)
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
requestLoading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间格式化
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const min = Math.floor(diff / 60000)
|
||||||
|
if (min < 1) return '刚刚'
|
||||||
|
if (min < 60) return `${min}分钟前`
|
||||||
|
const hour = Math.floor(min / 60)
|
||||||
|
if (hour < 24) return `${hour}小时前`
|
||||||
|
const day = Math.floor(hour / 24)
|
||||||
|
if (day < 30) return `${day}天前`
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notifications-mobile">
|
||||||
|
<van-tabs v-model:active="activeTab" sticky>
|
||||||
|
<van-tab title="邀请/申请" name="invitations">
|
||||||
|
<div v-if="invitations.length === 0 && joinRequests.length === 0" class="empty-state">
|
||||||
|
<van-empty description="暂无待处理邀请" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 组队邀请 -->
|
||||||
|
<div v-if="invitations.length > 0" class="section-block">
|
||||||
|
<div class="block-title">组队邀请</div>
|
||||||
|
<div
|
||||||
|
v-for="inv in invitations"
|
||||||
|
:key="inv.id"
|
||||||
|
class="invite-card"
|
||||||
|
>
|
||||||
|
<div class="invite-info">
|
||||||
|
<img
|
||||||
|
:src="inv.expand?.from?.avatar || '/default-avatar.svg'"
|
||||||
|
class="invite-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="invite-text">
|
||||||
|
<div class="invite-from">{{ inv.expand?.from?.name || inv.expand?.from?.username || '未知' }}</div>
|
||||||
|
<div class="invite-detail">
|
||||||
|
邀请你组队:{{ inv.expand?.teamSession?.gameName || '游戏' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invite-actions">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:loading="invitationLoading === inv.id"
|
||||||
|
@click="handleInvitation(inv.id, 'accepted')"
|
||||||
|
>
|
||||||
|
接受
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:loading="invitationLoading === inv.id"
|
||||||
|
@click="handleInvitation(inv.id, 'rejected')"
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 入群申请 -->
|
||||||
|
<div v-if="joinRequests.length > 0" class="section-block">
|
||||||
|
<div class="block-title">入群申请</div>
|
||||||
|
<div
|
||||||
|
v-for="req in joinRequests"
|
||||||
|
:key="req.id"
|
||||||
|
class="invite-card"
|
||||||
|
>
|
||||||
|
<div class="invite-info">
|
||||||
|
<img
|
||||||
|
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
|
||||||
|
class="invite-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="invite-text">
|
||||||
|
<div class="invite-from">{{ req.expand?.user?.name || req.expand?.user?.username || '未知' }}</div>
|
||||||
|
<div class="invite-detail">
|
||||||
|
申请加入:{{ req.expand?.group?.name || '群组' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invite-actions">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:loading="requestLoading === req.id"
|
||||||
|
@click="handleJoinRequest(req.id, 'approved')"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:loading="requestLoading === req.id"
|
||||||
|
@click="handleJoinRequest(req.id, 'rejected')"
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab title="通知" name="notifications">
|
||||||
|
<div v-if="notifications.length > 0" class="read-all-bar">
|
||||||
|
<van-button plain hairline size="small" round icon="success" @click="onReadAll">
|
||||||
|
全部标为已读
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notifications.length === 0" class="empty-state">
|
||||||
|
<van-empty description="暂无通知" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="notify-list">
|
||||||
|
<van-swipe-cell
|
||||||
|
v-for="n in notifications"
|
||||||
|
:key="n.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="notify-item"
|
||||||
|
:class="{ 'notify-unread': !n.read }"
|
||||||
|
@click="!n.read && onRead(n)"
|
||||||
|
>
|
||||||
|
<van-icon :name="notifyIcon(n.type)" class="notify-icon" size="22" />
|
||||||
|
<div class="notify-body">
|
||||||
|
<div class="notify-title">{{ n.title }}</div>
|
||||||
|
<div v-if="n.content" class="notify-content">{{ n.content }}</div>
|
||||||
|
<div class="notify-time">{{ timeAgo(n.created) }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="!n.read" class="unread-dot" />
|
||||||
|
</div>
|
||||||
|
<template #right>
|
||||||
|
<van-button square type="danger" text="删除" class="delete-btn" @click="onDelete(n)" />
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notifications-mobile {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-all-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-from {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-detail {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知列表 */
|
||||||
|
.notify-list {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-unread {
|
||||||
|
background: rgba(5, 150, 105, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-icon {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<!-- src/views-mobile/ProfileMobile.vue -->
|
||||||
|
<!-- 手机端个人中心 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { UserStatusMap } from '@/types'
|
||||||
|
import type { UserStatus } from '@/types'
|
||||||
|
import { resetDeviceMode, setDeviceMode } from '@/mobile/useDevice'
|
||||||
|
import { showSuccessToast, showConfirmDialog } from 'vant'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const user = computed(() => userStore.user)
|
||||||
|
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
|
||||||
|
|
||||||
|
const showStatusSheet = ref(false)
|
||||||
|
const statusActions = [
|
||||||
|
{ status: 'idle' as UserStatus, label: '空闲', color: 'var(--gg-success)' },
|
||||||
|
{ status: 'away' as UserStatus, label: '离开', color: 'var(--gg-text-muted)' },
|
||||||
|
{ status: 'working' as UserStatus, label: '工作中', color: 'var(--gg-danger)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function onStatusSelect(action: any) {
|
||||||
|
showStatusSheet.value = false
|
||||||
|
try {
|
||||||
|
await userStore.setStatus(action.status)
|
||||||
|
showSuccessToast('已切换')
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goGroups() {
|
||||||
|
router.push('/mobile-groups')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goChangelog() {
|
||||||
|
router.push('/changelog')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSettings() {
|
||||||
|
router.push('/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换桌面版
|
||||||
|
function switchToDesktop() {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '切换到桌面版',
|
||||||
|
message: '切换后页面将以桌面版显示,确定吗?'
|
||||||
|
}).then(() => {
|
||||||
|
setDeviceMode('desktop')
|
||||||
|
location.reload()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
showConfirmDialog({ title: '退出登录', message: '确定退出吗?' })
|
||||||
|
.then(() => {
|
||||||
|
resetDeviceMode()
|
||||||
|
userStore.logout()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="profile-mobile">
|
||||||
|
<!-- 用户头部 -->
|
||||||
|
<div class="user-header">
|
||||||
|
<img :src="user?.avatar || '/default-avatar.svg'" class="user-avatar" alt="" />
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">{{ user?.name || user?.username }}</div>
|
||||||
|
<div class="user-id">@{{ user?.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据概览 -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">{{ user?.points ?? 0 }}</div>
|
||||||
|
<div class="stat-label">积分</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" @click="goGroups">
|
||||||
|
<div class="stat-num">{{ groupStore.groups.length }}</div>
|
||||||
|
<div class="stat-label">群组</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态切换 -->
|
||||||
|
<van-cell-group inset title="我的状态">
|
||||||
|
<van-cell title="当前状态" :value="statusText" is-link @click="showStatusSheet = true" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 功能入口 -->
|
||||||
|
<van-cell-group inset title="更多">
|
||||||
|
<van-cell title="我的群组" icon="friends-o" is-link @click="goGroups" />
|
||||||
|
<van-cell title="更新日志" icon="notes-o" is-link @click="goChangelog" />
|
||||||
|
<van-cell title="设置" icon="setting-o" is-link @click="goSettings" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 视图切换 -->
|
||||||
|
<van-cell-group inset title="视图">
|
||||||
|
<van-cell title="切换到桌面版" icon="desktop-o" is-link @click="switchToDesktop" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 退出 -->
|
||||||
|
<div class="logout-area">
|
||||||
|
<van-button type="danger" block round plain @click="handleLogout">退出登录</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态选择 -->
|
||||||
|
<van-action-sheet v-model:show="showStatusSheet" title="切换状态">
|
||||||
|
<div class="status-sheet">
|
||||||
|
<div
|
||||||
|
v-for="a in statusActions"
|
||||||
|
:key="a.status"
|
||||||
|
class="status-sheet-item"
|
||||||
|
@click="onStatusSelect(a)"
|
||||||
|
>
|
||||||
|
<span class="status-sheet-dot" :style="{ background: a.color }" />
|
||||||
|
<span class="status-sheet-label">{{ a.label }}</span>
|
||||||
|
<van-icon v-if="userStore.userStatus === a.status" name="success" class="status-sheet-check" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-action-sheet>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-mobile { padding: 12px 0 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.user-header { display: flex; align-items: center; gap: 14px; padding: 20px 16px; background: var(--gg-bg-card); margin: 0 12px; border-radius: var(--gg-radius-lg); box-shadow: var(--gg-shadow); }
|
||||||
|
.user-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid var(--gg-border); }
|
||||||
|
.user-info { min-width: 0; }
|
||||||
|
.user-name { font-size: 18px; font-weight: 700; color: var(--gg-text); }
|
||||||
|
.user-id { font-size: 13px; color: var(--gg-text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.stats-row { display: flex; gap: 12px; padding: 0 12px; }
|
||||||
|
.stat-item { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; text-align: center; box-shadow: var(--gg-shadow); }
|
||||||
|
.stat-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
|
||||||
|
.stat-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.logout-area { padding: 8px 28px; }
|
||||||
|
|
||||||
|
/* 状态切换面板 */
|
||||||
|
.status-sheet { padding: 8px 0 calc(16px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.status-sheet-item { display: flex; align-items: center; gap: 12px; padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
|
||||||
|
.status-sheet-item:active { background: var(--gg-bg-elevated); }
|
||||||
|
.status-sheet-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.status-sheet-label { flex: 1; }
|
||||||
|
.status-sheet-check { color: var(--gg-primary); font-size: 18px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<!-- src/views-mobile/RegisterMobile.vue -->
|
||||||
|
<!-- 手机端注册页 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { showFailToast, showSuccessToast } from 'vant'
|
||||||
|
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const passwordConfirm = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 自动生成 username(与桌面端 Register 一致:'u' + 时间戳base36 + 随机)
|
||||||
|
const generatedUsername = computed(() => {
|
||||||
|
return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
showFailToast('请输入昵称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value || password.value.length < 6) {
|
||||||
|
showFailToast('密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== passwordConfirm.value) {
|
||||||
|
showFailToast('两次密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const username = generatedUsername.value
|
||||||
|
// 注册用 email 占位(与桌面端一致,username 为系统标识,email 用 username@local 模拟)
|
||||||
|
await userStore.register({
|
||||||
|
email: `${username}@gamegroup.local`,
|
||||||
|
password: password.value,
|
||||||
|
passwordConfirm: passwordConfirm.value,
|
||||||
|
username,
|
||||||
|
name: name.value.trim()
|
||||||
|
})
|
||||||
|
showSuccessToast('注册成功')
|
||||||
|
const redirect = (route.query.redirect as string) || '/'
|
||||||
|
router.replace(redirect)
|
||||||
|
} catch (error: any) {
|
||||||
|
showFailToast(error.message || '注册失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="register-mobile">
|
||||||
|
<van-nav-bar title="注册" left-arrow @click-left="router.back()" />
|
||||||
|
|
||||||
|
<div class="form-area">
|
||||||
|
<div class="intro">
|
||||||
|
<div class="intro-icon"><MobileIcon name="logo" :size="48" color="var(--gg-primary)" /></div>
|
||||||
|
<h2 class="intro-title">创建账号</h2>
|
||||||
|
<p class="intro-desc">输入昵称开始你的组队之旅</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field
|
||||||
|
v-model="name"
|
||||||
|
label="昵称"
|
||||||
|
placeholder="请输入中文昵称"
|
||||||
|
left-icon="contact"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
label="密码"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
left-icon="lock"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="passwordConfirm"
|
||||||
|
type="password"
|
||||||
|
label="确认密码"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
left-icon="lock"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div class="submit-area">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
:loading="loading"
|
||||||
|
loading-text="注册中..."
|
||||||
|
@click="handleRegister"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-links">
|
||||||
|
已有账号?
|
||||||
|
<router-link :to="{ name: 'Login', query: route.query }" class="link">
|
||||||
|
去登录
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-mobile {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-area {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-area {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!-- src/views-mobile/SettingsMobile.vue -->
|
||||||
|
<!-- 手机端设置 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { setDeviceMode } from '@/mobile/useDevice'
|
||||||
|
import { showConfirmDialog, showSuccessToast } from 'vant'
|
||||||
|
|
||||||
|
const notifyEnabled = ref(true)
|
||||||
|
const soundEnabled = ref(true)
|
||||||
|
|
||||||
|
function onNotifyToggle(val: boolean) {
|
||||||
|
notifyEnabled.value = val
|
||||||
|
showSuccessToast(val ? '已开启通知' : '已关闭通知')
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToDesktop() {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '切换到桌面版',
|
||||||
|
message: '切换后页面将以桌面版显示,确定吗?'
|
||||||
|
}).then(() => {
|
||||||
|
setDeviceMode('desktop')
|
||||||
|
location.reload()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function about() {
|
||||||
|
showSuccessToast('Game Group V2')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-mobile">
|
||||||
|
<van-cell-group inset title="通知">
|
||||||
|
<van-cell title="站内通知" center>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-switch :model-value="notifyEnabled" size="22px" @update:model-value="onNotifyToggle" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="提示音" center>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-switch v-model="soundEnabled" size="22px" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="视图">
|
||||||
|
<van-cell title="切换到桌面版" icon="desktop-o" is-link @click="switchToDesktop" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="关于">
|
||||||
|
<van-cell title="版本" value="v2.0.0" />
|
||||||
|
<van-cell title="关于应用" is-link @click="about" />
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-mobile { padding: 12px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<!-- src/views-mobile/VoiceRoomMobile.vue -->
|
||||||
|
<!-- 手机端语音房:成员网格 + 控制条 + 占位提示(实际语音待 App) -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useTeamStore } from '@/stores/team'
|
||||||
|
import { getUser } from '@/api/users'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const teamStore = useTeamStore()
|
||||||
|
|
||||||
|
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 members = ref<User[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// UI 状态(实际未连接 WebRTC)
|
||||||
|
const micEnabled = ref(true)
|
||||||
|
const speakerEnabled = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
|
||||||
|
await teamStore.loadActiveSession()
|
||||||
|
}
|
||||||
|
// 加载成员详情
|
||||||
|
const s = teamStore.currentSession
|
||||||
|
if (s?.members?.length) {
|
||||||
|
try {
|
||||||
|
const users = await Promise.all(s.members.map(id => getUser(id).catch(() => null)))
|
||||||
|
members.value = users.filter(Boolean) as User[]
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleMic() {
|
||||||
|
micEnabled.value = !micEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSpeaker() {
|
||||||
|
speakerEnabled.value = !speakerEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLeave() {
|
||||||
|
router.replace(`/group/${groupId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id as string | undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="voice-room-mobile">
|
||||||
|
<!-- 顶部 -->
|
||||||
|
<div class="room-header">
|
||||||
|
<van-icon name="arrow-left" size="20" @click="handleLeave" />
|
||||||
|
<div class="room-title-area">
|
||||||
|
<div class="room-game">{{ gameName }}</div>
|
||||||
|
<div class="room-status">
|
||||||
|
<span class="status-dot online" />
|
||||||
|
<span>{{ members.length }} 人在线</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 占位提示 -->
|
||||||
|
<van-notice-bar
|
||||||
|
left-icon="info-o"
|
||||||
|
text="语音功能请在 App 中使用,当前为预览界面"
|
||||||
|
class="voice-notice"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 成员网格 -->
|
||||||
|
<div v-if="loading" class="loading-box">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="members-grid">
|
||||||
|
<div
|
||||||
|
v-for="m in members"
|
||||||
|
:key="m.id"
|
||||||
|
class="member-tile"
|
||||||
|
:class="{ 'is-me': m.id === currentUserId }"
|
||||||
|
>
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||||
|
<div class="mic-indicator" :class="{ off: !micEnabled && m.id === currentUserId }">
|
||||||
|
<van-icon :name="micEnabled && m.id === currentUserId ? 'volume-o' : 'volume'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-name">
|
||||||
|
{{ displayName(m) }}
|
||||||
|
<span v-if="m.id === currentUserId" class="me-tag">我</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="members.length === 0" class="empty-members">
|
||||||
|
<van-empty description="房间无人" image-size="80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部控制条 -->
|
||||||
|
<div class="control-bar">
|
||||||
|
<div class="control-btn" :class="{ active: micEnabled }" @click="toggleMic">
|
||||||
|
<van-icon :name="micEnabled ? 'volume-o' : 'volume'" size="26" />
|
||||||
|
<span>{{ micEnabled ? '麦克风' : '已静音' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="control-btn" :class="{ active: speakerEnabled }" @click="toggleSpeaker">
|
||||||
|
<van-icon :name="speakerEnabled ? 'ear' : 'closed-eye'" size="26" />
|
||||||
|
<span>{{ speakerEnabled ? '扬声器' : '已关闭' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="control-btn leave-btn" @click="handleLeave">
|
||||||
|
<van-icon name="cross" size="26" />
|
||||||
|
<span>退出</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.voice-room-mobile {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title-area { flex: 1; }
|
||||||
|
.room-game { font-size: 18px; font-weight: 700; }
|
||||||
|
.room-status { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.8; margin-top: 4px; }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.status-dot.online { background: #10b981; }
|
||||||
|
|
||||||
|
.voice-notice { margin: 0 12px; border-radius: var(--gg-radius-sm); }
|
||||||
|
|
||||||
|
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||||
|
|
||||||
|
.members-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-tile.is-me .member-avatar {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-success);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #1e293b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-indicator.off {
|
||||||
|
background: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.me-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-members { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.control-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 16px 24px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leave-btn {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,64 @@ interface LogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = ref<LogEntry[]>([
|
const logs = ref<LogEntry[]>([
|
||||||
|
{
|
||||||
|
version: 'v0.3.6',
|
||||||
|
date: '2026-04-24',
|
||||||
|
title: '游戏库别名与编辑权限',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
|
||||||
|
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
|
||||||
|
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
|
||||||
|
{ type: 'feat', text: '游戏详情弹窗以蓝色标签展示别名列表' },
|
||||||
|
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
|
||||||
|
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
|
||||||
|
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.5',
|
||||||
|
date: '2026-04-23',
|
||||||
|
title: 'Bug 修复与体验优化',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '群组页面顶部新增待审核入群申请徽章(脉冲动画提示),点击可滚动定位到审核列表' },
|
||||||
|
{ type: 'feat', text: '个人中心新增"我的入群申请"记录,展示申请状态、时间和拒绝原因' },
|
||||||
|
{ type: 'fix', text: '修复群组审核队列从首页进入时不显示的问题,改用 watch 监听群组数据加载' },
|
||||||
|
{ type: 'fix', text: '修复拒绝入群申请后标题区待审核计数不同步更新的问题' },
|
||||||
|
{ type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错的问题,添加 execCommand 降级方案' },
|
||||||
|
{ type: 'fix', text: '修复小队邀请链接页面未加载用户群组数据,导致始终提示"需要先加入群组"' },
|
||||||
|
{ type: 'fix', text: '修复加入群组页面 isMember 判断错误,使用了群组对象而非用户 ID' },
|
||||||
|
{ type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' },
|
||||||
|
{ type: 'fix', text: '修复活动详情加载时未获取活动本身数据的问题' },
|
||||||
|
{ type: 'fix', text: '修复活动评论头像 URL 未拼接 PocketBase baseUrl 导致图片加载失败' },
|
||||||
|
{ type: 'fix', text: '修复活动创建未校验结束时间必须晚于开始时间' },
|
||||||
|
{ type: 'fix', text: '修复活动管理权限判断,拆分编辑权限(创建者+群主)和删除权限(创建者+群主)' },
|
||||||
|
{ type: 'fix', text: '修复活动发起按钮仅管理员可见,改为所有群组成员可发起' },
|
||||||
|
{ type: 'fix', text: '修复活动展开详情后未订阅评论和 RSVP 实时更新' },
|
||||||
|
{ type: 'fix', text: '修复活动相对时间未使用 status 字段判断状态的问题' },
|
||||||
|
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的"创建群组""加入群组"按钮' },
|
||||||
|
{ type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床,添加游戏时拖拽或点击选择文件即可' },
|
||||||
|
{ type: 'fix', text: '修复非群主成员无法看到群组内游戏的问题(PB listRule 语法修正)' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.4',
|
||||||
|
date: '2026-04-21',
|
||||||
|
title: '公告详情弹窗',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '公告卡片点击打开详情弹窗,展示完整内容(保留换行格式)' },
|
||||||
|
{ type: 'feat', text: '详情弹窗显示优先级标签、作者、发布时间、截止时间等信息' },
|
||||||
|
{ type: 'feat', text: '详情弹窗内置编辑和删除操作按钮' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.3',
|
||||||
|
date: '2026-04-20',
|
||||||
|
title: 'Electron 桌面客户端',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: 'Electron 桌面端封装:将 Web 应用包装为独立桌面应用,支持 Windows/Linux/macOS' },
|
||||||
|
{ type: 'fix', text: '修复 HTTP 环境下 mediaDevices 不可用导致语音认证失败的问题' },
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: 'v0.3.2',
|
version: 'v0.3.2',
|
||||||
date: '2026-04-19',
|
date: '2026-04-19',
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
|
import { getGroupGames, deleteGame, exportGames, getAllPlatforms, getGameCoverUrl } from '@/api/games'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
import type { Game, GamePlatform } from '@/types'
|
import type { Game, GamePlatform } from '@/types'
|
||||||
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
||||||
import AddGameDialog from '@/components/game/AddGameDialog.vue'
|
import GameFormDialog from '@/components/game/GameFormDialog.vue'
|
||||||
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
|
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Close } from '@element-plus/icons-vue'
|
import { Close, Monitor, Box } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ const showImport = ref(false)
|
|||||||
const groupOptions = computed(() => groupStore.groups)
|
const groupOptions = computed(() => groupStore.groups)
|
||||||
const hasGroups = computed(() => groupOptions.value.length > 0)
|
const hasGroups = computed(() => groupOptions.value.length > 0)
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||||
|
|
||||||
|
function canModifyGame(game: Game): boolean {
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (game.addedBy === currentUserId.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (groupStore.groups.length === 0) {
|
if (groupStore.groups.length === 0) {
|
||||||
await groupStore.loadGroups()
|
await groupStore.loadGroups()
|
||||||
@@ -87,6 +97,7 @@ function handleExport() {
|
|||||||
exportGames(selectedGroupId.value).then(data => {
|
exportGames(selectedGroupId.value).then(data => {
|
||||||
const json = JSON.stringify(data.map(g => ({
|
const json = JSON.stringify(data.map(g => ({
|
||||||
name: g.name,
|
name: g.name,
|
||||||
|
aliases: g.aliases,
|
||||||
platform: g.platform,
|
platform: g.platform,
|
||||||
tags: g.tags,
|
tags: g.tags,
|
||||||
cover: g.cover
|
cover: g.cover
|
||||||
@@ -102,7 +113,7 @@ function handleExport() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGameAdded() {
|
async function handleGameSaved() {
|
||||||
showAddGame.value = false
|
showAddGame.value = false
|
||||||
await loadGames()
|
await loadGames()
|
||||||
}
|
}
|
||||||
@@ -117,7 +128,7 @@ async function handleImportComplete() {
|
|||||||
<div class="games-library">
|
<div class="games-library">
|
||||||
<!-- 无群组引导 -->
|
<!-- 无群组引导 -->
|
||||||
<div v-if="!hasGroups" class="no-group">
|
<div v-if="!hasGroups" class="no-group">
|
||||||
<div class="no-group-icon">🎮</div>
|
<div class="no-group-icon"><el-icon :size="56"><Monitor /></el-icon></div>
|
||||||
<p class="no-group-text">还没有群组</p>
|
<p class="no-group-text">还没有群组</p>
|
||||||
<p class="no-group-hint">先创建或加入一个群组,然后就可以管理游戏库了</p>
|
<p class="no-group-hint">先创建或加入一个群组,然后就可以管理游戏库了</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,14 +175,14 @@ async function handleImportComplete() {
|
|||||||
<div v-if="loading" class="loading">加载中...</div>
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
|
||||||
<div v-else-if="games.length === 0" class="empty">
|
<div v-else-if="games.length === 0" class="empty">
|
||||||
<div class="empty-icon">📦</div>
|
<div class="empty-icon"><el-icon :size="48"><Box /></el-icon></div>
|
||||||
<p class="empty-text">暂无游戏</p>
|
<p class="empty-text">暂无游戏</p>
|
||||||
<p class="empty-hint">点击「添加游戏」或「导入」开始管理游戏库</p>
|
<p class="empty-hint">点击「添加游戏」或「导入」开始管理游戏库</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="games-grid">
|
<div v-else class="games-grid">
|
||||||
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
|
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
|
||||||
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
|
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
|
||||||
<div class="game-info">
|
<div class="game-info">
|
||||||
<h3 class="game-name">{{ game.name }}</h3>
|
<h3 class="game-name">{{ game.name }}</h3>
|
||||||
<p class="game-platform">{{ game.platform }}</p>
|
<p class="game-platform">{{ game.platform }}</p>
|
||||||
@@ -179,12 +190,12 @@ async function handleImportComplete() {
|
|||||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
|
<button v-if="canModifyGame(game)" class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
|
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
|
||||||
<AddGameDialog v-model="showAddGame" :group-id="selectedGroupId" @created="handleGameAdded" />
|
<GameFormDialog v-model="showAddGame" :group-id="selectedGroupId" @saved="handleGameSaved" />
|
||||||
<ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
|
<ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,6 +216,8 @@ async function handleImportComplete() {
|
|||||||
.no-group-icon {
|
.no-group-icon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-group-text {
|
.no-group-text {
|
||||||
@@ -306,6 +319,7 @@ async function handleImportComplete() {
|
|||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user