Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d76ecb15d6 | |||
| f3fe7d4f2d | |||
| ceafc873c7 | |||
| 625645dad4 | |||
| a762a5bb4c | |||
| a062889a11 | |||
| 2fec2108ca | |||
| 0cde794c85 | |||
| 7f17dc826e | |||
| 01412a0a94 | |||
| 671933d960 | |||
| 6b3cd288b1 | |||
| d3ef22f06e | |||
| 068708bbd7 | |||
| f99c44f716 | |||
| 860ad7cbd8 |
@@ -43,3 +43,9 @@ backend/pb_migrations.bak/
|
||||
|
||||
# PocketBase UAT data
|
||||
backend/pb_data_uat/
|
||||
|
||||
# Electron update build artifacts
|
||||
electron-update/
|
||||
|
||||
# Docs plans (working drafts)
|
||||
docs/plans/
|
||||
|
||||
@@ -10,21 +10,34 @@ Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队
|
||||
|
||||
```bash
|
||||
# 构建前端
|
||||
# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理
|
||||
# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat
|
||||
cd frontend && npm run build
|
||||
|
||||
# 本地开发(一般不用,用 Docker 部署代替)
|
||||
# 本地 vite dev server(一般不用,用 Docker 部署代替)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 部署脚本(根目录)
|
||||
./deploy-backend.sh # 部署 PocketBase 后端
|
||||
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
|
||||
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
|
||||
./deploy-dev.sh # 构建 + 部署 Dev 全套 (PB + LiveKit + 前端, 端口 7033/8090)
|
||||
./deploy-uat.sh # 构建 + 部署 UAT 全套 (PB + LiveKit + 前端, 端口 7034/8712)
|
||||
./stop-all.sh # 停止所有服务
|
||||
|
||||
# Electron 桌面端
|
||||
cd electron && npm install
|
||||
cd electron && npm run start # 启动桌面端(默认连接 Dev)
|
||||
cd electron && npm run start:dev # 同上,显式指定 dev 环境
|
||||
cd electron && npm run start:uat # 连接 UAT 环境
|
||||
cd electron && npm run build # 打包 Windows 可执行文件
|
||||
|
||||
# 查看日志
|
||||
docker logs -f gamegroup-pb # 后端
|
||||
docker logs -f gamegroup-frontend-dev # Dev
|
||||
docker logs -f gamegroup-frontend-uat # UAT
|
||||
docker logs -f gamegroup-pb # Dev PocketBase
|
||||
docker logs -f gamegroup-pb-uat # UAT PocketBase
|
||||
docker logs -f gamegroup-livekit-dev # Dev LiveKit
|
||||
docker logs -f gamegroup-livekit-uat # UAT LiveKit
|
||||
docker logs -f gamegroup-voice-token-dev # Dev Voice Token
|
||||
docker logs -f gamegroup-voice-token-uat # UAT Voice Token
|
||||
docker logs -f gamegroup-frontend-dev # Dev 前端
|
||||
docker logs -f gamegroup-frontend-uat # UAT 前端
|
||||
```
|
||||
|
||||
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
|
||||
@@ -36,6 +49,8 @@ docker logs -f gamegroup-frontend-uat # UAT
|
||||
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
||||
- **实时通信**: PocketBase realtime subscriptions
|
||||
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
||||
- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务
|
||||
- **桌面端**: Electron 35 + `electron-store` + `electron-builder`
|
||||
|
||||
## 环境与端口
|
||||
|
||||
@@ -43,8 +58,12 @@ docker logs -f gamegroup-frontend-uat # UAT
|
||||
|------|-----|-----|
|
||||
| 前端 (nginx) | 7033 | 7034 |
|
||||
| PocketBase | 8090 | 8712 |
|
||||
| LiveKit | 7880/7881/7882(udp) | 7890/7891/7892(udp) |
|
||||
| Voice Token | 7883 | 7893 |
|
||||
|
||||
Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
|
||||
Docker Compose 文件:`docker-compose.dev.yml`(Dev 全套)、`docker-compose.uat.yml`(UAT 全套),共享 `gamegroup-net` 网络。每个环境独立运行完整的 PB + LiveKit + Voice Token + 前端服务。
|
||||
|
||||
前端 `.env` 文件:`frontend/.env.dev`(VITE_PB_URL=8711, PORT=7033)和 `frontend/.env.uat`(VITE_PB_URL=8711, PORT=7034)。注意 Dev/UAT 构建时 VITE_PB_URL 都指向 8711,因为 Docker 构建时会将其覆盖为空,由 nginx 反向代理处理。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -59,9 +78,10 @@ pocketbase.ts (PB 客户端初始化)
|
||||
```
|
||||
|
||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`)
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`, `bulletins.ts`)
|
||||
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)
|
||||
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||
- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制
|
||||
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
||||
|
||||
### 认证流程
|
||||
@@ -72,17 +92,41 @@ pocketbase.ts (PB 客户端初始化)
|
||||
|
||||
### 路由结构
|
||||
|
||||
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
|
||||
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), VoiceRoom (`/group/:groupId/voice/:sessionId`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
|
||||
|
||||
### Vite 代理 vs Nginx
|
||||
|
||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
|
||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 voice-token 端口(Dev: 7883, UAT: 7893)。
|
||||
|
||||
前端 nginx 配置有两份:`nginx.conf`(dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。
|
||||
|
||||
### 语音通话架构
|
||||
|
||||
组队会话 (`team_sessions`) 支持语音房间。语音流程:
|
||||
1. 用户点击语音按钮 → 路由跳转到 `VoiceRoom.vue`
|
||||
2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts` 的 `fetchVoiceToken`
|
||||
3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token
|
||||
4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT
|
||||
5. 前端用 token 连接 LiveKit server(Dev: `ws://192.168.1.14:7880`, UAT: `ws://192.168.1.14:7890`)
|
||||
|
||||
HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。
|
||||
|
||||
### Electron 桌面端
|
||||
|
||||
- `electron/main.js` — 主进程,加载远程 URL(dev/uat 可切换),窗口大小 1280x800
|
||||
- `electron/preload.js` — 预加载脚本(当前为空壳,保留扩展点)
|
||||
- `electron/package.json` — 独立包,依赖 `electron-store`(用于本地配置持久化)
|
||||
- 构建产物为 Windows portable 可执行文件
|
||||
|
||||
### 实时订阅管理
|
||||
|
||||
所有 PocketBase 实时订阅统一通过 `useRealtime.ts` composable 管理。组件使用时在 `onMounted` 中调用订阅方法,`onUnmounted` 时自动调用 `unsubscribeAll` 清理。不要直接在组件中创建孤立的 `pb.collection().subscribe()` 而不做清理。
|
||||
|
||||
### 数据模型(PocketBase Collections)
|
||||
|
||||
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
||||
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
|
||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom` 和 `voiceActive` 字段
|
||||
- **invitations** — 组队邀请,pending/accepted/rejected
|
||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||
- **game_comments** / **game_favorites** — 评论和收藏
|
||||
@@ -90,12 +134,13 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`),
|
||||
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
||||
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
||||
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
|
||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group。nginx 配置 `client_max_body_size 500m` 支持大文件上传
|
||||
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
||||
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
||||
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
||||
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
||||
- **notifications** — 站内通知(投票/组队/入群等事件)
|
||||
- **bulletin_posts** / **bulletin_reads** — 群组信息公示板,支持置顶/优先级/已读追踪/过期时间
|
||||
|
||||
### PocketBase 注意事项
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("gblacklist_col")
|
||||
|
||||
collection.updateRule = "reporter = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("gblacklist_col")
|
||||
|
||||
collection.updateRule = null
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
// add admins field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sf_admins",
|
||||
"name": "admins",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
}))
|
||||
|
||||
// add allowMemberManage field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sf_allowmm",
|
||||
"name": "allowMemberManage",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.schema.removeField("sf_admins")
|
||||
collection.schema.removeField("sf_allowmm")
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_events_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "events",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_title",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 2000,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_location",
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_start",
|
||||
"name": "startTime",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_end",
|
||||
"name": "endTime",
|
||||
"type": "date",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"upcoming",
|
||||
"ongoing",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_max",
|
||||
"name": "maxParticipants",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("sf_events_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_ec_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "event_comments",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_event",
|
||||
"name": "event",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "sf_events_001",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_content",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("sf_ec_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_er_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "event_rsvps",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_event",
|
||||
"name": "event",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "sf_events_001",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"going",
|
||||
"interested",
|
||||
"maybe"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_comment",
|
||||
"name": "comment",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX idx_event_user ON event_rsvps (event, user)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("sf_er_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove old url cover field
|
||||
collection.schema.removeField("cover_field")
|
||||
|
||||
// add new file cover field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "cover_file",
|
||||
"name": "cover",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"mimeTypes": ["image/jpeg","image/png","image/gif","image/webp","image/svg+xml"],
|
||||
"thumbs": null,
|
||||
"protected": false
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove file cover field
|
||||
collection.schema.removeField("cover_file")
|
||||
|
||||
// restore url cover field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "cover_field",
|
||||
"name": "cover",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"exceptDomains": null,
|
||||
"onlyDomains": null
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||
collection.viewRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
collection.listRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||
collection.viewRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// add aliases field (json array of strings)
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "aliases_field",
|
||||
"name": "aliases",
|
||||
"type": "json",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSize": 5000
|
||||
}
|
||||
}))
|
||||
|
||||
// update rules: allow group admins to update/delete
|
||||
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove aliases field
|
||||
collection.schema.removeField("aliases_field")
|
||||
|
||||
// restore original rules
|
||||
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 部署 PocketBase 后端
|
||||
|
||||
echo "🚀 部署 PocketBase 后端..."
|
||||
|
||||
docker compose -f docker-compose.backend.yml up -d --force-recreate
|
||||
|
||||
echo "✅ PocketBase 已启动"
|
||||
echo "📡 API 地址: http://192.168.1.14:8711/api/"
|
||||
echo "🔧 管理面板: http://192.168.1.14:8711/_/"
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
#!/bin/bash
|
||||
# 部署 Dev 前端
|
||||
# 部署 Dev 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||
|
||||
echo "🚀 部署 Dev 前端..."
|
||||
echo "🚀 部署 Dev 环境..."
|
||||
|
||||
# 确保网络存在
|
||||
docker network create gamegroup-net 2>/dev/null || true
|
||||
|
||||
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
||||
|
||||
echo ""
|
||||
echo "✅ Dev 环境已启动"
|
||||
echo "🌐 访问地址: http://192.168.1.14:7033"
|
||||
echo "🌐 前端: http://192.168.1.14:7033"
|
||||
echo "📡 PB API: http://192.168.1.14:8090/api/"
|
||||
echo "🔧 PB 管理面板: http://192.168.1.14:8090/_/"
|
||||
echo "🎙️ LiveKit: ws://192.168.1.14:7880"
|
||||
echo "🔑 Token: http://192.168.1.14:7883"
|
||||
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
#!/bin/bash
|
||||
# 部署 UAT 前端
|
||||
# 部署 UAT 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||
|
||||
echo "🚀 部署 UAT 前端..."
|
||||
echo "🚀 部署 UAT 环境..."
|
||||
|
||||
# 确保网络存在
|
||||
docker network create gamegroup-net 2>/dev/null || true
|
||||
|
||||
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||
|
||||
docker compose -f docker-compose.uat.yml up -d --build --force-recreate
|
||||
|
||||
echo ""
|
||||
echo "✅ UAT 环境已启动"
|
||||
echo "🌐 访问地址: http://192.168.1.14:7034"
|
||||
echo "🌐 前端: http://192.168.1.14:7034"
|
||||
echo "📡 PB API: http://192.168.1.14:8712/api/"
|
||||
echo "🔧 PB 管理面板: http://192.168.1.14:8712/_/"
|
||||
echo "🎙️ LiveKit: ws://192.168.1.14:7890"
|
||||
echo "🔑 Token: http://192.168.1.14:7893"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
- ./backend/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- GO_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token
|
||||
ports:
|
||||
- "7882:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
networks:
|
||||
gamegroup-net:
|
||||
driver: bridge
|
||||
@@ -1,4 +1,55 @@
|
||||
services:
|
||||
pocketbase-dev:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
- ./backend/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- GO_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit-dev:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit-dev
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token-dev:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token-dev
|
||||
ports:
|
||||
- "7883:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-dev
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
frontend-dev:
|
||||
build:
|
||||
context: ./frontend
|
||||
@@ -6,9 +57,13 @@ services:
|
||||
container_name: gamegroup-frontend-dev
|
||||
ports:
|
||||
- "7033:80"
|
||||
volumes:
|
||||
- ./electron-update/dev:/usr/share/nginx/html/electron-update
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-dev
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
|
||||
+10
-8
@@ -20,13 +20,13 @@ services:
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit:
|
||||
livekit-uat:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit
|
||||
container_name: gamegroup-livekit-uat
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
- "7890:7880"
|
||||
- "7891:7881/udp"
|
||||
- "7892:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
@@ -34,12 +34,12 @@ services:
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token:
|
||||
voice-token-uat:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token
|
||||
container_name: gamegroup-voice-token-uat
|
||||
ports:
|
||||
- "7882:7882"
|
||||
- "7893:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
@@ -59,6 +59,8 @@ services:
|
||||
container_name: gamegroup-frontend-uat
|
||||
ports:
|
||||
- "7034:80"
|
||||
volumes:
|
||||
- ./electron-update/uat:/usr/share/nginx/html/electron-update
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
|
||||
+125
-2
@@ -1,5 +1,22 @@
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { app, BrowserWindow, session, dialog, ipcMain } = require('electron')
|
||||
const { autoUpdater } = require('electron-updater')
|
||||
const path = require('path')
|
||||
const Store = require('electron-store')
|
||||
|
||||
const store = new Store()
|
||||
|
||||
// 同步 IPC:供 preload 读写持久化数据
|
||||
ipcMain.on('store-get-sync', (event, key) => {
|
||||
event.returnValue = store.get(key)
|
||||
})
|
||||
ipcMain.on('store-set-sync', (event, key, value) => {
|
||||
store.set(key, value)
|
||||
event.returnValue = true
|
||||
})
|
||||
ipcMain.on('store-delete-sync', (event, key) => {
|
||||
store.delete(key)
|
||||
event.returnValue = true
|
||||
})
|
||||
|
||||
const ENV_URLS = {
|
||||
dev: 'http://192.168.1.14:7033',
|
||||
@@ -15,11 +32,19 @@ function getWindowUrl() {
|
||||
return ENV_URLS.dev
|
||||
}
|
||||
|
||||
function getEnvName() {
|
||||
const url = getWindowUrl()
|
||||
if (url.includes('7034')) return 'uat'
|
||||
return 'dev'
|
||||
}
|
||||
|
||||
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
|
||||
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
|
||||
const insecureOrigins = Object.values(ENV_URLS).join(',')
|
||||
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
|
||||
|
||||
let mainWindow = null
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -36,6 +61,8 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow = win
|
||||
|
||||
const url = getWindowUrl()
|
||||
win.loadURL(url)
|
||||
|
||||
@@ -44,6 +71,101 @@ function createWindow() {
|
||||
event.preventDefault()
|
||||
win.setTitle(`Game Group - ${title}`)
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// 自动更新配置
|
||||
function setupAutoUpdater(win) {
|
||||
const env = getEnvName()
|
||||
const feedUrl = `${ENV_URLS[env]}/electron-update/`
|
||||
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl })
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('[updater] Checking for update at', feedUrl)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('[updater] Update available:', info.version)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-available', info)
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: '发现新版本',
|
||||
message: `发现新版本 ${info.version},是否立即下载更新?`,
|
||||
buttons: ['立即下载', '稍后再说'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
}).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
console.error('[updater] Download failed:', err.message)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('[updater] No update available')
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('[updater] Error:', err.message)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-error', err.message)
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
console.log(`[updater] Download progress: ${progress.percent.toFixed(1)}%`)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('download-progress', progress)
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log('[updater] Update downloaded:', info.version)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-downloaded', info)
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: '更新就绪',
|
||||
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
|
||||
buttons: ['立即重启', '稍后'],
|
||||
defaultId: 0,
|
||||
}).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发检查更新(供前端调用)
|
||||
ipcMain.on('check-for-updates', () => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error('[updater] Manual check failed:', err.message)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('quit-and-install', () => {
|
||||
autoUpdater.quitAndInstall()
|
||||
})
|
||||
|
||||
// 启动后延迟 5 秒检查更新(等窗口稳定后再检查)
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error('[updater] Initial check failed:', err.message)
|
||||
})
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -64,7 +186,8 @@ app.whenReady().then(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
createWindow()
|
||||
const win = createWindow()
|
||||
setupAutoUpdater(win)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
+28
-6
@@ -1,17 +1,25 @@
|
||||
{
|
||||
"name": "gamegroup-electron",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"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"
|
||||
"bump": "node scripts/bump-version.js",
|
||||
"bump:minor": "node scripts/bump-version.js minor",
|
||||
"bump:major": "node scripts/bump-version.js major",
|
||||
"build:dev": "node scripts/bump-version.js && electron-builder --win --config scripts/build.dev.json && node scripts/copy-to-nas.js dev",
|
||||
"build:uat": "node scripts/bump-version.js && electron-builder --win && node scripts/copy-to-nas.js uat",
|
||||
"build:portable": "node scripts/bump-version.js && electron-builder --win portable && node scripts/copy-to-nas.js uat",
|
||||
"copy-to-nas": "node scripts/copy-to-nas.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2"
|
||||
"electron-store": "^8.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0",
|
||||
@@ -26,12 +34,26 @@
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "GameGroup"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "http://nas.wjl-work.top:7034/electron-update/",
|
||||
"channel": "latest"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
|
||||
+15
-2
@@ -1,6 +1,19 @@
|
||||
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
|
||||
const { contextBridge } = require('electron')
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
|
||||
// 自动更新相关 IPC
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (_event, info) => callback(info)),
|
||||
onUpdateError: (callback) => ipcRenderer.on('update-error', (_event, message) => callback(message)),
|
||||
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, progress) => callback(progress)),
|
||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (_event, info) => callback(info)),
|
||||
|
||||
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
||||
|
||||
// 持久化存储(用于记住登录状态)
|
||||
storeGet: (key) => ipcRenderer.sendSync('store-get-sync', key),
|
||||
storeSet: (key, value) => ipcRenderer.sendSync('store-set-sync', key, value),
|
||||
storeDelete: (key) => ipcRenderer.sendSync('store-delete-sync', key),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "http://192.168.1.14:7033/electron-update/",
|
||||
"channel": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const type = process.argv[2] || 'patch' // patch | minor | major
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
||||
|
||||
let [major, minor, patch] = pkg.version.split('.').map(Number)
|
||||
|
||||
switch (type) {
|
||||
case 'major':
|
||||
major++
|
||||
minor = 0
|
||||
patch = 0
|
||||
break
|
||||
case 'minor':
|
||||
minor++
|
||||
patch = 0
|
||||
break
|
||||
case 'patch':
|
||||
default:
|
||||
patch++
|
||||
break
|
||||
}
|
||||
|
||||
pkg.version = `${major}.${minor}.${patch}`
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||
console.log(`[bump-version] ${type} -> ${pkg.version}`)
|
||||
@@ -0,0 +1,63 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const env = process.argv[2] || 'uat'
|
||||
const srcDir = path.join(__dirname, '..', 'dist')
|
||||
// 项目根目录下的 electron-update/{env},用于 docker volume 挂载给 nginx
|
||||
const localUpdateDir = path.join(__dirname, '..', '..', 'electron-update', env)
|
||||
// NAS 共享路径
|
||||
const nasDir = path.join('\\\\JIULUGNAS\\personal_folder\\CodeSpace\\GameGroup2\\electron-update', env)
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[copy-to-nas] failed to create dir ${dir}:`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function copyFilesTo(destDir, label) {
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.log(`[copy-to-nas] dist not found, skipping ${label}`)
|
||||
return
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(srcDir).filter((f) => {
|
||||
const ext = path.extname(f).toLowerCase()
|
||||
return (
|
||||
ext === '.exe' ||
|
||||
ext === '.yml' ||
|
||||
ext === '.blockmap' ||
|
||||
ext === '.nupkg' ||
|
||||
ext === '.yaml'
|
||||
)
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[copy-to-nas] no update files found in dist, skipping ${label}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!ensureDir(destDir)) return
|
||||
|
||||
for (const file of files) {
|
||||
const src = path.join(srcDir, file)
|
||||
const dest = path.join(destDir, file)
|
||||
try {
|
||||
fs.copyFileSync(src, dest)
|
||||
console.log(`[copy-to-nas] copied ${file} -> ${destDir}`)
|
||||
} catch (err) {
|
||||
console.error(`[copy-to-nas] failed to copy ${file} to ${label}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到本地目录(供 docker volume 挂载)
|
||||
copyFilesTo(localUpdateDir, 'local')
|
||||
|
||||
// 复制到 NAS
|
||||
copyFilesTo(nasDir, 'NAS')
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7033
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7883
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# UAT Environment
|
||||
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
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7890
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7893
|
||||
|
||||
+9
-1
@@ -32,13 +32,21 @@ server {
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_pass http://192.168.1.14:7883/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Electron 自动更新文件托管
|
||||
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||
location /electron-update/ {
|
||||
alias /usr/share/nginx/html/electron-update/;
|
||||
autoindex on;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
client_max_body_size 500m;
|
||||
|
||||
+11
-3
@@ -32,13 +32,21 @@ server {
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_pass http://192.168.1.14:7893/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Electron 自动更新文件托管
|
||||
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||
location /electron-update/ {
|
||||
alias /usr/share/nginx/html/electron-update/;
|
||||
autoindex on;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
@@ -52,8 +60,8 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# 静态资源缓存(排除 /api/ 路径,避免拦截 PB 文件请求)
|
||||
location ~* ^/(?!api/|voice-api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { BulletinPost, BulletinRead } from '@/types'
|
||||
|
||||
export async function createBulletin(data: {
|
||||
group: string
|
||||
title: string
|
||||
content: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}): Promise<BulletinPost> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const post = await pb.collection('bulletin_posts').create({
|
||||
group: data.group,
|
||||
creator: user.id,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
priority: data.priority || 'normal',
|
||||
pinned: data.pinned || false,
|
||||
expiresAt: data.expiresAt || '',
|
||||
})
|
||||
return post as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function listBulletins(groupId: string): Promise<BulletinPost[]> {
|
||||
const result = await pb.collection('bulletin_posts').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-pinned,-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinPost[]
|
||||
}
|
||||
|
||||
export async function getBulletin(postId: string): Promise<BulletinPost> {
|
||||
const result = await pb.collection('bulletin_posts').getOne(postId, {
|
||||
expand: 'creator',
|
||||
})
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function updateBulletin(
|
||||
postId: string,
|
||||
data: {
|
||||
title?: string
|
||||
content?: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}
|
||||
): Promise<BulletinPost> {
|
||||
const payload: Record<string, any> = {}
|
||||
if (data.title !== undefined) payload.title = data.title
|
||||
if (data.content !== undefined) payload.content = data.content
|
||||
if (data.priority !== undefined) payload.priority = data.priority
|
||||
if (data.pinned !== undefined) payload.pinned = data.pinned
|
||||
if (data.expiresAt !== undefined) payload.expiresAt = data.expiresAt
|
||||
|
||||
const result = await pb.collection('bulletin_posts').update(postId, payload)
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function deleteBulletin(postId: string): Promise<void> {
|
||||
await pb.collection('bulletin_posts').delete(postId)
|
||||
}
|
||||
|
||||
// 已读追踪
|
||||
export async function markBulletinAsRead(postId: string): Promise<BulletinRead | null> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return null
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await pb.collection('bulletin_reads').getList(1, 1, {
|
||||
filter: `post="${postId}" && user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
if (existing.items.length > 0) return existing.items[0] as unknown as BulletinRead
|
||||
|
||||
const result = await pb.collection('bulletin_reads').create({
|
||||
post: postId,
|
||||
user: user.id,
|
||||
})
|
||||
return result as unknown as BulletinRead
|
||||
}
|
||||
|
||||
export async function listMyReads(): Promise<BulletinRead[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('bulletin_reads').getFullList({
|
||||
filter: `user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinRead[]
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
export async function subscribeBulletins(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
await pb.collection('bulletin_posts').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('bulletin_posts').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import pb from './pocketbase'
|
||||
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
|
||||
|
||||
// 获取群组的活动列表
|
||||
export async function listEvents(groupId: string): Promise<Event[]> {
|
||||
const result = await pb.collection('events').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-startTime',
|
||||
expand: 'creator',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Event[]
|
||||
}
|
||||
|
||||
// 获取活动详情
|
||||
export async function getEvent(eventId: string): Promise<Event> {
|
||||
return pb.collection('events').getOne(eventId, {
|
||||
expand: 'creator,group',
|
||||
$autoCancel: false
|
||||
}) as unknown as Event
|
||||
}
|
||||
|
||||
// 创建活动
|
||||
export async function createEvent(data: {
|
||||
group: string
|
||||
title: string
|
||||
description?: string
|
||||
location?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
maxParticipants?: number
|
||||
}): Promise<Event> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
return pb.collection('events').create({
|
||||
...data,
|
||||
creator: user.id,
|
||||
status: 'upcoming'
|
||||
}) as unknown as Event
|
||||
}
|
||||
|
||||
// 更新活动
|
||||
export async function updateEvent(eventId: string, data: Partial<Event>): Promise<Event> {
|
||||
return pb.collection('events').update(eventId, data) as unknown as Event
|
||||
}
|
||||
|
||||
// 删除活动
|
||||
export async function deleteEvent(eventId: string) {
|
||||
return pb.collection('events').delete(eventId)
|
||||
}
|
||||
|
||||
// 获取活动的评论
|
||||
export async function listEventComments(eventId: string): Promise<EventComment[]> {
|
||||
const result = await pb.collection('event_comments').getFullList({
|
||||
filter: `event="${eventId}"`,
|
||||
sort: '-created',
|
||||
expand: 'user',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as EventComment[]
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
export async function createEventComment(eventId: string, content: string): Promise<EventComment> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
return pb.collection('event_comments').create({
|
||||
event: eventId,
|
||||
user: user.id,
|
||||
content
|
||||
}) as unknown as EventComment
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
export async function deleteEventComment(commentId: string) {
|
||||
return pb.collection('event_comments').delete(commentId)
|
||||
}
|
||||
|
||||
// 获取活动的 RSVP 列表
|
||||
export async function listEventRSVPs(eventId: string): Promise<EventRSVP[]> {
|
||||
const result = await pb.collection('event_rsvps').getFullList({
|
||||
filter: `event="${eventId}"`,
|
||||
sort: '-created',
|
||||
expand: 'user',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as EventRSVP[]
|
||||
}
|
||||
|
||||
// 创建/更新 RSVP
|
||||
export async function setEventRSVP(
|
||||
eventId: string,
|
||||
type: RSVPType,
|
||||
comment?: string
|
||||
): Promise<EventRSVP> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 检查是否已有 RSVP
|
||||
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||
filter: `event="${eventId}" && user="${user.id}"`
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
return pb.collection('event_rsvps').update(existing.items[0].id, {
|
||||
type,
|
||||
comment
|
||||
}) as unknown as EventRSVP
|
||||
}
|
||||
|
||||
return pb.collection('event_rsvps').create({
|
||||
event: eventId,
|
||||
user: user.id,
|
||||
type,
|
||||
comment
|
||||
}) as unknown as EventRSVP
|
||||
}
|
||||
|
||||
// 取消 RSVP
|
||||
export async function cancelEventRSVP(eventId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||
filter: `event="${eventId}" && user="${user.id}"`
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
return pb.collection('event_rsvps').delete(existing.items[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅活动变更
|
||||
export function subscribeEvents(groupId: string, callback: () => void) {
|
||||
return pb.collection('events').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.group === groupId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 订阅活动评论变更
|
||||
export function subscribeEventComments(eventId: string, callback: () => void) {
|
||||
return pb.collection('event_comments').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.event === eventId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 订阅 RSVP 变更
|
||||
export function subscribeEventRSVPs(eventId: string, callback: () => void) {
|
||||
return pb.collection('event_rsvps').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.event === eventId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
+48
-11
@@ -1,6 +1,12 @@
|
||||
import pb from './pocketbase'
|
||||
import type { Game, GamePlatform, GameComment } from '@/types'
|
||||
|
||||
// 生成游戏封面 URL
|
||||
export function getGameCoverUrl(game: Game, thumb?: string): string {
|
||||
if (!game.cover) return ''
|
||||
return pb.files.getUrl(game, game.cover, thumb ? { thumb } : undefined) as string
|
||||
}
|
||||
|
||||
// 获取群组的游戏列表
|
||||
export async function getGroupGames(groupId: string, options?: {
|
||||
page?: number
|
||||
@@ -11,7 +17,7 @@ export async function getGroupGames(groupId: string, options?: {
|
||||
const { page = 1, limit = 50, platform, search } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (platform) filter += ` && platform="${platform}"`
|
||||
if (search) filter += ` && name ~ "${search}"`
|
||||
if (search) filter += ` && (name ~ "${search}" || aliases ~ "${search}")`
|
||||
|
||||
const result = await pb.collection('games').getList(page, limit, {
|
||||
filter,
|
||||
@@ -24,17 +30,49 @@ export async function getGroupGames(groupId: string, options?: {
|
||||
// 添加游戏到群组
|
||||
export async function addGame(groupId: string, data: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
coverFile?: File
|
||||
}) {
|
||||
const user = pb.authStore.model
|
||||
return pb.collection('games').create({
|
||||
...data,
|
||||
group: groupId,
|
||||
addedBy: user?.id,
|
||||
popularCount: 0
|
||||
}, { $autoCancel: false })
|
||||
const formData = new FormData()
|
||||
formData.append('name', data.name)
|
||||
formData.append('group', groupId)
|
||||
formData.append('addedBy', user?.id || '')
|
||||
formData.append('popularCount', '0')
|
||||
if (data.platform) formData.append('platform', data.platform)
|
||||
if (data.aliases && data.aliases.length > 0) formData.append('aliases', JSON.stringify(data.aliases))
|
||||
if (data.tags && data.tags.length > 0) formData.append('tags', JSON.stringify(data.tags))
|
||||
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||
|
||||
return pb.collection('games').create(formData, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 更新游戏
|
||||
export async function updateGame(gameId: string, data: {
|
||||
name?: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform | ''
|
||||
tags?: string[]
|
||||
coverFile?: File
|
||||
}) {
|
||||
const formData = new FormData()
|
||||
if (data.name !== undefined) formData.append('name', data.name)
|
||||
if (data.aliases && data.aliases.length > 0) {
|
||||
formData.append('aliases', JSON.stringify(data.aliases))
|
||||
} else {
|
||||
formData.append('aliases', '[]')
|
||||
}
|
||||
if (data.platform !== undefined) formData.append('platform', data.platform || '')
|
||||
if (data.tags && data.tags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(data.tags))
|
||||
} else {
|
||||
formData.append('tags', '[]')
|
||||
}
|
||||
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||
|
||||
return pb.collection('games').update(gameId, formData, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 删除游戏
|
||||
@@ -42,12 +80,11 @@ export async function deleteGame(gameId: string) {
|
||||
return pb.collection('games').delete(gameId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 导入游戏(批量添加)
|
||||
// 导入游戏(批量添加,无封面文件)
|
||||
export async function importGames(groupId: string, games: Array<{
|
||||
name: string
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
}>) {
|
||||
const results = []
|
||||
for (const game of games) {
|
||||
@@ -137,7 +174,7 @@ export function getAllPlatforms(): GamePlatform[] {
|
||||
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
||||
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
||||
if (!query.trim()) return []
|
||||
let filter = `name ~ "${query}"`
|
||||
let filter = `(name ~ "${query}" || aliases ~ "${query}")`
|
||||
if (groupId) filter += ` && group="${groupId}"`
|
||||
|
||||
const result = await pb.collection('games').getList(1, limit, {
|
||||
|
||||
@@ -118,6 +118,20 @@ export async function getMyGroupsJoinRequests(): Promise<JoinRequest[]> {
|
||||
return result.items as unknown as JoinRequest[]
|
||||
}
|
||||
|
||||
// 获取当前用户的所有入群申请(包括所有状态)
|
||||
export async function getMyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('join_requests').getList(1, 50, {
|
||||
filter: `user="${user.id}"`,
|
||||
sort: '-created',
|
||||
expand: 'group',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as JoinRequest[]
|
||||
}
|
||||
|
||||
// 审批加入申请
|
||||
export async function respondJoinRequest(
|
||||
requestId: string,
|
||||
@@ -189,6 +203,78 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void
|
||||
})
|
||||
}
|
||||
|
||||
// 转让群主
|
||||
// 安全提示:PB groups updateRule 允许认证用户更新,前端校验是唯一防线。
|
||||
// 如需服务端强制保护,需添加 PocketBase JS hooks 或改用更严格的 updateRule。
|
||||
export async function transferGroupOwnership(groupId: string, newOwnerId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以转让群组')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
|
||||
// 将原群主加入 admins,新群主从 admins 中移除
|
||||
const newAdmins = [...new Set([...admins, user.id])].filter(id => id !== newOwnerId)
|
||||
|
||||
return pb.collection('groups').update(groupId, {
|
||||
owner: newOwnerId,
|
||||
admins: newAdmins
|
||||
})
|
||||
}
|
||||
|
||||
// 添加管理员
|
||||
export async function addGroupAdmin(groupId: string, userId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以设置管理员')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
if (admins.includes(userId)) {
|
||||
throw new Error('该用户已是管理员')
|
||||
}
|
||||
|
||||
return pb.collection('groups').update(groupId, {
|
||||
admins: [...admins, userId]
|
||||
})
|
||||
}
|
||||
|
||||
// 移除管理员
|
||||
export async function removeGroupAdmin(groupId: string, userId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以移除管理员')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
return pb.collection('groups').update(groupId, {
|
||||
admins: admins.filter(id => id !== userId)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新全员管理设置
|
||||
export async function updateGroupMemberManage(groupId: string, allow: boolean) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以修改此设置')
|
||||
}
|
||||
|
||||
return pb.collection('groups').update(groupId, { allowMemberManage: allow })
|
||||
}
|
||||
|
||||
// 订阅加入申请变更
|
||||
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
|
||||
return pb.collection('join_requests').subscribe('*', (payload) => {
|
||||
|
||||
@@ -7,6 +7,27 @@ export const pb = new PocketBase(pbUrl)
|
||||
|
||||
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
||||
|
||||
// Electron 环境下使用 electron-store 做更持久的备份
|
||||
const eAPI = (window as any).electronAPI
|
||||
if (eAPI?.storeGet) {
|
||||
// 若 localStorage 无有效认证,尝试从 electron-store 恢复
|
||||
if (!pb.authStore.isValid) {
|
||||
const saved = eAPI.storeGet('pb_auth')
|
||||
if (saved?.token && saved?.model) {
|
||||
pb.authStore.save(saved.token, saved.model)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证状态变化时同步到 electron-store
|
||||
pb.authStore.onChange((token: string, model: any) => {
|
||||
if (token && model) {
|
||||
eAPI.storeSet('pb_auth', { token, model })
|
||||
} else {
|
||||
eAPI.storeDelete('pb_auth')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export function getCurrentUser() {
|
||||
return pb.authStore.model
|
||||
@@ -20,6 +41,10 @@ export function isAuthenticated(): boolean {
|
||||
// 登出
|
||||
export function logout() {
|
||||
pb.authStore.clear()
|
||||
const eAPI = (window as any).electronAPI
|
||||
if (eAPI?.storeDelete) {
|
||||
eAPI.storeDelete('pb_auth')
|
||||
}
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise<TeamSession
|
||||
return result.items as unknown as TeamSession[]
|
||||
}
|
||||
|
||||
// 获取单个临时小组详情
|
||||
export async function getTeamSession(sessionId: string): Promise<TeamSession> {
|
||||
return pb.collection('team_sessions').getOne(sessionId, {
|
||||
expand: 'sourceGroup',
|
||||
$autoCancel: false
|
||||
}) as unknown as TeamSession
|
||||
}
|
||||
|
||||
// 更新临时小组状态
|
||||
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
|
||||
const updateData: Partial<TeamSession> = { status }
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { subscribeBulletins } from '@/api/bulletins'
|
||||
import BulletinPostCard from './BulletinPostCard.vue'
|
||||
import CreateBulletinDialog from './CreateBulletinDialog.vue'
|
||||
import type { BulletinPost } from '@/types'
|
||||
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const editingPost = ref<BulletinPost | null>(null)
|
||||
const detailPost = ref<BulletinPost | null>(null)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
async function loadAll() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await bulletinStore.loadPosts(groupId)
|
||||
}
|
||||
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBulletins(groupId, () => {
|
||||
loadAll()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(post: BulletinPost) {
|
||||
detailPost.value = post
|
||||
bulletinStore.markAsRead(post.id)
|
||||
}
|
||||
|
||||
function handleEdit(post: BulletinPost) {
|
||||
editingPost.value = post
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(post: BulletinPost) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除公告 "${post.title}" 吗?`,
|
||||
'确认删除',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await bulletinStore.remove(post.id)
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
editingPost.value = null
|
||||
}
|
||||
|
||||
const detailVisible = computed({
|
||||
get: () => !!detailPost.value,
|
||||
set: (v: boolean) => { if (!v) detailPost.value = null }
|
||||
})
|
||||
|
||||
function priorityLabel(post: BulletinPost) { return BulletinPriorityMap[post.priority] }
|
||||
function priorityColor(post: BulletinPost) { return BulletinPriorityColor[post.priority] }
|
||||
function creatorName(post: BulletinPost) { return displayName(post.expand?.creator) }
|
||||
function isExpired(post: BulletinPost) {
|
||||
return post.expiresAt ? new Date(post.expiresAt) <= new Date() : false
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function handleDeleteFromDetail(post: BulletinPost) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除公告 "${post.title}" 吗?`,
|
||||
'确认删除',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await bulletinStore.remove(post.id)
|
||||
detailPost.value = null
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadAll()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadAll()
|
||||
if (!unsubscribeFn) startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulletin-board">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bulletin-board__header">
|
||||
<h3 class="bulletin-board__title">公告</h3>
|
||||
<button class="bulletin-board__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bulletin-board__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
发布公告
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateBulletinDialog
|
||||
v-model="showCreate"
|
||||
:edit-post="editingPost"
|
||||
@created="loadAll(); showCreate = false; handleDialogClose()"
|
||||
@updated="loadAll(); showCreate = false; handleDialogClose()"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="null"
|
||||
width="580px"
|
||||
:close-on-click-modal="true"
|
||||
class="bulletin-detail-dialog"
|
||||
@closed="detailPost = null"
|
||||
>
|
||||
<template v-if="detailPost">
|
||||
<div class="bulletin-detail__tags">
|
||||
<span
|
||||
class="bulletin-detail__priority"
|
||||
:style="{ background: priorityColor(detailPost) + '18', color: priorityColor(detailPost) }"
|
||||
>
|
||||
{{ priorityLabel(detailPost) }}
|
||||
</span>
|
||||
<span v-if="detailPost.pinned" class="bulletin-detail__pinned">置顶</span>
|
||||
<span v-if="isExpired(detailPost)" class="bulletin-detail__expired">已过期</span>
|
||||
</div>
|
||||
<h2 class="bulletin-detail__title">{{ detailPost.title }}</h2>
|
||||
<div class="bulletin-detail__meta">
|
||||
<span class="bulletin-detail__author">{{ creatorName(detailPost) }}</span>
|
||||
<span class="bulletin-detail__sep">·</span>
|
||||
<span class="bulletin-detail__time">{{ formatTime(detailPost.created) }}</span>
|
||||
<span v-if="detailPost.expiresAt" class="bulletin-detail__expires">
|
||||
· 截止 {{ formatDate(detailPost.expiresAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bulletin-detail__content">{{ detailPost.content }}</div>
|
||||
<div class="bulletin-detail__footer">
|
||||
<button class="bulletin-detail__btn" @click="handleEdit(detailPost!); detailPost = null">编辑</button>
|
||||
<button class="bulletin-detail__btn bulletin-detail__btn--danger" @click="handleDeleteFromDetail(detailPost!)">删除</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 置顶公告 -->
|
||||
<section v-if="bulletinStore.pinnedPosts.length > 0" class="bulletin-board__section">
|
||||
<div class="bulletin-board__section-header">
|
||||
<span class="bulletin-board__section-dot bulletin-board__section-dot--pinned"></span>
|
||||
<span class="bulletin-board__section-label">置顶公告</span>
|
||||
<span class="bulletin-board__section-count">{{ bulletinStore.pinnedPosts.length }}</span>
|
||||
</div>
|
||||
<div class="bulletin-board__grid">
|
||||
<BulletinPostCard
|
||||
v-for="post in bulletinStore.pinnedPosts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:is-read="bulletinStore.isRead(post.id)"
|
||||
@click="handleCardClick(post)"
|
||||
@edit="handleEdit(post)"
|
||||
@delete="handleDelete(post)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 普通公告 -->
|
||||
<section v-if="bulletinStore.normalPosts.length > 0" class="bulletin-board__section">
|
||||
<div class="bulletin-board__section-header">
|
||||
<span class="bulletin-board__section-dot bulletin-board__section-dot--normal"></span>
|
||||
<span class="bulletin-board__section-label">普通公告</span>
|
||||
<span class="bulletin-board__section-count">{{ bulletinStore.normalPosts.length }}</span>
|
||||
</div>
|
||||
<div class="bulletin-board__grid">
|
||||
<BulletinPostCard
|
||||
v-for="post in bulletinStore.normalPosts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:is-read="bulletinStore.isRead(post.id)"
|
||||
@click="handleCardClick(post)"
|
||||
@edit="handleEdit(post)"
|
||||
@delete="handleDelete(post)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="bulletinStore.pinnedPosts.length === 0 && bulletinStore.normalPosts.length === 0 && !bulletinStore.loading"
|
||||
class="bulletin-board__empty"
|
||||
>
|
||||
<svg class="bulletin-board__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<p class="bulletin-board__empty-text">暂无公告</p>
|
||||
<p class="bulletin-board__empty-hint">群组内还没有发布过公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bulletin-board__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bulletin-board__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bulletin-board__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-board__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bulletin-board__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 分栏 */
|
||||
.bulletin-board__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bulletin-board__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot--pinned {
|
||||
background: var(--gg-primary);
|
||||
box-shadow: 0 0 6px var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot--normal {
|
||||
background: var(--gg-info);
|
||||
box-shadow: 0 0 6px var(--gg-info);
|
||||
}
|
||||
|
||||
.bulletin-board__section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bulletin-board__section-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.bulletin-board__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.bulletin-board__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bulletin-board__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.bulletin-detail-dialog .el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
.bulletin-detail-dialog .el-dialog__body {
|
||||
padding: 28px 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-detail__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bulletin-detail__priority,
|
||||
.bulletin-detail__pinned,
|
||||
.bulletin-detail__expired {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulletin-detail__pinned {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-detail__expired {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bulletin-detail__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bulletin-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.bulletin-detail__sep {
|
||||
color: var(--gg-border);
|
||||
}
|
||||
|
||||
.bulletin-detail__content {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bulletin-detail__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.bulletin-detail__btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-detail__btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-detail__btn--danger:hover {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { BulletinPriorityMap } from '@/types'
|
||||
import type { BulletinPost } from '@/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewAll: []
|
||||
}>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
|
||||
const hasPinned = computed(() => bulletinStore.pinnedPosts.length > 0)
|
||||
|
||||
function handleClick(post: BulletinPost) {
|
||||
bulletinStore.markAsRead(post.id)
|
||||
emit('viewAll')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasPinned" class="bulletin-pinned">
|
||||
<div
|
||||
v-for="post in bulletinStore.pinnedPosts"
|
||||
:key="post.id"
|
||||
class="pinned-item"
|
||||
:class="{ 'pinned-item--urgent': post.priority === 'urgent' }"
|
||||
@click="handleClick(post)"
|
||||
>
|
||||
<span class="pinned-item__dot" />
|
||||
<span class="pinned-item__priority">{{ BulletinPriorityMap[post.priority] }}</span>
|
||||
<span class="pinned-item__title">{{ post.title }}</span>
|
||||
<span v-if="!bulletinStore.isRead(post.id)" class="pinned-item__unread" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-pinned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pinned-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-left: 3px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pinned-item:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.pinned-item--urgent {
|
||||
border-left-color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
|
||||
.pinned-item--urgent:hover {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.pinned-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinned-item--urgent .pinned-item__dot {
|
||||
background: var(--gg-danger);
|
||||
}
|
||||
|
||||
.pinned-item__priority {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinned-item--urgent .pinned-item__priority {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.pinned-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinned-item__unread {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
box-shadow: 0 0 6px var(--gg-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { BulletinPost } from '@/types'
|
||||
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
post: BulletinPost
|
||||
isRead: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
edit: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const priorityLabel = computed(() => BulletinPriorityMap[props.post.priority])
|
||||
const priorityColor = computed(() => BulletinPriorityColor[props.post.priority])
|
||||
|
||||
const creatorName = computed(() =>
|
||||
displayName(props.post.expand?.creator)
|
||||
)
|
||||
|
||||
const creatorAvatar = computed(() =>
|
||||
props.post.expand?.creator?.avatar || '/default-avatar.svg'
|
||||
)
|
||||
|
||||
// 内容摘要(最多3行)
|
||||
const contentSummary = computed(() => {
|
||||
const text = props.post.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
return text.length > 120 ? text.slice(0, 120) + '...' : text
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const timeText = computed(() => {
|
||||
const d = new Date(props.post.created)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!props.post.expiresAt) return false
|
||||
return new Date(props.post.expiresAt) <= new Date()
|
||||
})
|
||||
|
||||
function handleEdit(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
function handleDelete(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bulletin-card"
|
||||
:class="{
|
||||
'bulletin-card--pinned': post.pinned,
|
||||
'bulletin-card--urgent': post.priority === 'urgent',
|
||||
'bulletin-card--expired': isExpired,
|
||||
'bulletin-card--unread': !isRead,
|
||||
}"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- 未读标记 -->
|
||||
<span v-if="!isRead" class="bulletin-card__unread-dot" />
|
||||
|
||||
<!-- 顶部标签 -->
|
||||
<div class="bulletin-card__tags">
|
||||
<span
|
||||
class="bulletin-card__priority-tag"
|
||||
:style="{ background: priorityColor + '18', color: priorityColor }"
|
||||
>
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
<span v-if="post.pinned" class="bulletin-card__pinned-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<path d="M12 2l3 7h7l-5.5 4 2 7-6.5-5-6.5 5 2-7L2 9h7z" />
|
||||
</svg>
|
||||
置顶
|
||||
</span>
|
||||
<span v-if="isExpired" class="bulletin-card__expired-tag">已过期</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="bulletin-card__title">{{ post.title }}</div>
|
||||
|
||||
<!-- 内容摘要 -->
|
||||
<div class="bulletin-card__content" v-html="contentSummary" />
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="bulletin-card__footer">
|
||||
<div class="bulletin-card__author">
|
||||
<img :src="creatorAvatar" :alt="creatorName" class="bulletin-card__avatar" />
|
||||
<span class="bulletin-card__author-name">{{ creatorName }}</span>
|
||||
</div>
|
||||
<span class="bulletin-card__time">{{ timeText }}</span>
|
||||
<div class="bulletin-card__actions" @click.stop>
|
||||
<button class="bulletin-card__action-btn" @click="handleEdit">编辑</button>
|
||||
<button class="bulletin-card__action-btn bulletin-card__action-btn--danger" @click="handleDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bulletin-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bulletin-card--pinned {
|
||||
border-left: 3px solid var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card--urgent {
|
||||
border-left: 3px solid var(--gg-danger);
|
||||
}
|
||||
|
||||
.bulletin-card--urgent:hover {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.bulletin-card--expired {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* 未读标记 */
|
||||
.bulletin-card__unread-dot {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
box-shadow: 0 0 6px var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.bulletin-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bulletin-card__priority-tag,
|
||||
.bulletin-card__pinned-tag,
|
||||
.bulletin-card__expired-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.bulletin-card__pinned-tag {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card__expired-tag {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.bulletin-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 内容 */
|
||||
.bulletin-card__content {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bulletin-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.bulletin-card__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bulletin-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bulletin-card__author-name {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bulletin-card__time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulletin-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-card:hover .bulletin-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn {
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn:hover {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn--danger:hover {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bulletin-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { BulletinPriority } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
editPost?: {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
priority: BulletinPriority
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
} | null
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const isEdit = computed(() => !!props.editPost)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'normal' as BulletinPriority,
|
||||
pinned: false,
|
||||
expiresAt: '' as string | Date,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const priorityOptions: { label: string; value: BulletinPriority }[] = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '普通', value: 'normal' },
|
||||
{ label: '高', value: 'high' },
|
||||
{ label: '紧急', value: 'urgent' },
|
||||
]
|
||||
|
||||
function resetForm() {
|
||||
if (props.editPost) {
|
||||
form.value = {
|
||||
title: props.editPost.title,
|
||||
content: props.editPost.content,
|
||||
priority: props.editPost.priority,
|
||||
pinned: props.editPost.pinned,
|
||||
expiresAt: props.editPost.expiresAt || '',
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'normal',
|
||||
pinned: false,
|
||||
expiresAt: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!form.value.content.trim()) {
|
||||
ElMessage.warning('请输入公告内容')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
title: form.value.title.trim(),
|
||||
content: form.value.content.trim(),
|
||||
priority: form.value.priority,
|
||||
pinned: form.value.pinned,
|
||||
expiresAt: form.value.expiresAt
|
||||
? new Date(String(form.value.expiresAt)).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (isEdit.value && props.editPost) {
|
||||
await bulletinStore.update(props.editPost.id, data)
|
||||
ElMessage.success('公告更新成功')
|
||||
emit('updated')
|
||||
} else {
|
||||
await bulletinStore.create({ group: groupId, ...data })
|
||||
ElMessage.success('公告发布成功')
|
||||
emit('created')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑公告' : '发布公告'"
|
||||
width="520px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入公告标题"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="form-field">
|
||||
<label>内容 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="支持 HTML 标签(如 <b>加粗</b>、<i>斜体</i>、<a href='...'>链接</a>)"
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 优先级 -->
|
||||
<div class="form-field">
|
||||
<label>优先级</label>
|
||||
<el-select v-model="form.priority" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in priorityOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 置顶开关 -->
|
||||
<div class="form-field form-field--inline">
|
||||
<label>置顶显示</label>
|
||||
<el-switch v-model="form.pinned" />
|
||||
</div>
|
||||
|
||||
<!-- 过期时间 -->
|
||||
<div class="form-field">
|
||||
<label>过期时间(可选)</label>
|
||||
<el-date-picker
|
||||
v-model="form.expiresAt"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '提交中...' : (isEdit ? '保存' : '发布') }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useEventStore } from '@/stores/event'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
groupId: string
|
||||
editEvent?: { id: string; title: string; description?: string; location?: string; startTime: string; endTime?: string; maxParticipants?: number } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const eventStore = useEventStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const location = ref('')
|
||||
const startTime = ref('')
|
||||
const endTime = ref('')
|
||||
const maxParticipants = ref<number | undefined>(undefined)
|
||||
const submitting = ref(false)
|
||||
|
||||
const isEdit = computed(() => !!props.editEvent)
|
||||
|
||||
watch(() => props.editEvent, (evt) => {
|
||||
if (evt) {
|
||||
title.value = evt.title
|
||||
description.value = evt.description || ''
|
||||
location.value = evt.location || ''
|
||||
startTime.value = formatForInput(evt.startTime)
|
||||
endTime.value = evt.endTime ? formatForInput(evt.endTime) : ''
|
||||
maxParticipants.value = evt.maxParticipants
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatForInput(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
title.value = ''
|
||||
description.value = ''
|
||||
location.value = ''
|
||||
startTime.value = ''
|
||||
endTime.value = ''
|
||||
maxParticipants.value = undefined
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.value.trim()) {
|
||||
ElMessage.warning('请输入活动标题')
|
||||
return
|
||||
}
|
||||
if (!startTime.value) {
|
||||
ElMessage.warning('请选择开始时间')
|
||||
return
|
||||
}
|
||||
if (endTime.value && new Date(endTime.value) <= new Date(startTime.value)) {
|
||||
ElMessage.warning('结束时间必须晚于开始时间')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
group: props.groupId,
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
location: location.value.trim() || undefined,
|
||||
startTime: new Date(startTime.value).toISOString(),
|
||||
endTime: endTime.value ? new Date(endTime.value).toISOString() : undefined,
|
||||
maxParticipants: maxParticipants.value
|
||||
}
|
||||
|
||||
if (isEdit.value && props.editEvent) {
|
||||
await eventStore.editEvent(props.editEvent.id, data)
|
||||
ElMessage.success('活动已更新')
|
||||
emit('updated')
|
||||
} else {
|
||||
await eventStore.addEvent(data)
|
||||
ElMessage.success('活动创建成功')
|
||||
emit('created')
|
||||
}
|
||||
visible.value = false
|
||||
reset()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (!isEdit.value) reset()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑活动' : '发起活动'"
|
||||
width="520px"
|
||||
@close="onClose"
|
||||
>
|
||||
<div class="form">
|
||||
<div class="field">
|
||||
<label>活动标题 <span class="required">*</span></label>
|
||||
<el-input v-model="title" placeholder="例如:周末面基开黑" maxlength="200" show-word-limit />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>活动地点</label>
|
||||
<el-input v-model="location" placeholder="例如:XX 网咖 / XX 餐厅" maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field field--half">
|
||||
<label>开始时间 <span class="required">*</span></label>
|
||||
<el-date-picker
|
||||
v-model="startTime"
|
||||
type="datetime"
|
||||
placeholder="选择开始时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DDTHH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="field field--half">
|
||||
<label>结束时间</label>
|
||||
<el-date-picker
|
||||
v-model="endTime"
|
||||
type="datetime"
|
||||
placeholder="选择结束时间(可选)"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DDTHH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>人数上限</label>
|
||||
<el-input-number v-model="maxParticipants" :min="1" :max="999" placeholder="不限" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>活动详情</label>
|
||||
<el-input
|
||||
v-model="description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述一下活动内容..."
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field--half {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useEventStore } from '@/stores/event'
|
||||
import { EventStatusMap, EventStatusColor, RSVPTypeMap, displayName } from '@/types'
|
||||
import type { Event, RSVPType } from '@/types'
|
||||
import { Calendar, Location, User, ChatDotRound } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
event: Event
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [event: Event]
|
||||
rsvp: [eventId: string, type: RSVPType]
|
||||
comment: [eventId: string, content: string]
|
||||
delete: [event: Event]
|
||||
edit: [event: Event]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const eventStore = useEventStore()
|
||||
|
||||
const commentInput = ref('')
|
||||
|
||||
const canManage = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
return groupStore.isGroupOwner ||
|
||||
(props.event.creator === userId)
|
||||
})
|
||||
|
||||
const canDelete = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
return groupStore.isGroupOwner ||
|
||||
(props.event.creator === userId)
|
||||
})
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatRelative(event: Event): string {
|
||||
if (event.status === 'completed') return '已结束'
|
||||
if (event.status === 'cancelled') return '已取消'
|
||||
if (event.status === 'ongoing') return '进行中'
|
||||
const diff = new Date(event.startTime).getTime() - Date.now()
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
if (days <= 0) return '今天'
|
||||
if (days === 1) return '明天'
|
||||
return `${days} 天后`
|
||||
}
|
||||
|
||||
function getRSVPCounts(eventId: string) {
|
||||
return {
|
||||
going: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'going').length,
|
||||
interested: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'interested').length,
|
||||
maybe: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'maybe').length
|
||||
}
|
||||
}
|
||||
|
||||
function getMyRSVP(eventId: string) {
|
||||
const userId = pb.authStore.model?.id
|
||||
return eventStore.rsvps.find(r => r.event === eventId && r.user === userId)
|
||||
}
|
||||
|
||||
const counts = computed(() => getRSVPCounts(props.event.id))
|
||||
const myRSVP = computed(() => getMyRSVP(props.event.id))
|
||||
const comments = computed(() =>
|
||||
eventStore.comments.filter(c => c.event === props.event.id)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-card">
|
||||
<div class="event-card-header" @click="emit('toggle', event)">
|
||||
<div class="event-main">
|
||||
<h3 class="event-title">{{ event.title }}</h3>
|
||||
<span
|
||||
class="event-status-tag"
|
||||
:style="{ background: EventStatusColor[event.status] + '20', color: EventStatusColor[event.status] }"
|
||||
>
|
||||
{{ EventStatusMap[event.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="event-relative">{{ formatRelative(event) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-info">
|
||||
<span class="info-item">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
{{ formatDateTime(event.startTime) }}
|
||||
</span>
|
||||
<span v-if="event.location" class="info-item">
|
||||
<el-icon><Location /></el-icon>
|
||||
{{ event.location }}
|
||||
</span>
|
||||
<span class="info-item">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ counts.going }} 参加 · {{ counts.interested }} 感兴趣
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="event-expanded">
|
||||
<p v-if="event.description" class="event-desc">{{ event.description }}</p>
|
||||
|
||||
<div class="rsvp-section">
|
||||
<span class="rsvp-label">我要参加:</span>
|
||||
<div class="rsvp-buttons">
|
||||
<button
|
||||
v-for="type in (['going', 'interested', 'maybe'] as RSVPType[])"
|
||||
:key="type"
|
||||
class="rsvp-btn"
|
||||
:class="{ 'rsvp-btn--active': myRSVP?.type === type }"
|
||||
@click.stop="emit('rsvp', event.id, type)"
|
||||
>
|
||||
{{ RSVPTypeMap[type] }} {{ counts[type] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<div class="comments-header">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
评论 ({{ comments.length }})
|
||||
</div>
|
||||
<p v-if="comments.length === 0" class="no-comments">暂无评论,来说两句吧</p>
|
||||
<div v-else class="comments-list">
|
||||
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||
<img :src="c.expand?.user?.avatar ? `${pb.baseUrl}/api/files/users/${c.user}/${c.expand.user.avatar}` : '/default-avatar.svg'" class="comment-avatar" />
|
||||
<div class="comment-body">
|
||||
<div class="comment-meta">
|
||||
<span class="comment-author">{{ displayName(c.expand?.user) }}</span>
|
||||
<span class="comment-time">{{ formatDateTime(c.created) }}</span>
|
||||
</div>
|
||||
<p class="comment-text">{{ c.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input-row">
|
||||
<input
|
||||
v-model="commentInput"
|
||||
class="comment-input"
|
||||
placeholder="写个评论..."
|
||||
@keyup.enter="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||
/>
|
||||
<button
|
||||
class="comment-send-btn"
|
||||
@click.stop="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canManage || canDelete" class="event-actions">
|
||||
<button v-if="canManage" class="action-link" @click.stop="emit('edit', event)">编辑</button>
|
||||
<button v-if="canDelete" class="action-link action-link--danger" @click.stop="emit('delete', event)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.event-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-status-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-relative {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 0 18px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.event-expanded {
|
||||
padding: 0 18px 16px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
margin-top: 4px;
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rsvp-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rsvp-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.rsvp-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rsvp-btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rsvp-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.rsvp-btn--active {
|
||||
background: var(--gg-primary);
|
||||
border-color: var(--gg-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--gg-bg);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.comment-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comment-input:focus {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.comment-send-btn {
|
||||
padding: 8px 16px;
|
||||
background: var(--gg-gradient);
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comment-send-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gg-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-link--danger {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useEventStore } from '@/stores/event'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { Event } from '@/types'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import CreateEventDialog from './CreateEventDialog.vue'
|
||||
import EventCard from './EventCard.vue'
|
||||
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
|
||||
|
||||
const route = useRoute()
|
||||
const eventStore = useEventStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const groupId = route.params.id as string
|
||||
const showCreateDialog = ref(false)
|
||||
const editingEvent = ref<Event | null>(null)
|
||||
const expandedEventId = ref<string | null>(null)
|
||||
|
||||
const isMember = computed(() => !!groupStore.currentGroup)
|
||||
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
let unsubscribeCommentsFn: (() => void) | null = null
|
||||
let unsubscribeRSVPsFn: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await eventStore.loadEvents(groupId)
|
||||
unsubscribeFn = await subscribeEvents(groupId, () => {
|
||||
eventStore.loadEvents(groupId)
|
||||
if (expandedEventId.value) {
|
||||
eventStore.loadCommentsAndRSVPs(expandedEventId.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) unsubscribeFn()
|
||||
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||
unsubscribeFn = null
|
||||
unsubscribeCommentsFn = null
|
||||
unsubscribeRSVPsFn = null
|
||||
eventStore.clear()
|
||||
})
|
||||
|
||||
async function toggleExpand(event: Event) {
|
||||
if (expandedEventId.value === event.id) {
|
||||
expandedEventId.value = null
|
||||
if (unsubscribeCommentsFn) { unsubscribeCommentsFn(); unsubscribeCommentsFn = null }
|
||||
if (unsubscribeRSVPsFn) { unsubscribeRSVPsFn(); unsubscribeRSVPsFn = null }
|
||||
} else {
|
||||
expandedEventId.value = event.id
|
||||
await eventStore.loadCommentsAndRSVPs(event.id)
|
||||
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||
unsubscribeCommentsFn = await subscribeEventComments(event.id, () => {
|
||||
eventStore.loadCommentsAndRSVPs(event.id)
|
||||
})
|
||||
unsubscribeRSVPsFn = await subscribeEventRSVPs(event.id, () => {
|
||||
eventStore.loadCommentsAndRSVPs(event.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRSVP(eventId: string, type: 'going' | 'interested' | 'maybe') {
|
||||
const current = eventStore.rsvps.find(r => r.event === eventId && r.user === pb.authStore.model?.id)
|
||||
if (current?.type === type) {
|
||||
await eventStore.cancelRSVP(eventId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await eventStore.rsvp(eventId, type)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComment(eventId: string, content: string) {
|
||||
const text = content.trim()
|
||||
if (!text) return
|
||||
try {
|
||||
await eventStore.addComment(eventId, text)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '评论失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(event: Event) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个活动吗?', '确认删除', { type: 'warning' })
|
||||
await eventStore.removeEvent(event.id)
|
||||
ElMessage.success('已删除')
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(event: Event) {
|
||||
editingEvent.value = event
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
editingEvent.value = null
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-list">
|
||||
<div class="list-header">
|
||||
<h2 class="list-title">群组活动</h2>
|
||||
<button v-if="isMember" class="create-btn" @click="showCreateDialog = true">
|
||||
<el-icon><Plus /></el-icon> 发起活动
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="eventStore.loading && eventStore.events.length === 0" class="loading-state">
|
||||
<div class="loading-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="eventStore.events.length === 0" class="empty-state">
|
||||
<p>暂无活动</p>
|
||||
<p v-if="isMember" class="empty-hint">点击右上角发起一个活动吧</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="event-sections">
|
||||
<div v-if="eventStore.upcomingEvents.length > 0" class="section">
|
||||
<h3 class="section-label">即将开始</h3>
|
||||
<div class="event-cards">
|
||||
<EventCard
|
||||
v-for="event in eventStore.upcomingEvents"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:expanded="expandedEventId === event.id"
|
||||
@toggle="toggleExpand"
|
||||
@rsvp="handleRSVP"
|
||||
@comment="handleComment"
|
||||
@delete="handleDelete"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="eventStore.pastEvents.length > 0" class="section">
|
||||
<h3 class="section-label">历史活动</h3>
|
||||
<div class="event-cards">
|
||||
<EventCard
|
||||
v-for="event in eventStore.pastEvents"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:expanded="expandedEventId === event.id"
|
||||
@toggle="toggleExpand"
|
||||
@rsvp="handleRSVP"
|
||||
@comment="handleComment"
|
||||
@delete="handleDelete"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateEventDialog
|
||||
v-model="showCreateDialog"
|
||||
:group-id="groupId"
|
||||
:edit-event="editingEvent"
|
||||
@created="eventStore.loadEvents(groupId)"
|
||||
@updated="eventStore.loadEvents(groupId)"
|
||||
@update:model-value="onDialogClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gg-border);
|
||||
border-top-color: var(--gg-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
margin-top: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.event-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.event-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,8 @@ import { addGame } from '@/api/games'
|
||||
import type { GamePlatform } from '@/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAllPlatforms } from '@/api/games'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -23,10 +25,24 @@ const visible = computed({
|
||||
const name = ref('')
|
||||
const platform = ref<GamePlatform | ''>('')
|
||||
const tagsInput = ref('')
|
||||
const cover = ref('')
|
||||
const coverFile = ref<File | null>(null)
|
||||
const coverPreview = ref('')
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const loading = ref(false)
|
||||
const platforms = getAllPlatforms()
|
||||
|
||||
function handleFileChange(uploadFile: UploadFile) {
|
||||
if (uploadFile.raw) {
|
||||
coverFile.value = uploadFile.raw
|
||||
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
ElMessage.warning('请输入游戏名称')
|
||||
@@ -39,13 +55,15 @@ async function handleSubmit() {
|
||||
name: name.value.trim(),
|
||||
platform: platform.value || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
cover: cover.value.trim() || undefined
|
||||
coverFile: coverFile.value || undefined
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
name.value = ''
|
||||
platform.value = ''
|
||||
tagsInput.value = ''
|
||||
cover.value = ''
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
uploadRef.value?.clearFiles()
|
||||
emit('created')
|
||||
visible.value = false
|
||||
} catch (error: any) {
|
||||
@@ -74,8 +92,24 @@ async function handleSubmit() {
|
||||
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>封面图 URL</label>
|
||||
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
|
||||
<label>封面图</label>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept="image/*"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
drag
|
||||
>
|
||||
<div v-if="coverPreview" class="cover-preview">
|
||||
<img :src="coverPreview" alt="封面预览" />
|
||||
</div>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon :size="28"><Plus /></el-icon>
|
||||
<span>点击或拖拽上传封面</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -89,4 +123,19 @@ async function handleSubmit() {
|
||||
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import type { Game } from '@/types'
|
||||
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import type { Game, GamePlatform } from '@/types'
|
||||
import { toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms } from '@/api/games'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
|
||||
import { TrendCharts, StarFilled, Star, Delete, Edit, Plus } from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||
import GameComments from './GameComments.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,12 +21,40 @@ const emit = defineEmits<{
|
||||
'deleted': []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const platforms = getAllPlatforms()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const favorited = ref(false)
|
||||
const editing = ref(false)
|
||||
const editName = ref('')
|
||||
const editAliases = ref('')
|
||||
const editPlatform = ref<GamePlatform | ''>('')
|
||||
const editTags = ref('')
|
||||
const editCoverFile = ref<File | null>(null)
|
||||
const editCoverPreview = ref('')
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const saving = ref(false)
|
||||
|
||||
const canModify = computed(() => {
|
||||
if (!props.game) return false
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
if (groupStore.isGroupOwner) return true
|
||||
if (groupStore.isGroupAdmin) return true
|
||||
if (props.game.addedBy === userId) return true
|
||||
return false
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
editing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.game) {
|
||||
@@ -31,6 +62,59 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.game) return
|
||||
editName.value = props.game.name
|
||||
editAliases.value = (props.game.aliases || []).join(', ')
|
||||
editPlatform.value = props.game.platform || ''
|
||||
editTags.value = (props.game.tags || []).join(', ')
|
||||
editCoverFile.value = null
|
||||
editCoverPreview.value = getGameCoverUrl(props.game) || ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function handleEditFileChange(uploadFile: UploadFile) {
|
||||
if (uploadFile.raw) {
|
||||
editCoverFile.value = uploadFile.raw
|
||||
editCoverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditFileRemove() {
|
||||
editCoverFile.value = null
|
||||
editCoverPreview.value = getGameCoverUrl(props.game!) || ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.game || !editName.value.trim()) {
|
||||
ElMessage.warning('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
saving.value = true
|
||||
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await updateGame(props.game.id, {
|
||||
name: editName.value.trim(),
|
||||
aliases: aliases.length > 0 ? aliases : undefined,
|
||||
platform: editPlatform.value || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
coverFile: editCoverFile.value || undefined
|
||||
})
|
||||
ElMessage.success('修改成功')
|
||||
editing.value = false
|
||||
emit('deleted') // trigger reload
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '修改失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFavorite() {
|
||||
if (!props.game) return
|
||||
favorited.value = await toggleFavorite(props.game.id)
|
||||
@@ -60,13 +144,66 @@ function handleCreateTeam() {
|
||||
<template>
|
||||
<el-dialog v-model="visible" width="480px" :show-close="true">
|
||||
<template v-if="game">
|
||||
<div class="game-detail">
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="editing" class="game-detail">
|
||||
<div class="form-fields">
|
||||
<div class="field">
|
||||
<label>游戏名称 *</label>
|
||||
<el-input v-model="editName" placeholder="输入游戏名称" maxlength="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>别名</label>
|
||||
<el-input v-model="editAliases" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>平台</label>
|
||||
<el-select v-model="editPlatform" placeholder="选择平台" clearable style="width: 100%">
|
||||
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>标签</label>
|
||||
<el-input v-model="editTags" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>封面图</label>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept="image/*"
|
||||
:on-change="handleEditFileChange"
|
||||
:on-remove="handleEditFileRemove"
|
||||
drag
|
||||
>
|
||||
<div v-if="editCoverPreview" class="cover-preview">
|
||||
<img :src="editCoverPreview" alt="封面预览" />
|
||||
</div>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon :size="28"><Plus /></el-icon>
|
||||
<span>点击或拖拽上传封面</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<el-button @click="cancelEdit">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情模式 -->
|
||||
<div v-else class="game-detail">
|
||||
<div class="detail-cover-wrap">
|
||||
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
||||
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
||||
</div>
|
||||
|
||||
<h2 class="detail-name">{{ game.name }}</h2>
|
||||
|
||||
<div v-if="game.aliases?.length" class="detail-aliases">
|
||||
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-meta">
|
||||
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
|
||||
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
|
||||
@@ -81,7 +218,8 @@ function handleCreateTeam() {
|
||||
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
|
||||
{{ favorited ? '已收藏' : '收藏' }}
|
||||
</button>
|
||||
<button class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
||||
<button v-if="canModify" class="edit-btn" @click="startEdit"><el-icon><Edit /></el-icon> 编辑</button>
|
||||
<button v-if="canModify" class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
||||
@@ -102,6 +240,9 @@ function handleCreateTeam() {
|
||||
|
||||
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
||||
|
||||
.detail-aliases { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||
.alias-tag { padding: 3px 10px; background: rgba(59, 130, 246, 0.1); color: #3b82f6; border-radius: 6px; font-size: 12px; font-weight: 500; }
|
||||
|
||||
.detail-meta { display: flex; align-items: center; gap: 12px; }
|
||||
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
||||
@@ -117,6 +258,12 @@ function handleCreateTeam() {
|
||||
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
||||
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
||||
|
||||
.edit-btn {
|
||||
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.edit-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
|
||||
|
||||
.delete-detail-btn {
|
||||
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||
@@ -124,4 +271,15 @@ function handleCreateTeam() {
|
||||
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
|
||||
|
||||
.team-btn { width: 100%; margin-top: 4px; }
|
||||
|
||||
/* 编辑表单 */
|
||||
.form-fields { display: flex; flex-direction: column; gap: 16px; width: 100%; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||
.cover-preview img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 6px; }
|
||||
.upload-placeholder {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px;
|
||||
color: var(--gg-text-muted); font-size: 13px;
|
||||
}
|
||||
.edit-actions { display: flex; gap: 12px; width: 100%; justify-content: flex-end; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { addGame, updateGame, getGameCoverUrl } from '@/api/games'
|
||||
import type { Game, GamePlatform } from '@/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAllPlatforms } from '@/api/games'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
groupId: string
|
||||
game?: Game | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.game)
|
||||
|
||||
const name = ref('')
|
||||
const aliasesInput = ref('')
|
||||
const platform = ref<GamePlatform | ''>('')
|
||||
const tagsInput = ref('')
|
||||
const coverFile = ref<File | null>(null)
|
||||
const coverPreview = ref('')
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const loading = ref(false)
|
||||
const platforms = getAllPlatforms()
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val && props.game) {
|
||||
name.value = props.game.name
|
||||
aliasesInput.value = (props.game.aliases || []).join(', ')
|
||||
platform.value = props.game.platform || ''
|
||||
tagsInput.value = (props.game.tags || []).join(', ')
|
||||
coverFile.value = null
|
||||
coverPreview.value = getGameCoverUrl(props.game) || ''
|
||||
} else if (val) {
|
||||
name.value = ''
|
||||
aliasesInput.value = ''
|
||||
platform.value = ''
|
||||
tagsInput.value = ''
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleFileChange(uploadFile: UploadFile) {
|
||||
if (uploadFile.raw) {
|
||||
coverFile.value = uploadFile.raw
|
||||
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
ElMessage.warning('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
const aliases = aliasesInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
|
||||
if (isEdit.value && props.game) {
|
||||
await updateGame(props.game.id, {
|
||||
name: name.value.trim(),
|
||||
aliases: aliases.length > 0 ? aliases : undefined,
|
||||
platform: platform.value || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
coverFile: coverFile.value || undefined
|
||||
})
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await addGame(props.groupId, {
|
||||
name: name.value.trim(),
|
||||
aliases: aliases.length > 0 ? aliases : undefined,
|
||||
platform: platform.value || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
coverFile: coverFile.value || undefined
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
|
||||
name.value = ''
|
||||
aliasesInput.value = ''
|
||||
platform.value = ''
|
||||
tagsInput.value = ''
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
uploadRef.value?.clearFiles()
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || (isEdit.value ? '修改失败' : '添加失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="isEdit ? '编辑游戏' : '添加游戏'" width="440px">
|
||||
<div class="form-fields">
|
||||
<div class="field">
|
||||
<label>游戏名称 *</label>
|
||||
<el-input v-model="name" placeholder="输入游戏名称" maxlength="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>别名</label>
|
||||
<el-input v-model="aliasesInput" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>平台</label>
|
||||
<el-select v-model="platform" placeholder="选择平台" clearable style="width: 100%">
|
||||
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>标签</label>
|
||||
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>封面图</label>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept="image/*"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
drag
|
||||
>
|
||||
<div v-if="coverPreview" class="cover-preview">
|
||||
<img :src="coverPreview" alt="封面预览" />
|
||||
</div>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon :size="28"><Plus /></el-icon>
|
||||
<span>点击或拖拽上传封面</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">{{ isEdit ? '保存' : '添加' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,7 @@ function parseCSV(text: string) {
|
||||
const values = lines[i].split(',').map(v => v.trim())
|
||||
const obj: any = {}
|
||||
headers.forEach((h, idx) => {
|
||||
if (h === 'tags' && values[idx]) {
|
||||
if ((h === 'tags' || h === 'aliases') && values[idx]) {
|
||||
obj[h] = values[idx].split(';').map((t: string) => t.trim())
|
||||
} else {
|
||||
obj[h] = values[idx] || undefined
|
||||
@@ -97,8 +97,8 @@ function reset() {
|
||||
</div>
|
||||
|
||||
<div class="format-hint">
|
||||
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
|
||||
<p><strong>CSV 格式:</strong> name,platform,tags,cover(tags 用分号分隔)</p>
|
||||
<p><strong>JSON 格式:</strong> [{"name":"英雄联盟","aliases":["LOL","撸啊撸"],"platform":"PC","tags":["MOBA"]}]</p>
|
||||
<p><strong>CSV 格式:</strong> name,aliases,platform,tags,cover(aliases/tags 用分号分隔)</p>
|
||||
</div>
|
||||
|
||||
<div v-if="previewData.length > 0" class="preview">
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
|
||||
import {
|
||||
getGroupJoinRequests,
|
||||
updateGroupApproval,
|
||||
subscribeJoinRequests,
|
||||
transferGroupOwnership,
|
||||
addGroupAdmin,
|
||||
removeGroupAdmin,
|
||||
updateGroupMemberManage
|
||||
} from '@/api/groups'
|
||||
import { ElSwitch } from 'element-plus'
|
||||
import type { JoinRequest } from '@/types'
|
||||
import JoinRequestCard from './JoinRequestCard.vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
const emit = defineEmits<{ requestHandled: [] }>()
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
const isOwner = computed(() => group.value?.owner === userStore.userId)
|
||||
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||
const isAdmin = computed(() => groupStore.isGroupAdmin)
|
||||
const canManage = computed(() => groupStore.canManageGroup)
|
||||
|
||||
const joinRequests = ref<JoinRequest[]>([])
|
||||
const approvalLoading = ref(false)
|
||||
const memberManageLoading = ref(false)
|
||||
const inviteLink = computed(() => {
|
||||
if (!group.value?.id) return ''
|
||||
return `${window.location.origin}/join/group/${group.value.id}`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (group.value && isOwner.value) {
|
||||
let subscribedGroupId = ''
|
||||
|
||||
watch(group, async (g) => {
|
||||
if (g && canManage.value && g.id !== subscribedGroupId) {
|
||||
subscribedGroupId = g.id
|
||||
await loadJoinRequests()
|
||||
subscribeJoinRequests(group.value.id, () => {
|
||||
subscribeJoinRequests(g.id, () => {
|
||||
loadJoinRequests()
|
||||
})
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
// cleanup handled by PocketBase
|
||||
@@ -38,22 +57,38 @@ async function loadJoinRequests() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function copyGroupId() {
|
||||
if (group.value?.id) {
|
||||
navigator.clipboard.writeText(group.value.id)
|
||||
ElMessage.success('群组 ID 已复制,分享给好友即可加入')
|
||||
function copyInviteLink() {
|
||||
if (!group.value?.id) return
|
||||
const text = inviteLink.value
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
})
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(userId: string, username: string) {
|
||||
if (!isOwner.value) return
|
||||
if (!canManage.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
||||
const grp = groupStore.currentGroup
|
||||
if (!grp) return
|
||||
const { pb } = await import('@/api/pocketbase')
|
||||
const newMembers = grp.members.filter(id => id !== userId)
|
||||
await pb.collection('groups').update(grp.id, { members: newMembers })
|
||||
const newAdmins = (grp.admins || []).filter(id => id !== userId)
|
||||
await pb.collection('groups').update(grp.id, { members: newMembers, admins: newAdmins })
|
||||
await groupStore.setCurrentGroup(grp.id)
|
||||
ElMessage.success('已移除成员')
|
||||
} catch {
|
||||
@@ -75,11 +110,64 @@ async function handleApprovalChange(val: string | number | boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMemberManageChange(val: string | number | boolean) {
|
||||
if (!group.value || !isOwner.value) return
|
||||
memberManageLoading.value = true
|
||||
try {
|
||||
await updateGroupMemberManage(group.value.id, !!val)
|
||||
await groupStore.setCurrentGroup(group.value.id)
|
||||
ElMessage.success(val ? '已开启全员管理' : '已关闭全员管理')
|
||||
} catch {
|
||||
ElMessage.error('更新设置失败')
|
||||
} finally {
|
||||
memberManageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onJoinRequestResponded(requestId: string) {
|
||||
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||
if (group.value) {
|
||||
await groupStore.setCurrentGroup(group.value.id)
|
||||
}
|
||||
emit('requestHandled')
|
||||
}
|
||||
|
||||
async function handleTransferOwnership(userId: string, username: string) {
|
||||
if (!isOwner.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要将群主转让给 ${username} 吗?转让后你将成为管理员。`,
|
||||
'确认转让群主',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await transferGroupOwnership(group.value!.id, userId)
|
||||
await groupStore.setCurrentGroup(group.value!.id)
|
||||
ElMessage.success('群主转让成功')
|
||||
} catch (err: any) {
|
||||
if (err.message) ElMessage.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAdmin(userId: string, username: string) {
|
||||
if (!isOwner.value) return
|
||||
try {
|
||||
await addGroupAdmin(group.value!.id, userId)
|
||||
await groupStore.setCurrentGroup(group.value!.id)
|
||||
ElMessage.success(`${username} 已成为管理员`)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAdmin(userId: string, username: string) {
|
||||
if (!isOwner.value) return
|
||||
try {
|
||||
await removeGroupAdmin(group.value!.id, userId)
|
||||
await groupStore.setCurrentGroup(group.value!.id)
|
||||
ElMessage.success(`${username} 已取消管理员身份`)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,17 +177,17 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
<h3>群组信息</h3>
|
||||
</div>
|
||||
|
||||
<!-- 分享群组 ID -->
|
||||
<!-- 分享邀请链接 -->
|
||||
<div class="share-section">
|
||||
<label>群组 ID(分享给好友)</label>
|
||||
<label>邀请链接(分享给好友)</label>
|
||||
<div class="share-row">
|
||||
<code class="group-id">{{ group.id }}</code>
|
||||
<button class="copy-btn" @click="copyGroupId">复制</button>
|
||||
<code class="group-id">{{ inviteLink }}</code>
|
||||
<button class="copy-btn" @click="copyInviteLink">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核开关(仅群主可见) -->
|
||||
<div v-if="isOwner" class="approval-row">
|
||||
<!-- 审核开关(管理员可见) -->
|
||||
<div v-if="canManage" class="approval-row">
|
||||
<span class="info-label">加入需审核</span>
|
||||
<el-switch
|
||||
:model-value="group.requireApproval"
|
||||
@@ -108,13 +196,23 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 全员管理开关(仅群主可见) -->
|
||||
<div v-if="isOwner" class="approval-row">
|
||||
<span class="info-label">全员管理</span>
|
||||
<el-switch
|
||||
:model-value="group.allowMemberManage"
|
||||
:loading="memberManageLoading"
|
||||
@change="handleMemberManageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">成员</span>
|
||||
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 待审核申请(仅群主可见) -->
|
||||
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
|
||||
<!-- 待审核申请(管理员可见) -->
|
||||
<div v-if="canManage && joinRequests.length > 0" class="requests-section">
|
||||
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
|
||||
<div class="requests-list">
|
||||
<JoinRequestCard
|
||||
@@ -132,9 +230,40 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{ member.name || member.username }}</span>
|
||||
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
||||
<span v-else-if="(group.admins || []).includes(member.id)" class="admin-badge">管理</span>
|
||||
</div>
|
||||
<!-- 管理操作菜单 -->
|
||||
<div v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId" class="member-actions">
|
||||
<button
|
||||
v-if="member.id !== group.owner && !(group.admins || []).includes(member.id)"
|
||||
class="action-btn action-btn--admin"
|
||||
@click="handleAddAdmin(member.id, member.name || member.username)"
|
||||
>
|
||||
设管
|
||||
</button>
|
||||
<button
|
||||
v-if="(group.admins || []).includes(member.id)"
|
||||
class="action-btn action-btn--admin"
|
||||
@click="handleRemoveAdmin(member.id, member.name || member.username)"
|
||||
>
|
||||
取消管理
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn--transfer"
|
||||
@click="handleTransferOwnership(member.id, member.name || member.username)"
|
||||
>
|
||||
转让
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn--remove"
|
||||
@click="removeMember(member.id, member.name || member.username)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<!-- 管理员移除成员 -->
|
||||
<button
|
||||
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
|
||||
v-else-if="isAdmin && member.id !== group.owner && member.id !== userStore.userId"
|
||||
class="remove-btn"
|
||||
@click="removeMember(member.id, member.name || member.username)"
|
||||
>
|
||||
@@ -253,6 +382,16 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requests-section--highlight {
|
||||
animation: highlight-pulse 2s ease;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse {
|
||||
0% { background: rgba(245, 158, 11, 0.06); }
|
||||
30% { background: rgba(245, 158, 11, 0.3); }
|
||||
100% { background: rgba(245, 158, 11, 0.06); }
|
||||
}
|
||||
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -284,10 +423,14 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.owner-badge {
|
||||
@@ -296,6 +439,63 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
background: var(--gg-gradient);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn--admin {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.action-btn--admin:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.action-btn--transfer {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.action-btn--transfer:hover {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.action-btn--remove {
|
||||
background: none;
|
||||
border: 1px solid var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-btn--remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
@@ -308,6 +508,7 @@ async function onJoinRequestResponded(requestId: string) {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { searchGames } from '@/api/games'
|
||||
import { searchGames, getGameCoverUrl } from '@/api/games'
|
||||
import type { Game } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
groupId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,7 +38,7 @@ async function handleSearch() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
games.value = await searchGames(searchQuery.value)
|
||||
games.value = await searchGames(searchQuery.value, props.groupId)
|
||||
} catch (error) {
|
||||
console.error('搜索游戏失败:', error)
|
||||
} finally {
|
||||
@@ -82,7 +83,7 @@ function confirmCustomGame() {
|
||||
@click="selectGame(game)"
|
||||
>
|
||||
<img
|
||||
:src="game.cover || '/game-placeholder.svg'"
|
||||
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||
:alt="game.name"
|
||||
class="game-cover"
|
||||
/>
|
||||
|
||||
@@ -73,6 +73,7 @@ async function sendInvite(teamSessionId: string) {
|
||||
|
||||
<GameSelectDialog
|
||||
v-model="showGameSelect"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
@select="handleGameSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -50,6 +50,33 @@ async function endGame() {
|
||||
}
|
||||
}
|
||||
|
||||
const inviteLink = computed(() => {
|
||||
if (!session.value) return ''
|
||||
return `${window.location.origin}/join/team/${session.value.id}`
|
||||
})
|
||||
|
||||
function copyInviteLink() {
|
||||
if (!inviteLink.value) return
|
||||
const text = inviteLink.value
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('邀请链接已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
})
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('邀请链接已复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGameSelected(gameName: string) {
|
||||
let group = groupStore.currentGroup
|
||||
if (!group) {
|
||||
@@ -92,6 +119,12 @@ async function handleGameSelected(gameName: string) {
|
||||
<span class="game-name">{{ session.gameName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="invite-link-row">
|
||||
<span class="link-label">邀请:</span>
|
||||
<code class="link-code">{{ inviteLink }}</code>
|
||||
<button class="link-copy-btn" @click="copyInviteLink">复制</button>
|
||||
</div>
|
||||
|
||||
<div class="members-section">
|
||||
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
|
||||
<div class="members-list">
|
||||
@@ -135,7 +168,7 @@ async function handleGameSelected(gameName: string) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
|
||||
<GameSelectDialog v-model="showGameSelect" :group-id="groupStore.currentGroupId" @select="handleGameSelected" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -181,6 +214,49 @@ async function handleGameSelected(gameName: string) {
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.invite-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--gg-bg);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.link-label {
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-code {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-copy-btn {
|
||||
padding: 4px 12px;
|
||||
background: var(--gg-gradient);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.game-label {
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -83,6 +83,18 @@ const routes: RouteRecordRaw[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/join/group/:groupId',
|
||||
name: 'JoinGroup',
|
||||
component: () => import('@/views/JoinGroupPage.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/join/team/:sessionId',
|
||||
name: 'JoinTeam',
|
||||
component: () => import('@/views/JoinTeamPage.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { BulletinPost, BulletinRead } from '@/types'
|
||||
import {
|
||||
listBulletins,
|
||||
listMyReads,
|
||||
markBulletinAsRead,
|
||||
createBulletin,
|
||||
updateBulletin,
|
||||
deleteBulletin,
|
||||
} from '@/api/bulletins'
|
||||
|
||||
export const useBulletinStore = defineStore('bulletin', () => {
|
||||
const posts = ref<BulletinPost[]>([])
|
||||
const reads = ref<BulletinRead[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const now = computed(() => new Date())
|
||||
|
||||
const activePosts = computed(() => {
|
||||
return posts.value.filter((p) => {
|
||||
if (!p.expiresAt) return true
|
||||
return new Date(p.expiresAt) > now.value
|
||||
})
|
||||
})
|
||||
|
||||
const pinnedPosts = computed(() =>
|
||||
activePosts.value
|
||||
.filter((p) => p.pinned)
|
||||
.sort((a, b) => {
|
||||
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||
return prioOrder[a.priority] - prioOrder[b.priority]
|
||||
})
|
||||
)
|
||||
|
||||
const normalPosts = computed(() =>
|
||||
activePosts.value
|
||||
.filter((p) => !p.pinned)
|
||||
.sort((a, b) => {
|
||||
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||
const prioDiff = prioOrder[a.priority] - prioOrder[b.priority]
|
||||
if (prioDiff !== 0) return prioDiff
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime()
|
||||
})
|
||||
)
|
||||
|
||||
const readPostIds = computed(() => new Set(reads.value.map((r) => r.post)))
|
||||
|
||||
function isRead(postId: string): boolean {
|
||||
return readPostIds.value.has(postId)
|
||||
}
|
||||
|
||||
function unreadCount(groupId: string): number {
|
||||
return activePosts.value.filter(
|
||||
(p) => p.group === groupId && !isRead(p.id)
|
||||
).length
|
||||
}
|
||||
|
||||
async function loadPosts(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const [fetchedPosts, fetchedReads] = await Promise.all([
|
||||
listBulletins(groupId),
|
||||
listMyReads(),
|
||||
])
|
||||
posts.value = fetchedPosts
|
||||
reads.value = fetchedReads
|
||||
} catch (error) {
|
||||
console.error('加载公告列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(postId: string) {
|
||||
if (isRead(postId)) return
|
||||
try {
|
||||
const record = await markBulletinAsRead(postId)
|
||||
if (record) {
|
||||
reads.value.push(record)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead(groupId: string) {
|
||||
const unread = activePosts.value.filter(
|
||||
(p) => p.group === groupId && !isRead(p.id)
|
||||
)
|
||||
await Promise.all(unread.map((p) => markAsRead(p.id)))
|
||||
}
|
||||
|
||||
async function create(data: {
|
||||
group: string
|
||||
title: string
|
||||
content: string
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
}) {
|
||||
try {
|
||||
const post = await createBulletin(data)
|
||||
posts.value.unshift(post)
|
||||
return post
|
||||
} catch (error: any) {
|
||||
console.error('创建公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function update(
|
||||
postId: string,
|
||||
data: {
|
||||
title?: string
|
||||
content?: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const post = await updateBulletin(postId, data)
|
||||
const index = posts.value.findIndex((p) => p.id === postId)
|
||||
if (index !== -1) {
|
||||
posts.value[index] = { ...posts.value[index], ...post }
|
||||
}
|
||||
return post
|
||||
} catch (error: any) {
|
||||
console.error('更新公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(postId: string) {
|
||||
try {
|
||||
await deleteBulletin(postId)
|
||||
posts.value = posts.value.filter((p) => p.id !== postId)
|
||||
reads.value = reads.value.filter((r) => r.post !== postId)
|
||||
} catch (error: any) {
|
||||
console.error('删除公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
posts.value = []
|
||||
reads.value = []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
posts,
|
||||
reads,
|
||||
loading,
|
||||
activePosts,
|
||||
pinnedPosts,
|
||||
normalPosts,
|
||||
readPostIds,
|
||||
isRead,
|
||||
unreadCount,
|
||||
loadPosts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import pb from '@/api/pocketbase'
|
||||
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
|
||||
import {
|
||||
listEvents,
|
||||
getEvent,
|
||||
listEventComments,
|
||||
listEventRSVPs,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
createEventComment,
|
||||
deleteEventComment,
|
||||
setEventRSVP,
|
||||
cancelEventRSVP
|
||||
} from '@/api/events'
|
||||
|
||||
export const useEventStore = defineStore('event', () => {
|
||||
const events = ref<Event[]>([])
|
||||
const currentEvent = ref<Event | null>(null)
|
||||
const comments = ref<EventComment[]>([])
|
||||
const rsvps = ref<EventRSVP[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const upcomingEvents = computed(() =>
|
||||
events.value.filter(e => e.status === 'upcoming' || e.status === 'ongoing')
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
|
||||
)
|
||||
|
||||
const pastEvents = computed(() =>
|
||||
events.value.filter(e => e.status === 'completed' || e.status === 'cancelled')
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||
)
|
||||
|
||||
const goingCount = computed(() =>
|
||||
rsvps.value.filter(r => r.type === 'going').length
|
||||
)
|
||||
|
||||
const interestedCount = computed(() =>
|
||||
rsvps.value.filter(r => r.type === 'interested').length
|
||||
)
|
||||
|
||||
async function loadEvents(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
events.value = await listEvents(groupId)
|
||||
} catch (error) {
|
||||
console.error('加载活动列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEventDetail(eventId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const [eventData, commentData, rsvpData] = await Promise.all([
|
||||
getEvent(eventId),
|
||||
listEventComments(eventId),
|
||||
listEventRSVPs(eventId)
|
||||
])
|
||||
currentEvent.value = eventData
|
||||
comments.value = commentData
|
||||
rsvps.value = rsvpData
|
||||
} catch (error) {
|
||||
console.error('加载活动详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentsAndRSVPs(eventId: string) {
|
||||
try {
|
||||
const [commentData, rsvpData] = await Promise.all([
|
||||
listEventComments(eventId),
|
||||
listEventRSVPs(eventId)
|
||||
])
|
||||
comments.value = commentData
|
||||
rsvps.value = rsvpData
|
||||
} catch (error) {
|
||||
console.error('加载评论和 RSVP 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addEvent(data: Parameters<typeof createEvent>[0]) {
|
||||
try {
|
||||
const event = await createEvent(data)
|
||||
events.value.unshift(event)
|
||||
return event
|
||||
} catch (error: any) {
|
||||
console.error('创建活动失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEvent(eventId: string) {
|
||||
try {
|
||||
await deleteEvent(eventId)
|
||||
events.value = events.value.filter(e => e.id !== eventId)
|
||||
if (currentEvent.value?.id === eventId) {
|
||||
currentEvent.value = null
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('删除活动失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function editEvent(eventId: string, data: Partial<Event>) {
|
||||
try {
|
||||
const updated = await updateEvent(eventId, data)
|
||||
const index = events.value.findIndex(e => e.id === eventId)
|
||||
if (index !== -1) {
|
||||
events.value[index] = updated
|
||||
}
|
||||
if (currentEvent.value?.id === eventId) {
|
||||
currentEvent.value = updated
|
||||
}
|
||||
return updated
|
||||
} catch (error: any) {
|
||||
console.error('更新活动失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(eventId: string, content: string) {
|
||||
try {
|
||||
const comment = await createEventComment(eventId, content)
|
||||
comments.value.unshift(comment)
|
||||
return comment
|
||||
} catch (error: any) {
|
||||
console.error('创建评论失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeComment(commentId: string) {
|
||||
try {
|
||||
await deleteEventComment(commentId)
|
||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||
} catch (error: any) {
|
||||
console.error('删除评论失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function rsvp(eventId: string, type: RSVPType, comment?: string) {
|
||||
try {
|
||||
const rsvp = await setEventRSVP(eventId, type, comment)
|
||||
const index = rsvps.value.findIndex(r => r.id === rsvp.id)
|
||||
if (index !== -1) {
|
||||
rsvps.value[index] = rsvp
|
||||
} else {
|
||||
rsvps.value.push(rsvp)
|
||||
}
|
||||
return rsvp
|
||||
} catch (error: any) {
|
||||
console.error('RSVP 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRSVP(eventId: string) {
|
||||
try {
|
||||
await cancelEventRSVP(eventId)
|
||||
const userId = pb.authStore.model?.id
|
||||
rsvps.value = rsvps.value.filter(r => !(r.event === eventId && r.user === userId))
|
||||
} catch (error: any) {
|
||||
console.error('取消 RSVP 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
events.value = []
|
||||
currentEvent.value = null
|
||||
comments.value = []
|
||||
rsvps.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
currentEvent,
|
||||
comments,
|
||||
rsvps,
|
||||
loading,
|
||||
upcomingEvents,
|
||||
pastEvents,
|
||||
goingCount,
|
||||
interestedCount,
|
||||
loadEvents,
|
||||
loadEventDetail,
|
||||
loadCommentsAndRSVPs,
|
||||
addEvent,
|
||||
removeEvent,
|
||||
editEvent,
|
||||
addComment,
|
||||
removeComment,
|
||||
rsvp,
|
||||
cancelRSVP,
|
||||
clear
|
||||
}
|
||||
})
|
||||
@@ -17,6 +17,15 @@ export const useGroupStore = defineStore('group', () => {
|
||||
const isGroupOwner = computed(() => {
|
||||
return currentGroup.value?.owner === pb.authStore.model?.id
|
||||
})
|
||||
const isGroupAdmin = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId || !currentGroup.value) return false
|
||||
return (currentGroup.value.admins || []).includes(userId)
|
||||
})
|
||||
const canManageGroup = computed(() => {
|
||||
if (!currentGroup.value) return false
|
||||
return isGroupOwner.value || isGroupAdmin.value || currentGroup.value.allowMemberManage
|
||||
})
|
||||
|
||||
// 加载用户的群组列表
|
||||
async function loadGroups() {
|
||||
@@ -78,6 +87,8 @@ export const useGroupStore = defineStore('group', () => {
|
||||
loading,
|
||||
currentGroupId,
|
||||
isGroupOwner,
|
||||
isGroupAdmin,
|
||||
canManageGroup,
|
||||
loadGroups,
|
||||
setCurrentGroup,
|
||||
clearCurrentGroup,
|
||||
|
||||
@@ -66,13 +66,16 @@ export interface Group {
|
||||
description?: string
|
||||
owner: string
|
||||
members: string[]
|
||||
admins: string[]
|
||||
maxMembers: number
|
||||
requireApproval: boolean
|
||||
allowMemberManage: boolean
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
owner?: User
|
||||
members?: User[]
|
||||
admins?: User[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +120,7 @@ export interface Invitation {
|
||||
export interface Game {
|
||||
id: string
|
||||
name: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
@@ -408,6 +412,123 @@ export interface PlayerBlacklistEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// 公告优先级
|
||||
export type BulletinPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
|
||||
export const BulletinPriorityMap: Record<BulletinPriority, string> = {
|
||||
low: '低',
|
||||
normal: '普通',
|
||||
high: '高',
|
||||
urgent: '紧急'
|
||||
}
|
||||
|
||||
export const BulletinPriorityColor: Record<BulletinPriority, string> = {
|
||||
low: 'var(--gg-info)',
|
||||
normal: 'var(--gg-text-muted)',
|
||||
high: 'var(--gg-warning)',
|
||||
urgent: 'var(--gg-danger)'
|
||||
}
|
||||
|
||||
export interface BulletinPost {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
title: string
|
||||
content: string
|
||||
priority: BulletinPriority
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
}
|
||||
}
|
||||
|
||||
export interface BulletinRead {
|
||||
id: string
|
||||
post: string
|
||||
user: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
// 活动状态
|
||||
export type EventStatus = 'upcoming' | 'ongoing' | 'completed' | 'cancelled'
|
||||
|
||||
export const EventStatusMap: Record<EventStatus, string> = {
|
||||
upcoming: '即将开始',
|
||||
ongoing: '进行中',
|
||||
completed: '已结束',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
export const EventStatusColor: Record<EventStatus, string> = {
|
||||
upcoming: 'var(--gg-info)',
|
||||
ongoing: 'var(--gg-primary)',
|
||||
completed: 'var(--gg-text-muted)',
|
||||
cancelled: 'var(--gg-danger)'
|
||||
}
|
||||
|
||||
// RSVP 类型
|
||||
export type RSVPType = 'going' | 'interested' | 'maybe'
|
||||
|
||||
export const RSVPTypeMap: Record<RSVPType, string> = {
|
||||
going: '参加',
|
||||
interested: '感兴趣',
|
||||
maybe: '也许'
|
||||
}
|
||||
|
||||
// 活动
|
||||
export interface Event {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
title: string
|
||||
description?: string
|
||||
location?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
status: EventStatus
|
||||
maxParticipants?: number
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
}
|
||||
}
|
||||
|
||||
// 活动评论
|
||||
export interface EventComment {
|
||||
id: string
|
||||
event: string
|
||||
user: string
|
||||
content: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
user?: User
|
||||
event?: Event
|
||||
}
|
||||
}
|
||||
|
||||
// 活动 RSVP
|
||||
export interface EventRSVP {
|
||||
id: string
|
||||
event: string
|
||||
user: string
|
||||
type: RSVPType
|
||||
comment?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
user?: User
|
||||
event?: Event
|
||||
}
|
||||
}
|
||||
|
||||
// 竞猜状态
|
||||
export type BetStatus = 'open' | 'closed' | 'settled'
|
||||
|
||||
|
||||
@@ -10,6 +10,64 @@ interface LogEntry {
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([
|
||||
{
|
||||
version: 'v0.3.6',
|
||||
date: '2026-04-24',
|
||||
title: '游戏库别名与编辑权限',
|
||||
items: [
|
||||
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
|
||||
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
|
||||
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
|
||||
{ type: 'feat', text: '游戏详情弹窗以蓝色标签展示别名列表' },
|
||||
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
|
||||
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
|
||||
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.3.5',
|
||||
date: '2026-04-23',
|
||||
title: 'Bug 修复与体验优化',
|
||||
items: [
|
||||
{ type: 'feat', text: '群组页面顶部新增待审核入群申请徽章(脉冲动画提示),点击可滚动定位到审核列表' },
|
||||
{ type: 'feat', text: '个人中心新增"我的入群申请"记录,展示申请状态、时间和拒绝原因' },
|
||||
{ type: 'fix', text: '修复群组审核队列从首页进入时不显示的问题,改用 watch 监听群组数据加载' },
|
||||
{ type: 'fix', text: '修复拒绝入群申请后标题区待审核计数不同步更新的问题' },
|
||||
{ type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错的问题,添加 execCommand 降级方案' },
|
||||
{ type: 'fix', text: '修复小队邀请链接页面未加载用户群组数据,导致始终提示"需要先加入群组"' },
|
||||
{ type: 'fix', text: '修复加入群组页面 isMember 判断错误,使用了群组对象而非用户 ID' },
|
||||
{ type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' },
|
||||
{ type: 'fix', text: '修复活动详情加载时未获取活动本身数据的问题' },
|
||||
{ type: 'fix', text: '修复活动评论头像 URL 未拼接 PocketBase baseUrl 导致图片加载失败' },
|
||||
{ type: 'fix', text: '修复活动创建未校验结束时间必须晚于开始时间' },
|
||||
{ type: 'fix', text: '修复活动管理权限判断,拆分编辑权限(创建者+群主)和删除权限(创建者+群主)' },
|
||||
{ type: 'fix', text: '修复活动发起按钮仅管理员可见,改为所有群组成员可发起' },
|
||||
{ type: 'fix', text: '修复活动展开详情后未订阅评论和 RSVP 实时更新' },
|
||||
{ type: 'fix', text: '修复活动相对时间未使用 status 字段判断状态的问题' },
|
||||
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的"创建群组""加入群组"按钮' },
|
||||
{ type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床,添加游戏时拖拽或点击选择文件即可' },
|
||||
{ type: 'fix', text: '修复非群主成员无法看到群组内游戏的问题(PB listRule 语法修正)' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.3.4',
|
||||
date: '2026-04-21',
|
||||
title: '公告详情弹窗',
|
||||
items: [
|
||||
{ type: 'feat', text: '公告卡片点击打开详情弹窗,展示完整内容(保留换行格式)' },
|
||||
{ type: 'feat', text: '详情弹窗显示优先级标签、作者、发布时间、截止时间等信息' },
|
||||
{ type: 'feat', text: '详情弹窗内置编辑和删除操作按钮' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.3.3',
|
||||
date: '2026-04-20',
|
||||
title: 'Electron 桌面客户端',
|
||||
items: [
|
||||
{ type: 'feat', text: 'Electron 桌面端封装:将 Web 应用包装为独立桌面应用,支持 Windows/Linux/macOS' },
|
||||
{ type: 'fix', text: '修复 HTTP 环境下 mediaDevices 不可用导致语音认证失败的问题' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.3.2',
|
||||
date: '2026-04-19',
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
|
||||
import { getGroupGames, deleteGame, exportGames, getAllPlatforms, getGameCoverUrl } from '@/api/games'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { Game, GamePlatform } from '@/types'
|
||||
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
||||
import AddGameDialog from '@/components/game/AddGameDialog.vue'
|
||||
import GameFormDialog from '@/components/game/GameFormDialog.vue'
|
||||
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
@@ -26,6 +27,15 @@ const showImport = ref(false)
|
||||
const groupOptions = computed(() => groupStore.groups)
|
||||
const hasGroups = computed(() => groupOptions.value.length > 0)
|
||||
|
||||
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||
|
||||
function canModifyGame(game: Game): boolean {
|
||||
if (groupStore.isGroupOwner) return true
|
||||
if (groupStore.isGroupAdmin) return true
|
||||
if (game.addedBy === currentUserId.value) return true
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupStore.groups.length === 0) {
|
||||
await groupStore.loadGroups()
|
||||
@@ -87,6 +97,7 @@ function handleExport() {
|
||||
exportGames(selectedGroupId.value).then(data => {
|
||||
const json = JSON.stringify(data.map(g => ({
|
||||
name: g.name,
|
||||
aliases: g.aliases,
|
||||
platform: g.platform,
|
||||
tags: g.tags,
|
||||
cover: g.cover
|
||||
@@ -102,7 +113,7 @@ function handleExport() {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleGameAdded() {
|
||||
async function handleGameSaved() {
|
||||
showAddGame.value = false
|
||||
await loadGames()
|
||||
}
|
||||
@@ -171,7 +182,7 @@ async function handleImportComplete() {
|
||||
|
||||
<div v-else class="games-grid">
|
||||
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
|
||||
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
|
||||
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
|
||||
<div class="game-info">
|
||||
<h3 class="game-name">{{ game.name }}</h3>
|
||||
<p class="game-platform">{{ game.platform }}</p>
|
||||
@@ -179,12 +190,12 @@ async function handleImportComplete() {
|
||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
|
||||
<button v-if="canModifyGame(game)" class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
|
||||
<AddGameDialog v-model="showAddGame" :group-id="selectedGroupId" @created="handleGameAdded" />
|
||||
<GameFormDialog v-model="showAddGame" :group-id="selectedGroupId" @saved="handleGameSaved" />
|
||||
<ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTeamStore } from '@/stores/team'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { settlePoll } from '@/api/polls'
|
||||
import { closeBet } from '@/api/bets'
|
||||
import { getGroupJoinRequests } from '@/api/groups'
|
||||
import type { JoinRequest } from '@/types'
|
||||
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
|
||||
import IdleMembersList from '@/components/team/IdleMembersList.vue'
|
||||
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
|
||||
@@ -16,14 +18,17 @@ import MemoryGrid from '@/components/memory/MemoryGrid.vue'
|
||||
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
|
||||
import BetList from '@/components/bet/BetList.vue'
|
||||
import BetDetail from '@/components/bet/BetDetail.vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning } from '@element-plus/icons-vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning, Bell, Calendar } from '@element-plus/icons-vue'
|
||||
import { DataLine, PictureFilled, TrendCharts, Trophy } from '@element-plus/icons-vue'
|
||||
import BulletinBoard from '@/components/bulletin/BulletinBoard.vue'
|
||||
import BulletinPinned from '@/components/bulletin/BulletinPinned.vue'
|
||||
import EventList from '@/components/event/EventList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : 'activity')
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : route.query.tab === 'bulletins' ? 'bulletins' : route.query.tab === 'events' ? 'events' : 'activity')
|
||||
const viewingPollId = ref<string | null>(null)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
@@ -35,6 +40,10 @@ const groupId = route.params.id as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
const canManage = computed(() => groupStore.canManageGroup)
|
||||
|
||||
const pendingJoinRequests = ref<JoinRequest[]>([])
|
||||
const showPendingRequests = computed(() => canManage.value && pendingJoinRequests.value.length > 0)
|
||||
|
||||
const ownerName = computed(() => {
|
||||
const owner = members.value.find(m => m.id === group.value?.owner)
|
||||
@@ -73,9 +82,20 @@ const statusColors: Record<string, string> = {
|
||||
away: 'var(--gg-text-muted)'
|
||||
}
|
||||
|
||||
async function refreshPendingRequests() {
|
||||
if (canManage.value) {
|
||||
try {
|
||||
pendingJoinRequests.value = await getGroupJoinRequests(groupId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
|
||||
// 加载待审核入群申请(管理员可见)
|
||||
await refreshPendingRequests()
|
||||
|
||||
// 订阅用户状态变更
|
||||
unsubFns.push(await pb.collection('users').subscribe('*', () => {
|
||||
groupStore.setCurrentGroup(groupId)
|
||||
@@ -168,6 +188,15 @@ async function checkExpiredBets() {
|
||||
console.error('检查过期竞猜失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToRequests() {
|
||||
const el = document.querySelector('.requests-section')
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('requests-section--highlight')
|
||||
setTimeout(() => el.classList.remove('requests-section--highlight'), 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -177,6 +206,10 @@ async function checkExpiredBets() {
|
||||
<div class="header-main">
|
||||
<h1 class="group-name">{{ group?.name }}</h1>
|
||||
<span class="member-count-badge">{{ members.length }} 成员</span>
|
||||
<div v-if="showPendingRequests" class="pending-requests-badge" @click="scrollToRequests">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>{{ pendingJoinRequests.length }} 个待审核</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="group-desc">{{ group?.description || '暂无群组简介' }}</p>
|
||||
<div class="header-meta">
|
||||
@@ -204,6 +237,9 @@ async function checkExpiredBets() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 置顶公告 -->
|
||||
<BulletinPinned @view-all="activeTab = 'bulletins'" />
|
||||
|
||||
<!-- Tab 系统 -->
|
||||
<el-tabs v-model="activeTab" class="group-tabs group-tabs--fancy">
|
||||
<el-tab-pane name="activity">
|
||||
@@ -264,11 +300,18 @@ async function checkExpiredBets() {
|
||||
|
||||
<!-- 右列: 群组成员管理面板 -->
|
||||
<aside class="right-col">
|
||||
<GroupMembersPanel />
|
||||
<GroupMembersPanel @request-handled="refreshPendingRequests" />
|
||||
</aside>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bulletins">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><Bell /></el-icon> 公告</span>
|
||||
</template>
|
||||
<BulletinBoard />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="polls">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><DataLine /></el-icon> 投票</span>
|
||||
@@ -298,6 +341,13 @@ async function checkExpiredBets() {
|
||||
<BetDetail v-if="viewingBetId" :bet-id="viewingBetId" @back="viewingBetId = null" />
|
||||
<BetList v-else @view-bet="viewingBetId = $event" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="events">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><Calendar /></el-icon> 活动</span>
|
||||
</template>
|
||||
<EventList />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -353,6 +403,34 @@ async function checkExpiredBets() {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pending-requests-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
padding: 6px 16px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
border-radius: 20px;
|
||||
color: #d97706;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
animation: pulse-badge 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pending-requests-badge:hover {
|
||||
background: rgba(245, 158, 11, 0.22);
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.25); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
|
||||
}
|
||||
|
||||
.group-desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { getPopularGames, getGameCoverUrl } from '@/api/games'
|
||||
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
||||
import { UserStatusMap } from '@/types'
|
||||
import type { Game } from '@/types'
|
||||
@@ -78,14 +78,6 @@ function openGameDetail(game: Game) {
|
||||
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button class="cta-btn cta-btn--primary" @click="showCreateGroup = true">
|
||||
<el-icon><Plus /></el-icon> 创建群组
|
||||
</button>
|
||||
<button class="cta-btn cta-btn--secondary" @click="showJoinGroup = true">
|
||||
<el-icon><Search /></el-icon> 加入群组
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 无群组引导 -->
|
||||
@@ -168,7 +160,7 @@ function openGameDetail(game: Game) {
|
||||
>
|
||||
<div class="game-cover-wrap">
|
||||
<img
|
||||
:src="game.cover || '/game-placeholder.svg'"
|
||||
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||
:alt="game.name"
|
||||
class="game-cover"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb, isAuthenticated } from '@/api/pocketbase'
|
||||
import type { Group } from '@/types'
|
||||
import { User, Link } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const group = ref<Group | null>(null)
|
||||
const loading = ref(true)
|
||||
const joining = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
group.value = await getGroup(groupId)
|
||||
} catch {
|
||||
error.value = '群组不存在或已解散'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
}
|
||||
|
||||
function goToGroup() {
|
||||
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (!isAuthenticated()) {
|
||||
goToLogin()
|
||||
return
|
||||
}
|
||||
|
||||
joining.value = true
|
||||
try {
|
||||
if (group.value?.requireApproval) {
|
||||
await createJoinRequest(groupId)
|
||||
ElMessage.success('已提交加入申请,等待群主审核')
|
||||
} else {
|
||||
await joinGroup(groupId)
|
||||
ElMessage.success('已成功加入群组')
|
||||
await groupStore.loadGroups()
|
||||
goToGroup()
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isMember = computed(() => {
|
||||
if (!group.value) return false
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
return group.value.members?.includes(userId) || false
|
||||
})
|
||||
|
||||
const isLoggedIn = computed(() => isAuthenticated())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="join-page">
|
||||
<div class="join-card">
|
||||
<div class="join-header">
|
||||
<el-icon :size="40" class="header-icon"><Link /></el-icon>
|
||||
<h1>群组邀请</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p>{{ error }}</p>
|
||||
<button class="action-btn" @click="$router.push('/')">返回首页</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="group" class="group-info">
|
||||
<div class="info-block">
|
||||
<h2 class="group-name">{{ group.name }}</h2>
|
||||
<p v-if="group.description" class="group-desc">{{ group.description }}</p>
|
||||
<div class="meta-row">
|
||||
<span class="meta-item">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ group.members?.length || 0 }} / {{ group.maxMembers }} 人
|
||||
</span>
|
||||
<span v-if="group.requireApproval" class="tag tag--warning">需审核</span>
|
||||
<span v-else class="tag tag--success">可直接加入</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMember" class="action-block">
|
||||
<p class="tip">你已经是该群组的成员</p>
|
||||
<button class="action-btn action-btn--primary" @click="goToGroup">
|
||||
进入群组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isLoggedIn" class="action-block">
|
||||
<p class="tip">登录后即可加入群组</p>
|
||||
<button class="action-btn action-btn--primary" @click="goToLogin">
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="action-block">
|
||||
<p class="tip">{{ group.requireApproval ? '加入该群组需要群主审核' : '点击即可加入群组' }}</p>
|
||||
<button
|
||||
class="action-btn action-btn--primary"
|
||||
:disabled="joining"
|
||||
@click="handleJoin"
|
||||
>
|
||||
{{ joining ? '处理中...' : (group.requireApproval ? '申请加入' : '立即加入') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.join-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.join-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.join-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 12px 0 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gg-border);
|
||||
border-top-color: var(--gg-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.group-desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag--success {
|
||||
background: rgba(5, 150, 105, 0.12);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.tag--warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.action-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn--primary {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.action-btn--primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pb, isAuthenticated } from '@/api/pocketbase'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { joinTeamSession, getTeamSession } from '@/api/sessions'
|
||||
import type { TeamSession } from '@/types'
|
||||
import { Promotion, User } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const session = ref<TeamSession | null>(null)
|
||||
const loading = ref(true)
|
||||
const joining = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const sessionId = route.params.sessionId as string
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (isAuthenticated()) {
|
||||
await groupStore.loadGroups()
|
||||
}
|
||||
session.value = await getTeamSession(sessionId)
|
||||
} catch {
|
||||
error.value = '小队不存在或已解散'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
}
|
||||
|
||||
function goToGroup() {
|
||||
const groupId = session.value?.sourceGroup
|
||||
if (groupId) {
|
||||
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (!isAuthenticated()) {
|
||||
goToLogin()
|
||||
return
|
||||
}
|
||||
|
||||
joining.value = true
|
||||
try {
|
||||
await joinTeamSession(sessionId)
|
||||
ElMessage.success('已成功加入小队')
|
||||
goToGroup()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => isAuthenticated())
|
||||
|
||||
const isInTeam = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId || !session.value) return false
|
||||
return session.value.members?.includes(userId)
|
||||
})
|
||||
|
||||
const isInSourceGroup = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId || !session.value) return false
|
||||
return groupStore.groups.some(g => g.id === session.value?.sourceGroup)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="join-page">
|
||||
<div class="join-card">
|
||||
<div class="join-header">
|
||||
<el-icon :size="40" class="header-icon"><Promotion /></el-icon>
|
||||
<h1>组队邀请</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p>{{ error }}</p>
|
||||
<button class="action-btn" @click="$router.push('/')">返回首页</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="session" class="session-info">
|
||||
<div class="info-block">
|
||||
<h2 class="session-name">{{ session.name }}</h2>
|
||||
<p class="session-game">游戏:{{ session.gameName }}</p>
|
||||
<div class="meta-row">
|
||||
<span class="meta-item">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ session.members?.length || 0 }} 人
|
||||
</span>
|
||||
<span class="tag tag--info">{{ session.status === 'recruiting' ? '招募中' : '游戏中' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isInTeam" class="action-block">
|
||||
<p class="tip">你已经是该小队的成员</p>
|
||||
<button class="action-btn action-btn--primary" @click="goToGroup">
|
||||
进入小队
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isLoggedIn" class="action-block">
|
||||
<p class="tip">登录后即可加入小队</p>
|
||||
<button class="action-btn action-btn--primary" @click="goToLogin">
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isInSourceGroup" class="action-block">
|
||||
<p class="tip">你需要先加入该小队所属的群组</p>
|
||||
<button class="action-btn action-btn--primary" @click="goToGroup">
|
||||
查看群组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="action-block">
|
||||
<p class="tip">点击即可加入临时小队</p>
|
||||
<button
|
||||
class="action-btn action-btn--primary"
|
||||
:disabled="joining"
|
||||
@click="handleJoin"
|
||||
>
|
||||
{{ joining ? '处理中...' : '立即加入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.join-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.join-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.join-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 12px 0 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gg-border);
|
||||
border-top-color: var(--gg-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.session-game {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag--info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.action-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn--primary {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.action-btn--primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -173,14 +173,6 @@ const pageTitle = computed(() => {
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="header-action-btn" @click="showCreateGroup = true" title="创建群组">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span class="header-action-label">创建群组</span>
|
||||
</button>
|
||||
<button class="header-action-btn" @click="showJoinGroup = true" title="加入群组">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span class="header-action-label">加入群组</span>
|
||||
</button>
|
||||
<button class="icon-btn" @click="notificationStore.togglePanel()">
|
||||
<span v-if="notificationStore.unreadCount > 0" class="badge">
|
||||
{{ notificationStore.unreadCount }}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<!-- src/views/Profile.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElCard, ElInput, ElButton, ElMessage } from 'element-plus'
|
||||
import { ElCard, ElInput, ElButton, ElMessage, ElEmpty } from 'element-plus'
|
||||
import { getMyJoinRequests } from '@/api/groups'
|
||||
import type { JoinRequest } from '@/types'
|
||||
import { Clock, CircleCheck, CircleClose, Warning } from '@element-plus/icons-vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -11,6 +14,40 @@ const email = ref(userStore.user?.email || '')
|
||||
const avatar = ref(userStore.user?.avatar || '')
|
||||
const loading = ref(false)
|
||||
|
||||
const myJoinRequests = ref<JoinRequest[]>([])
|
||||
const requestsLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMyJoinRequests()
|
||||
})
|
||||
|
||||
async function loadMyJoinRequests() {
|
||||
requestsLoading.value = true
|
||||
try {
|
||||
myJoinRequests.value = await getMyJoinRequests()
|
||||
} catch (error) {
|
||||
console.error('加载入群申请失败:', error)
|
||||
} finally {
|
||||
requestsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; bg: string; icon: any }> = {
|
||||
pending: { label: '待审核', color: '#d97706', bg: 'rgba(245, 158, 11, 0.1)', icon: Clock },
|
||||
approved: { label: '已通过', color: '#059669', bg: 'rgba(5, 150, 105, 0.1)', icon: CircleCheck },
|
||||
rejected: { label: '已拒绝', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)', icon: CircleClose }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -64,6 +101,52 @@ function handleAvatarUpload() {
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 我的入群申请 -->
|
||||
<el-card class="requests-card">
|
||||
<template #header>
|
||||
<div class="requests-header">
|
||||
<span class="requests-title">我的入群申请</span>
|
||||
<button class="refresh-btn" @click="loadMyJoinRequests">刷新</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="requestsLoading" class="requests-loading">
|
||||
<div class="loading-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="myJoinRequests.length === 0" description="暂无入群申请记录" />
|
||||
|
||||
<div v-else class="requests-list">
|
||||
<div
|
||||
v-for="req in myJoinRequests"
|
||||
:key="req.id"
|
||||
class="request-item"
|
||||
>
|
||||
<div class="request-main">
|
||||
<span class="request-group">{{ req.expand?.group?.name || '未知群组' }}</span>
|
||||
<span
|
||||
class="request-status"
|
||||
:style="{
|
||||
color: statusConfig[req.status].color,
|
||||
background: statusConfig[req.status].bg
|
||||
}"
|
||||
>
|
||||
<el-icon><component :is="statusConfig[req.status].icon" /></el-icon>
|
||||
{{ statusConfig[req.status].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="request-meta">
|
||||
<span class="request-time">{{ formatDate(req.created) }} {{ formatTime(req.created) }}</span>
|
||||
</div>
|
||||
<div v-if="req.status === 'rejected' && req.rejectReason" class="request-reason">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>拒绝原因:{{ req.rejectReason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,19 +154,23 @@ function handleAvatarUpload() {
|
||||
.profile-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-page h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 24px;
|
||||
margin: 0;
|
||||
background: var(--gg-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
.profile-card,
|
||||
.requests-card {
|
||||
border-radius: var(--gg-radius-md);
|
||||
}
|
||||
|
||||
@@ -133,4 +220,134 @@ function handleAvatarUpload() {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── 入群申请列表 ── */
|
||||
.requests-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.requests-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.requests-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gg-border);
|
||||
border-top-color: var(--gg-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.request-item {
|
||||
padding: 14px 16px;
|
||||
background: var(--gg-bg);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.request-item:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.request-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.request-group {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.request-status .el-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.request-time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.request-reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(220, 38, 38, 0.06);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.request-reason .el-icon {
|
||||
font-size: 14px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-1
@@ -3,8 +3,10 @@
|
||||
|
||||
echo "🛑 停止所有服务..."
|
||||
|
||||
docker compose -f docker-compose.backend.yml down
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.uat.yml down
|
||||
|
||||
# 清理旧名称的残留容器
|
||||
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||
|
||||
echo "✅ 所有服务已停止"
|
||||
|
||||
Reference in New Issue
Block a user