Compare commits

20 Commits

Author SHA1 Message Date
zcode 0d411ea77e feat(mobile): complete mobile frontend - all pages + code splitting 2026-06-15 17:05:05 +08:00
zcode 9ce4683bc0 fix(mobile): remove unused logout handler from MobileLayout 2026-06-15 16:27:42 +08:00
zcode a556a4f06d feat(mobile): register vant, MobileLayout, device-based routing, viewport meta 2026-06-15 16:24:31 +08:00
zcode 515bd27528 feat(mobile): add device detection composable and placeholder view 2026-06-15 16:22:15 +08:00
zcode 336c19024a feat(mobile): add vant theme override css 2026-06-15 16:21:16 +08:00
zcode 3a757e1fed feat(mobile): add vant 4 dependency 2026-06-15 16:10:33 +08:00
zcode 9aaa8f6656 docs: add mobile phase 1 infrastructure implementation plan 2026-06-15 15:58:48 +08:00
zcode 33fa4f2119 docs: add mobile frontend design spec 2026-06-15 15:51:08 +08:00
congsh 3c2b68bbc3 fix(electron): enable mediaDevices on HTTP origins and fix voice auth
- Add --unsafely-treat-insecure-origin-as-secure flag for dev/uat URLs
- Set auto-granted permission handlers for mic/camera in main process
- Adapt useVoiceRoom error message for Electron (no Chrome flags hint)
- Add debug logging to voice-token service and frontend voice API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:08:05 +08:00
congsh 4c7152ff50 feat(electron): add Electron desktop wrapper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:54:27 +08:00
congsh 10574845f6 docs: add v0.3.2 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:48:35 +08:00
congsh 6b9fef1d69 fix(voice): add clear error message for HTTP mediaDevices restriction
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:42:29 +08:00
congsh c01aef48bd fix(voice): set LiveKit node-ip to host IP for WebRTC ICE connectivity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:39:01 +08:00
congsh 2ed582faf0 fix(voice): upgrade LiveKit server from v1.7 to v1.10 for client v2 compatibility
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:36:01 +08:00
congsh f96652a8aa fix(voice): fix LiveKit LIVEKIT_KEYS env format in docker-compose
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:29:47 +08:00
congsh 5d434ead6f feat(voice): add real-time voice room with LiveKit
- LiveKit WebRTC SFU container in docker-compose
- Voice token microservice (Node.js + Express)
- VoiceRoom page with member grid and controls
- useVoiceRoom composable for LiveKit connection
- Voice entry button in TeamSessionPanel
- Nginx proxy for voice-token service API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:24:28 +08:00
congsh 9d224e2fcd feat(voice): add livekit-client dependency
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:12:51 +08:00
congsh 81abb4b220 feat(voice): add LiveKit server container to docker-compose
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:06:21 +08:00
congsh c3d34c4660 docs: add v0.3.1 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:30:55 +08:00
congsh d528358867 feat: 玩家黑名单 - 记录外部平台坑玩家
- 新增 player_blacklist collection 迁移
- 添加 PlayerTag/PlayerBlacklistEntry 类型定义和 API
- 创建 PlayerBlacklistMain + CreatePlayerBlacklistDialog 组件
- BlacklistView 支持 Tab 切换游戏/玩家黑名单
- 支持搜索、标签筛选、严重程度筛选、实时订阅

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:03:55 +08:00
60 changed files with 9815 additions and 41 deletions
+24 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库。
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产
## 开发命令
@@ -20,6 +20,11 @@ cd frontend && npm run dev
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
./stop-all.sh # 停止所有服务
# 查看日志
docker logs -f gamegroup-pb # 后端
docker logs -f gamegroup-frontend-dev # Dev
docker logs -f gamegroup-frontend-uat # UAT
```
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
@@ -48,16 +53,16 @@ Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`
```
pocketbase.ts (PB 客户端初始化)
→ router guards (isAuthenticated 检查)
→ stores (user/group/team/notification)
→ api/ (PocketBase CRUD 封装)
→ stores (user/group/team/notification/poll/ledger/asset/memory)
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
→ components + views
```
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb``getCurrentUser()``isAuthenticated()``logout()`
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 CRUD 和过滤逻辑
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap``TeamStatusMap`
### 认证流程
@@ -65,6 +70,10 @@ pocketbase.ts (PB 客户端初始化)
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页
### 路由结构
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
### Vite 代理 vs Nginx
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
@@ -78,6 +87,15 @@ pocketbase.ts (PB 客户端初始化)
- **games** — 游戏库,归属 group,含平台、标签、封面
- **game_comments** / **game_favorites** — 评论和收藏
- **join_requests** — 入群申请
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
- **notifications** — 站内通知(投票/组队/入群等事件)
### PocketBase 注意事项
@@ -86,3 +104,4 @@ pocketbase.ts (PB 客户端初始化)
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
- PocketBase 管理面板:`admin@example.com` / `admin123456`
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
@@ -0,0 +1,124 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "pblacklist_col",
"created": "2026-04-19 10:00:01.000Z",
"updated": "2026-04-19 10:00:01.000Z",
"name": "player_blacklist",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "pb_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_reporter",
"name": "reporter",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_playerid",
"name": "playerId",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "pb_platform",
"name": "platform",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "pb_tags",
"name": "tags",
"type": "select",
"required": true,
"options": {
"maxSelect": 5,
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
}
},
{
"system": false,
"id": "pb_customtag",
"name": "customTag",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 50,
"pattern": ""
}
},
{
"system": false,
"id": "pb_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "pb_severity",
"name": "severity",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["mild", "medium", "severe"]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": null,
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("pblacklist_col");
return dao.deleteCollection(collection);
})
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY server.js .
EXPOSE 7882
CMD ["npm", "start"]
+12
View File
@@ -0,0 +1,12 @@
{
"name": "gamegroup-voice-token",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"livekit-server-sdk": "^2.0",
"express": "^4.21"
}
}
+87
View File
@@ -0,0 +1,87 @@
import express from 'express'
import { AccessToken } from 'livekit-server-sdk'
const app = express()
app.use(express.json())
const API_KEY = process.env.LIVEKIT_API_KEY || 'APIyxZGQjM2'
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi'
const PB_URL = process.env.PB_URL || 'http://gamegroup-pb:8090'
const PORT = process.env.PORT || 7882
app.post('/api/voice-token/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params
const authHeader = req.headers.authorization
console.log('Voice token request:', { sessionId, authHeader: authHeader ? authHeader.slice(0, 20) + '...' : null })
if (!authHeader) {
console.log('Missing auth header')
return res.status(401).json({ error: '未登录' })
}
// 验证用户 token — 调用 PocketBase
const pbRefreshUrl = `${PB_URL}/api/collections/users/auth-refresh`
console.log('Calling PB auth-refresh:', pbRefreshUrl)
const pbRes = await fetch(pbRefreshUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: '{}',
})
console.log('PB auth-refresh status:', pbRes.status)
if (!pbRes.ok) {
const pbBody = await pbRes.text().catch(() => 'unknown')
console.log('PB auth-refresh error body:', pbBody)
return res.status(401).json({ error: '认证失败', detail: pbBody })
}
const userData = await pbRes.json()
console.log('PB auth-refresh success, userId:', userData.record?.id)
const userId = userData.record?.id
const userName = userData.record?.name || userData.record?.username || userId
if (!userId) {
return res.status(401).json({ error: '无效用户' })
}
// 获取 session 并验证成员
const sessionRes = await fetch(`${PB_URL}/api/collections/team_sessions/records/${sessionId}`, {
headers: { Authorization: authHeader },
})
if (!sessionRes.ok) {
return res.status(404).json({ error: '未找到临时小组' })
}
const session = await sessionRes.json()
const members = session.members || []
if (!members.includes(userId)) {
return res.status(403).json({ error: '你不是该小队的成员' })
}
// 签发 LiveKit token
const at = new AccessToken(API_KEY, API_SECRET, {
identity: userId,
name: userName,
})
at.addGrant({
roomJoin: true,
room: `team-${sessionId}`,
canPublish: true,
canSubscribe: true,
})
const token = await at.toJwt()
res.json({ token })
} catch (err) {
console.error('Voice token error:', err)
res.status(500).json({ error: '服务器错误' })
}
})
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, () => {
console.log(`Voice token service listening on :${PORT}`)
})
+30
View File
@@ -20,6 +20,36 @@ services:
networks:
- gamegroup-net
livekit:
image: livekit/livekit-server:v1.10
container_name: gamegroup-livekit
ports:
- "7880:7880"
- "7881:7881/udp"
- "7882:7882/udp"
environment:
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
command: --dev --node-ip 192.168.1.14
restart: unless-stopped
networks:
- gamegroup-net
voice-token:
build:
context: ./backend/voice-token-service
container_name: gamegroup-voice-token
ports:
- "7882:7882"
environment:
- LIVEKIT_API_KEY=APIyxZGQjM2
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
- PB_URL=http://gamegroup-pb:8090
restart: unless-stopped
depends_on:
- pocketbase
networks:
- gamegroup-net
networks:
gamegroup-net:
driver: bridge
+30
View File
@@ -20,6 +20,36 @@ services:
networks:
- gamegroup-net
livekit:
image: livekit/livekit-server:v1.10
container_name: gamegroup-livekit
ports:
- "7880:7880"
- "7881:7881/udp"
- "7882:7882/udp"
environment:
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
command: --dev --node-ip 192.168.1.14
restart: unless-stopped
networks:
- gamegroup-net
voice-token:
build:
context: ./backend/voice-token-service
container_name: gamegroup-voice-token
ports:
- "7882:7882"
environment:
- LIVEKIT_API_KEY=APIyxZGQjM2
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
- PB_URL=http://gamegroup-pb-uat:8090
restart: unless-stopped
depends_on:
- pocketbase-uat
networks:
- gamegroup-net
frontend-uat:
build:
context: ./frontend
@@ -0,0 +1,74 @@
# 语音房间功能设计
## 概述
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
## 架构
```
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
```
## 技术选型
- **LiveKit Server** — 开源 WebRTC SFUDocker 部署
- **livekit-client** — 前端核心 SDK
- **@livekit/components-vue** — Vue 组件封装
- **livekit-server-sdk (Node.js)** — PocketBase hook 中签发 token
## 数据模型
team_sessions 新增字段:
- `voiceRoom: string` — LiveKit 房间名
- `voiceActive: boolean` — 语音房间是否活跃
## Token 签发
PocketBase JS hook (`/api/voice-token/{sessionId}`)
1. 验证请求者是 session 成员
2. 用 LiveKit API key/secret 签发 JWT
3. room = `team-{sessionId}`identity = userId
4. 返回 `{ token: "..." }`
## 前端
### 路由
`/group/:groupId/voice/:sessionId` — 独立语音房间页面
### 页面结构 (VoiceRoom.vue)
- 顶部:房间名 + 离开按钮
- 中部:成员头像网格,说话时绿圈动画
- 底部:麦克风开关、扬声器开关、成员列表
### 入口
GroupView 组队卡片上加"语音"按钮,recruiting/playing 状态时显示。
## 文件变更
### 新增
- `frontend/src/views/VoiceRoom.vue`
- `frontend/src/api/voice.ts`
- `frontend/src/composables/useVoiceRoom.ts`
- `frontend/src/components/voice/VoiceMemberGrid.vue`
- `frontend/src/components/voice/VoiceControls.vue`
- `backend/pb_hooks/voice-token.pb.js`
- `backend/pb_hooks/package.json`
### 修改
- `frontend/src/types/index.ts` — TeamSession 加字段
- `frontend/src/router/index.ts` — 加路由
- `frontend/src/views/GroupView.vue` — 加入口按钮
- `docker-compose.backend.yml` — 加 LiveKit 容器
- `docker-compose.uat.yml` — 同步加 LiveKit
## 部署
- PocketBase 启用 JS hooks
- LiveKit 端口:7880 (HTTP)、7881 (UDP/RTC)
- 局域网无需 TURN 服务器
@@ -0,0 +1,924 @@
# 语音房间功能 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。
**Architecture:** LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。
**Tech Stack:** LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)
---
### Task 1: Docker 基础设施 — 添加 LiveKit 容器
**Files:**
- Modify: `docker-compose.backend.yml`
- Modify: `docker-compose.uat.yml`
**Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务**
在 pocketbase 服务之后、networks 之前添加:
```yaml
livekit:
image: livekit/livekit-server:v1.7
container_name: gamegroup-livekit
ports:
- "7880:7880"
- "7881:7881/udp"
environment:
- LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
command: --dev
restart: unless-stopped
networks:
- gamegroup-net
```
**Step 2: 在 docker-compose.uat.yml 同步添加**
在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。
**Step 3: 启动验证**
Run: `docker compose -f docker-compose.backend.yml up -d livekit`
Expected: 容器正常启动,`docker logs gamegroup-livekit` 显示 LiveKit listening on :7880
**Step 4: Commit**
```bash
git add docker-compose.backend.yml docker-compose.uat.yml
git commit -m "feat(voice): add LiveKit server container to docker-compose"
```
---
### Task 2: 安装前端 LiveKit 依赖
**Files:**
- Modify: `frontend/package.json`
**Step 1: 安装依赖**
Run: `cd frontend && npm install livekit-client @livekit/components-vue`
**Step 2: 验证安装**
Run: `cd frontend && npm ls livekit-client @livekit/components-vue`
Expected: 两个包正常列出
**Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "feat(voice): add livekit-client and components-vue dependencies"
```
---
### Task 3: 前端 API 层 — voice.ts
**Files:**
- Create: `frontend/src/api/voice.ts`
**Step 1: 创建 voice.ts API 封装**
```typescript
// src/api/voice.ts
import { pb } from './pocketbase'
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
export function getLiveKitUrl(): string {
return LIVEKIT_URL
}
export async function fetchVoiceToken(sessionId: string): Promise<string> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 先验证用户是该 session 的成员
const session = await pb.collection('team_sessions').getOne(sessionId)
const members: string[] = (session as any).members || []
if (!members.includes(user.id)) {
throw new Error('你不是该小队的成员')
}
// 调用 PocketBase hook 获取 token
// 如果 hook 未部署,用前端 fallback 方案(仅开发用)
try {
const res = await pb.send(`/api/voice-token/${sessionId}`, {
method: 'POST',
})
return res.token
} catch {
// Hook 不可用时,提示用户
throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
}
}
```
**Step 2: 添加 .env 配置**
`frontend/.env.dev``frontend/.env.uat` 中添加:
```
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
```
**Step 3: Commit**
```bash
git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"
```
---
### Task 4: 类型更新 — TeamSession 加语音字段
**Files:**
- Modify: `frontend/src/types/index.ts:80-94`
**Step 1: 在 TeamSession interface 中添加字段**
`updated: string` 之后、`expand?` 之前添加:
```typescript
voiceRoom?: string
voiceActive?: boolean
```
**Step 2: Commit**
```bash
git add frontend/src/types/index.ts
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"
```
---
### Task 5: composable — useVoiceRoom.ts
**Files:**
- Create: `frontend/src/composables/useVoiceRoom.ts`
**Step 1: 创建 useVoiceRoom composable**
```typescript
// src/composables/useVoiceRoom.ts
import { ref, onUnmounted } from 'vue'
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
export interface VoiceParticipant {
identity: string
name: string
isSpeaking: boolean
isMuted: boolean
avatar?: string
}
export function useVoiceRoom() {
const room = ref<Room | null>(null)
const connected = ref(false)
const participants = ref<Map<string, VoiceParticipant>>(new Map())
const micEnabled = ref(true)
const speakerEnabled = ref(true)
const error = ref<string | null>(null)
async function connect(sessionId: string, userId: string) {
try {
error.value = null
const token = await fetchVoiceToken(sessionId)
const livekitUrl = getLiveKitUrl()
const newRoom = new Room()
newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
participants.value.delete(participant.identity)
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const speakerIds = new Set(speakers.map(s => s.identity))
for (const [id, p] of participants.value) {
p.isSpeaking = speakerIds.has(id)
}
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
updateParticipant(participant)
})
await newRoom.connect(livekitUrl, token)
// 添加本地参与者
updateParticipant(newRoom.localParticipant)
// 添加已有远端参与者
for (const p of newRoom.remoteParticipants.values()) {
updateParticipant(p)
}
// 发布本地麦克风
await newRoom.localParticipant.setMicrophoneEnabled(true)
room.value = newRoom
connected.value = true
} catch (e: any) {
error.value = e.message || '连接语音房间失败'
console.error('Voice room connect error:', e)
}
}
function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
const isLocal = participant instanceof LocalParticipant
const audioTrack = isLocal
? participant.audioTrackPublications.values().next().value
: participant.audioTrackPublications.values().next().value
const vp: VoiceParticipant = {
identity: participant.identity,
name: participant.name || participant.identity,
isSpeaking: participant.isSpeaking,
isMuted: audioTrack?.isMuted ?? true,
}
participants.value.set(participant.identity, vp)
participants.value = new Map(participants.value)
}
async function toggleMic() {
if (!room.value) return
const enabled = !micEnabled.value
await room.value.localParticipant.setMicrophoneEnabled(enabled)
micEnabled.value = enabled
updateParticipant(room.value.localParticipant)
}
async function toggleSpeaker() {
if (!room.value) return
speakerEnabled.value = !speakerEnabled.value
for (const p of room.value.remoteParticipants.values()) {
for (const pub of p.audioTrackPublications.values()) {
if (pub.track) {
pub.track.enabled = speakerEnabled.value
}
}
}
}
async function disconnect() {
if (room.value) {
await room.value.disconnect()
room.value = null
}
connected.value = false
participants.value = new Map()
micEnabled.value = true
speakerEnabled.value = true
}
return {
room,
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
}
}
```
**Step 2: Commit**
```bash
git add frontend/src/composables/useVoiceRoom.ts
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"
```
---
### Task 6: 语音组件 — VoiceMemberGrid + VoiceControls
**Files:**
- Create: `frontend/src/components/voice/VoiceMemberGrid.vue`
- Create: `frontend/src/components/voice/VoiceControls.vue`
**Step 1: 创建 VoiceMemberGrid.vue**
成员头像网格,说话时有绿圈呼吸动画。
```vue
<!-- src/components/voice/VoiceMemberGrid.vue -->
<script setup lang="ts">
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
import { displayName } from '@/types'
defineProps<{
participants: Map<string, VoiceParticipant>
}>()
</script>
<template>
<div class="voice-member-grid">
<div
v-for="[id, p] of participants"
:key="id"
class="voice-member"
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
>
<div class="avatar-ring">
<img
:src="p.avatar || '/default-avatar.svg'"
:alt="p.name"
class="avatar"
/>
</div>
<span class="name">{{ p.name }}</span>
<span v-if="p.isMuted" class="mic-icon muted">🎤</span>
<span v-else-if="p.isSpeaking" class="mic-icon active">🎤</span>
</div>
</div>
</template>
<style scoped>
.voice-member-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
padding: 24px;
}
.voice-member {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
padding: 3px;
border: 2px solid var(--gg-border);
transition: border-color 0.2s, box-shadow 0.3s;
}
.speaking .avatar-ring {
border-color: var(--gg-primary);
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
animation: pulse-ring 1.5s ease-in-out infinite;
}
.muted .avatar-ring {
opacity: 0.5;
}
@keyframes pulse-ring {
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
}
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
text-align: center;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mic-icon {
font-size: 11px;
}
.mic-icon.active {
color: var(--gg-primary);
}
.mic-icon.muted {
color: var(--gg-text-muted);
}
</style>
```
**Step 2: 创建 VoiceControls.vue**
底部控制栏。
```vue
<!-- src/components/voice/VoiceControls.vue -->
<script setup lang="ts">
defineProps<{
micEnabled: boolean
speakerEnabled: boolean
connected: boolean
}>()
const emit = defineEmits<{
toggleMic: []
toggleSpeaker: []
leave: []
}>()
</script>
<template>
<div class="voice-controls">
<button
class="ctrl-btn"
:class="{ active: micEnabled, off: !micEnabled }"
@click="emit('toggleMic')"
>
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
<span class="ctrl-label">麦克风</span>
</button>
<button
class="ctrl-btn"
:class="{ active: speakerEnabled, off: !speakerEnabled }"
@click="emit('toggleSpeaker')"
>
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
<span class="ctrl-label">扬声器</span>
</button>
<button class="ctrl-btn leave-btn" @click="emit('leave')">
<span class="ctrl-icon">🚪</span>
<span class="ctrl-label">离开</span>
</button>
</div>
</template>
<style scoped>
.voice-controls {
display: flex;
justify-content: center;
gap: 20px;
padding: 20px;
background: var(--gg-bg-card);
border-top: 1px solid var(--gg-border);
}
.ctrl-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 24px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg);
color: var(--gg-text);
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.ctrl-btn:hover {
border-color: var(--gg-primary);
}
.ctrl-btn.off {
background: var(--gg-bg);
opacity: 0.7;
}
.ctrl-btn.active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.1);
}
.ctrl-icon {
font-size: 22px;
}
.ctrl-label {
font-size: 12px;
font-weight: 500;
}
.leave-btn {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.leave-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: var(--gg-danger);
}
</style>
```
**Step 3: Commit**
```bash
git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"
```
---
### Task 7: 语音房间页面 — VoiceRoom.vue
**Files:**
- Create: `frontend/src/views/VoiceRoom.vue`
- Modify: `frontend/src/router/index.ts`
**Step 1: 创建 VoiceRoom.vue**
```vue
<!-- src/views/VoiceRoom.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useVoiceRoom } from '@/composables/useVoiceRoom'
import { pb } from '@/api/pocketbase'
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
import VoiceControls from '@/components/voice/VoiceControls.vue'
const route = useRoute()
const router = useRouter()
const teamStore = useTeamStore()
const groupStore = useGroupStore()
const sessionId = route.params.sessionId as string
const groupId = route.params.groupId as string
const session = computed(() => teamStore.currentSession)
const gameName = computed(() => session.value?.gameName || '语音房间')
const {
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
} = useVoiceRoom()
onMounted(async () => {
// 加载 session 数据
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
const userId = pb.authStore.model?.id
if (!userId) {
router.replace('/login')
return
}
await connect(sessionId, userId)
})
onUnmounted(async () => {
await disconnect()
})
async function handleLeave() {
await disconnect()
router.replace(`/group/${groupId}`)
}
</script>
<template>
<div class="voice-room">
<!-- 顶部栏 -->
<header class="voice-header">
<button class="back-btn" @click="handleLeave">
返回
</button>
<h1 class="room-title">{{ gameName }}</h1>
<div class="conn-status" :class="{ on: connected }">
{{ connected ? '已连接' : '连接中...' }}
</div>
</header>
<!-- 错误提示 -->
<div v-if="error" class="error-banner">
{{ error }}
</div>
<!-- 成员区域 -->
<main class="voice-body">
<VoiceMemberGrid :participants="participants" />
<div v-if="participants.size === 0" class="empty-hint">
正在连接语音...
</div>
</main>
<!-- 底部控制栏 -->
<VoiceControls
:mic-enabled="micEnabled"
:speaker-enabled="speakerEnabled"
:connected="connected"
@toggle-mic="toggleMic"
@toggle-speaker="toggleSpeaker"
@leave="handleLeave"
/>
</div>
</template>
<style scoped>
.voice-room {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--gg-bg);
}
.voice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--gg-bg-card);
border-bottom: 1px solid var(--gg-border);
}
.back-btn {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.room-title {
font-size: 18px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.conn-status {
font-size: 13px;
padding: 4px 12px;
border-radius: 12px;
background: var(--gg-bg);
color: var(--gg-text-muted);
}
.conn-status.on {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.error-banner {
margin: 16px 24px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--gg-danger);
border-radius: var(--gg-radius-sm);
color: var(--gg-danger);
font-size: 14px;
}
.voice-body {
flex: 1;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.empty-hint {
color: var(--gg-text-muted);
font-size: 15px;
}
</style>
```
**Step 2: 添加路由**
`frontend/src/router/index.ts` 中,在 `blacklist` 路由之后、`games` 路由之前添加:
```typescript
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: () => import('@/views/VoiceRoom.vue'),
props: true,
meta: { requiresAuth: true }
},
```
**Step 3: Commit**
```bash
git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
git commit -m "feat(voice): add VoiceRoom page and route"
```
---
### Task 8: 入口按钮 — TeamSessionPanel 加语音入口
**Files:**
- Modify: `frontend/src/components/team/TeamSessionPanel.vue:110-120`
**Step 1: 在 TeamSessionPanel 中添加语音按钮**
`end-game-btn` 按钮之后、`dissolve-btn` 之前(约第 116 行区域),添加语音房间入口:
`<script setup>` 中导入 useRoute
```typescript
import { useRoute } from 'vue-router'
const route = useRoute()
```
在 template 中,`start-game-btn` 按钮之后、`end-game-btn` 之前添加:
```html
<router-link
v-if="session.status === 'recruiting' || session.status === 'playing'"
:to="`/group/${route.params.id}/voice/${session.id}`"
class="voice-btn"
>
语音房间
</router-link>
```
添加对应样式:
```css
.voice-btn {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 8px;
border: 1px solid var(--gg-primary);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-primary);
font-size: 14px;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.voice-btn:hover {
background: rgba(5, 150, 105, 0.1);
}
```
**Step 2: Commit**
```bash
git add frontend/src/components/team/TeamSessionPanel.vue
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"
```
---
### Task 9: PocketBase hook — 签发 LiveKit token
**Files:**
- Modify: `backend/pb_hooks/main.js`
- Create: `backend/pb_hooks/package.json`
**注意:** PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。
**Step 1: 创建 package.json**
```json
{
"name": "gamegroup-pb-hooks",
"private": true,
"dependencies": {
"livekit-server-sdk": "^2.0"
}
}
```
Run: `cd backend/pb_hooks && npm install`
**Step 2: 更新 main.js — 添加 token 签发路由**
在现有注释之后添加:
```javascript
// LiveKit voice token endpoint
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
const sessionId = c.pathParam("sessionId")
const user = c.authRecord
if (!user) {
throw new BadRequestError("未登录")
}
// 验证用户是 session 成员
const session = $app.dao().findRecordById("team_sessions", sessionId)
const members = session.getStringSlice("members")
if (!members.includes(user.id)) {
throw new ForbiddenError("你不是该小队的成员")
}
// LiveKit token 签发
// 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
// 可以改用外部 API 或 PocketBase Go middleware
const { AccessToken } = require("livekit-server-sdk")
const apiKey = "APIyxZGQjM2"
const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
const at = new AccessToken(apiKey, apiSecret, {
identity: user.id,
name: user.getString("name") || user.getString("username"),
})
at.addGrant({
roomJoin: true,
room: `team-${sessionId}`,
canPublish: true,
canSubscribe: true,
})
const token = at.toJwt()
return c.json(200, { token: token })
}, $apis.requireAuth())
```
**Step 3: Commit**
```bash
git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"
```
---
### Task 10: 构建验证 + 部署测试
**Files:** 无新文件
**Step 1: 前端构建**
Run: `cd frontend && npm run build`
Expected: 构建成功,无 TypeScript 错误
**Step 2: 部署 Dev 环境**
Run: `./deploy-dev.sh`
Expected: 前端容器构建并启动在 7033 端口
**Step 3: 功能验证**
打开 `http://192.168.1.14:7033`
1. 登录 → 进入群组 → 创建/查看临时小组
2. 组队卡片中应显示"语音房间"按钮
3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
4. 启动 LiveKit 容器后重试验证完整流程
**Step 4: Commit**
```bash
git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"
```
@@ -0,0 +1,846 @@
# 手机端阶段 1:基础设施 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 搭建手机端基础设施——引入 Vant 组件库、设备检测、路由分流、MobileLayout(底部 Tab + 顶部栏)、主题适配,使手机浏览器访问能自动进入手机版框架页。
**Architecture:** 在现有 Vue 3 项目内新增手机端视图层,路由层根据设备检测结果动态 import 桌面或手机视图。Vant 全量引入(与现有 ElementPlus 全量引入风格一致),主题 CSS 变量映射到现有 `--gg-*` 设计系统。本阶段不改任何桌面端代码,仅新增文件 + 改造路由与入口。
**Tech Stack:** Vue 3 + TypeScript + Vite + Vue Router 4 + Vant 4(新增)+ 现有 PocketBase SDK / Pinia(复用)
**Spec 参考:** `docs/superpowers/specs/2026-06-15-mobile-frontend-design.md` 第 2-5 节
**项目约定(执行前必读):**
- 构建走 Docker**不要**本地启动 vite dev server。测试通过 `deploy-dev.sh` 部署后访问 `http://192.168.1.14:7033`
- Windows 环境,命令行用 cmd 语法
- 现有 `main.ts` 全量引入 ElementPlus,手机端 Vant 同样全量引入保持一致
- 提交用 `git -c user.name="zcode" -c user.email="zcode@local" commit`
---
## 文件结构(本阶段涉及)
**新增:**
- `frontend/src/mobile/useDevice.ts` — 设备检测 composableUA + 屏宽 + localStorage
- `frontend/src/mobile/MobileLayout.vue` — 手机端主布局(顶部栏 + 底部 Tab + router-view
- `frontend/src/assets/mobile.css` — Vant 主题变量覆盖(映射到 `--gg-*`
- `frontend/src/views-mobile/Placeholder.vue` — 占位页(未实现的页面暂用)
**修改:**
- `frontend/package.json` — 新增 vant 依赖
- `frontend/src/main.ts` — 注册 Vant + 引入 mobile.css
- `frontend/src/router/index.ts` — 路由分流(设备检测 + 动态 import)
- `frontend/src/App.vue` — viewport meta 适配(已在 index.html 处理,本阶段确认)
**不改:**
- 所有 `frontend/src/views/*``frontend/src/components/*`(桌面端零改动)
- `frontend/src/api/*``frontend/src/stores/*``frontend/src/types/*``frontend/src/composables/*`(共享层零改动)
- `frontend/src/assets/design.css`(设计系统零改动,手机端只在其之上叠加 mobile.css
---
## Task 1: 安装 Vant 依赖
**Files:**
- Modify: `frontend/package.json`
- [ ] **Step 1: 进入 frontend 目录安装 vant**
Run(在 `D:\ZcodeProj\FT\frontend` 下):
```cmd
npm install vant@^4
```
Expected: package.json 的 dependencies 出现 `"vant": "^4.x.x"`,生成/更新 package-lock.json
- [ ] **Step 2: 验证安装成功**
Run:
```cmd
node -e "console.log(require('vant/package.json').version)"
```
Expected: 输出 4.x.x 版本号,无报错
- [ ] **Step 3: 提交**
```cmd
git add frontend/package.json frontend/package-lock.json
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant 4 dependency"
```
---
## Task 2: 创建设备检测 composable
**Files:**
- Create: `frontend/src/mobile/useDevice.ts`
- [ ] **Step 1: 创建 useDevice.ts**
创建 `frontend/src/mobile/useDevice.ts`
```ts
// src/mobile/useDevice.ts
// 设备检测:判断当前是否移动端,结果存 localStorage 避免重复检测
const STORAGE_KEY = 'device_mode'
/** UA 关键字匹配移动设备 */
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
/** 屏幕宽度阈值(含平板竖屏) */
const MOBILE_WIDTH = 768
/**
* 原始检测:综合 UA 和屏幕宽度判断
* - UA 命中移动设备关键字 → 手机
* - 屏宽 <= 768 → 手机
* - 否则 → 桌面
*/
function detectRaw(): 'mobile' | 'desktop' {
if (typeof navigator === 'undefined') return 'desktop'
if (MOBILE_UA.test(navigator.userAgent)) return 'mobile'
if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_WIDTH) return 'mobile'
return 'desktop'
}
/** 当前设备模式(读取 localStorage,无则检测并存入) */
export function getDeviceMode(): 'mobile' | 'desktop' {
if (typeof localStorage === 'undefined') return detectRaw()
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'mobile' || stored === 'desktop') return stored
const detected = detectRaw()
localStorage.setItem(STORAGE_KEY, detected)
return detected
}
/** 是否移动端(路由分流用,同步函数) */
export function isMobile(): boolean {
return getDeviceMode() === 'mobile'
}
/**
* 手动切换设备模式(设置页"切换到桌面版/手机版"用)
* 切换后需整页刷新以重新走路由解析
*/
export function setDeviceMode(mode: 'mobile' | 'desktop') {
localStorage.setItem(STORAGE_KEY, mode)
}
/** 重置为自动检测(登出时调用,避免下个用户沿用上个用户的偏好) */
export function resetDeviceMode() {
localStorage.removeItem(STORAGE_KEY)
}
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/mobile/useDevice.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device detection composable"
```
---
## Task 3: 创建 Vant 主题覆盖 CSS
**Files:**
- Create: `frontend/src/assets/mobile.css`
- [ ] **Step 1: 创建 mobile.css**
创建 `frontend/src/assets/mobile.css`,将 Vant 的 CSS 变量映射到现有 `--gg-*` 设计系统(参考 `frontend/src/assets/design.css` 的变量定义):
```css
/* src/assets/mobile.css */
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
:root {
/* 主色(Vant primary → gg-primary 绿色) */
--van-primary-color: var(--gg-primary); /* #059669 */
--van-success-color: var(--gg-success); /* #10b981 */
--van-danger-color: var(--gg-danger); /* #ef4444 */
--van-warning-color: var(--gg-warning); /* #f59e0b */
/* 文字色 */
--van-text-color: var(--gg-text); /* #1e293b */
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
/* 背景 */
--van-background: var(--gg-bg); /* #f0fdf4 */
--van-background-2: var(--gg-bg-card); /* #ffffff */
/* 边框 */
--van-border-color: var(--gg-border); /* #e2e8f0 */
/* 圆角 */
--van-radius-sm: var(--gg-radius-sm);
--van-radius-md: var(--gg-radius-md);
--van-radius-lg: var(--gg-radius-lg);
/* Tabbar(底部导航) */
--van-tabbar-background: var(--gg-bg-card);
--van-tabbar-item-active-color: var(--gg-primary);
--van-tabbar-item-text-color: var(--gg-text-muted);
--van-tabbar-height: 56px;
/* NavBar(顶部栏) */
--van-nav-bar-background: var(--gg-bg-card);
--van-nav-bar-title-font-size: 17px;
--van-nav-bar-height: 52px;
--van-nav-bar-icon-color: var(--gg-text);
/* 按钮主色渐变 */
--van-button-primary-background: var(--gg-primary);
--van-button-primary-border-color: var(--gg-primary);
}
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
.mobile-app {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 禁止移动端点击高亮 */
.mobile-app * {
-webkit-tap-highlight-color: transparent;
}
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/assets/mobile.css
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant theme override css"
```
---
## Task 4: 注册 Vant 并引入主题 CSS
**Files:**
- Modify: `frontend/src/main.ts`
- [ ] **Step 1: 修改 main.ts**
`frontend/src/main.ts` 改为(在现有 ElementPlus 之后注册 Vant):
```ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Vant from 'vant'
import 'vant/lib/index.css'
import './assets/design.css'
import './assets/mobile.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(Vant)
app.mount('#app')
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/main.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): register vant and import theme css"
```
---
## Task 5: 创建占位页(未实现的手机页面暂用)
**Files:**
- Create: `frontend/src/views-mobile/Placeholder.vue`
- [ ] **Step 1: 创建占位组件**
创建 `frontend/src/views-mobile/Placeholder.vue`
```vue
<!-- src/views-mobile/Placeholder.vue -->
<!-- 占位页手机端尚未实现的页面暂时显示此组件 -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<div class="placeholder-page">
<van-empty description="该页面手机版开发中">
<p class="placeholder-hint">当前页面{{ route.name }}</p>
</van-empty>
</div>
</template>
<style scoped>
.placeholder-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 24px;
}
.placeholder-hint {
margin-top: 8px;
font-size: 12px;
color: var(--gg-text-muted);
}
</style>
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/views-mobile/Placeholder.vue
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add placeholder view for unimplemented pages"
```
---
## Task 6: 创建 MobileLayout 主布局
**Files:**
- Create: `frontend/src/mobile/MobileLayout.vue`
- [ ] **Step 1: 创建 MobileLayout.vue**
创建 `frontend/src/mobile/MobileLayout.vue`。这是手机端主布局:顶部栏(返回 + 标题 + 通知铃铛)+ 底部 5 Tab + 内容区。复用现有 `useUserStore``useGroupStore``useTeamStore``useNotificationStore`(与桌面 Layout.vue 相同的初始化逻辑)。
底部 Tab 对应路由:首页 `/`、群组 `/mobile-groups`、游戏 `/games`、通知 `/mobile-notifications`、我的 `/profile`。其中群组和通知在后续阶段才真正实现,本阶段先指向占位页。
```vue
<!-- src/mobile/MobileLayout.vue -->
<!-- 手机端主布局顶部栏 + 底部 Tab + 内容区 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useNotificationStore } from '@/stores/notification'
import { displayName } from '@/types'
import { resetDeviceMode } from '@/mobile/useDevice'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const notificationStore = useNotificationStore()
// 与桌面 Layout 相同的初始化
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await userStore.initUser()
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
refreshTimer = setInterval(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
}, 30000)
})
onUnmounted(() => {
notificationStore.stopListening()
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
// 当前激活的底部 Tab(根据路由匹配)
const activeTab = computed(() => {
if (route.path.startsWith('/mobile-groups')) return 'groups'
if (route.path.startsWith('/games')) return 'games'
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
if (route.path.startsWith('/profile')) return 'profile'
return 'home'
})
// 顶部栏标题
const pageTitle = computed(() => {
const titles: Record<string, string> = {
home: 'Game Group',
groups: '我的群组',
games: '游戏库',
notifications: '通知',
profile: '我的'
}
// 群组详情页显示群组名
if (route.name === 'GroupView' && groupStore.currentGroup) {
return groupStore.currentGroup.name
}
return titles[activeTab.value] || 'Game Group'
})
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
const showBack = computed(() => {
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
})
// 底部 Tab 切换
function onTabChange(name: string) {
const routes: Record<string, string> = {
home: '/',
groups: '/mobile-groups',
games: '/games',
notifications: '/mobile-notifications',
profile: '/profile'
}
const target = routes[name]
if (target && route.path !== target) {
router.push(target)
}
}
// 返回上一页
function onBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
// 通知未读数
const unreadCount = computed(() => notificationStore.unreadCount)
function goNotifications() {
router.push('/mobile-notifications')
}
// 退出登录
function handleLogout() {
resetDeviceMode()
userStore.logout()
router.push({ name: 'Login' })
}
</script>
<template>
<div class="mobile-app">
<!-- 顶部栏 -->
<van-nav-bar
:title="pageTitle"
:left-arrow="showBack"
fixed
placeholder
@click-left="onBack"
>
<template #right>
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
<van-icon name="bell" size="22" @click="goNotifications" />
</van-badge>
</template>
</van-nav-bar>
<!-- 内容区 -->
<main class="mobile-content">
<router-view />
</main>
<!-- 底部 Tab -->
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<style scoped>
.mobile-app {
min-height: 100vh;
background: var(--gg-bg);
display: flex;
flex-direction: column;
}
.mobile-content {
flex: 1;
padding-bottom: 8px;
}
</style>
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/mobile/MobileLayout.vue
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add MobileLayout with navbar and tabbar"
```
---
## Task 7: 改造路由实现设备分流
**Files:**
- Modify: `frontend/src/router/index.ts`
这是本阶段核心。改造路由:同一 URL,根据 `isMobile()` 动态 import 桌面或手机视图。手机端用 `MobileLayout` 作为布局容器(替代桌面的 `Layout.vue`),子路由指向手机视图(本阶段除 Home 外先用 Placeholder 占位)。
- [ ] **Step 1: 重写 router/index.ts**
`frontend/src/router/index.ts` 完整替换为:
```ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { isAuthenticated } from '@/api/pocketbase'
import { isMobile } from '@/mobile/useDevice'
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
const LayoutComponent = () =>
isMobile()
? import('@/mobile/MobileLayout.vue')
: import('@/views/Layout.vue')
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
return isMobile() ? mobile : desktop
}
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: view(
() => import('@/views/Login.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 LoginMobile
),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'Register',
component: view(
() => import('@/views/Register.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 RegisterMobile
),
meta: { requiresGuest: true }
},
{
path: '/',
component: LayoutComponent,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 HomeMobile
)
},
{
path: 'mobile-groups',
name: 'MobileGroups',
component: view(
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 GroupsMobile
)
},
{
path: 'mobile-notifications',
name: 'MobileNotifications',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 NotificationsMobile
)
},
{
path: 'group/:id',
name: 'GroupView',
component: view(
() => import('@/views/GroupView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 4 替换为 GroupViewMobile
),
props: true
},
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: view(
() => import('@/views/LedgerView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 LedgerMobile
),
props: true
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: view(
() => import('@/views/AssetView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 AssetMobile
),
props: true
},
{
path: 'group/:groupId/blacklist',
name: 'BlacklistView',
component: view(
() => import('@/views/BlacklistView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 BlacklistMobile
),
props: true
},
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: view(
() => import('@/views/VoiceRoom.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 5 替换为 VoiceRoomMobile
),
props: true
},
{
path: 'games',
name: 'GamesLibrary',
component: view(
() => import('@/views/GamesLibrary.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 7 替换为 GamesLibraryMobile
)
},
{
path: 'profile',
name: 'Profile',
component: view(
() => import('@/views/Profile.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ProfileMobile
)
},
{
path: 'settings',
name: 'Settings',
component: view(
() => import('@/views/Settings.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 SettingsMobile
)
},
{
path: 'changelog',
name: 'Changelog',
component: view(
() => import('@/views/Changelog.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ChangelogMobile
)
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫(与原逻辑一致)
router.beforeEach((to, _from, next) => {
const authenticated = isAuthenticated()
if (to.meta.requiresAuth && !authenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
if (to.meta.requiresGuest && authenticated) {
next({ name: 'Home' })
return
}
next()
})
export default router
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/router/index.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device-based route splitting"
```
---
## Task 8: 确认 viewport meta 标签
**Files:**
- Check/Modify: `frontend/index.html`
手机端必须禁止缩放、适配视口宽度。检查 `index.html``<meta name="viewport">`
- [ ] **Step 1: 查看现有 index.html**
Run:
```cmd
type frontend\index.html
```
- [ ] **Step 2: 确认/修改 viewport meta**
如果 `<head>` 内没有 viewport meta,或不是以下内容,将其改为:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
```
`viewport-fit=cover` 配合 Task 3 CSS 里的 `env(safe-area-inset-bottom)` 处理刘海屏。保留现有 `<title>` 等其他内容不变。
> 注意:如果 index.html 已有正确 viewport,此 Task 跳过修改直接进入 Step 3。
- [ ] **Step 3: 提交(如有修改)**
```cmd
git add frontend/index.html
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): ensure viewport meta for mobile scaling"
```
---
## Task 9: TypeScript 类型检查与构建验证
**Files:**
- 无修改(仅验证)
- [ ] **Step 1: 运行 TypeScript 类型检查**
Run(在 `D:\ZcodeProj\FT\frontend` 下):
```cmd
npx vue-tsc --noEmit
```
Expected: 无类型错误。如果报错,常见原因:
- Vant 组件类型未识别 → 确认 `vant` 已安装且 `main.ts` 引入
- `@/mobile/useDevice` 路径找不到 → 确认 `tsconfig.json` 的 paths 配置包含 `@/*`(现有项目已有)
- 修复后重新运行直到通过
- [ ] **Step 2: 运行生产构建**
Run:
```cmd
npm run build
```
Expected: 构建成功,`dist/` 目录生成。如果失败,根据报错修复(通常是 import 路径或类型问题)。
- [ ] **Step 3: 提交(如有修复)**
如果构建过程中修复了任何问题:
```cmd
git add -A
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): resolve type/build errors"
```
---
## Task 10: 部署到 Dev 环境并验证
**Files:**
- 无修改(部署与验收测试)
- [ ] **Step 1: 部署到 Dev 环境**
Run(在项目根 `D:\ZcodeProj\FT` 下):
```cmd
bash deploy-dev.sh
```
> 如果 Windows 无 bash,参考 `deploy-dev.sh` 内容手动执行等价命令(通常是 `cd frontend && npm run build` 后用 docker-compose 重建 `gamegroup-frontend-dev` 容器)。
Expected: 容器重建成功,监听 7033 端口。
- [ ] **Step 2: 桌面浏览器验证(回归不破坏)**
在桌面浏览器打开 `http://192.168.1.14:7033`
- [ ] 登录页正常显示(桌面版 Login.vue)
- [ ] 登录后进入桌面版首页(左侧边栏 + 右侧主区)
- [ ] 桌面端布局与改动前完全一致
- [ ] 打开浏览器 DevTools Console 无报错
- [ ] **Step 3: 手机端验证(模拟移动设备)**
在桌面浏览器 DevTools 切换到移动设备模拟(如 Chrome 的 Toggle device toolbar,选 iPhone 12):
- [ ] 刷新页面 → 自动进入手机版(顶部 NavBar + 底部 Tabbar
- [ ] 顶部栏显示标题 + 右侧通知铃铛图标
- [ ] 底部 5 个 Tab 显示:首页/群组/游戏/通知/我的
- [ ] 点击底部各 Tab → 路由切换,内容区显示 Placeholder 占位页("该页面手机版开发中")
- [ ] 顶部栏在非 Tab 根页面(如点群组进入详情占位)显示返回箭头
- [ ] 整体绿色主题(按钮、Tab 激活态为 #059669
- [ ] Console 无报错
- [ ] **Step 4: 真机验证(可选但推荐)**
用手机浏览器打开 `http://192.168.1.14:7033`(需与电脑同 WiFi):
- [ ] 自动进入手机版
- [ ] 底部 Tab 单手可达,无横向滚动条
- [ ] 顶部栏和底部 Tab 不被刘海/Home 指示条遮挡
- [ ] **Step 5: 设备切换验证**
在手机版页面的浏览器 Console 执行:
```js
localStorage.setItem('device_mode', 'desktop'); location.reload()
```
- [ ] 刷新后切换到桌面版
- [ ] 执行 `localStorage.removeItem('device_mode'); location.reload()` 恢复自动检测
---
## 验收标准(本阶段完成标志)
- [ ] 桌面端完全不受影响(回归测试通过)
- [ ] 手机端自动识别设备并加载 MobileLayout
- [ ] 底部 5 Tab 导航可切换,路由跳转正常
- [ ] 顶部栏标题、返回箭头、通知铃铛显示正常
- [ ] 绿色主题统一(Vant 变量映射成功)
- [ ] 未实现的页面显示 Placeholder 占位
- [ ] TypeScript 类型检查通过,生产构建成功
- [ ] 已部署到 Dev(7033)并可访问
---
## 后续阶段预告(本计划不实现)
本阶段完成后,后续按以下顺序逐步将各 Placeholder 替换为真实手机视图:
| 阶段 | 内容 | 替换的占位路由 |
|------|------|--------------|
| 2 | 登录/注册 | Login, Register |
| 3 | 首页 + 群组列表 + 通知 | Home, MobileGroups, MobileNotifications |
| 4 | 群组详情核心(动态 + 成员) | GroupView |
| 5 | 组队 + 语音房 UI | VoiceRoom |
| 6 | 投票 + 竞猜 | GroupView 内标签 |
| 7 | 游戏库 | GamesLibrary |
| 8 | 账本 + 资产 + 黑名单 | LedgerView, AssetView, BlacklistView |
| 9 | 回忆 + 统计 + 个人/设置/更新日志 | Profile, Settings, Changelog |
每个阶段一份独立实施计划,每阶段产出可测试的手机页面。
@@ -0,0 +1,263 @@
# 手机端前端设计文档
> 日期:2026-06-15
> 仓库:gamegroup2http://jiulu-gameplay.com.cn:13001/congsh/gamegroup2.git
> 状态:已确认,待实施
## 1. 目标
为现有 Game Group V2(游戏组队管理平台)增加完整的手机端前端,使移动设备用户通过手机浏览器即可使用全部功能。手机端与桌面端共存于同一项目、同一域名、同一后端,访问时根据设备自动分流。
**成功标准**:手机浏览器打开 `http://192.168.1.14:7033`Dev/ `7034`(UAT),自动进入手机版界面,可完成登录、群组管理、组队、投票、竞猜、账本、资产、黑名单、回忆、游戏库等全部现有功能,交互符合移动端操作习惯。
## 2. 技术决策
### 2.1 方案:单项目双视图层 + 路由分流
在现有 `frontend` 项目内新增手机端视图层,与桌面端共存:
- **共享层(零改动,手机端直接复用)**:`api/`PocketBase 封装)、`stores/`Pinia)、`types/`(接口定义)、`composables/`useRealtime 等)、`assets/design.css`(设计系统变量)
- **桌面层(不动)**:现有 `views/``components/` 保持原样
- **手机层(全新)**`views-mobile/``components-mobile/``mobile/`(布局与设备检测)
理由:业务逻辑全在前端、API 为标准 PocketBase SDK,共享层与 UI 无关;分成独立项目会导致 api/stores/types/composables 要么复制要么抽包,维护成本翻倍。单项目共享核心 + 双视图层是最低成本、最高复用的方案。
### 2.2 手机端组件库:Vant 4
引入 **Vant 4**(Vue 3 移动端组件库)作为手机端 UI 组件库。
- 现有 Element Plus 面向桌面(弹窗、表格、复杂表单),手机端的 Tabbar、ActionSheet、PullRefresh、Toast、SwipeCell、NavBar 等交互它不擅长
- Vant 专注移动端,与 Vite / Vue 3 / TypeScript 无缝集成
- 桌面端继续用 Element Plus,两套通过路由分流隔离,互不干扰
**新增依赖**`vant@^4` + `@vant/auto-import-resolver`(Vant 按需引入插件,确保未使用的组件不打包进桌面端产物)
### 2.3 主题一致性
将 Vant 的 CSS 变量覆盖为现有 `design.css``--gg-*` 值,保证手机端与桌面端视觉统一:
- 主色 `--van-primary-color: var(--gg-primary)` (#059669)
- 背景色、文字色、圆角、阴影等全部映射到 `--gg-*` 对应变量
### 2.4 语音能力
手机端 VoiceRoom 做完整 UI(成员网格、控制条、房间布局),实际 WebRTC 连接与麦克风采集暂不接入。原因:非 HTTPS / 部分手机浏览器(如微信内置)限制 `mediaDevices`,桌面 Electron 端已遇到同类问题。
手机端语音房显示明确占位提示:"语音功能请在 App 中使用"。完整语音能力待后续打包为原生 AppCapacitor / Tauri Mobile)时再接入。
## 3. 目录结构
```
frontend/src/
├─ api/ # 共享(PocketBase 封装,零改动)
├─ stores/ # 共享(Pinia,零改动)
├─ types/ # 共享(接口定义,零改动)
├─ composables/ # 共享(useRealtime 等,零改动)
├─ assets/
│ ├─ design.css # 共享设计系统(桌面+手机)
│ └─ mobile.css # 手机端主题覆盖(Vant 变量映射)
├─ views/ # 桌面端页面(现有,不动)
├─ components/ # 桌面端组件(现有,不动)
├─ views-mobile/ # 手机端页面(全新)
├─ components-mobile/ # 手机端组件(全新)
├─ mobile/ # 手机端专用
│ ├─ MobileLayout.vue # 底部 Tab + 顶部栏 主布局
│ ├─ useDevice.ts # 设备检测 composable
│ └─ DeviceGuard.ts # 路由级设备分流
├─ router/
│ └─ index.ts # 改造:设备检测 + 分流
└─ main.ts # 改造:按需注册 Vant
```
## 4. 设备检测与路由分流
### 4.1 检测策略
- **主判据**`navigator.userAgent` 匹配移动设备关键字(iPhone/Android/Windows Phone 等)
- **辅助判据**`window.innerWidth <= 768`(覆盖平板竖屏)
- 检测结果在应用启动时执行一次,存入 `localStorage``device_mode`)避免重复检测
- 提供手动切换入口(设置页:"切换到桌面版"),少数平板/桌面用户可能需要
### 4.2 路由结构
每个业务路由定义 `desktop``mobile` 两个组件,路由守卫根据设备模式返回对应组件:
```ts
// 示例:路由记录
{
path: '/group/:id',
name: 'GroupView',
meta: { requiresAuth: true },
component: () => isMobile()
? import('@/views-mobile/GroupViewMobile.vue')
: import('@/views/GroupView.vue')
}
```
- 登录态校验(`requiresAuth` / `requiresGuest`)逻辑不变
- 所有路由 URL 与桌面端完全一致(同一 `/group/:id`),只是加载的视图不同
- 切换设备模式后整页刷新以重新走路由解析
## 5. 手机端布局与导航
### 5.1 整体框架
```
┌──────────────────────────┐
│ ← 群组名 🔔(3) │ 顶部栏:返回 / 标题 / 通知
├──────────────────────────┤
│ │
│ 页面内容区 │
│ (下拉刷新、滚动) │
│ │
├──────────────────────────┤
│🏠首页 👥群组 🎮游戏 🔔通知 👤我│ 底部 5 Tab
└──────────────────────────┘
```
- **顶部栏(NavBar)**:左侧返回按钮(次级页面显示)、中间页面标题、右侧通知铃铛(带未读数徽标)
- **底部 TabTabbar)**:5 个固定入口,单手拇指可达
- **内容区**:占满中间空间,支持下拉刷新(列表页)、无限滚动加载
### 5.2 底部 Tab 定义
| Tab | 图标 | 页面 | 说明 |
|-----|------|------|------|
| 首页 | home-o | HomeMobile | 状态 + 当前组队 + 我的群组 + 热门游戏 |
| 群组 | friends-o | GroupsMobile | 群组列表 + 创建/加入入口 |
| 游戏 | game-o | GamesLibraryMobile | 游戏库浏览 + 搜索 |
| 通知 | bell | NotificationsMobile | 全部站内通知(邀请/组队/入群) |
| 我的 | user-o | ProfileMobile | 个人信息 + 状态 + 设置 |
**投票、竞猜、账本、资产、黑名单、回忆、统计** 不占底部位置,通过群组详情内的标签栏进入(见 5.3)。
### 5.3 群组详情的子页面组织
群组详情(`GroupViewMobile`)承载桌面端 GroupView 的全部 Tab 内容,手机端用**可横滑的顶部标签栏**组织:
```
┌──────────────────────────┐
│ ← 群组名 [12人] [⚙] │
├──────────────────────────┤
│ 动态 投票 竞猜 回忆 → │ 可横滑标签(横向滚动)
├──────────────────────────┤
│ │
│ [当前标签内容] │
│ │
└──────────────────────────┘
(其余标签:成员 账本 资产 黑名单 统计)
```
- 9 个标签:动态 / 投票 / 竞猜 / 回忆 / 成员 / 账本 / 资产 / 黑名单 / 统计
- 首次进入默认「动态」Tab
- 横向可滑动,超出屏幕的标签左右滑出
- 群组级独立页面(账本 `/group/:id/ledger`、资产 `/group/:id/assets`、黑名单 `/group/:id/blacklist`)也可从标签进入,URL 保持一致以支持直接跳转和返回
## 6. 全部页面设计
### 6.1 认证
| 页面 | 文件 | 设计要点 |
|------|------|---------|
| 登录 | `views-mobile/LoginMobile.vue` | 全屏卡片式,顶部 Logo + 标语,大号输入框(昵称/邮箱/用户名 + 密码),主按钮全宽,底部"去注册"链接。键盘弹出时内容上推不遮挡。 |
| 注册 | `views-mobile/RegisterMobile.vue` | 步骤化表单:昵称 → 密码 → 确认密码。复用桌面端自动生成 username 的逻辑(`'u' + Date.now().toString(36) + random`)。 |
### 6.2 主功能
| 页面 | 文件 | 设计要点 |
|------|------|---------|
| 首页 | `views-mobile/HomeMobile.vue` | 顶部:当前用户状态卡片(在线/忙碌/离开 + 一键切换 + 状态备注)。中部:当前临时小组卡片(如有,显示成员头像 + 进入语音房按钮)。下方:我的群组横滑卡片列表(点击进群组详情)。底部:热门游戏横滑封面。无群组时显示引导卡(创建/加入)。 |
| 群组列表 | `views-mobile/GroupsMobile.vue` | 群组卡片纵向列表(群名 + 成员数 + 简介 + 未读标记),顶部右侧浮动「+」按钮弹出 ActionSheet(创建群组 / 加入群组)。下拉刷新。 |
| 群组详情 | `views-mobile/GroupViewMobile.vue` | 顶部群信息条(群名 / 成员数 / 群主 / 容量)+ 快捷操作(账本/资产/黑名单图标入口)+ 可横滑标签栏(见 5.3)。 |
| 语音房 | `views-mobile/VoiceRoomMobile.vue` | 成员头像大网格(2-3 列,显示麦克风开关状态、说话动效)。底部固定控制条(麦克风开关 / 静音 / 退出)。顶部显示房间名和在线人数。WebRTC 未接入时显示占位提示。 |
### 6.3 群组内功能(标签栏子页面)
| 功能 | 组件 | 设计要点 |
|------|------|---------|
| 动态 | `components-mobile/group/ActivityFeed.vue` | 当前组队状态卡 + 空闲成员列表 + 所有成员按状态分组列表。 |
| 投票 | `components-mobile/poll/PollListMobile.vue` + `PollDetailMobile.vue` | 卡片列表(标题 / 选项数 / 截止时间 / 状态),下拉刷新,点击进详情投票。创建投票走底部弹出表单。 |
| 竞猜 | `components-mobile/bet/BetListMobile.vue` + `BetDetailMobile.vue` | 卡片列表(标题 / 下注范围 / 截止时间 / 我的下注),点击进详情下注。结算展示结果。 |
| 回忆 | `components-mobile/memory/MemoryGridMobile.vue` | 九宫格图片视频缩略图,点击全屏浏览(支持滑动切换、捏合缩放)。上传走底部弹出(拍照/相册选择)。 |
| 成员 | `components-mobile/group/MemberListMobile.vue` | 按状态分组列表(头像 + 昵称 + 状态备注),群主可管理(踢人/审核入群)。 |
| 账本 | `views-mobile/LedgerMobile.vue` | 顶部月度汇总卡片(总收入/支出/结余),下方账目列表,右下角浮动「+」添加(弹出表单:金额/类型/备注)。 |
| 资产 | `views-mobile/AssetMobile.vue` | 资产卡片列表(名称 / 类型 / 当前持有者),点击查看详情 / 转移。 |
| 黑名单 | `views-mobile/BlacklistMobile.vue` | 顶部 Tab 切换「游戏黑名单 / 玩家黑名单」。游戏:卡片列表(游戏名 + 标签 + 备注)。玩家:列表(昵称 + 标签 + 备注)。 |
| 统计 | `components-mobile/stats/StatsPanelMobile.vue` | 桌面 GroupStatsPanel 的简化只读版,展示群组活跃度、游戏偏好等核心数据(图表用简化柱状/环形,避免手机端图表过密)。 |
### 6.4 全局页面
| 页面 | 文件 | 设计要点 |
|------|------|---------|
| 游戏库 | `views-mobile/GamesLibraryMobile.vue` | 顶部搜索栏,下方游戏封面瀑布流(2 列),点击弹出详情底部面板(封面 / 平台 / 标签 / 评论 / 收藏 / 发起组队)。 |
| 通知 | `views-mobile/NotificationsMobile.vue` | 全部通知列表(类型图标 + 内容 + 时间),按时间倒序,支持标记已读 / 接受 / 拒绝操作。下拉刷新。 |
| 个人中心 | `views-mobile/ProfileMobile.vue` | 头像 + 昵称 + 用户名,状态切换,积分余额,我的群组数 / 组队次数等数据,设置 / 更新日志 / 退出登录入口。 |
| 设置 | `views-mobile/SettingsMobile.vue` | 列表式设置项:通知开关、主题(预留)、切换到桌面版、关于。 |
| 更新日志 | `views-mobile/ChangelogMobile.vue` | 时间线列表展示版本更新记录。 |
## 7. 共享层改动说明
手机端零改动地复用以下现有代码:
| 层 | 内容 | 说明 |
|----|------|------|
| `api/pocketbase.ts` | PocketBase 单例、`getCurrentUser()``isAuthenticated()``logout()` | 手机端直接 import 使用 |
| `api/*` | users / groups / sessions / invitations / games / polls / bets / points / ledgers / assets / memories / notifications / gameBlacklist / playerBlacklist | 全部领域 API 封装,手机端复用 |
| `stores/*` | user / group / team / notification / poll / ledger / asset / memory | Pinia stores,组合式 API,手机端复用(UI 状态可按需扩展) |
| `composables/useRealtime.ts` | PocketBase 实时订阅管理 | 手机端复用,组件卸载自动清理 |
| `types/index.ts` | 全部接口定义 + `displayName()` + 状态映射常量 | 手机端复用 |
**桌面端代码零改动**:现有 `views/``components/` 保持原样,不影响桌面用户。
## 8. 构建与部署
- **Vite 构建合并**:手机端和桌面端在同一份构建产物中,路由层负责分流。Vant 按需引入(`@vant/auto-import-resolver`),不使用的组件不打包,避免桌面端访问时加载手机端组件库。
- **部署不变**:现有 `deploy-dev.sh` / `deploy-uat.sh` 流程不变,nginx 配置不变,同一端口同一域名同时服务两端。
- **环境变量**`.env``VITE_PB_URL``VITE_PORT` 对两端同样生效。
## 9. 实施顺序
分 9 个阶段,每阶段产出可验证的成果(可在手机浏览器实测):
1. **基础设施**:安装 Vant、设备检测(`useDevice`)、路由分流改造、`MobileLayout`(底部 Tab + 顶部栏)、`mobile.css` 主题覆盖
2. **认证**LoginMobile + RegisterMobile(打通登录注册全流程)
3. **首页 + 群组列表**HomeMobile + GroupsMobile(含创建/加入群组弹层)
4. **群组详情核心**GroupViewMobile + 动态标签(组队状态 + 成员列表)+ 成员管理
5. **组队 + 语音房 UI**:发起组队、邀请、接受邀请、状态切换、VoiceRoomMobile(占位)
6. **投票 + 竞猜**:列表 + 详情 + 创建/下注 + 结算
7. **游戏库**GamesLibraryMobile + 详情弹层 + 搜索
8. **账本 + 资产 + 黑名单**:三个管理类页面(列表 + 增删改 + 资产转移)
9. **回忆 + 统计 + 个人/设置/更新日志**:收尾页面
## 10. 不在本次范围内
以下事项明确排除,待后续迭代:
- **语音 WebRTC 实际接入**:手机端语音房仅做 UI 和占位提示,实际通话能力待打包为原生 AppCapacitor / Tauri Mobile)时实现
- **PWA / 离线支持**:本次不做离线缓存和可安装 Web App
- **推送通知(原生)**:本次仅做站内通知,App 级推送待后续
- **桌面端改动**:现有桌面端代码不做任何修改
## 11. 风险与缓解
| 风险 | 缓解 |
|------|------|
| Vant 与 Element Plus 样式冲突 | 两套组件库通过路由分流隔离,不同时挂载;Vant 主题变量全部覆盖为 `--gg-*` 值 |
| 手机端浏览器实时订阅稳定性 | 复用现有 `useRealtime`,组件卸载自动清理;移动端切后台时 PocketBase SSE 断连,回前台后自动重连(现有机制) |
| 构建体积增长 | Vant 按需引入,仅打包手机端实际用到的组件 |
| 微信内置浏览器限制 | 语音功能已有占位;如遇其他 API 限制(如某些路由 history 模式),预留 hash 模式降级方案 |
## 12. 验收标准
- [ ] 手机浏览器访问 Dev/UAT 自动进入手机版界面
- [ ] 桌面浏览器访问仍是原桌面版,行为不变
- [ ] 登录 / 注册 / 退出全流程正常
- [ ] 首页:状态切换、群组入口、当前组队卡片显示正常
- [ ] 群组详情:9 个标签全部可访问,数据正确
- [ ] 组队流程:发起 / 邀请 / 接受 / 状态流转正常
- [ ] 投票 / 竞猜:创建 / 参与 / 结算正常
- [ ] 账本 / 资产 / 黑名单:增删改查正常,资产转移正常
- [ ] 回忆:浏览 + 上传正常
- [ ] 游戏库:浏览 + 搜索 + 详情正常
- [ ] 通知:接收 / 标记已读 / 接受拒绝正常
- [ ] 个人中心 / 设置:状态切换、切换桌面版正常
- [ ] 语音房界面完整,占位提示明确
+1
View File
@@ -0,0 +1 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
+72
View File
@@ -0,0 +1,72 @@
const { app, BrowserWindow, session } = require('electron')
const path = require('path')
const ENV_URLS = {
dev: 'http://192.168.1.14:7033',
uat: 'http://nas.wjl-work.top:7034',
}
function getWindowUrl() {
const envArg = process.argv.find(a => a.startsWith('--env='))
if (envArg) {
const env = envArg.split('=')[1]
return ENV_URLS[env] || ENV_URLS.dev
}
return ENV_URLS.dev
}
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
const insecureOrigins = Object.values(ENV_URLS).join(',')
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
title: 'Game Group',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
webSecurity: false,
allowRunningInsecureContent: true,
},
})
const url = getWindowUrl()
win.loadURL(url)
// 页面标题同步
win.on('page-title-updated', (event, title) => {
event.preventDefault()
win.setTitle(`Game Group - ${title}`)
})
}
app.whenReady().then(() => {
// 自动批准麦克风/摄像头权限请求,无需用户手动确认
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
callback(true)
} else {
callback(false)
}
})
// 绕过权限检查,确保 mediaDevices 可用
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
return true
}
return false
})
createWindow()
})
app.on('window-all-closed', () => {
app.quit()
})
+41
View File
@@ -0,0 +1,41 @@
{
"name": "gamegroup-electron",
"version": "0.3.2",
"description": "Game Group V2 桌面客户端",
"main": "main.js",
"scripts": {
"start": "electron .",
"start:dev": "electron . --env=dev",
"start:uat": "electron . --env=uat",
"build": "electron-builder --win",
"build:portable": "electron-builder --win portable"
},
"dependencies": {
"electron-store": "^8.2"
},
"devDependencies": {
"electron": "^35.0",
"electron-builder": "^26.0"
},
"build": {
"appId": "com.gamegroup.v2",
"productName": "GameGroup",
"directories": {
"output": "dist"
},
"win": {
"target": [
{
"target": "portable",
"arch": ["x64"]
}
],
"icon": "build/icon.ico"
},
"files": [
"main.js",
"preload.js",
"build/**/*"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
})
+2
View File
@@ -1,3 +1,5 @@
# Dev Environment
VITE_PB_URL=http://192.168.1.14:8711
VITE_PORT=7033
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
+2
View File
@@ -1,3 +1,5 @@
# UAT Environment
VITE_PB_URL=http://192.168.1.14:8711
VITE_PORT=7034
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Game Group V2</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
</head>
+9
View File
@@ -30,6 +30,15 @@ server {
gzip off;
}
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# API 代理到局域网 PocketBase
location /api/ {
client_max_body_size 500m;
+9
View File
@@ -30,6 +30,15 @@ server {
gzip off;
}
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# API 代理到局域网 PocketBase
location /api/ {
proxy_pass http://192.168.1.14:8712;
+12 -10
View File
@@ -10,20 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"element-plus": "^2.6.3",
"@element-plus/icons-vue": "^2.3.1",
"pocketbase": "^0.21.1"
"element-plus": "^2.6.3",
"livekit-client": "^2.18.3",
"pinia": "^2.1.7",
"pocketbase": "^0.21.1",
"vant": "^4.9.24",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.5",
"vue-tsc": "^2.0.11",
"vite": "^5.2.8",
"tailwindcss": "^3.4.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38"
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vue-tsc": "^2.0.11"
}
}
+59
View File
@@ -0,0 +1,59 @@
import { pb } from './pocketbase'
import type { PlayerBlacklistEntry } from '@/types'
export async function createPlayerBlacklistEntry(data: {
group: string
playerId: string
platform: string
tags: string[]
customTag?: string
description: string
severity: string
}): Promise<PlayerBlacklistEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const payload: Record<string, any> = {
group: data.group,
reporter: user.id,
playerId: data.playerId,
platform: data.platform,
tags: data.tags,
description: data.description,
severity: data.severity,
}
if (data.customTag) payload.customTag = data.customTag
const record = await pb.collection('player_blacklist').create(payload)
return record as unknown as PlayerBlacklistEntry
}
export async function listPlayerBlacklist(
groupId: string,
options?: { tag?: string; severity?: string }
): Promise<PlayerBlacklistEntry[]> {
let filter = `group="${groupId}"`
if (options?.tag) filter += ` && tags~"${options.tag}"`
if (options?.severity) filter += ` && severity="${options.severity}"`
const result = await pb.collection('player_blacklist').getFullList({
filter,
sort: '-created',
expand: 'reporter',
$autoCancel: false,
})
return result as unknown as PlayerBlacklistEntry[]
}
export async function deletePlayerBlacklistEntry(entryId: string): Promise<void> {
await pb.collection('player_blacklist').delete(entryId)
}
export async function subscribePlayerBlacklist(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('player_blacklist').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+34
View File
@@ -0,0 +1,34 @@
// src/api/voice.ts
import { pb } from './pocketbase'
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
export function getLiveKitUrl(): string {
return LIVEKIT_URL
}
export async function fetchVoiceToken(sessionId: string): Promise<string> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const token = pb.authStore.token
console.log('[voice] fetching token for session:', sessionId, 'token prefix:', token?.slice(0, 20))
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
})
console.log('[voice] token service response status:', res.status)
if (!res.ok) {
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
console.log('[voice] token service error:', data)
throw new Error(data.error || data.detail || '语音服务暂不可用')
}
const data = await res.json()
return data.token
}
+53
View File
@@ -0,0 +1,53 @@
/* src/assets/mobile.css */
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
:root {
/* 主色(Vant primary → gg-primary 绿色) */
--van-primary-color: var(--gg-primary); /* #059669 */
--van-success-color: var(--gg-success); /* #10b981 */
--van-danger-color: var(--gg-danger); /* #ef4444 */
--van-warning-color: var(--gg-warning); /* #f59e0b */
/* 文字色 */
--van-text-color: var(--gg-text); /* #1e293b */
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
/* 背景 */
--van-background: var(--gg-bg); /* #f0fdf4 */
--van-background-2: var(--gg-bg-card); /* #ffffff */
/* 边框 */
--van-border-color: var(--gg-border); /* #e2e8f0 */
/* 圆角 */
--van-radius-sm: var(--gg-radius-sm);
--van-radius-md: var(--gg-radius-md);
--van-radius-lg: var(--gg-radius-lg);
/* Tabbar(底部导航) */
--van-tabbar-background: var(--gg-bg-card);
--van-tabbar-item-active-color: var(--gg-primary);
--van-tabbar-item-text-color: var(--gg-text-muted);
--van-tabbar-height: 56px;
/* NavBar(顶部栏) */
--van-nav-bar-background: var(--gg-bg-card);
--van-nav-bar-title-font-size: 17px;
--van-nav-bar-height: 52px;
--van-nav-bar-icon-color: var(--gg-text);
/* 按钮主色渐变 */
--van-button-primary-background: var(--gg-primary);
--van-button-primary-border-color: var(--gg-primary);
}
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
.mobile-app {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 禁止移动端点击高亮 */
.mobile-app * {
-webkit-tap-highlight-color: transparent;
}
@@ -0,0 +1,410 @@
<!-- src/components-mobile/bet/BetListMobile.vue -->
<!-- 手机端竞猜列表 + 详情 + 下注 + 创建 + 结算 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, closeBet, settleBet } from '@/api/bets'
import { useUserStore } from '@/stores/user'
import type { Bet, BetOption, BetEntry } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const userStore = useUserStore()
const bets = ref<Bet[]>([])
const loading = ref(false)
const viewingBetId = ref<string | null>(null)
const currentBet = ref<Bet | null>(null)
const options = ref<BetOption[]>([])
const entries = ref<BetEntry[]>([])
const detailLoading = ref(false)
async function loadBets() {
loading.value = true
try {
bets.value = await listBets(props.groupId)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadBets()
})
async function viewBet(betId: string) {
viewingBetId.value = betId
detailLoading.value = true
try {
const [bet, opts, ents] = await Promise.all([
getBet(betId),
getBetOptions(betId),
getBetEntries(betId)
])
currentBet.value = bet
options.value = opts
entries.value = ents
} catch (e) {
showFailToast('加载失败')
} finally {
detailLoading.value = false
}
}
function backToList() {
viewingBetId.value = null
currentBet.value = null
loadBets()
}
function optionEntryCount(optionId: string): number {
return entries.value.filter(e => e.option === optionId).length
}
function optionTotalStake(optionId: string): number {
return entries.value.filter(e => e.option === optionId).reduce((sum, e) => sum + e.stake, 0)
}
const myEntry = () => entries.value.find(e => e.user === userStore.userId)
// 下注
const showBet = ref(false)
const selectedOption = ref<string>('')
const stakeAmount = ref(0)
const placeLoading = ref(false)
function openBetSheet(optionId: string) {
if (!currentBet.value || currentBet.value.status !== 'open') return
if (myEntry()) {
showFailToast('你已经下注了')
return
}
selectedOption.value = optionId
stakeAmount.value = currentBet.value.minStake
showBet.value = true
}
async function handlePlaceBet() {
if (!currentBet.value) return
if (stakeAmount.value < currentBet.value.minStake || stakeAmount.value > currentBet.value.maxStake) {
showFailToast(`下注范围 ${currentBet.value.minStake}-${currentBet.value.maxStake}`)
return
}
placeLoading.value = true
try {
await placeBet(currentBet.value.id, selectedOption.value, stakeAmount.value)
showSuccessToast('下注成功')
showBet.value = false
await viewBet(currentBet.value.id)
} catch (e: any) {
showFailToast(e.message || '下注失败')
} finally {
placeLoading.value = false
}
}
// 创建竞猜
const showCreate = ref(false)
const createForm = ref({
title: '',
description: '',
optionsText: '',
minStake: 1,
maxStake: 100,
deadline: ''
})
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.title.trim()) {
showFailToast('请输入标题')
return
}
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
if (opts.length < 2) {
showFailToast('至少 2 个选项')
return
}
if (!createForm.value.deadline) {
showFailToast('请设置截止时间')
return
}
createLoading.value = true
try {
await createBet({
group: props.groupId,
title: createForm.value.title.trim(),
description: createForm.value.description.trim() || undefined,
options: opts,
minStake: createForm.value.minStake,
maxStake: createForm.value.maxStake,
deadline: createForm.value.deadline
})
showSuccessToast('创建成功')
showCreate.value = false
createForm.value = { title: '', description: '', optionsText: '', minStake: 1, maxStake: 100, deadline: '' }
await loadBets()
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
// 关闭/结算(创建者)
async function handleClose() {
if (!currentBet.value) return
showConfirmDialog({ title: '关闭竞猜', message: '关闭后将停止下注,确定?' })
.then(async () => {
try {
await closeBet(currentBet.value!.id)
showSuccessToast('已关闭')
await viewBet(currentBet.value!.id)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}).catch(() => {})
}
const showSettle = ref(false)
const settleOption = ref('')
async function handleSettle() {
if (!currentBet.value || !settleOption.value) return
try {
await settleBet(currentBet.value.id, settleOption.value)
showSuccessToast('已结算')
showSettle.value = false
await viewBet(currentBet.value.id)
} catch (e: any) {
showFailToast(e.message || '结算失败')
}
}
const isCreator = () => currentBet.value?.creator === userStore.userId
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="bet-mobile">
<!-- 详情 -->
<div v-if="viewingBetId && currentBet">
<van-nav-bar :title="currentBet.title" left-arrow @click-left="backToList" />
<div v-if="detailLoading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="detail-content">
<div class="detail-header">
<van-tag :type="currentBet.status === 'open' ? 'success' : currentBet.status === 'closed' ? 'warning' : 'default'">
{{ currentBet.status === 'open' ? '下注中' : currentBet.status === 'closed' ? '待结算' : '已结算' }}
</van-tag>
<span class="detail-meta">范围 {{ currentBet.minStake }}-{{ currentBet.maxStake }} 积分</span>
</div>
<p v-if="currentBet.description" class="detail-desc">{{ currentBet.description }}</p>
<div class="options-list">
<div
v-for="opt in options"
:key="opt.id"
class="option-item"
@click="currentBet.status === 'open' && openBetSheet(opt.id)"
>
<div class="option-row">
<span class="option-text">{{ opt.content }}</span>
<span v-if="currentBet.resultOption === opt.id" class="winner-badge">🏆 胜出</span>
</div>
<div class="option-stats">
<span>{{ optionEntryCount(opt.id) }} </span>
<span>{{ optionTotalStake(opt.id) }} 积分</span>
</div>
</div>
</div>
<!-- 我的结果 -->
<div v-if="currentBet.status === 'settled' && myEntry()" class="my-result">
<van-notice-bar
:type="myEntry()?.won ? 'success' : 'warning'"
wrapable
>
{{ myEntry()?.won ? `🎉 你赢了!` : '本次未中奖' }}
</van-notice-bar>
</div>
<!-- 创建者操作 -->
<div v-if="isCreator()" class="detail-actions">
<van-button v-if="currentBet.status === 'open'" type="warning" block round plain @click="handleClose">
关闭下注
</van-button>
<van-button v-if="currentBet.status === 'closed'" type="primary" block round @click="showSettle = true">
结算
</van-button>
</div>
</div>
</div>
<!-- 列表 -->
<template v-else>
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起竞猜
</van-button>
</div>
<van-pull-refresh v-model="loading" @refresh="loadBets">
<div v-if="bets.length === 0 && !loading" class="empty">
<van-empty description="暂无竞猜" image-size="100" />
</div>
<div class="bet-list">
<div
v-for="bet in bets"
:key="bet.id"
class="bet-card"
@click="viewBet(bet.id)"
>
<div class="bet-card-header">
<van-tag :type="bet.status === 'open' ? 'success' : bet.status === 'closed' ? 'warning' : 'default'" size="medium">
{{ bet.status === 'open' ? '下注中' : bet.status === 'closed' ? '待结算' : '已结算' }}
</van-tag>
<span class="bet-stake">{{ bet.minStake }}-{{ bet.maxStake }} 积分</span>
</div>
<div class="bet-title">{{ bet.title }}</div>
<div class="bet-card-footer">
<span class="bet-time">{{ timeAgo(bet.created) }}</span>
<van-icon name="arrow" />
</div>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 下注弹层 -->
<van-action-sheet v-model:show="showBet" title="下注">
<div class="bet-sheet-content">
<div class="stake-display">
<span class="stake-num">{{ stakeAmount }}</span>
<span class="stake-unit">积分</span>
</div>
<van-stepper
v-model="stakeAmount"
:min="currentBet?.minStake"
:max="currentBet?.maxStake"
integer
/>
<div class="bet-sheet-actions">
<van-button type="primary" block round :loading="placeLoading" @click="handlePlaceBet">
确认下注
</van-button>
</div>
</div>
</van-action-sheet>
<!-- 创建弹层 -->
<van-popup v-model:show="showCreate" position="bottom" round closeable :style="{ height: '85%' }">
<div class="popup-content">
<div class="popup-title">发起竞猜</div>
<van-cell-group inset>
<van-field v-model="createForm.title" label="标题" placeholder="竞猜主题" required />
<van-field v-model="createForm.description" label="说明" placeholder="可选" />
<van-field
v-model="createForm.optionsText"
type="textarea"
label="选项"
placeholder="每行一个选项"
rows="3"
/>
<van-field name="stepper" label="最低下注">
<template #input><van-stepper v-model="createForm.minStake" min="1" /></template>
</van-field>
<van-field name="stepper" label="最高下注">
<template #input><van-stepper v-model="createForm.maxStake" min="1" /></template>
</van-field>
<van-field v-model="createForm.deadline" label="截止时间" placeholder="如 2026-12-31 23:59" required />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
<!-- 结算弹层 -->
<van-action-sheet v-model:show="showSettle" title="选择胜出选项">
<div class="settle-content">
<div
v-for="opt in options"
:key="opt.id"
class="settle-option"
:class="{ 'settle-selected': settleOption === opt.id }"
@click="settleOption = opt.id"
>
{{ opt.content }}
</div>
<div class="settle-actions">
<van-button type="primary" block round @click="handleSettle">确认结算</van-button>
</div>
</div>
</van-action-sheet>
</div>
</template>
<style scoped>
.bet-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 0; }
.list-header { display: flex; justify-content: flex-end; margin-bottom: 10px; }
.bet-list { display: flex; flex-direction: column; gap: 10px; }
.bet-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
.bet-card:active { opacity: 0.85; }
.bet-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.bet-stake { font-size: 12px; color: var(--gg-text-muted); }
.bet-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
.bet-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--gg-text-muted); font-size: 13px; }
.detail-content { padding: 12px; }
.detail-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.detail-meta { font-size: 12px; color: var(--gg-text-muted); }
.detail-desc { font-size: 14px; color: var(--gg-text-secondary); margin: 0 0 16px; line-height: 1.5; }
.options-list { display: flex; flex-direction: column; gap: 10px; }
.option-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 12px 14px; border: 1px solid var(--gg-border); }
.option-row { display: flex; align-items: center; justify-content: space-between; }
.option-text { font-size: 14px; font-weight: 500; color: var(--gg-text); }
.winner-badge { font-size: 13px; color: var(--gg-warning); font-weight: 600; }
.option-stats { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--gg-text-muted); }
.my-result { margin-top: 16px; }
.detail-actions { margin-top: 20px; }
/* 下注弹层 */
.bet-sheet-content { padding: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.stake-display { display: flex; align-items: baseline; gap: 4px; }
.stake-num { font-size: 40px; font-weight: 700; color: var(--gg-primary); }
.stake-unit { font-size: 14px; color: var(--gg-text-muted); }
.bet-sheet-actions { width: 100%; }
/* 弹层通用 */
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.popup-actions { padding: 20px 16px 0; }
.settle-content { padding: 16px; }
.settle-option { padding: 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm); margin-bottom: 8px; text-align: center; border: 1px solid var(--gg-border); font-size: 14px; }
.settle-selected { background: rgba(5, 150, 105, 0.1); border-color: var(--gg-primary); color: var(--gg-primary); font-weight: 600; }
.settle-actions { margin-top: 16px; }
</style>
@@ -0,0 +1,400 @@
<!-- src/components-mobile/group/ActivityFeedMobile.vue -->
<!-- 群组动态当前组队状态 + 空闲成员 + 所有成员按状态分组 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { createTeamSession } from '@/api/sessions'
import { sendBulkInvitations } from '@/api/invitations'
import type { User } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const userStore = useUserStore()
const router = useRouter()
const members = computed(() => groupStore.currentMembers)
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = {
idle: '空闲',
in_team: '组队中',
working: '工作中',
away: '离开'
}
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)',
in_team: 'var(--gg-info)',
working: 'var(--gg-danger)',
away: 'var(--gg-text-muted)'
}
// 发起组队
const showCreateTeam = ref(false)
const teamName = ref('')
const teamGame = ref('')
const selectedMembers = ref<string[]>([])
const teamLoading = ref(false)
const idleMembers = computed(() => membersByStatus.value.idle)
async function handleCreateTeam() {
if (!teamGame.value.trim()) {
showFailToast('请输入游戏名称')
return
}
teamLoading.value = true
try {
const me = userStore.userId
const memberIds = [...new Set([me, ...selectedMembers.value])]
const session = await createTeamSession({
sourceGroup: props.groupId,
name: teamName.value.trim() || `${teamGame.value}小队`,
gameName: teamGame.value.trim(),
members: memberIds
})
// 邀请选中的成员
const toInvite = selectedMembers.value.filter(id => id !== me)
if (toInvite.length > 0 && session?.id) {
await sendBulkInvitations(toInvite, session.id)
}
await teamStore.loadActiveSession()
showCreateTeam.value = false
teamName.value = ''
teamGame.value = ''
selectedMembers.value = []
showSuccessToast('组队成功')
} catch (e: any) {
showFailToast(e.message || '组队失败')
} finally {
teamLoading.value = false
}
}
function toggleMember(id: string) {
const idx = selectedMembers.value.indexOf(id)
if (idx === -1) selectedMembers.value.push(id)
else selectedMembers.value.splice(idx, 1)
}
// 当前组队状态
const hasSession = computed(() => !!teamStore.currentSession)
const sessionInThisGroup = computed(() =>
teamStore.currentSession?.sourceGroup === props.groupId
)
function goVoiceRoom() {
const s = teamStore.currentSession
if (s) {
router.push({
name: 'VoiceRoom',
params: { groupId: s.sourceGroup, sessionId: s.id }
})
}
}
</script>
<template>
<div class="activity-feed">
<!-- 当前组队卡片 -->
<div v-if="hasSession && sessionInThisGroup" class="current-team-card" @click="goVoiceRoom">
<div class="team-header">
<van-icon name="volume-o" />
<span class="team-label">进行中</span>
</div>
<div class="team-game">{{ teamStore.currentSession?.gameName }}</div>
<div class="team-meta">
{{ teamStore.currentSession?.name }} · {{ teamStore.currentSession?.members?.length || 0 }}
</div>
<div class="team-go">进入语音房 </div>
</div>
<!-- 快速组队按钮 -->
<van-button
v-else
type="primary"
block
round
icon="plus"
@click="showCreateTeam = true"
>
发起组队
</van-button>
<!-- 成员状态分组 -->
<div class="members-section">
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-group"
>
<div class="status-group-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-group-label">{{ statusLabels[status] }}</span>
<span class="status-group-count">{{ list.length }}</span>
</div>
<div class="member-list">
<div v-for="m in list" :key="m.id" class="member-item">
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">{{ m.name || m.username }}</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 发起组队弹层 -->
<van-popup
v-model:show="showCreateTeam"
position="bottom"
round
closeable
:style="{ height: '75%' }"
>
<div class="popup-content">
<div class="popup-title">发起组队</div>
<van-cell-group inset>
<van-field v-model="teamGame" label="游戏" placeholder="玩什么游戏" required />
<van-field v-model="teamName" label="队名" placeholder="可选,默认游戏+小队" />
</van-cell-group>
<div class="invite-title">邀请成员空闲{{ idleMembers.length }}</div>
<div class="invite-list">
<div
v-for="m in idleMembers.filter(x => x.id !== userStore.userId)"
:key="m.id"
class="invite-member"
:class="{ 'invite-selected': selectedMembers.includes(m.id) }"
@click="toggleMember(m.id)"
>
<img :src="m.avatar || '/default-avatar.svg'" class="invite-avatar" alt="" />
<span class="invite-name">{{ m.name || m.username }}</span>
<van-icon
v-if="selectedMembers.includes(m.id)"
name="success"
class="invite-check"
/>
</div>
<div v-if="idleMembers.length <= 1" class="no-idle">暂无其他空闲成员</div>
</div>
<div class="popup-actions">
<van-button
type="primary"
block
round
:loading="teamLoading"
@click="handleCreateTeam"
>
开始组队
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.activity-feed {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.current-team-card {
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
border-radius: var(--gg-radius-lg);
padding: 16px;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
}
.team-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: 0.9;
}
.team-game {
font-size: 18px;
font-weight: 700;
margin-top: 6px;
}
.team-meta {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.team-go {
text-align: right;
font-size: 13px;
margin-top: 8px;
font-weight: 500;
}
.members-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 2px 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-group-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-group-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 8px;
border-radius: 10px;
}
.member-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 6px;
background: var(--gg-bg-card);
padding: 6px 10px 6px 6px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
min-width: 0;
}
.member-name {
font-size: 13px;
color: var(--gg-text);
}
.member-note {
font-size: 11px;
color: var(--gg-text-muted);
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
color: var(--gg-text);
}
.invite-title {
padding: 16px 16px 8px;
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.invite-list {
padding: 0 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invite-member {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.invite-member.invite-selected {
background: rgba(5, 150, 105, 0.1);
border-color: var(--gg-primary);
}
.invite-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.invite-name {
font-size: 13px;
color: var(--gg-text);
}
.invite-check {
color: var(--gg-primary);
font-size: 14px;
}
.no-idle {
font-size: 13px;
color: var(--gg-text-muted);
padding: 8px 0;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,315 @@
<!-- src/components-mobile/group/MemberListMobile.vue -->
<!-- 群组成员列表按状态展示 + 群主管理移除成员审核开关入群申请 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval } from '@/api/groups'
import { respondJoinRequest } from '@/api/groups'
import { pb } from '@/api/pocketbase'
import type { JoinRequest, User } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const userStore = useUserStore()
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const isOwner = computed(() => group.value?.owner === userStore.userId)
const joinRequests = ref<JoinRequest[]>([])
onMounted(async () => {
if (isOwner.value && group.value) {
try {
joinRequests.value = await getGroupJoinRequests(group.value.id)
} catch { /* ignore */ }
}
})
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = { idle: '空闲', in_team: '组队中', working: '工作中', away: '离开' }
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)', in_team: 'var(--gg-info)',
working: 'var(--gg-danger)', away: 'var(--gg-text-muted)'
}
// 移除成员(群主)
async function removeMember(userId: string, name: string) {
if (!isOwner.value || !group.value) return
showConfirmDialog({
title: '移除成员',
message: `确定要将 ${name} 移出群组吗?`
}).then(async () => {
try {
const newMembers = group.value!.members.filter(id => id !== userId)
await pb.collection('groups').update(group.value!.id, { members: newMembers })
await groupStore.setCurrentGroup(group.value!.id)
showSuccessToast('已移除')
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}).catch(() => {})
}
// 审核开关
async function toggleApproval(val: boolean) {
if (!group.value) return
try {
await updateGroupApproval(group.value.id, val)
await groupStore.setCurrentGroup(group.value.id)
showSuccessToast(val ? '已开启审核' : '已关闭审核')
} catch (e: any) {
showFailToast('更新失败')
}
}
// 处理入群申请
const requestLoading = ref<string | null>(null)
async function handleRequest(requestId: string, status: 'approved' | 'rejected') {
requestLoading.value = requestId
try {
await respondJoinRequest(requestId, status)
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
await groupStore.setCurrentGroup(props.groupId)
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
requestLoading.value = null
}
}
</script>
<template>
<div class="member-list-mobile">
<!-- 群主管理区 -->
<div v-if="isOwner" class="owner-panel">
<!-- 入群申请 -->
<div v-if="joinRequests.length > 0" class="requests-block">
<div class="block-title">入群申请{{ joinRequests.length }}</div>
<div
v-for="req in joinRequests"
:key="req.id"
class="request-item"
>
<img
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
class="req-avatar"
alt=""
/>
<div class="req-info">
<div class="req-name">{{ req.expand?.user?.name || req.expand?.user?.username }}</div>
</div>
<div class="req-actions">
<van-button
type="primary"
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'approved')"
>通过</van-button>
<van-button
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'rejected')"
>拒绝</van-button>
</div>
</div>
</div>
<!-- 审核开关 -->
<van-cell-group inset>
<van-cell title="加入审核" center>
<template #right-icon>
<van-switch
:model-value="group?.requireApproval"
size="22px"
@update:model-value="toggleApproval"
/>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 成员分组列表 -->
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-section"
>
<div class="status-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-label">{{ statusLabels[status] }}</span>
<span class="status-count">{{ list.length }}</span>
</div>
<div class="members-grid">
<div
v-for="m in list"
:key="m.id"
class="member-card"
>
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">
{{ m.name || m.username }}
<van-tag v-if="m.id === group?.owner" type="warning" size="medium">群主</van-tag>
</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
<van-button
v-if="isOwner && m.id !== group?.owner && m.id !== userStore.userId"
size="mini"
plain
type="danger"
@click="removeMember(m.id, m.name || m.username)"
>移除</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.member-list-mobile {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.owner-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.request-item {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
margin-bottom: 8px;
box-shadow: var(--gg-shadow);
}
.req-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.req-info {
flex: 1;
min-width: 0;
}
.req-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.req-actions {
display: flex;
gap: 6px;
}
.status-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-count {
font-size: 12px;
color: var(--gg-text-muted);
}
.members-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
display: flex;
align-items: center;
gap: 4px;
}
.member-note {
font-size: 12px;
color: var(--gg-text-muted);
margin-top: 2px;
}
</style>
@@ -0,0 +1,211 @@
<!-- src/components-mobile/memory/MemoryGridMobile.vue -->
<!-- 手机端回忆九宫格 + 上传 + 全屏浏览 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listMemories, deleteMemory } from '@/api/memories'
import { pb } from '@/api/pocketbase'
import type { Memory } from '@/types'
import { showSuccessToast, showFailToast, showImagePreview, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const memories = ref<Memory[]>([])
const loading = ref(false)
async function loadMemories() {
loading.value = true
try {
const result = await listMemories(props.groupId, { limit: 100 })
memories.value = result.items
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadMemories()
})
// 文件 URL
function fileUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file) as string
}
function thumbUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file, { thumb: '300x300' }) as string
}
// 图片记忆列表(用于全屏预览)
const imageMemories = computed(() => memories.value.filter(m => m.fileType === 'image'))
function previewImage(index: number) {
const urls = imageMemories.value.map(m => fileUrl(m))
showImagePreview(urls, index)
}
// 上传
const showUpload = ref(false)
const uploadFiles = ref<File[]>([])
const uploadTitle = ref('')
const uploading = ref(false)
function onFileSelect(items: any) {
const arr = Array.isArray(items) ? items : [items]
uploadFiles.value = arr.map((it: any) => it.file).filter(Boolean)
}
async function handleUpload() {
if (uploadFiles.value.length === 0) {
showFailToast('请选择文件')
return
}
uploading.value = true
try {
for (const file of uploadFiles.value) {
await (await import('@/api/memories')).uploadMemory(props.groupId, file, {
title: uploadTitle.value.trim() || file.name
})
}
showSuccessToast(`上传 ${uploadFiles.value.length} 个文件成功`)
showUpload.value = false
uploadFiles.value = []
uploadTitle.value = ''
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '上传失败')
} finally {
uploading.value = false
}
}
// 删除
async function handleDelete(m: Memory) {
showConfirmDialog({ title: '删除', message: '确定删除这个回忆吗?' })
.then(async () => {
try {
await deleteMemory(m.id)
showSuccessToast('已删除')
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function fileTypeIcon(type: string): string {
const map: Record<string, string> = {
image: 'photo-o', video: 'video-o', audio: 'music-o', document: 'description', other: 'description'
}
return map[type] || 'description'
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="memory-mobile">
<div class="list-header">
<span class="count-text">{{ memories.length }} 个回忆</span>
<van-button type="primary" size="small" round icon="photo-o" @click="showUpload = true">
上传
</van-button>
</div>
<div v-if="loading && memories.length === 0" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="memories.length === 0" class="empty">
<van-empty description="还没有回忆" image-size="100" />
</div>
<div v-else class="memory-grid">
<div
v-for="m in memories"
:key="m.id"
class="memory-item"
>
<div class="item-thumb" @click="m.fileType === 'image' ? previewImage(imageMemories.indexOf(m)) : null">
<img
v-if="m.fileType === 'image' && m.file"
:src="thumbUrl(m)"
class="thumb-img"
alt=""
/>
<div v-else class="thumb-placeholder">
<van-icon :name="fileTypeIcon(m.fileType)" size="32" />
</div>
<van-tag v-if="m.fileType !== 'image'" class="type-tag" size="medium">
{{ m.fileType }}
</van-tag>
</div>
<div class="item-info">
<div class="item-title">{{ m.title }}</div>
<div class="item-meta">{{ timeAgo(m.created) }}</div>
</div>
<van-icon name="delete-o" class="delete-icon" @click="handleDelete(m)" />
</div>
</div>
<!-- 上传弹层 -->
<van-popup v-model:show="showUpload" position="bottom" round closeable :style="{ height: '60%' }">
<div class="popup-content">
<div class="popup-title">上传回忆</div>
<van-cell-group inset>
<van-field v-model="uploadTitle" label="标题" placeholder="可选" />
</van-cell-group>
<div class="upload-area">
<van-uploader
multiple
:after-read="onFileSelect"
:max-count="9"
accept="image/*,video/*,audio/*"
/>
</div>
<div class="popup-actions">
<van-button type="primary" block round :loading="uploading" @click="handleUpload">
上传 {{ uploadFiles.length > 0 ? `(${uploadFiles.length})` : '' }}
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.memory-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 0; }
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.count-text { font-size: 13px; color: var(--gg-text-muted); }
.memory-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.memory-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); overflow: hidden; box-shadow: var(--gg-shadow); position: relative; }
.item-thumb { width: 100%; aspect-ratio: 1; background: var(--gg-bg-elevated); position: relative; }
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--gg-text-muted); }
.type-tag { position: absolute; top: 6px; right: 6px; }
.item-info { padding: 8px 10px; }
.item-title { font-size: 13px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
.delete-icon { position: absolute; top: 6px; left: 6px; color: var(--gg-danger); background: rgba(255,255,255,0.8); border-radius: 50%; padding: 2px; font-size: 16px; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.upload-area { padding: 16px; }
.popup-actions { padding: 16px; }
</style>
@@ -0,0 +1,463 @@
<!-- src/components-mobile/poll/PollListMobile.vue -->
<!-- 手机端投票列表 + 详情 + 创建 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { listPolls, getPoll, getPollOptions, getPollVotes, getUserVote, votePoll, createPoll, settlePoll } from '@/api/polls'
import type { Poll, PollOption, PollVote } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const polls = ref<Poll[]>([])
const loading = ref(false)
const viewingPollId = ref<string | null>(null)
// 详情数据
const currentPoll = ref<Poll | null>(null)
const options = ref<PollOption[]>([])
const votes = ref<PollVote[]>([])
const userVote = ref<PollVote | null>(null)
const detailLoading = ref(false)
let unsub: (() => void) | null = null
async function loadPolls() {
loading.value = true
try {
polls.value = await listPolls(props.groupId)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadPolls()
})
onUnmounted(() => {
if (unsub) { (unsub as () => void)() }
})
async function viewPoll(pollId: string) {
viewingPollId.value = pollId
detailLoading.value = true
try {
const [poll, opts, allVotes, myVote] = await Promise.all([
getPoll(pollId),
getPollOptions(pollId),
getPollVotes(pollId),
getUserVote(pollId)
])
currentPoll.value = poll
options.value = opts
votes.value = allVotes
userVote.value = myVote
} catch (e) {
showFailToast('加载失败')
} finally {
detailLoading.value = false
}
}
function backToList() {
viewingPollId.value = null
currentPoll.value = null
loadPolls()
}
// 每个选项的票数
function optionVoteCount(optionId: string): number {
return votes.value.filter(v => v.option === optionId).length
}
function totalVotes(): number {
return votes.value.length || 1
}
async function doVote(optionId: string) {
if (!currentPoll.value || currentPoll.value.status === 'settled') return
if (userVote.value) {
showFailToast('你已经投过票了')
return
}
try {
await votePoll(currentPoll.value.id, optionId)
showSuccessToast('投票成功')
await viewPoll(currentPoll.value.id)
} catch (e: any) {
showFailToast(e.message || '投票失败')
}
}
async function doSettle() {
if (!currentPoll.value) return
showConfirmDialog({
title: '结算投票',
message: '结算后将结束投票,确定吗?'
}).then(async () => {
try {
await settlePoll(currentPoll.value!.id)
showSuccessToast('已结算')
await viewPoll(currentPoll.value!.id)
} catch (e: any) {
showFailToast(e.message || '结算失败')
}
}).catch(() => {})
}
// 创建投票
const showCreate = ref(false)
const createForm = ref({
title: '',
type: 'option' as 'option' | 'rollcall',
anonymous: false,
deadline: '',
optionsText: ''
})
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.title.trim()) {
showFailToast('请输入标题')
return
}
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
if (opts.length < 2) {
showFailToast('至少输入 2 个选项(每行一个)')
return
}
createLoading.value = true
try {
await createPoll({
group: props.groupId,
title: createForm.value.title.trim(),
type: createForm.value.type,
anonymous: createForm.value.anonymous,
deadline: createForm.value.deadline || undefined,
options: opts
})
showSuccessToast('创建成功')
showCreate.value = false
createForm.value = { title: '', type: 'option', anonymous: false, deadline: '', optionsText: '' }
await loadPolls()
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="poll-mobile">
<!-- 详情视图 -->
<div v-if="viewingPollId && currentPoll" class="poll-detail">
<van-nav-bar
:title="currentPoll.title"
left-arrow
@click-left="backToList"
/>
<div v-if="detailLoading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="detail-content">
<div class="detail-header">
<van-tag :type="currentPoll.status === 'settled' ? 'default' : 'success'">
{{ currentPoll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="detail-meta">{{ votes.length }} 人参与 · {{ timeAgo(currentPoll.created) }}</span>
</div>
<div class="options-list">
<div
v-for="opt in options"
:key="opt.id"
class="option-item"
:class="{
'option-voted': userVote?.option === opt.id,
'option-disabled': currentPoll.status === 'settled' || !!userVote
}"
@click="doVote(opt.id)"
>
<div class="option-bar">
<div
class="option-bar-fill"
:style="{ width: `${(optionVoteCount(opt.id) / totalVotes()) * 100}%` }"
/>
</div>
<div class="option-content">
<span class="option-text">{{ opt.content }}</span>
<span class="option-count">{{ optionVoteCount(opt.id) }} </span>
</div>
</div>
</div>
<div v-if="currentPoll.status === 'active'" class="detail-actions">
<van-button
type="warning"
block
round
plain
@click="doSettle"
>
结算投票
</van-button>
</div>
</div>
</div>
<!-- 列表视图 -->
<template v-else>
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起投票
</van-button>
</div>
<van-pull-refresh v-model="loading" @refresh="loadPolls">
<div v-if="polls.length === 0 && !loading" class="empty">
<van-empty description="暂无投票" image-size="100" />
</div>
<div class="poll-list">
<div
v-for="poll in polls"
:key="poll.id"
class="poll-card"
@click="viewPoll(poll.id)"
>
<div class="poll-card-header">
<van-tag :type="poll.status === 'settled' ? 'default' : 'success'" size="medium">
{{ poll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="poll-time">{{ timeAgo(poll.created) }}</span>
</div>
<div class="poll-title">{{ poll.title }}</div>
<div class="poll-card-footer">
<span class="poll-type">{{ poll.type === 'rollcall' ? '点名' : '选项投票' }}</span>
<van-icon name="arrow" />
</div>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 创建弹层 -->
<van-popup
v-model:show="showCreate"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="popup-content">
<div class="popup-title">发起投票</div>
<van-cell-group inset>
<van-field v-model="createForm.title" label="标题" placeholder="投票主题" required />
<van-field name="radio" label="类型">
<template #input>
<van-radio-group v-model="createForm.type" direction="horizontal">
<van-radio name="option">选项投票</van-radio>
<van-radio name="rollcall">点名</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="createForm.optionsText"
type="textarea"
label="选项"
placeholder="每行输入一个选项"
rows="4"
/>
<van-field
v-model="createForm.deadline"
label="截止时间"
placeholder="留空则不截止"
/>
<van-cell title="匿名投票" center>
<template #right-icon>
<van-switch v-model="createForm.anonymous" size="22px" />
</template>
</van-cell>
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.poll-mobile {
padding: 12px;
}
.loading-box {
display: flex;
justify-content: center;
padding: 40px;
}
.empty {
padding: 30px 0;
}
.list-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.poll-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
box-shadow: var(--gg-shadow);
}
.poll-card:active {
opacity: 0.85;
}
.poll-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.poll-time {
font-size: 12px;
color: var(--gg-text-muted);
}
.poll-title {
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
}
.poll-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
color: var(--gg-text-muted);
font-size: 13px;
}
/* 详情 */
.detail-content {
padding: 12px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.detail-meta {
font-size: 12px;
color: var(--gg-text-muted);
}
.options-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-item {
position: relative;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-sm);
overflow: hidden;
border: 1px solid var(--gg-border);
}
.option-voted {
border-color: var(--gg-primary);
}
.option-bar {
position: absolute;
inset: 0;
}
.option-bar-fill {
height: 100%;
background: rgba(5, 150, 105, 0.08);
transition: width 0.3s;
}
.option-content {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
}
.option-text {
font-size: 14px;
color: var(--gg-text);
font-weight: 500;
}
.option-count {
font-size: 13px;
color: var(--gg-primary);
font-weight: 600;
}
.option-disabled {
opacity: 0.9;
}
.detail-actions {
margin-top: 20px;
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,109 @@
<!-- src/components-mobile/stats/StatsPanelMobile.vue -->
<!-- 手机端统计简化版积分排行 + 群组数据 -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getGroupMemberRanking } from '@/api/points'
import { getGroupGames } from '@/api/games'
import { useGroupStore } from '@/stores/group'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const ranking = ref<{ userId: string; points: number; name?: string }[]>([])
const gameCount = ref(0)
const loading = ref(false)
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
onMounted(async () => {
loading.value = true
try {
const [rank, games] = await Promise.all([
getGroupMemberRanking(props.groupId, 20),
getGroupGames(props.groupId, { limit: 1 })
])
ranking.value = rank
gameCount.value = games.total
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
})
// 排名样式
function rankColor(index: number): string {
if (index === 0) return '#f59e0b'
if (index === 1) return '#94a3b8'
if (index === 2) return '#d97706'
return 'var(--gg-text-muted)'
}
</script>
<template>
<div class="stats-mobile">
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<template v-else>
<!-- 概览卡片 -->
<div class="overview-card">
<div class="overview-item">
<div class="overview-num">{{ members.length }}</div>
<div class="overview-label">成员</div>
</div>
<div class="overview-item">
<div class="overview-num">{{ gameCount }}</div>
<div class="overview-label">游戏</div>
</div>
<div class="overview-item">
<div class="overview-num">{{ group?.maxMembers || '-' }}</div>
<div class="overview-label">上限</div>
</div>
</div>
<!-- 积分排行 -->
<div class="section">
<div class="section-title">积分排行</div>
<div v-if="ranking.length === 0" class="empty-row">暂无数据</div>
<div class="rank-list">
<div
v-for="(item, idx) in ranking"
:key="item.userId"
class="rank-item"
>
<div class="rank-num" :style="{ color: rankColor(idx) }">{{ idx + 1 }}</div>
<div class="rank-info">
<div class="rank-name">{{ item.name || '玩家' }}</div>
</div>
<div class="rank-points">{{ item.points }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.stats-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.overview-card { display: flex; background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 20px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
.overview-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
.overview-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
.overview-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.section { margin-bottom: 16px; }
.section-title { font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 10px; }
.empty-row { text-align: center; padding: 20px; color: var(--gg-text-muted); font-size: 13px; }
.rank-list { display: flex; flex-direction: column; gap: 8px; }
.rank-item { display: flex; align-items: center; gap: 12px; background: var(--gg-bg-card); padding: 12px 14px; border-radius: var(--gg-radius-sm); box-shadow: var(--gg-shadow); }
.rank-num { font-size: 18px; font-weight: 700; width: 28px; text-align: center; }
.rank-info { flex: 1; min-width: 0; }
.rank-name { font-size: 14px; color: var(--gg-text); }
.rank-points { font-size: 15px; font-weight: 600; color: var(--gg-primary); }
</style>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createPlayerBlacklistEntry } from '@/api/playerBlacklist'
import { useGroupStore } from '@/stores/group'
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
import type { PlayerTag, BlacklistSeverity } from '@/types'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
const form = ref({
playerId: '',
platform: '',
tags: [] as PlayerTag[],
customTag: '',
severity: '' as BlacklistSeverity | '',
description: '',
})
const loading = ref(false)
function resetForm() {
form.value = {
playerId: '',
platform: '',
tags: [],
customTag: '',
severity: '',
description: '',
}
}
async function handleSubmit() {
if (!form.value.playerId.trim()) {
ElMessage.warning('请输入玩家ID')
return
}
if (!form.value.platform.trim()) {
ElMessage.warning('请输入游戏/平台')
return
}
if (form.value.tags.length === 0) {
ElMessage.warning('请至少选择一个标签')
return
}
if (!form.value.severity) {
ElMessage.warning('请选择严重程度')
return
}
if (!form.value.description.trim()) {
ElMessage.warning('请填写描述')
return
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('请先选择群组')
return
}
loading.value = true
try {
await createPlayerBlacklistEntry({
group: groupId,
playerId: form.value.playerId.trim(),
platform: form.value.platform.trim(),
tags: form.value.tags,
customTag: form.value.customTag.trim() || undefined,
description: form.value.description.trim(),
severity: form.value.severity,
})
visible.value = false
resetForm()
ElMessage.success('标记成功')
emit('created')
} catch (error: any) {
ElMessage.error(error.message || '标记失败')
} finally {
loading.value = false
}
}
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="标记坑玩家"
width="460px"
@open="handleOpen"
>
<div class="create-form">
<div class="form-field">
<label>玩家ID <span class="required">*</span></label>
<el-input
v-model="form.playerId"
placeholder="输入玩家的游戏内ID或昵称"
maxlength="200"
show-word-limit
/>
</div>
<div class="form-field">
<label>游戏/平台 <span class="required">*</span></label>
<el-input
v-model="form.platform"
placeholder="如:英雄联盟、Steam、绝地求生"
maxlength="100"
/>
</div>
<div class="form-field">
<label>标签 <span class="required">*</span></label>
<el-checkbox-group v-model="form.tags">
<el-checkbox
v-for="(label, key) in PlayerTagMap"
:key="key"
:value="key"
:label="label"
/>
</el-checkbox-group>
</div>
<div class="form-field">
<label>自定义标签</label>
<el-input
v-model="form.customTag"
placeholder="补充标签(选填)"
maxlength="50"
/>
</div>
<div class="form-field">
<label>严重程度 <span class="required">*</span></label>
<el-select v-model="form.severity" placeholder="选择严重程度" style="width: 100%">
<el-option
v-for="(label, key) in BlacklistSeverityMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<div class="form-field">
<label>描述 <span class="required">*</span></label>
<el-input
v-model="form.description"
type="textarea"
placeholder="描述一下遇到的情况"
:rows="3"
maxlength="500"
show-word-limit
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '提交中...' : '提交' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,694 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { listPlayerBlacklist, subscribePlayerBlacklist } from '@/api/playerBlacklist'
import { deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
import { ElMessage, ElPopconfirm } from 'element-plus'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
import type { PlayerBlacklistEntry, PlayerTag, BlacklistSeverity } from '@/types'
import CreatePlayerBlacklistDialog from './CreatePlayerBlacklistDialog.vue'
const groupStore = useGroupStore()
const allEntries = ref<PlayerBlacklistEntry[]>([])
const loading = ref(false)
const showCreate = ref(false)
// 筛选
const filterTag = ref<PlayerTag | ''>('')
const filterSeverity = ref<BlacklistSeverity | ''>('')
// 搜索
const searchPlayerId = ref('')
let unsubscribeFn: (() => void) | null = null
// 按玩家ID聚合
const groupedByPlayer = computed(() => {
let filtered = allEntries.value
if (searchPlayerId.value.trim()) {
const q = searchPlayerId.value.trim().toLowerCase()
filtered = filtered.filter(e => e.playerId.toLowerCase().includes(q))
}
const map = new Map<string, PlayerBlacklistEntry[]>()
for (const entry of filtered) {
const key = `${entry.playerId}|${entry.platform}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(entry)
}
for (const entries of map.values()) {
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
}
return map
})
// 玩家列表(按被标记次数降序)
const playerGroups = computed(() => {
const groups: { playerId: string; platform: string; entries: PlayerBlacklistEntry[] }[] = []
for (const [key, entries] of groupedByPlayer.value) {
const [playerId, platform] = key.split('|')
groups.push({ playerId, platform, entries })
}
groups.sort((a, b) => b.entries.length - a.entries.length)
return groups
})
// 展开的玩家
const expandedPlayer = ref<string | null>(null)
async function loadEntries() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
const options: { tag?: string; severity?: string } = {}
if (filterTag.value) options.tag = filterTag.value
if (filterSeverity.value) options.severity = filterSeverity.value
allEntries.value = await listPlayerBlacklist(groupId, options)
} catch (error) {
console.error('加载玩家黑名单失败:', error)
} finally {
loading.value = false
}
}
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribePlayerBlacklist(groupId, () => loadEntries())
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
function togglePlayer(key: string) {
expandedPlayer.value = expandedPlayer.value === key ? null : key
}
function handleCreated() {
showCreate.value = false
loadEntries()
}
const currentUserId = computed(() => pb.authStore.model?.id)
const isOwner = computed(() => groupStore.isGroupOwner)
function canDelete(entry: PlayerBlacklistEntry): boolean {
return entry.reporter === currentUserId.value || isOwner.value
}
async function handleDelete(entryId: string) {
try {
await deletePlayerBlacklistEntry(entryId)
ElMessage.success('已删除')
loadEntries()
} catch (error: any) {
ElMessage.error(error.message || '删除失败')
}
}
function formatTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 30) return `${date.getMonth() + 1}${date.getDate()}`
if (diffDays > 0) return `${diffDays}天前`
if (diffHours > 0) return `${diffHours}小时前`
if (diffMinutes > 0) return `${diffMinutes}分钟前`
return '刚刚'
}
function reporterName(entry: PlayerBlacklistEntry): string {
return entry.expand?.reporter ? displayName(entry.expand.reporter) : '未知用户'
}
function severityClass(severity: BlacklistSeverity): string {
return `severity--${severity}`
}
onMounted(() => {
if (groupStore.currentGroupId) {
loadEntries()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
expandedPlayer.value = null
stopSubscription()
loadEntries()
startSubscription()
}
})
watch([filterTag, filterSeverity], () => loadEntries())
onUnmounted(() => stopSubscription())
</script>
<template>
<div class="player-blacklist-main">
<div class="player-blacklist-main__header">
<h3 class="player-blacklist-main__title">玩家黑名单</h3>
<div class="player-blacklist-main__actions">
<div class="player-blacklist-main__filters">
<el-input
v-model="searchPlayerId"
placeholder="搜索玩家ID"
clearable
size="small"
class="player-blacklist-main__search"
/>
<el-select
v-model="filterTag"
placeholder="标签筛选"
clearable
size="small"
class="player-blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in PlayerTagMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
<el-select
v-model="filterSeverity"
placeholder="严重程度"
clearable
size="small"
class="player-blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in BlacklistSeverityMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<button class="player-blacklist-main__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-blacklist-main__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
标记玩家
</button>
</div>
</div>
<CreatePlayerBlacklistDialog v-model="showCreate" @created="handleCreated" />
<!-- 玩家卡片列表 -->
<div v-if="playerGroups.length > 0" class="player-blacklist-main__content">
<div v-for="group in playerGroups" :key="group.playerId + group.platform" class="player-blacklist-main__player-group">
<!-- 玩家卡片 -->
<div class="player-card" @click="togglePlayer(group.playerId + group.platform)">
<div class="player-card__info">
<div class="player-card__name-row">
<span class="player-card__name">{{ group.playerId }}</span>
<span class="player-card__platform">{{ group.platform }}</span>
<span class="player-card__badge">{{ group.entries.length }} 次标记</span>
</div>
<div class="player-card__tags">
<span
v-for="tag in [...new Set(group.entries.flatMap(e => e.tags))].slice(0, 4)"
:key="tag"
class="player-card__tag"
>
{{ PlayerTagMap[tag] }}
</span>
<span
v-for="entry in [...new Set(group.entries.map(e => e.customTag).filter(Boolean))].slice(0, 2)"
:key="entry"
class="player-card__tag player-card__tag--custom"
>
{{ entry }}
</span>
</div>
<div v-if="group.entries[0]?.description" class="player-card__desc">
{{ group.entries[0].description }}
</div>
</div>
<div class="player-card__arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-card__arrow-icon">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
<!-- 展开的详细记录 -->
<div v-if="expandedPlayer === group.playerId + group.platform" class="player-blacklist-main__expanded">
<div
v-for="(entry, index) in group.entries"
:key="entry.id"
:class="['entry', { 'entry--bordered': index > 0 }]"
>
<div class="entry__header">
<div class="entry__reporter">
<div class="entry__avatar">{{ reporterName(entry).charAt(0) }}</div>
<span class="entry__name">{{ reporterName(entry) }}</span>
</div>
<div class="entry__header-right">
<span class="entry__time">{{ formatTime(entry.created) }}</span>
<el-popconfirm
v-if="canDelete(entry)"
title="确定删除?"
confirm-button-text="删除"
cancel-button-text="取消"
@confirm="handleDelete(entry.id)"
>
<template #reference>
<button class="entry__delete-btn" title="删除" @click.stop>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div class="entry__tags">
<span v-for="tag in entry.tags" :key="tag" class="entry__tag">{{ PlayerTagMap[tag] }}</span>
<span v-if="entry.customTag" class="entry__tag entry__tag--custom">{{ entry.customTag }}</span>
<span class="entry__severity" :class="severityClass(entry.severity)">{{ BlacklistSeverityMap[entry.severity] }}</span>
</div>
<div class="entry__desc">{{ entry.description }}</div>
</div>
</div>
</div>
</div>
<div v-else-if="loading" class="player-blacklist-main__loading">
<p class="player-blacklist-main__loading-text">加载中...</p>
</div>
<div v-else class="player-blacklist-main__empty">
<svg class="player-blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<p class="player-blacklist-main__empty-text">暂无玩家黑名单记录</p>
<p class="player-blacklist-main__empty-hint">标记坑人的玩家下次遇到有准备</p>
</div>
</div>
</template>
<style scoped>
.player-blacklist-main {
display: flex;
flex-direction: column;
gap: 20px;
}
.player-blacklist-main__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.player-blacklist-main__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.player-blacklist-main__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.player-blacklist-main__filters {
display: flex;
align-items: center;
gap: 8px;
}
.player-blacklist-main__search {
width: 140px;
}
.player-blacklist-main__filter-select {
width: 110px;
}
.player-blacklist-main__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-primary);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.player-blacklist-main__create-btn:hover {
opacity: 0.85;
}
.player-blacklist-main__create-icon {
width: 16px;
height: 16px;
}
.player-blacklist-main__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.player-blacklist-main__player-group {
display: flex;
flex-direction: column;
}
.player-blacklist-main__expanded {
padding: 0 16px;
background: var(--gg-bg-elevated);
border: 1px solid var(--gg-border);
border-top: none;
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
}
/* 玩家卡片 */
.player-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.player-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
.player-card__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.player-card__name-row {
display: flex;
align-items: center;
gap: 10px;
}
.player-card__name {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-card__platform {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.player-card__badge {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
.player-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.player-card__tag {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.player-card__tag--custom {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.player-card__desc {
font-size: 13px;
color: var(--gg-text-secondary);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.player-card__arrow {
flex-shrink: 0;
display: flex;
align-items: center;
}
.player-card__arrow-icon {
width: 18px;
height: 18px;
color: var(--gg-text-muted);
transition: transform 0.2s;
}
.player-card:hover .player-card__arrow-icon {
color: var(--gg-primary);
}
/* 记录条目 */
.entry {
padding: 12px 0;
}
.entry--bordered {
border-top: 1px solid var(--gg-border);
}
.entry__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.entry__reporter {
display: flex;
align-items: center;
gap: 8px;
}
.entry__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--gg-primary);
color: #ffffff;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.entry__name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
}
.entry__header-right {
display: flex;
align-items: center;
gap: 8px;
}
.entry__time {
font-size: 12px;
color: var(--gg-text-muted);
}
.entry__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-muted);
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.entry__delete-btn:hover {
color: var(--gg-danger);
background: rgba(239, 68, 68, 0.08);
}
.entry__delete-icon {
width: 16px;
height: 16px;
}
.entry__tags {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.entry__tag {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.entry__tag--custom {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.entry__severity {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.severity--mild {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.severity--medium {
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.severity--severe {
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
.entry__desc {
font-size: 13px;
color: var(--gg-text-secondary);
line-height: 1.6;
}
/* 加载/空状态 */
.player-blacklist-main__loading {
display: flex;
justify-content: center;
padding: 40px 20px;
}
.player-blacklist-main__loading-text {
font-size: 14px;
color: var(--gg-text-muted);
}
.player-blacklist-main__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.player-blacklist-main__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.player-blacklist-main__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.player-blacklist-main__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
@media (max-width: 640px) {
.player-blacklist-main__header {
flex-direction: column;
align-items: flex-start;
}
.player-blacklist-main__search {
width: 100px;
}
.player-blacklist-main__filter-select {
width: 90px;
}
}
</style>
@@ -1,6 +1,7 @@
<!-- src/components/team/TeamSessionPanel.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
@@ -15,6 +16,7 @@ const userStore = useUserStore()
const session = computed(() => teamStore.currentSession)
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
const showGameSelect = ref(false)
const route = useRoute()
const memberDetails = computed(() => {
if (!session.value) return []
@@ -114,6 +116,13 @@ async function handleGameSelected(gameName: string) {
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
游戏结束
</button>
<router-link
v-if="session.status === 'recruiting' || session.status === 'playing'"
:to="`/group/${route.params.id}/voice/${session.id}`"
class="voice-btn"
>
语音房间
</router-link>
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
解散小组
</button>
@@ -315,4 +324,25 @@ async function handleGameSelected(gameName: string) {
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
transform: translateY(-1px);
}
.voice-btn {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 8px;
border: 1px solid var(--gg-primary);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-primary);
font-size: 14px;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.voice-btn:hover {
background: rgba(5, 150, 105, 0.1);
}
</style>
@@ -0,0 +1,98 @@
<!-- src/components/voice/VoiceControls.vue -->
<script setup lang="ts">
defineProps<{
micEnabled: boolean
speakerEnabled: boolean
connected: boolean
}>()
const emit = defineEmits<{
toggleMic: []
toggleSpeaker: []
leave: []
}>()
</script>
<template>
<div class="voice-controls">
<button
class="ctrl-btn"
:class="{ active: micEnabled, off: !micEnabled }"
@click="emit('toggleMic')"
>
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
<span class="ctrl-label">麦克风</span>
</button>
<button
class="ctrl-btn"
:class="{ active: speakerEnabled, off: !speakerEnabled }"
@click="emit('toggleSpeaker')"
>
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
<span class="ctrl-label">扬声器</span>
</button>
<button class="ctrl-btn leave-btn" @click="emit('leave')">
<span class="ctrl-icon">🚪</span>
<span class="ctrl-label">离开</span>
</button>
</div>
</template>
<style scoped>
.voice-controls {
display: flex;
justify-content: center;
gap: 20px;
padding: 20px;
background: var(--gg-bg-card);
border-top: 1px solid var(--gg-border);
}
.ctrl-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 24px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg);
color: var(--gg-text);
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.ctrl-btn:hover {
border-color: var(--gg-primary);
}
.ctrl-btn.off {
opacity: 0.7;
}
.ctrl-btn.active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.1);
}
.ctrl-icon {
font-size: 22px;
}
.ctrl-label {
font-size: 12px;
font-weight: 500;
}
.leave-btn {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.leave-btn:hover {
background: rgba(239, 68, 68, 0.1);
}
</style>
@@ -0,0 +1,101 @@
<!-- src/components/voice/VoiceMemberGrid.vue -->
<script setup lang="ts">
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
defineProps<{
participants: Map<string, VoiceParticipant>
}>()
</script>
<template>
<div class="voice-member-grid">
<div
v-for="[id, p] of participants"
:key="id"
class="voice-member"
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
>
<div class="avatar-ring">
<img
:src="'/default-avatar.svg'"
:alt="p.name"
class="avatar"
/>
</div>
<span class="name">{{ p.name }}</span>
<span v-if="p.isMuted" class="mic-status muted">静音</span>
<span v-else-if="p.isSpeaking" class="mic-status active">说话中</span>
</div>
</div>
</template>
<style scoped>
.voice-member-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
padding: 24px;
}
.voice-member {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
padding: 3px;
border: 2px solid var(--gg-border);
transition: border-color 0.2s, box-shadow 0.3s;
}
.speaking .avatar-ring {
border-color: var(--gg-primary);
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
animation: pulse-ring 1.5s ease-in-out infinite;
}
.muted .avatar-ring {
opacity: 0.5;
}
@keyframes pulse-ring {
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
}
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
text-align: center;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mic-status {
font-size: 11px;
font-weight: 500;
}
.mic-status.active {
color: var(--gg-primary);
}
.mic-status.muted {
color: var(--gg-text-muted);
}
</style>
+126
View File
@@ -0,0 +1,126 @@
// src/composables/useVoiceRoom.ts
import { ref } from 'vue'
import { Room, RoomEvent, Participant, RemoteTrackPublication } from 'livekit-client'
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
export interface VoiceParticipant {
identity: string
name: string
isSpeaking: boolean
isMuted: boolean
}
export function useVoiceRoom() {
const room = ref<Room | null>(null)
const connected = ref(false)
const participants = ref<Map<string, VoiceParticipant>>(new Map())
const micEnabled = ref(true)
const speakerEnabled = ref(true)
const error = ref<string | null>(null)
function syncParticipant(participant: Participant) {
const audioPub = participant.audioTrackPublications.values().next().value
participants.value.set(participant.identity, {
identity: participant.identity,
name: participant.name || participant.identity,
isSpeaking: participant.isSpeaking,
isMuted: audioPub?.isMuted ?? true,
})
participants.value = new Map(participants.value)
}
async function connect(sessionId: string) {
try {
error.value = null
if (!navigator.mediaDevices?.getUserMedia) {
const isElectron = /Electron/.test(navigator.userAgent)
if (isElectron) {
throw new Error('麦克风权限未获取,请检查 Electron 是否已正确配置安全源和权限处理器。')
}
throw new Error('浏览器不允许在 HTTP 下使用麦克风。请在 Chrome 地址栏输入 chrome://flags/#unsafely-treat-insecure-origin-as-secure,启用后将 http://192.168.1.14:7033 加入白名单,然后重启浏览器。')
}
const token = await fetchVoiceToken(sessionId)
const livekitUrl = getLiveKitUrl()
const newRoom = new Room()
newRoom.on(RoomEvent.ParticipantConnected, syncParticipant)
newRoom.on(RoomEvent.ParticipantDisconnected, (p) => {
participants.value.delete(p.identity)
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const ids = new Set(speakers.map(s => s.identity))
for (const [id, p] of participants.value) {
p.isSpeaking = ids.has(id)
}
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.TrackMuted, (_pub, p) => syncParticipant(p))
newRoom.on(RoomEvent.TrackUnmuted, (_pub, p) => syncParticipant(p))
newRoom.on(RoomEvent.TrackSubscribed, (_track, _pub, p) => syncParticipant(p))
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, _pub, p) => syncParticipant(p))
await newRoom.connect(livekitUrl, token)
syncParticipant(newRoom.localParticipant as unknown as Participant)
for (const p of newRoom.remoteParticipants.values()) {
syncParticipant(p as unknown as Participant)
}
await newRoom.localParticipant.setMicrophoneEnabled(true)
room.value = newRoom
connected.value = true
} catch (e: any) {
error.value = e.message || '连接语音房间失败'
console.error('Voice room connect error:', e)
}
}
async function toggleMic() {
if (!room.value) return
const enabled = !micEnabled.value
await room.value.localParticipant.setMicrophoneEnabled(enabled)
micEnabled.value = enabled
syncParticipant(room.value.localParticipant as unknown as Participant)
}
async function toggleSpeaker() {
if (!room.value) return
speakerEnabled.value = !speakerEnabled.value
for (const p of room.value.remoteParticipants.values()) {
for (const pub of p.audioTrackPublications.values()) {
if (pub instanceof RemoteTrackPublication) {
pub.setEnabled(speakerEnabled.value)
}
}
}
}
async function disconnect() {
if (room.value) {
await room.value.disconnect()
room.value = null
}
connected.value = false
participants.value = new Map()
micEnabled.value = true
speakerEnabled.value = true
}
return {
room,
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
}
}
+4
View File
@@ -3,7 +3,10 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Vant from 'vant'
import 'vant/lib/index.css'
import './assets/design.css'
import './assets/mobile.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
@@ -19,5 +22,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(Vant)
app.mount('#app')
+149
View File
@@ -0,0 +1,149 @@
<!-- src/mobile/MobileLayout.vue -->
<!-- 手机端主布局顶部栏 + 底部 Tab + 内容区 -->
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useNotificationStore } from '@/stores/notification'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const notificationStore = useNotificationStore()
// 与桌面 Layout 相同的初始化
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await userStore.initUser()
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
refreshTimer = setInterval(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
}, 30000)
})
onUnmounted(() => {
notificationStore.stopListening()
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
// 当前激活的底部 Tab(根据路由匹配)
const activeTab = computed(() => {
if (route.path.startsWith('/mobile-groups')) return 'groups'
if (route.path.startsWith('/games')) return 'games'
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
if (route.path.startsWith('/profile')) return 'profile'
return 'home'
})
// 顶部栏标题
const pageTitle = computed(() => {
const titles: Record<string, string> = {
home: 'Game Group',
groups: '我的群组',
games: '游戏库',
notifications: '通知',
profile: '我的'
}
// 群组详情页显示群组名
if (route.name === 'GroupView' && groupStore.currentGroup) {
return groupStore.currentGroup.name
}
return titles[activeTab.value] || 'Game Group'
})
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
const showBack = computed(() => {
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
})
// 底部 Tab 切换
function onTabChange(name: string | number) {
const routes: Record<string, string> = {
home: '/',
groups: '/mobile-groups',
games: '/games',
notifications: '/mobile-notifications',
profile: '/profile'
}
const target = routes[String(name)]
if (target && route.path !== target) {
router.push(target)
}
}
// 返回上一页
function onBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
// 通知未读数
const unreadCount = computed(() => notificationStore.unreadCount)
function goNotifications() {
router.push('/mobile-notifications')
}
</script>
<template>
<div class="mobile-app">
<!-- 顶部栏 -->
<van-nav-bar
:title="pageTitle"
:left-arrow="showBack"
fixed
placeholder
@click-left="onBack"
>
<template #right>
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
<van-icon name="bell" size="22" @click="goNotifications" />
</van-badge>
</template>
</van-nav-bar>
<!-- 内容区 -->
<main class="mobile-content">
<router-view />
</main>
<!-- 底部 Tab -->
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<style scoped>
.mobile-app {
min-height: 100vh;
background: var(--gg-bg);
display: flex;
flex-direction: column;
}
.mobile-content {
flex: 1;
padding-bottom: 8px;
}
</style>
+51
View File
@@ -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)
}
+86 -21
View File
@@ -2,77 +2,144 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { isAuthenticated } from '@/api/pocketbase'
import { isMobile } from '@/mobile/useDevice'
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
const LayoutComponent = () =>
isMobile()
? import('@/mobile/MobileLayout.vue')
: import('@/views/Layout.vue')
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
return isMobile() ? mobile : desktop
}
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
component: view(
() => import('@/views/Login.vue'),
() => import('@/views-mobile/LoginMobile.vue')
),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
component: view(
() => import('@/views/Register.vue'),
() => import('@/views-mobile/RegisterMobile.vue')
),
meta: { requiresGuest: true }
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
component: LayoutComponent,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue')
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/HomeMobile.vue')
)
},
{
path: 'mobile-groups',
name: 'MobileGroups',
component: view(
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
() => import('@/views-mobile/GroupsMobile.vue')
)
},
{
path: 'mobile-notifications',
name: 'MobileNotifications',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/NotificationsMobile.vue')
)
},
{
path: 'group/:id',
name: 'GroupView',
component: () => import('@/views/GroupView.vue'),
component: view(
() => import('@/views/GroupView.vue'),
() => import('@/views-mobile/GroupViewMobile.vue')
),
props: true
},
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: () => import('@/views/LedgerView.vue'),
props: true,
meta: { requiresAuth: true }
component: view(
() => import('@/views/LedgerView.vue'),
() => import('@/views-mobile/LedgerMobile.vue')
),
props: true
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: () => import('@/views/AssetView.vue'),
props: true,
meta: { requiresAuth: true }
component: view(
() => import('@/views/AssetView.vue'),
() => import('@/views-mobile/AssetMobile.vue')
),
props: true
},
{
path: 'group/:groupId/blacklist',
name: 'BlacklistView',
component: () => import('@/views/BlacklistView.vue'),
props: true,
meta: { requiresAuth: true }
component: view(
() => import('@/views/BlacklistView.vue'),
() => import('@/views-mobile/BlacklistMobile.vue')
),
props: true
},
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: view(
() => import('@/views/VoiceRoom.vue'),
() => import('@/views-mobile/VoiceRoomMobile.vue')
),
props: true
},
{
path: 'games',
name: 'GamesLibrary',
component: () => import('@/views/GamesLibrary.vue')
component: view(
() => import('@/views/GamesLibrary.vue'),
() => import('@/views-mobile/GamesLibraryMobile.vue')
)
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/Profile.vue')
component: view(
() => import('@/views/Profile.vue'),
() => import('@/views-mobile/ProfileMobile.vue')
)
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue')
component: view(
() => import('@/views/Settings.vue'),
() => import('@/views-mobile/SettingsMobile.vue')
)
},
{
path: 'changelog',
name: 'Changelog',
component: () => import('@/views/Changelog.vue')
component: view(
() => import('@/views/Changelog.vue'),
() => import('@/views-mobile/ChangelogMobile.vue')
)
}
]
},
@@ -89,17 +156,15 @@ const router = createRouter({
routes
})
// 路由守卫
// 路由守卫(与原逻辑一致)
router.beforeEach((to, _from, next) => {
const authenticated = isAuthenticated()
// 需要登录的页面
if (to.meta.requiresAuth && !authenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
// 已登录用户访问登录/注册页
if (to.meta.requiresGuest && authenticated) {
next({ name: 'Home' })
return
+35
View File
@@ -85,6 +85,8 @@ export interface TeamSession {
members: string[]
status: TeamStatus
dissolvedAt?: string
voiceRoom?: string
voiceActive?: boolean
created: string
updated: string
expand?: {
@@ -345,6 +347,20 @@ export const BlacklistReasonMap: Record<BlacklistReason, string> = {
other: '其他'
}
// 玩家黑名单标签
export type PlayerTag = 'afk' | 'feeder' | 'toxic' | 'cheater' | 'quitter' | 'noob' | 'fragile' | 'other'
export const PlayerTagMap: Record<PlayerTag, string> = {
afk: '挂机',
feeder: '送人头',
toxic: '喷人',
cheater: '外挂',
quitter: '始乱终弃',
noob: '坑货',
fragile: '玻璃心',
other: '其他'
}
// 黑名单严重程度
export type BlacklistSeverity = 'mild' | 'medium' | 'severe'
@@ -373,6 +389,25 @@ export interface BlacklistEntry {
}
}
// 玩家黑名单
export interface PlayerBlacklistEntry {
id: string
group: string
reporter: string
playerId: string
platform: string
tags: PlayerTag[]
customTag?: string
description: string
severity: BlacklistSeverity
created: string
updated: string
expand?: {
reporter?: User
group?: Group
}
}
// 竞猜状态
export type BetStatus = 'open' | 'closed' | 'settled'
+230
View File
@@ -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,101 @@
<!-- src/views-mobile/ChangelogMobile.vue -->
<!-- 手机端更新日志时间线列表 -->
<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-15',
title: '手机端前端',
items: [
{ type: 'feat', text: '完整手机端界面:底部 Tab 导航、顶部栏、所有功能页面移动端适配' },
{ type: 'feat', text: '设备自动识别:手机访问自动进入手机版,桌面访问保持桌面版' },
{ type: 'feat', text: 'Vant 组件库集成:下拉刷新、ActionSheet、弹层等移动端交互' },
{ type: 'feat', text: '语音房手机端预览:成员网格 + 控制条 UI(实际语音待 App)' },
{ type: 'style', text: '代码分割优化:vendor 拆分,减少首屏加载体积' },
]
},
{
version: 'v0.3.2',
date: '2026-04-19',
title: '实时语音房间',
items: [
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC' },
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
{ type: 'feat', text: '麦克风/扬声器开关:独立控制' },
{ type: 'fix', text: '修复 WebRTC ICE 连接失败' },
]
},
{
version: 'v0.3.1',
date: '2026-04-19',
title: '玩家黑名单',
items: [
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家(挂机、送人头、喷人等)' },
{ 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: '修复竞猜双重结算和竞态安全漏洞' },
]
},
])
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,170 @@
<!-- src/views-mobile/GamesLibraryMobile.vue -->
<!-- 手机端游戏库搜索 + 瀑布流 + 详情 -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getPopularGames, searchGames, getAllPlatforms } from '@/api/games'
import type { Game } from '@/types'
const popularGames = ref<Game[]>([])
const searchResults = ref<Game[]>([])
const keyword = ref('')
const loading = ref(false)
const selectedPlatform = ref<string>('')
const showDetail = ref(false)
const currentGame = ref<Game | null>(null)
const displayGames = computed(() => {
return keyword.value.trim() ? searchResults.value : popularGames.value
})
onMounted(async () => {
await loadPopular()
})
async function loadPopular() {
loading.value = true
try {
popularGames.value = await getPopularGames(50)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearch(val: string) {
keyword.value = val
if (searchTimer) clearTimeout(searchTimer)
if (!val.trim()) {
searchResults.value = []
return
}
searchTimer = setTimeout(async () => {
loading.value = true
try {
searchResults.value = await searchGames(val.trim(), undefined, 50)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}, 400)
}
function openDetail(game: Game) {
currentGame.value = game
showDetail.value = true
}
const platforms = getAllPlatforms()
</script>
<template>
<div class="games-mobile">
<van-search
v-model="keyword"
placeholder="搜索游戏"
shape="round"
show-action
@search="onSearch(keyword)"
@update:model-value="onSearch"
>
<template #action>
<span v-if="keyword" @click="keyword = ''; searchResults = []">取消</span>
</template>
</van-search>
<div class="platform-bar">
<van-tag
v-for="p in [''].concat(platforms)"
:key="p"
:type="selectedPlatform === p ? 'primary' : 'default'"
size="medium"
round
class="platform-tag"
@click="selectedPlatform = p"
>
{{ p || '全部' }}
</van-tag>
</div>
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="displayGames.length === 0" class="empty">
<van-empty :description="keyword ? '未找到游戏' : '暂无游戏'" image-size="100" />
</div>
<div v-else class="games-grid">
<div
v-for="game in displayGames.filter(g => !selectedPlatform || g.platform === selectedPlatform)"
:key="game.id"
class="game-card"
@click="openDetail(game)"
>
<img
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
<div class="game-name">{{ game.name }}</div>
<div class="game-tags">
<van-tag v-if="game.platform" plain size="medium">{{ game.platform }}</van-tag>
<van-tag v-if="game.popularCount > 0" type="danger" plain size="medium">热门{{ game.popularCount }}</van-tag>
</div>
</div>
</div>
<van-popup
v-model:show="showDetail"
position="bottom"
round
closeable
:style="{ height: '70%' }"
>
<div v-if="currentGame" class="detail-content">
<img
v-if="currentGame.cover"
:src="currentGame.cover"
class="detail-cover"
alt=""
/>
<div class="detail-info">
<h2 class="detail-name">{{ currentGame.name }}</h2>
<div class="detail-tags">
<van-tag v-if="currentGame.platform" type="primary">{{ currentGame.platform }}</van-tag>
<van-tag v-for="t in currentGame.tags" :key="t" plain>{{ t }}</van-tag>
</div>
<div v-if="currentGame.tags?.length === 0 && !currentGame.platform" class="detail-empty">
暂无详细信息
</div>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.games-mobile { padding-bottom: 16px; }
.platform-bar { display: flex; gap: 8px; padding: 0 12px 12px; overflow-x: auto; }
.platform-bar::-webkit-scrollbar { display: none; }
.platform-tag { flex-shrink: 0; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 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); }
.game-card:active { opacity: 0.85; }
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: var(--gg-bg-elevated); display: block; }
.game-name { font-size: 14px; font-weight: 500; color: var(--gg-text); padding: 8px 10px 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.game-tags { display: flex; gap: 4px; padding: 0 10px 10px; }
.detail-content { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.detail-cover { width: 100%; height: 200px; object-fit: cover; }
.detail-info { padding: 20px 16px; }
.detail-name { font-size: 22px; font-weight: 700; color: var(--gg-text); margin: 0 0 12px; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.detail-empty { color: var(--gg-text-muted); font-size: 14px; }
</style>
@@ -0,0 +1,231 @@
<!-- src/views-mobile/GroupViewMobile.vue -->
<!-- 手机端群组详情群信息 + 可横滑标签栏动态/投票/竞猜/回忆/成员/账本/资产/黑名单/统计 -->
<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 { Wallet, Box, Warning } from '@element-plus/icons-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 */ }
}
})
// 标签配置
const tabs = [
{ name: 'activity', 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">
<el-icon><Wallet /></el-icon>
<span>账本</span>
</div>
<div class="entry-item" @click="goAssets">
<el-icon><Box /></el-icon>
<span>资产</span>
</div>
<div class="entry-item" @click="goBlacklist">
<el-icon><Warning /></el-icon>
<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" />
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
</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 .el-icon {
font-size: 20px;
color: var(--gg-primary);
}
/* 标签内容 */
.tab-content {
min-height: 50vh;
}
</style>
+381
View File
@@ -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>
+463
View File
@@ -0,0 +1,463 @@
<!-- 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: '空闲', icon: '🟢' },
{ status: 'away' as const, label: '离开', icon: '⚫' },
{ status: 'working' as const, label: '工作中', icon: '🔴' },
]
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="切换状态"
:actions="statusActions.map(a => ({ name: `${a.icon} ${a.label}`, status: a.status, label: a.label }))"
@select="onStatusSelect"
/>
</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-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>
+260
View File
@@ -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>
+158
View File
@@ -0,0 +1,158 @@
<!-- 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'
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">🎮</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>
+31
View File
@@ -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>
+137
View File
@@ -0,0 +1,137 @@
<!-- 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, name: '🟢 空闲' },
{ status: 'away' as UserStatus, name: '⚫ 离开' },
{ status: 'working' as UserStatus, name: '🔴 工作中' },
]
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="切换状态"
:actions="statusActions"
@select="onStatusSelect"
/>
</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; }
</style>
@@ -0,0 +1,166 @@
<!-- 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'
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">🎮</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>
+72 -4
View File
@@ -1,9 +1,10 @@
<!-- src/views/BlacklistView.vue -->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import BlacklistMain from '@/components/gameBlacklist/BlacklistMain.vue'
import PlayerBlacklistMain from '@/components/playerBlacklist/PlayerBlacklistMain.vue'
import { ArrowLeft } from '@element-plus/icons-vue'
const route = useRoute()
@@ -12,6 +13,8 @@ const groupId = route.params.groupId as string
const group = computed(() => groupStore.currentGroup)
const activeTab = ref<'game' | 'player'>('game')
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
})
@@ -25,13 +28,39 @@ onMounted(async () => {
<el-icon><ArrowLeft /></el-icon> 返回群组
</router-link>
<div class="header-content">
<h1 class="page-title">游戏黑名单</h1>
<h1 class="page-title">黑名单</h1>
<span class="group-badge">{{ group?.name }}</span>
</div>
</section>
<!-- 黑名单内容 -->
<BlacklistMain />
<!-- Tab 切换 -->
<div class="blacklist-tabs">
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'game' }]"
@click="activeTab = 'game'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 12h4" />
<path d="M6 9h4" />
</svg>
游戏黑名单
</button>
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'player' }]"
@click="activeTab = 'player'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
玩家黑名单
</button>
</div>
<!-- 内容 -->
<BlacklistMain v-if="activeTab === 'game'" />
<PlayerBlacklistMain v-else />
</div>
</template>
@@ -99,4 +128,43 @@ onMounted(async () => {
color: var(--gg-primary-light);
font-weight: 500;
}
/* Tab */
.blacklist-tabs {
display: flex;
gap: 8px;
}
.blacklist-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg-card);
color: var(--gg-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.blacklist-tab:hover {
border-color: var(--gg-primary-light);
color: var(--gg-primary);
}
.blacklist-tab--active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
font-weight: 600;
}
.blacklist-tab__icon {
width: 16px;
height: 16px;
}
</style>
+27
View File
@@ -10,6 +10,33 @@ interface LogEntry {
}
const logs = ref<LogEntry[]>([
{
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 签发微服务' },
{ type: 'fix', text: 'LiveKit 服务端升级到 v1.10 兼容客户端 v2 SDK' },
{ type: 'fix', text: '修复 WebRTC ICE 连接失败,配置 node-ip 为宿主机地址' },
]
},
{
version: 'v0.3.1',
date: '2026-04-19',
title: '玩家黑名单',
items: [
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家(挂机、送人头、喷人、外挂等),记录玩家ID和游戏平台' },
{ type: 'feat', text: '玩家卡片聚合:按玩家ID+平台聚合展示,显示被标记次数和所有标签' },
{ type: 'feat', text: '展开详情:点击玩家卡片展开查看所有标记记录,含举报人、时间、严重程度' },
{ type: 'feat', text: '搜索筛选:按玩家ID搜索、按标签和严重程度筛选' },
{ type: 'feat', text: '黑名单页面 Tab 切换:游戏黑名单与玩家黑名单同一页面切换展示' },
{ type: 'feat', text: '自定义标签:除预定义标签外支持填写自定义标签' },
{ type: 'feat', text: '实时订阅:玩家黑名单变更实时更新,无需刷新页面' },
]
},
{
version: 'v0.3.0',
date: '2026-04-19',
+160
View File
@@ -0,0 +1,160 @@
<!-- src/views/VoiceRoom.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { useVoiceRoom } from '@/composables/useVoiceRoom'
import { pb } from '@/api/pocketbase'
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
import VoiceControls from '@/components/voice/VoiceControls.vue'
const route = useRoute()
const router = useRouter()
const teamStore = useTeamStore()
const sessionId = route.params.sessionId as string
const groupId = route.params.groupId as string
const session = computed(() => teamStore.currentSession)
const gameName = computed(() => session.value?.gameName || '语音房间')
const {
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
} = useVoiceRoom()
onMounted(async () => {
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
const userId = pb.authStore.model?.id
if (!userId) {
router.replace('/login')
return
}
await connect(sessionId)
})
onUnmounted(async () => {
await disconnect()
})
async function handleLeave() {
await disconnect()
router.replace(`/group/${groupId}`)
}
</script>
<template>
<div class="voice-room">
<header class="voice-header">
<button class="back-btn" @click="handleLeave"> 返回</button>
<h1 class="room-title">{{ gameName }}</h1>
<div class="conn-status" :class="{ on: connected }">
{{ connected ? '已连接' : '连接中...' }}
</div>
</header>
<div v-if="error" class="error-banner">{{ error }}</div>
<main class="voice-body">
<VoiceMemberGrid :participants="participants" />
<div v-if="participants.size === 0" class="empty-hint">正在连接语音...</div>
</main>
<VoiceControls
:mic-enabled="micEnabled"
:speaker-enabled="speakerEnabled"
:connected="connected"
@toggle-mic="toggleMic"
@toggle-speaker="toggleSpeaker"
@leave="handleLeave"
/>
</div>
</template>
<style scoped>
.voice-room {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--gg-bg);
}
.voice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--gg-bg-card);
border-bottom: 1px solid var(--gg-border);
}
.back-btn {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.room-title {
font-size: 18px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.conn-status {
font-size: 13px;
padding: 4px 12px;
border-radius: 12px;
background: var(--gg-bg);
color: var(--gg-text-muted);
}
.conn-status.on {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.error-banner {
margin: 16px 24px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--gg-danger);
border-radius: var(--gg-radius-sm);
color: var(--gg-danger);
font-size: 14px;
}
.voice-body {
flex: 1;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.empty-hint {
color: var(--gg-text-muted);
font-size: 15px;
}
</style>
+19
View File
@@ -9,6 +9,25 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src')
}
},
build: {
rollupOptions: {
output: {
// 代码分割:vendor 按依赖分组,避免单个超大 chunk
manualChunks: {
// Vue 核心运行时(vue + vue-router + pinia
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 桌面端 UI 库
'element-plus': ['element-plus', '@element-plus/icons-vue'],
// 手机端 UI 库
'vant': ['vant'],
// 后端 SDK
'pocketbase': ['pocketbase'],
// 语音房依赖(仅 VoiceRoom 用到,体积大,单独拆分)
'livekit': ['livekit-client'],
}
}
}
},
server: {
port: Number(process.env.VITE_PORT) || 5173,
proxy: {