Compare commits

43 Commits

Author SHA1 Message Date
congsh 3c2b68bbc3 fix(electron): enable mediaDevices on HTTP origins and fix voice auth
- Add --unsafely-treat-insecure-origin-as-secure flag for dev/uat URLs
- Set auto-granted permission handlers for mic/camera in main process
- Adapt useVoiceRoom error message for Electron (no Chrome flags hint)
- Add debug logging to voice-token service and frontend voice API

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:03:55 +08:00
congsh 60ad9a04cd feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配
黑名单功能:标记游戏、按原因/严重程度筛选、详情展开
修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 00:21:43 +08:00
congsh 2d56df940d docs: add v0.2.0 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:44:07 +08:00
congsh fdd1ae0929 fix: add $autoCancel:false to games API calls
GamesLibrary auto-cancellation error was caused by missing
$autoCancel:false on all games.ts PocketBase SDK calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:40:55 +08:00
congsh 19bf317d85 fix(phase3): description optional, restore nginx to host IP
- Make ledger description field optional (was required, caused 400)
- Revert nginx.conf back to 192.168.1.14:8090 (host IP, reliable)
- Keep docker-compose port mapping as 8090:8090
- Add $autoCancel:false to ledger/asset API calls

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:32:06 +08:00
congsh 221a8d7108 fix: restore Dev PocketBase port mapping to 8090
Was 8711:8090 but Dev PB should be on host port 8090 per CLAUDE.md
environment table. UAT remains on 8712.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:22:43 +08:00
congsh 09a7fe7708 fix: remove duplicate migrations, fix Dev nginx proxy target
- Remove 6 duplicate Phase 2 migration files (1776500001-0006) that
  failed on restart and blocked Phase 3 migrations from running
- Fix Dev nginx.conf proxy target from 192.168.1.14:8090 to
  gamegroup-pb:8090 (Docker internal DNS) since host port 8090 is
  not exposed (mapped as 8711:8090)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:14:11 +08:00
congsh dc11ef90fd fix(phase3): add $autoCancel:false to prevent SDK auto-cancellation
PocketBase JS SDK auto-cancels pending requests when a new request
targets the same collection. This causes errors when loadLedgers and
getLedgerSummary run in parallel via Promise.all. Added $autoCancel:false
to all API calls in ledgers.ts and assets.ts, matching project convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:05:27 +08:00
congsh e4b730c8db fix(phase3): subscription leak, image mime type validation
- Ledger store: add stopSubscription() to properly clean up realtime
  subscriptions, matching asset store pattern
- LedgerList: call stopSubscription on unmount
- Assets migration: restrict image upload to image/* mime types
  (C3 updateRule is a known tradeoff — PocketBase lacks field-level
  permissions, frontend enforces edit restrictions instead)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:52:34 +08:00
congsh c5413644f9 feat: phase 3 - ledger and asset management
Add group expense tracking (ledger) and public asset inventory (asset) features.
Ledger supports income/expense recording with monthly summary. Asset tracks
group equipment with free-form holder transfer. Both are independent pages
accessible from GroupView navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:42:04 +08:00
congsh 625d0baf7d feat: v0.1.1 - poll editing, notifications, fixes
- Poll editing by creator (title, options, deadline)
- Notification panel with app notifications and click-to-navigate
- Poll creation notifies group members
- Invitation rejection notifies inviter
- Fix: notification createRule, timezone, autocancel, nginx, tab timing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 18:51:23 +08:00
congsh c5d3ac01ca feat: phase 2 - polls, memories, notifications, stats v0.1.0
- Group polls with option/rollcall modes, edit by creator, auto-settle
- Multimedia memories with upload, preview, inline video playback
- In-app notifications for poll/team/group events
- Points system and group stats dashboard
- Group detail tabs with icons (activity/polls/memories/stats)
- Fix: nginx file upload size, static cache blocking API, timezone, auto-cancel

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 18:19:46 +08:00
congsh 71742da600 docs: add v0.0.3 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 13:36:02 +08:00
congsh 3173525a2e feat: support nickname login and nickname uniqueness check v0.0.3
- Login: query user by name field, authenticate with username
- Register: add blur-triggered nickname uniqueness validation
- Requires PocketBase users collection listRule/viewRule set to public

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 13:35:41 +08:00
congsh 0a7dcbb6b8 fix: login page uses email only since username is auto-generated
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 13:11:54 +08:00
congsh 5cec2101af feat: support Chinese nickname in registration
- User input "昵称" stored in `name` field (supports Chinese)
- `username` auto-generated (PocketBase requires ASCII)
- Password rules displayed inline with real-time validation
- All UI displays prefer `name` over `username`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 13:01:41 +08:00
congsh 262f946a4e feat: add changelog page with v0.0.1 and v0.0.2 entries
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 12:30:49 +08:00
congsh cfdbaf1095 feat: UI redesign v0.0.2 — color unification, navigation improvements, mobile support
- Unify color palette from mixed green/blue/purple to consistent green theme
- Sidebar: add text labels to create/join group buttons for discoverability
- Header: add quick action buttons (create group, join group, notifications)
- Mobile: add hamburger menu with slide-out sidebar and overlay
- Home: add prominent CTA buttons, onboarding card for empty state
- Join group dialog: add search-by-name mode alongside existing ID lookup
- Games library: inline group selector dropdown instead of external selection

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 12:24:20 +08:00
congsh 277a484f60 feat: UAT environment setup with separate PocketBase instance
- Add UAT PocketBase on port 8712 with separate pb_data_uat
- Add UAT nginx config proxying to UAT PB (port 8712)
- Update Dockerfile to support NGINX_CONF build arg
- Add .gitignore for .playwright-mcp/
- Auto-generated team_sessions migration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 11:11:38 +08:00
congsh 7299128a34 fix: resolve SSE realtime ERR_INCOMPLETE_CHUNKED_ENCODING error
Add dedicated nginx location block for /api/realtime with:
- proxy_buffering off to prevent response buffering
- gzip off to avoid chunked encoding issues
- proxy_read_timeout 86400s for long-lived SSE connections
- Connection '' instead of 'upgrade' for SSE protocol

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 11:05:40 +08:00
congsh 12b2cdbc02 fix: sync user status changes across team lifecycle
- Accept invitation: update local userStore status to in_team
- Start game: update userStore status to in_team
- End game: update userStore status to idle, simplify endGame logic
- Add $autoCancel:false to endGame session fetch

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:59:10 +08:00
congsh 8d3cce814a fix: team invitation acceptance 404 error
- Relax team_sessions collection rules to allow authenticated users to view/update
- Reorder respondInvitation: join session before marking invitation as accepted
- Add $autoCancel: false to prevent request cancellation during invitation flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:52:48 +08:00
congsh 3ae141ba56 fix: member status visibility, team creation improvements, join approval flow
- Fix other members' status not visible due to users collection viewRule restriction
- Fix empty status treated as 'away' instead of 'idle' in membersByStatus
- Auto-set creator to 'in_team' status when creating team session
- Filter current user from idle members invite list
- Fix group store isGroupOwner using pb.authStore instead of localStorage
- Add nginx no-cache headers for index.html
- Add join_requests collection migration and join approval flow
- Update groups collection rules and add requireApproval field
- Add Memory types for Phase 2 planning

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:42:11 +08:00
congsh 4dac4bc751 fix: disable PocketBase SDK auto-cancellation on group API calls
SSE subscriptions trigger concurrent requests to the same endpoints,
causing auto-cancellation errors. Add $autoCancel: false to group
queries that get called from realtime event handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:04:26 +08:00
congsh 6b684f8600 fix: login persistence, username login, realtime refresh, group name uniqueness
- Remove loadFromCookie that overwrites valid localStorage auth data
- Set user status to idle on first login (was empty string)
- Default empty status to idle instead of away

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 09:56:11 +08:00
congsh 9405406c47 fix: login persistence, username login, realtime refresh, group name uniqueness
- Fix cookie path to '/' for auth persistence across page refreshes
- Login field now accepts both username and email
- Add 30s polling for group list and team session status refresh
- Add group name uniqueness check before creation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 01:24:12 +08:00
congsh c76346294a feat: add group join approval flow with requireApproval setting
- New join_requests PocketBase collection for pending join applications
- Group requireApproval field (default true) with owner toggle
- JoinGroupDialog: apply when approval required, direct join when not
- JoinRequestCard component for accept/reject in notifications and group panel
- NotificationPanel shows both invitations and join requests
- GroupMembersPanel shows pending requests and approval switch for owners

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 00:46:31 +08:00
congsh 89d8ecec82 fix: allow authenticated users to search/view groups for joining
Changed groups collection listRule and viewRule from member-only to
any authenticated user, so users can find and join groups. Also
improved registration error messages with Chinese validation feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:43:59 +08:00
congsh d7f9c6860a fix: improve registration error messages with specific validation feedback
Extract PocketBase validation errors (duplicate email/username/password)
and display them in Chinese. Also improve login error message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:35:22 +08:00
congsh 83b7472594 refactor: replace all emoji icons with Element Plus SVG icons
Use @element-plus/icons-vue components for consistent, scalable vector
icons across sidebar navigation, section headers, ratings, and buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:36:44 +08:00
congsh 4b97c99e56 feat: add game library CRUD/import/export/favorites/comments, fix team creation
- Game library: add/delete games per group, JSON/CSV import/export, favorites, star ratings & comments
- Fix team session creation: add creator to members array, handle null currentGroup
- Fix image loading: rename SVG files from .png to .svg extensions
- Add PocketBase migrations for game_comments and game_favorites collections
- Remove seed data script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:03:20 +08:00
congsh 802712c662 feat: complete Phase 1 - game library, lifecycle, realtime sync
- Seed 33 popular games across 5 platforms via admin API script
- Add GameDetailDialog with game info and quick-team button
- Update GamesLibrary with game card click to open detail dialog
- Update Home hot games to open detail dialog instead of navigating
- Rewrite invitation accept: frontend auto-joins team + updates status
- Add user status reset on team dissolution (endGame)
- Add start game / dissolve buttons to TeamSessionPanel lifecycle
- Integrate realtime subscriptions in GroupView and Layout
- Add notification store realtime invitation listener
- Add placeholder images for game covers and avatars
- Remove Go hooks, add JS hooks placeholder + Docker mount

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 20:23:39 +08:00
142 changed files with 20042 additions and 659 deletions
+8 -1
View File
@@ -35,4 +35,11 @@ backend/pb_migrations.bak/
# Temporary files # Temporary files
*.tmp *.tmp
.cache/ .cache/
# Test screenshots and Playwright data
*.png
.playwright-mcp/
# PocketBase UAT data
backend/pb_data_uat/
+107
View File
@@ -0,0 +1,107 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产。
## 开发命令
```bash
# 构建前端
cd frontend && npm run build
# 本地开发(一般不用,用 Docker 部署代替)
cd frontend && npm run dev
# 部署脚本(根目录)
./deploy-backend.sh # 部署 PocketBase 后端
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
./stop-all.sh # 停止所有服务
# 查看日志
docker logs -f gamegroup-pb # 后端
docker logs -f gamegroup-frontend-dev # Dev
docker logs -f gamegroup-frontend-uat # UAT
```
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
## 技术栈
- **后端**: PocketBase 0.22.4 (Docker, `ghcr.io/muchobien/pocketbase`) — 无自定义 JS hooks,业务逻辑全在前端
- **前端**: Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + Vite
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包)localStorage 持久化认证
- **实时通信**: PocketBase realtime subscriptions
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
## 环境与端口
| 服务 | Dev | UAT |
|------|-----|-----|
| 前端 (nginx) | 7033 | 7034 |
| PocketBase | 8090 | 8712 |
Docker Compose 文件:`docker-compose.backend.yml``docker-compose.dev.yml``docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
## 架构
### 前端核心流程
```
pocketbase.ts (PB 客户端初始化)
→ router guards (isAuthenticated 检查)
→ stores (user/group/team/notification/poll/ledger/asset/memory)
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
→ components + views
```
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb``getCurrentUser()``isAuthenticated()``logout()`
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap``TeamStatusMap`
### 认证流程
- 注册:用户输入中文昵称存 `name` 字段,`username` 自动生成 ASCII 标识(`'u' + Date.now().toString(36) + random`
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页
### 路由结构
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
### Vite 代理 vs Nginx
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
### 数据模型(PocketBase Collections
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
- **groups** — owner + members 关系,支持审核加入(requireApproval
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
- **invitations** — 组队邀请,pending/accepted/rejected
- **games** — 游戏库,归属 group,含平台、标签、封面
- **game_comments** / **game_favorites** — 评论和收藏
- **join_requests** — 入群申请
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
- **notifications** — 站内通知(投票/组队/入群等事件)
### PocketBase 注意事项
- `users` collection 的 `listRule`/`viewRule` 设为空字符串(公开),以支持登录页查询用户
- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
- PocketBase 管理面板:`admin@example.com` / `admin123456`
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
+1
View File
@@ -10,6 +10,7 @@ services:
volumes: volumes:
- ./pb_data:/pb_data - ./pb_data:/pb_data
- ./pb_migrations:/pb_migrations - ./pb_migrations:/pb_migrations
- ./pb_hooks:/pb_hooks
environment: environment:
- GO_ENV=production - GO_ENV=production
restart: unless-stopped restart: unless-stopped
-5
View File
@@ -1,5 +0,0 @@
module gamegroup-hooks
go 1.21
require github.com/pocketbase/pocketbase v0.22.4
-159
View File
@@ -1,159 +0,0 @@
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// ── Groups ──
app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
e.Record.Set("owner", authRecord.Id)
e.Record.Set("members", []string{authRecord.Id})
return nil
})
app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordUpdateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
original, err := app.Dao().FindRecordById("groups", e.Record.Id)
if err != nil {
return apis.NewNotFoundError("群组不存在", nil)
}
if original.GetString("owner") != authRecord.Id {
return apis.NewForbiddenError("只有群组所有者可以更新群组", nil)
}
// 保护 owner 字段不被篡改
e.Record.Set("owner", original.GetString("owner"))
// 验证 members 数量
members := e.Record.GetStringSlice("members")
maxMembers := e.Record.GetInt("maxMembers")
if maxMembers > 0 && len(members) > maxMembers {
return apis.NewBadRequestError("成员数量超过上限", nil)
}
return nil
})
app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordDeleteEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
isOwner, err := isGroupOwner(app, e.Record.Id, authRecord.Id)
if err != nil || !isOwner {
return apis.NewForbiddenError("只有群组所有者可以删除群组", nil)
}
return nil
})
// ── Team Sessions ──
app.OnRecordBeforeCreateRequest("team_sessions").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
groupId := e.Record.GetString("sourceGroup")
isMember, err := isGroupMember(app, groupId, authRecord.Id)
if err != nil || !isMember {
return apis.NewForbiddenError("只有群组成员可以创建团队会话", nil)
}
return nil
})
// ── Invitations ──
app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
// 强制设置 from 为当前用户
e.Record.Set("from", authRecord.Id)
e.Record.Set("status", "pending")
return nil
})
// 接受邀请:自动加入 team session + 更新用户状态
app.OnRecordBeforeUpdateRequest("invitations").Add(func(e *core.RecordUpdateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
// 只有 recipient 可以更新邀请
if e.Record.GetString("to") != authRecord.Id {
return apis.NewForbiddenError("无权操作此邀请", nil)
}
newStatus := e.Record.GetString("status")
if newStatus == "accepted" {
teamSessionId := e.Record.GetString("teamSession")
teamSession, err := app.Dao().FindRecordById("team_sessions", teamSessionId)
if err != nil {
return apis.NewNotFoundError("临时小组不存在", nil)
}
// 将用户加入 team session members
members := teamSession.GetStringSlice("members")
alreadyIn := false
for _, m := range members {
if m == authRecord.Id {
alreadyIn = true
break
}
}
if !alreadyIn {
members = append(members, authRecord.Id)
teamSession.Set("members", members)
if err := app.Dao().SaveRecord(teamSession); err != nil {
return apis.NewBadRequestError("加入临时小组失败", nil)
}
}
// 更新用户状态为 in_team
user, err := app.Dao().FindRecordById("users", authRecord.Id)
if err == nil {
user.Set("status", "in_team")
app.Dao().SaveRecord(user)
}
}
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) {
group, err := app.Dao().FindRecordById("groups", groupId)
if err != nil {
return false, err
}
members := group.GetStringSlice("members")
for _, member := range members {
if member == userId {
return true, nil
}
}
return false, nil
}
func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) {
group, err := app.Dao().FindRecordById("groups", groupId)
if err != nil {
return false, err
}
return group.GetString("owner") == userId, nil
}
+5
View File
@@ -0,0 +1,5 @@
// JS hooks placeholder - PocketBase 0.22.4 muchobien image may not support JS VM
// Key logic handled in frontend instead:
// - Groups: frontend sets owner + members on create
// - Invitations: frontend auto-joins team session on accept
// - Team dissolution: frontend resets member statuses on end
@@ -84,10 +84,10 @@ migrate((db) => {
} }
], ],
"indexes": [], "indexes": [],
"listRule": "owner = @request.auth.id || members.id = @request.auth.id", "listRule": "@request.auth.id != \"\"",
"viewRule": "owner = @request.auth.id || members.id = @request.auth.id", "viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"", "createRule": "@request.auth.id != \"\"",
"updateRule": "owner = @request.auth.id", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "owner = @request.auth.id", "deleteRule": "owner = @request.auth.id",
"options": {} "options": {}
}); });
@@ -0,0 +1,87 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "4f73q31qelxycnu",
"created": "2026-04-17 12:37:24.610Z",
"updated": "2026-04-17 12:37:24.610Z",
"name": "game_comments",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gc_game",
"name": "game",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gc_author",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gc_content",
"name": "content",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 1000,
"pattern": ""
}
},
{
"system": false,
"id": "gc_rating",
"name": "rating",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": 5,
"noDecimal": true
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": null,
"deleteRule": "author = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("4f73q31qelxycnu");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,59 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "b7dbz8i2snrgw9g",
"created": "2026-04-17 12:37:35.573Z",
"updated": "2026-04-17 12:37:35.573Z",
"name": "game_favorites",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gf_game",
"name": "game",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gf_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": null,
"deleteRule": "user = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("b7dbz8i2snrgw9g");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((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"
collection.createRule = "@request.auth.id != \"\""
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_addedBy",
"name": "addedBy",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
collection.createRule = null
collection.updateRule = null
collection.deleteRule = null
// remove
collection.schema.removeField("sf_group")
// remove
collection.schema.removeField("sf_addedBy")
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("es63bkyiblpnxdf")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
collection.listRule = "owner = @request.auth.id || members.id = @request.auth.id"
collection.viewRule = "owner = @request.auth.id || members.id = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
collection.updateRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
collection.updateRule = "owner = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_approval",
"name": "requireApproval",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
// remove
collection.schema.removeField("sf_approval")
return dao.saveCollection(collection)
})
@@ -0,0 +1,90 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "ezklls35klxregi",
"created": "2026-04-17 16:30:48.222Z",
"updated": "2026-04-17 16:30:48.222Z",
"name": "join_requests",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_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_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_status",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"pending",
"approved",
"rejected"
]
}
},
{
"system": false,
"id": "sf_reason",
"name": "rejectReason",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": "group.owner = @request.auth.id",
"deleteRule": "group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("ezklls35klxregi");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = "id = @request.auth.id"
collection.viewRule = "id = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("sac8t6o9rspld8p")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
collection.updateRule = "@request.auth.id != \"\""
collection.deleteRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("sac8t6o9rspld8p")
collection.listRule = "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id"
collection.viewRule = "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id"
collection.updateRule = "sourceGroup.owner = @request.auth.id"
collection.deleteRule = "sourceGroup.owner = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.options = {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 6,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.options = {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
return dao.saveCollection(collection)
})
@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = ""
collection.viewRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
})
@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = ""
collection.viewRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
})
@@ -0,0 +1,155 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "vfk07d8w8tl2d75",
"created": "2026-04-18 09:11:15.379Z",
"updated": "2026-04-18 09:11:15.379Z",
"name": "polls",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "bdmbfbno",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "hvnldxgq",
"name": "creator",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "iuuorixx",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "g6ht5xdc",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"option",
"rollcall"
]
}
},
{
"system": false,
"id": "0y0jzy14",
"name": "anonymous",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "f0airh3m",
"name": "deadline",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "4le4t2ht",
"name": "maxParticipants",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "081izbpf",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"active",
"settled"
]
}
},
{
"system": false,
"id": "lqsw4nqu",
"name": "settledAt",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("vfk07d8w8tl2d75");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "w30law0vgssvgxm",
"created": "2026-04-18 09:11:35.737Z",
"updated": "2026-04-18 09:11:35.737Z",
"name": "poll_options",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "r2ztzdoo",
"name": "poll",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "vfk07d8w8tl2d75",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "klakmukb",
"name": "content",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "m0pd1wfk",
"name": "order",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 0,
"max": null,
"noDecimal": true
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\" && poll.creator = @request.auth.id",
"updateRule": "poll.creator = @request.auth.id",
"deleteRule": "poll.creator = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,77 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "liqeya2lycibs4y",
"created": "2026-04-18 09:12:13.979Z",
"updated": "2026-04-18 09:12:13.979Z",
"name": "poll_votes",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "e7aygbae",
"name": "poll",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "vfk07d8w8tl2d75",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "uaalzgys",
"name": "option",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "w30law0vgssvgxm",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "dvr0tpcl",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_poll_user` ON `poll_votes` (\n `poll`,\n `user`\n)"
],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && poll.group.members ~ @request.auth.id",
"updateRule": "user = @request.auth.id",
"deleteRule": "user = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("liqeya2lycibs4y");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,136 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "e7tjqmsm5ck66xl",
"created": "2026-04-18 09:12:14.033Z",
"updated": "2026-04-18 09:12:14.033Z",
"name": "memories",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "itx7thzd",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gezpwnor",
"name": "uploader",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "h51c22eh",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "mbzu9zlc",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "yfa85qjr",
"name": "file",
"type": "file",
"required": true,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": null,
"thumbs": null,
"maxSelect": 1,
"maxSize": 524288000,
"protected": false
}
},
{
"system": false,
"id": "tfclnicu",
"name": "fileType",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"image",
"video",
"audio",
"document",
"other"
]
}
},
{
"system": false,
"id": "pdok0jhi",
"name": "size",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 0,
"max": null,
"noDecimal": false
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "uploader = @request.auth.id",
"deleteRule": "uploader = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("e7tjqmsm5ck66xl");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,133 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "s63vtbeeqlv1xzu",
"created": "2026-04-18 09:12:14.062Z",
"updated": "2026-04-18 09:12:14.062Z",
"name": "notifications",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "elgovwo1",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ghhe48ku",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"poll_new",
"poll_deadline",
"poll_result",
"team_invite",
"team_starting",
"join_request",
"member_joined"
]
}
},
{
"system": false,
"id": "gw88luj3",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "qmazbl4u",
"name": "content",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "1qdnqn2w",
"name": "read",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "i4xrijz1",
"name": "relatedId",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "w1lzqcjc",
"name": "relatedType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"poll",
"team",
"group"
]
}
}
],
"indexes": [],
"listRule": "user = @request.auth.id",
"viewRule": "user = @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": "user = @request.auth.id",
"deleteRule": "user = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,88 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "h5adxdw9gm0aw8s",
"created": "2026-04-18 09:12:14.095Z",
"updated": "2026-04-18 09:12:14.095Z",
"name": "point_logs",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "xeqacyc7",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sd27cbh8",
"name": "action",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"vote",
"team",
"memory"
]
}
},
{
"system": false,
"id": "iszqa13h",
"name": "points",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "pnipfzbd",
"name": "relatedId",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": "user = @request.auth.id",
"viewRule": "user = @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm")
collection.createRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm")
collection.createRule = "@request.auth.id != \"\" && poll.creator = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
collection.createRule = "@request.auth.id != \"\""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
collection.createRule = "@request.auth.id != \"\" && user = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,151 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "ledgers_col",
"created": "2026-04-18 10:00:01.000Z",
"updated": "2026-04-18 10:00:01.000Z",
"name": "ledgers",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "lgr_group",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "lgr_creator",
"name": "creator",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "lgr_type",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"income",
"expense"
]
}
},
{
"system": false,
"id": "lgr_amount",
"name": "amount",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 0.01,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "lgr_category",
"name": "category",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"gaming",
"food",
"equipment",
"transport",
"other"
]
}
},
{
"system": false,
"id": "lgr_desc",
"name": "description",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "lgr_members",
"name": "relatedMembers",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
},
{
"system": false,
"id": "lgr_occurred",
"name": "occurredAt",
"type": "date",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("ledgers_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,138 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "assets_col",
"created": "2026-04-18 10:00:02.000Z",
"updated": "2026-04-18 10:00:02.000Z",
"name": "assets",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ast_group",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ast_creator",
"name": "creator",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ast_name",
"name": "name",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "ast_type",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"game_account",
"console",
"equipment",
"accessory",
"other"
]
}
},
{
"system": false,
"id": "ast_desc",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "ast_holder",
"name": "currentHolder",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ast_image",
"name": "image",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": ["image/*"],
"thumbs": ["200x200"],
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("assets_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,44 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("ledgers_col")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "lgr_desc",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("ledgers_col")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "lgr_desc",
"name": "description",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
}))
return dao.saveCollection(collection)
})
@@ -0,0 +1,114 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "gblacklist_col",
"created": "2026-04-18 21:00:01.000Z",
"updated": "2026-04-18 21:00:01.000Z",
"name": "game_blacklist",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gb_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_reporter",
"name": "reporter",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_game",
"name": "game",
"type": "relation",
"required": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_gamename",
"name": "gameName",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "gb_reason",
"name": "reason",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["behavior", "cheating", "abandonment", "toxic", "other"]
}
},
{
"system": false,
"id": "gb_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "gb_severity",
"name": "severity",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["mild", "medium", "severe"]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": null,
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("gblacklist_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,149 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "bets_col",
"created": "2026-04-18 21:00:02.000Z",
"updated": "2026-04-18 21:00:02.000Z",
"name": "bets",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "bt_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_title",
"name": "title",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "bt_desc",
"name": "description",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 1000,
"pattern": ""
}
},
{
"system": false,
"id": "bt_minstake",
"name": "minStake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "bt_maxstake",
"name": "maxStake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "bt_status",
"name": "status",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["open", "closed", "settled"]
}
},
{
"system": false,
"id": "bt_result",
"name": "resultOption",
"type": "relation",
"required": false,
"options": {
"collectionId": "betopts_col",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_deadline",
"name": "deadline",
"type": "date",
"required": true,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "bt_settledat",
"name": "settledAt",
"type": "date",
"required": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("bets_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,64 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "betopts_col",
"created": "2026-04-18 21:00:03.000Z",
"updated": "2026-04-18 21:00:03.000Z",
"name": "bet_options",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "bo_bet",
"name": "bet",
"type": "relation",
"required": true,
"options": {
"collectionId": "bets_col",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bo_content",
"name": "content",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "bo_order",
"name": "order",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"updateRule": "bet.creator = @request.auth.id",
"deleteRule": "bet.creator = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("betopts_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,88 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "betentries_col",
"created": "2026-04-18 21:00:04.000Z",
"updated": "2026-04-18 21:00:04.000Z",
"name": "bet_entries",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "be_bet",
"name": "bet",
"type": "relation",
"required": true,
"options": {
"collectionId": "bets_col",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_user",
"name": "user",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_option",
"name": "option",
"type": "relation",
"required": true,
"options": {
"collectionId": "betopts_col",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_stake",
"name": "stake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "be_won",
"name": "won",
"type": "bool",
"required": false,
"options": {}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && bet.group.members ~ @request.auth.id",
"updateRule": "bet.creator = @request.auth.id",
"deleteRule": "user = @request.auth.id && bet.status = \"open\"",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("betentries_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,116 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "sd27cbh8",
"name": "action",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"vote",
"team",
"memory",
"bet",
"settle"
]
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "iszqa13h",
"name": "points",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "pnipfzbd",
"name": "relatedId",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "sd27cbh8",
"name": "action",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"vote",
"team",
"memory"
]
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "iszqa13h",
"name": "points",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "pnipfzbd",
"name": "relatedId",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
})
@@ -0,0 +1,124 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "pblacklist_col",
"created": "2026-04-19 10:00:01.000Z",
"updated": "2026-04-19 10:00:01.000Z",
"name": "player_blacklist",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "pb_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_reporter",
"name": "reporter",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_playerid",
"name": "playerId",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "pb_platform",
"name": "platform",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "pb_tags",
"name": "tags",
"type": "select",
"required": true,
"options": {
"maxSelect": 5,
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
}
},
{
"system": false,
"id": "pb_customtag",
"name": "customTag",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 50,
"pattern": ""
}
},
{
"system": false,
"id": "pb_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "pb_severity",
"name": "severity",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["mild", "medium", "severe"]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": null,
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("pblacklist_col");
return dao.deleteCollection(collection);
})
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY server.js .
EXPOSE 7882
CMD ["npm", "start"]
+12
View File
@@ -0,0 +1,12 @@
{
"name": "gamegroup-voice-token",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"livekit-server-sdk": "^2.0",
"express": "^4.21"
}
}
+87
View File
@@ -0,0 +1,87 @@
import express from 'express'
import { AccessToken } from 'livekit-server-sdk'
const app = express()
app.use(express.json())
const API_KEY = process.env.LIVEKIT_API_KEY || 'APIyxZGQjM2'
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi'
const PB_URL = process.env.PB_URL || 'http://gamegroup-pb:8090'
const PORT = process.env.PORT || 7882
app.post('/api/voice-token/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params
const authHeader = req.headers.authorization
console.log('Voice token request:', { sessionId, authHeader: authHeader ? authHeader.slice(0, 20) + '...' : null })
if (!authHeader) {
console.log('Missing auth header')
return res.status(401).json({ error: '未登录' })
}
// 验证用户 token — 调用 PocketBase
const pbRefreshUrl = `${PB_URL}/api/collections/users/auth-refresh`
console.log('Calling PB auth-refresh:', pbRefreshUrl)
const pbRes = await fetch(pbRefreshUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: '{}',
})
console.log('PB auth-refresh status:', pbRes.status)
if (!pbRes.ok) {
const pbBody = await pbRes.text().catch(() => 'unknown')
console.log('PB auth-refresh error body:', pbBody)
return res.status(401).json({ error: '认证失败', detail: pbBody })
}
const userData = await pbRes.json()
console.log('PB auth-refresh success, userId:', userData.record?.id)
const userId = userData.record?.id
const userName = userData.record?.name || userData.record?.username || userId
if (!userId) {
return res.status(401).json({ error: '无效用户' })
}
// 获取 session 并验证成员
const sessionRes = await fetch(`${PB_URL}/api/collections/team_sessions/records/${sessionId}`, {
headers: { Authorization: authHeader },
})
if (!sessionRes.ok) {
return res.status(404).json({ error: '未找到临时小组' })
}
const session = await sessionRes.json()
const members = session.members || []
if (!members.includes(userId)) {
return res.status(403).json({ error: '你不是该小队的成员' })
}
// 签发 LiveKit token
const at = new AccessToken(API_KEY, API_SECRET, {
identity: userId,
name: userName,
})
at.addGrant({
roomJoin: true,
room: `team-${sessionId}`,
canPublish: true,
canSubscribe: true,
})
const token = await at.toJwt()
res.json({ token })
} catch (err) {
console.error('Voice token error:', err)
res.status(500).json({ error: '服务器错误' })
}
})
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, () => {
console.log(`Voice token service listening on :${PORT}`)
})
+32 -1
View File
@@ -3,10 +3,11 @@ services:
image: ghcr.io/muchobien/pocketbase:0.22.4 image: ghcr.io/muchobien/pocketbase:0.22.4
container_name: gamegroup-pb container_name: gamegroup-pb
ports: ports:
- "8711:8090" - "8090:8090"
volumes: volumes:
- ./backend/pb_data:/pb_data - ./backend/pb_data:/pb_data
- ./backend/pb_migrations:/pb_migrations - ./backend/pb_migrations:/pb_migrations
- ./backend/pb_hooks:/pb_hooks
environment: environment:
- GO_ENV=production - GO_ENV=production
restart: unless-stopped restart: unless-stopped
@@ -19,6 +20,36 @@ services:
networks: networks:
- gamegroup-net - 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: networks:
gamegroup-net: gamegroup-net:
driver: bridge driver: bridge
+55
View File
@@ -1,14 +1,69 @@
services: services:
pocketbase-uat:
image: ghcr.io/muchobien/pocketbase:0.22.4
container_name: gamegroup-pb-uat
ports:
- "8712:8090"
volumes:
- ./backend/pb_data_uat:/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-uat:8090
restart: unless-stopped
depends_on:
- pocketbase-uat
networks:
- gamegroup-net
frontend-uat: frontend-uat:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NGINX_CONF: nginx.uat.conf
container_name: gamegroup-frontend-uat container_name: gamegroup-frontend-uat
ports: ports:
- "7034:80" - "7034:80"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
restart: unless-stopped restart: unless-stopped
depends_on:
- pocketbase-uat
networks: networks:
- gamegroup-net - gamegroup-net
+15 -1
View File
@@ -159,6 +159,19 @@ NAS 服务器
icon: string icon: string
awardedAt: date awardedAt: date
} }
// memories - 多媒体记忆(音视频等)
{
id: string
groupId: string
uploader: string (userId)
title: string
description: string
file: string (PocketBase file field)
fileType: "image" | "video" | "audio" | "other"
size: number (bytes)
createdAt: date
}
``` ```
### 3.3 三期数据集合 ### 3.3 三期数据集合
@@ -398,11 +411,12 @@ NAS 服务器
### 第二期 ### 第二期
**目标**: 预约 + 积分 + 荣誉 **目标**: 预约 + 积分 + 荣誉 + 记忆
- [ ] 预约系统(简化投票) - [ ] 预约系统(简化投票)
- [ ] 积分系统(获取/记录) - [ ] 积分系统(获取/记录)
- [ ] 荣誉墙(自动授予) - [ ] 荣誉墙(自动授予)
- [ ] 多媒体记忆(支持音视频等多媒体文件的上传、下载与在线预览)
### 第三期 ### 第三期
@@ -0,0 +1,146 @@
# Game Group V2 — 三期功能设计
## 功能总览
| 优先级 | 功能 | 说明 |
|--------|------|------|
| P0 | 账目管理 | 群组费用流水记录,纯记录不分摊 |
| P0 | 资产管理 | 群组公共资产清单,自由标记持有人 |
---
## P0: 账目管理
### 定位
群组内费用流水记录。成员记录每笔收入/支出(聚餐、游戏充值、设备采购等),纯记录,不做分摊和结算。
### 数据模型
**`ledgers` Collection:**
| 字段 | 类型 | 说明 |
|------|------|------|
| group | relation → groups | 所属群组 |
| creator | relation → users | 记录人 |
| type | select: `income` / `expense` | 收入/支出 |
| amount | number | 金额 |
| category | select: `gaming` / `food` / `equipment` / `transport` / `other` | 分类 |
| description | text | 描述 |
| relatedMembers | relation → users (multiple) | 相关成员 |
| occurredAt | date | 发生时间 |
### 业务规则
- 所有群成员可记录和查看
- 只有记录人可编辑/删除
- 收入用绿色,支出用红色,直观区分
### 前端页面
路由:`/group/:groupId/ledger`
```
components/ledger/
├── LedgerList.vue # 账目列表(收入/支出分色显示)
├── LedgerCard.vue # 单条账目卡片
├── CreateLedgerDialog.vue # 新建账目弹窗
└── LedgerSummary.vue # 汇总面板(总收入/总支出/余额)
```
### API 层 (`api/ledgers.ts`)
- `createLedger(groupId, data)` — 新建记录
- `listLedgers(groupId, filter?)` — 列表(支持按月筛选)
- `updateLedger(ledgerId, data)` — 更新(仅记录人)
- `deleteLedger(ledgerId)` — 删除(仅记录人)
- `getLedgerSummary(groupId)` — 汇总统计
---
## P0: 资产管理
### 定位
群组公共资产清单。登记游戏账号、主机、手柄等物品,任何成员可自由标记当前持有人。空持有人表示资产在库。
### 数据模型
**`assets` Collection:**
| 字段 | 类型 | 说明 |
|------|------|------|
| group | relation → groups | 所属群组 |
| creator | relation → users | 登记人 |
| name | text | 资产名称 |
| type | select: `game_account` / `console` / `equipment` / `accessory` / `other` | 类型 |
| description | text, 可选 | 描述备注 |
| currentHolder | relation → users, 可选 | 当前持有人(空=在库) |
| image | file, 可选 | 资产照片 |
### 业务规则
- 所有群成员可登记和查看
- 任何成员可更新持有人(标记自己拿走/放回)
- 只有登记人可编辑/删除资产信息
### 前端页面
路由:`/group/:groupId/assets`
```
components/asset/
├── AssetList.vue # 资产列表(卡片网格)
├── AssetCard.vue # 资产卡片(显示持有人头像)
├── CreateAssetDialog.vue # 登记资产弹窗
└── TransferAssetDialog.vue # 转移持有人弹窗(选择成员)
```
### API 层 (`api/assets.ts`)
- `createAsset(groupId, data)` — 登记资产
- `listAssets(groupId)` — 资产列表
- `updateAsset(assetId, data)` — 更新信息
- `deleteAsset(assetId)` — 删除
- `transferAsset(assetId, userId?)` — 更新持有人(null=放回在库)
---
## 导航入口
GroupView 群组操作菜单中添加:
- "账目"按钮 → 跳转 `/group/:groupId/ledger`
- "资产"按钮 → 跳转 `/group/:groupId/assets`
---
## 实现文件变更总览
### 新增文件
| 文件 | 说明 |
|------|------|
| `frontend/src/api/ledgers.ts` | 账目 API |
| `frontend/src/api/assets.ts` | 资产 API |
| `frontend/src/components/ledger/*.vue` | 账目组件 (4 个) |
| `frontend/src/components/asset/*.vue` | 资产组件 (4 个) |
| `frontend/src/views/LedgerView.vue` | 账目页面 |
| `frontend/src/views/AssetView.vue` | 资产页面 |
| `backend/pb_migrations/xxxx_create_ledgers.js` | 账目表迁移 |
| `backend/pb_migrations/xxxx_create_assets.js` | 资产表迁移 |
### 修改文件
| 文件 | 变更 |
|------|------|
| `frontend/src/types/index.ts` | 新增 Ledger、Asset 类型定义 |
| `frontend/src/router/index.ts` | 新增 ledger、asset 路由 |
| `frontend/src/views/GroupView.vue` | 添加账目/资产入口按钮 |
### 建议实现顺序
1. 数据库迁移(ledgers → assets
2. 类型定义 (types/index.ts)
3. API 层 (ledgers.ts → assets.ts)
4. 账目页面(组件 + 路由 + 导航入口)
5. 资产页面(组件 + 路由 + 导航入口)
@@ -0,0 +1,920 @@
# Phase 3: Ledger + Asset Management 实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为群组添加账目流水记录和公共资产清单功能,两个独立页面。
**Architecture:** 新增两个 PocketBase Collectionledgers、assets),对应 API 层、类型定义、Pinia Store、Vue 组件和路由。通过 GroupView 操作入口跳转到独立页面。纯前端实现,无自定义后端逻辑。
**Tech Stack:** Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + PocketBase JS SDK
---
## Task 1: PocketBase 数据库迁移
**Files:**
- Create: `backend/pb_migrations/1776510001_created_ledgers.js`
- Create: `backend/pb_migrations/1776510002_created_assets.js`
**Step 1: 创建 ledgers 迁移文件**
```javascript
// backend/pb_migrations/1776510001_created_ledgers.js
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "ledgers_col",
"name": "ledgers",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_type",
"name": "type",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["income", "expense"]
}
},
{
"system": false,
"id": "sf_amount",
"name": "amount",
"type": "number",
"required": true,
"options": {
"min": 0.01,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "sf_category",
"name": "category",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["gaming", "food", "equipment", "transport", "other"]
}
},
{
"system": false,
"id": "sf_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "sf_members",
"name": "relatedMembers",
"type": "relation",
"required": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
},
{
"system": false,
"id": "sf_occurred",
"name": "occurredAt",
"type": "date",
"required": true,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("ledgers_col");
return dao.deleteCollection(collection);
})
```
**Step 2: 创建 assets 迁移文件**
```javascript
// backend/pb_migrations/1776510002_created_assets.js
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "assets_col",
"name": "assets",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_name",
"name": "name",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "sf_type",
"name": "type",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["game_account", "console", "equipment", "accessory", "other"]
}
},
{
"system": false,
"id": "sf_desc",
"name": "description",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "sf_holder",
"name": "currentHolder",
"type": "relation",
"required": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_image",
"name": "image",
"type": "file",
"required": false,
"options": {
"mimeTypes": ["image/*"],
"thumbs": ["200x200"],
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("assets_col");
return dao.deleteCollection(collection);
})
```
**Step 3: 部署后端并验证迁移**
Run: `./deploy-backend.sh`(或在 UAT 环境部署后检查 PocketBase 管理面板,确认 ledgers 和 assets collections 已创建)
**Step 4: Commit**
```bash
git add backend/pb_migrations/1776510001_created_ledgers.js backend/pb_migrations/1776510002_created_assets.js
git commit -m "feat(phase3): add ledgers and assets PocketBase migrations"
```
---
## Task 2: TypeScript 类型定义
**Files:**
- Modify: `frontend/src/types/index.ts`
**Step 1: 在 types/index.ts 末尾(`displayName` 函数之前)添加账目和资产类型**
```typescript
// 账目类型
export type LedgerType = 'income' | 'expense'
export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other'
export const LedgerTypeMap: Record<LedgerType, string> = {
income: '收入',
expense: '支出'
}
export const LedgerCategoryMap: Record<LedgerCategory, string> = {
gaming: '游戏',
food: '聚餐',
equipment: '设备',
transport: '交通',
other: '其他'
}
export interface Ledger {
id: string
group: string
creator: string
type: LedgerType
amount: number
category: LedgerCategory
description: string
relatedMembers: string[]
occurredAt: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
relatedMembers?: User[]
}
}
// 资产类型
export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other'
export const AssetTypeMap: Record<AssetType, string> = {
game_account: '游戏账号',
console: '主机',
equipment: '设备',
accessory: '配件',
other: '其他'
}
export interface Asset {
id: string
group: string
creator: string
name: string
type: AssetType
description?: string
currentHolder?: string
image?: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
currentHolder?: User
}
}
```
**Step 2: Commit**
```bash
git add frontend/src/types/index.ts
git commit -m "feat(phase3): add Ledger and Asset type definitions"
```
---
## Task 3: API 层 — 账目
**Files:**
- Create: `frontend/src/api/ledgers.ts`
**Step 1: 创建 ledgers API**
遵循 `api/memories.ts` 的模式:导入 pb 实例、类型、async 函数、expand 关联数据、subscribe 实时订阅。
```typescript
// frontend/src/api/ledgers.ts
import { pb } from './pocketbase'
import type { Ledger } from '@/types'
export async function createLedger(data: {
group: string
type: 'income' | 'expense'
amount: number
category: string
description: string
relatedMembers?: string[]
occurredAt: string
}): Promise<Ledger> {
const user = pb.authStore.model
const record = await pb.collection('ledgers').create({
group: data.group,
creator: user?.id,
type: data.type,
amount: data.amount,
category: data.category,
description: data.description,
relatedMembers: data.relatedMembers || [],
occurredAt: data.occurredAt,
})
return record as unknown as Ledger
}
export async function listLedgers(
groupId: string,
options?: {
page?: number
limit?: number
type?: string
category?: string
month?: string // "2026-04" 格式
}
): Promise<{ items: Ledger[]; total: number }> {
const { page = 1, limit = 30, type, category, month } = options || {}
const filters = [`group="${groupId}"`]
if (type) filters.push(`type="${type}"`)
if (category) filters.push(`category="${category}"`)
if (month) {
const [y, m] = month.split('-').map(Number)
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
const end = new Date(y, m, 1).toISOString().slice(0, 10)
filters.push(`occurredAt >= "${start}"`)
filters.push(`occurredAt < "${end}"`)
}
const result = await pb.collection('ledgers').getList(page, limit, {
filter: filters.join(' && '),
sort: '-occurredAt',
expand: 'creator,relatedMembers'
})
return { items: result.items as unknown as Ledger[], total: result.totalItems }
}
export async function updateLedger(
ledgerId: string,
data: Partial<Pick<Ledger, 'type' | 'amount' | 'category' | 'description' | 'relatedMembers' | 'occurredAt'>>
): Promise<Ledger> {
const record = await pb.collection('ledgers').update(ledgerId, data)
return record as unknown as Ledger
}
export async function deleteLedger(ledgerId: string): Promise<void> {
await pb.collection('ledgers').delete(ledgerId)
}
export async function getLedgerSummary(groupId: string, month?: string): Promise<{
totalIncome: number
totalExpense: number
balance: number
}> {
const filters = [`group="${groupId}"`]
if (month) {
const [y, m] = month.split('-').map(Number)
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
const end = new Date(y, m, 1).toISOString().slice(0, 10)
filters.push(`occurredAt >= "${start}"`)
filters.push(`occurredAt < "${end}"`)
}
let totalIncome = 0
let totalExpense = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
const result = await pb.collection('ledgers').getList(page, batchSize, {
filter: filters.join(' && '),
fields: 'type,amount'
})
for (const item of result.items as any[]) {
if (item.type === 'income') totalIncome += item.amount || 0
else totalExpense += item.amount || 0
}
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return { totalIncome, totalExpense, balance: totalIncome - totalExpense }
}
export function subscribeLedgers(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('ledgers').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
```
**Step 2: Commit**
```bash
git add frontend/src/api/ledgers.ts
git commit -m "feat(phase3): add ledgers API layer"
```
---
## Task 4: API 层 — 资产
**Files:**
- Create: `frontend/src/api/assets.ts`
**Step 1: 创建 assets API**
```typescript
// frontend/src/api/assets.ts
import { pb } from './pocketbase'
import type { Asset } from '@/types'
export async function createAsset(data: {
group: string
name: string
type: string
description?: string
image?: File
}): Promise<Asset> {
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', data.group)
formData.append('creator', user?.id || '')
formData.append('name', data.name)
formData.append('type', data.type)
if (data.description) formData.append('description', data.description)
if (data.image) formData.append('image', data.image)
const record = await pb.collection('assets').create(formData)
return record as unknown as Asset
}
export async function listAssets(groupId: string): Promise<Asset[]> {
const result = await pb.collection('assets').getFullList({
filter: `group="${groupId}"`,
sort: 'created',
expand: 'creator,currentHolder'
})
return result as unknown as Asset[]
}
export async function updateAsset(
assetId: string,
data: Partial<Pick<Asset, 'name' | 'type' | 'description'>>,
image?: File
): Promise<Asset> {
const formData = new FormData()
if (data.name) formData.append('name', data.name)
if (data.type) formData.append('type', data.type)
if (data.description !== undefined) formData.append('description', data.description)
if (image) formData.append('image', image)
const record = await pb.collection('assets').update(assetId, formData)
return record as unknown as Asset
}
export async function transferAsset(assetId: string, userId: string | null): Promise<Asset> {
const record = await pb.collection('assets').update(assetId, {
currentHolder: userId || ''
})
return record as unknown as Asset
}
export async function deleteAsset(assetId: string): Promise<void> {
await pb.collection('assets').delete(assetId)
}
export function subscribeAssets(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('assets').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
return pb.files.getURL({ id: assetId, collectionName: 'assets' }, filename, { thumb })
}
```
**Step 2: Commit**
```bash
git add frontend/src/api/assets.ts
git commit -m "feat(phase3): add assets API layer"
```
---
## Task 5: Pinia Store — 账目
**Files:**
- Create: `frontend/src/stores/ledger.ts`
**Step 1: 创建 ledger store**
遵循 `stores/poll.ts` 的模式。
```typescript
// frontend/src/stores/ledger.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Ledger } from '@/types'
import { listLedgers, getLedgerSummary, subscribeLedgers, createLedger, updateLedger, deleteLedger } from '@/api/ledgers'
export const useLedgerStore = defineStore('ledger', () => {
const ledgers = ref<Ledger[]>([])
const loading = ref(false)
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const currentMonth = ref(new Date().toISOString().slice(0, 7))
async function loadLedgers(groupId: string, month?: string) {
try {
loading.value = true
const m = month || currentMonth.value
const result = await listLedgers(groupId, { month: m, limit: 200 })
ledgers.value = result.items
} catch (error) {
console.error('加载账目列表失败:', error)
} finally {
loading.value = false
}
}
async function loadSummary(groupId: string, month?: string) {
try {
const m = month || currentMonth.value
summary.value = await getLedgerSummary(groupId, m)
} catch (error) {
console.error('加载账目汇总失败:', error)
}
}
async function addLedger(data: Parameters<typeof createLedger>[0]) {
const ledger = await createLedger(data)
return ledger
}
async function editLedger(ledgerId: string, data: Parameters<typeof updateLedger>[1]) {
return updateLedger(ledgerId, data)
}
async function removeLedger(ledgerId: string) {
await deleteLedger(ledgerId)
}
async function startSubscription(groupId: string) {
return subscribeLedgers(groupId, () => {
loadLedgers(groupId)
loadSummary(groupId)
})
}
return {
ledgers, loading, summary, currentMonth,
loadLedgers, loadSummary, addLedger, editLedger, removeLedger, startSubscription
}
})
```
**Step 2: Commit**
```bash
git add frontend/src/stores/ledger.ts
git commit -m "feat(phase3): add ledger Pinia store"
```
---
## Task 6: Pinia Store — 资产
**Files:**
- Create: `frontend/src/stores/asset.ts`
**Step 1: 创建 asset store**
```typescript
// frontend/src/stores/asset.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Asset } from '@/types'
import { listAssets, createAsset, updateAsset, transferAsset, deleteAsset as deleteAssetApi, subscribeAssets } from '@/api/assets'
export const useAssetStore = defineStore('asset', () => {
const assets = ref<Asset[]>([])
const loading = ref(false)
async function loadAssets(groupId: string) {
try {
loading.value = true
assets.value = await listAssets(groupId)
} catch (error) {
console.error('加载资产列表失败:', error)
} finally {
loading.value = false
}
}
async function addAsset(data: Parameters<typeof createAsset>[0]) {
return createAsset(data)
}
async function editAsset(assetId: string, data: Parameters<typeof updateAsset>[1], image?: File) {
return updateAsset(assetId, data, image)
}
async function transfer(assetId: string, userId: string | null) {
return transferAsset(assetId, userId)
}
async function removeAsset(assetId: string) {
await deleteAssetApi(assetId)
}
async function startSubscription(groupId: string) {
return subscribeAssets(groupId, () => {
loadAssets(groupId)
})
}
return {
assets, loading,
loadAssets, addAsset, editAsset, transfer, removeAsset, startSubscription
}
})
```
**Step 2: Commit**
```bash
git add frontend/src/stores/asset.ts
git commit -m "feat(phase3): add asset Pinia store"
```
---
## Task 7: 账目组件
**Files:**
- Create: `frontend/src/components/ledger/LedgerSummary.vue`
- Create: `frontend/src/components/ledger/LedgerCard.vue`
- Create: `frontend/src/components/ledger/LedgerList.vue`
- Create: `frontend/src/components/ledger/CreateLedgerDialog.vue`
**Step 1: 创建 LedgerSummary 组件**
汇总面板:显示当月总收入、总支出、余额。Element Plus 的 `el-statistic` 或自定义卡片。
参考项目中 `GroupStatsPanel.vue` 的卡片样式,使用 `var(--gg-*)` CSS 变量。
**Step 2: 创建 LedgerCard 组件**
单条账目卡片:类型标签(收入绿/支出红)、金额、分类、描述、相关成员头像、发生日期、操作按钮(编辑/删除,仅记录人可见)。
**Step 3: 创建 LedgerList 组件**
账目列表:顶部月份选择器 + 类型/分类筛选 + 新建按钮;下方按日期分组展示 LedgerCard 列表。
**Step 4: 创建 CreateLedgerDialog 组件**
新建/编辑弹窗:类型选择、金额输入、分类选择、描述输入、相关成员多选、日期选择器。使用 `el-dialog` + `el-form`
**Step 5: Commit**
```bash
git add frontend/src/components/ledger/
git commit -m "feat(phase3): add ledger components"
```
---
## Task 8: 资产组件
**Files:**
- Create: `frontend/src/components/asset/AssetCard.vue`
- Create: `frontend/src/components/asset/AssetList.vue`
- Create: `frontend/src/components/asset/CreateAssetDialog.vue`
- Create: `frontend/src/components/asset/TransferAssetDialog.vue`
**Step 1: 创建 AssetCard 组件**
资产卡片(网格布局):资产图片(有则显示缩略图,无则显示类型图标)、名称、类型标签、当前持有人头像和名称、点击可操作。
**Step 2: 创建 AssetList 组件**
资产列表:顶部类型筛选 + 新建按钮;下方网格展示 AssetCard。
**Step 3: 创建 CreateAssetDialog 组件**
登记/编辑弹窗:名称输入、类型选择、描述输入、图片上传。使用 `el-dialog` + `el-form` + `el-upload`
**Step 4: 创建 TransferAssetDialog 组件**
转移持有人弹窗:显示资产名称,选择群组成员作为新持有人(含"放回在库"选项)。使用 `el-dialog` + 群成员列表。
**Step 5: Commit**
```bash
git add frontend/src/components/asset/
git commit -m "feat(phase3): add asset components"
```
---
## Task 9: 页面与路由
**Files:**
- Create: `frontend/src/views/LedgerView.vue`
- Create: `frontend/src/views/AssetView.vue`
- Modify: `frontend/src/router/index.ts` — 新增两条路由
**Step 1: 创建 LedgerView.vue**
独立页面:顶部返回群组按钮 + 群组名;LedgerSummary 汇总面板;LedgerList 账目列表。
加载数据、订阅实时更新、页面卸载时取消订阅。参考 GroupView.vue 的订阅管理模式。
```typescript
// 核心结构
const route = useRoute()
const groupId = route.params.groupId as string
const groupStore = useGroupStore()
const ledgerStore = useLedgerStore()
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await Promise.all([
ledgerStore.loadLedgers(groupId),
ledgerStore.loadSummary(groupId)
])
unsubFns.push(await ledgerStore.startSubscription(groupId))
})
```
**Step 2: 创建 AssetView.vue**
独立页面:顶部返回群组按钮 + 群组名;AssetList 资产列表。
**Step 3: 在 router/index.ts 中添加路由**
在 Layout children 数组中添加:
```typescript
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: () => import('@/views/LedgerView.vue'),
props: true,
meta: { requiresAuth: true }
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: () => import('@/views/AssetView.vue'),
props: true,
meta: { requiresAuth: true }
}
```
**Step 4: Commit**
```bash
git add frontend/src/views/LedgerView.vue frontend/src/views/AssetView.vue frontend/src/router/index.ts
git commit -m "feat(phase3): add ledger and asset pages with routes"
```
---
## Task 10: GroupView 导航入口
**Files:**
- Modify: `frontend/src/views/GroupView.vue` — 添加账目/资产入口按钮
**Step 1: 在 GroupView 的 header-meta 区域添加入口按钮**
在群组信息条中添加"账目"和"资产"两个链接按钮,点击跳转到对应页面。使用 `router.push` 跳转。
参考现有样式,使用 Element Plus 的 `Wallet``Box` 图标(或 `Coin``Present` 等)。
```html
<!-- 在 header-meta 中添加 -->
<span class="meta-divider">|</span>
<router-link :to="`/group/${groupId}/ledger`" class="meta-link">
<el-icon><Wallet /></el-icon> 账目
</router-link>
<span class="meta-divider">|</span>
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
<el-icon><Box /></el-icon> 资产
</router-link>
```
**Step 2: Commit**
```bash
git add frontend/src/views/GroupView.vue
git commit -m "feat(phase3): add ledger and asset navigation in GroupView"
```
---
## Task 11: 构建验证与部署
**Step 1: 构建前端验证无报错**
Run: `cd frontend && npm run build`
**Step 2: 部署到 Dev 环境测试**
Run: `./deploy-dev.sh`
**Step 3: 在 Dev 环境手动测试**
访问 `http://192.168.1.14:7033`,测试:
- 群组页面能看到"账目"和"资产"入口
- 点击进入账目页面,创建收入/支出记录,查看汇总
- 点击进入资产页面,登记资产,转移持有人
- 其他成员能看到记录变化(实时订阅)
**Step 4: 最终 Commit**
如有修复则提交。
@@ -0,0 +1,74 @@
# 语音房间功能设计
## 概述
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
## 架构
```
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
```
## 技术选型
- **LiveKit Server** — 开源 WebRTC SFUDocker 部署
- **livekit-client** — 前端核心 SDK
- **@livekit/components-vue** — Vue 组件封装
- **livekit-server-sdk (Node.js)** — PocketBase hook 中签发 token
## 数据模型
team_sessions 新增字段:
- `voiceRoom: string` — LiveKit 房间名
- `voiceActive: boolean` — 语音房间是否活跃
## Token 签发
PocketBase JS hook (`/api/voice-token/{sessionId}`)
1. 验证请求者是 session 成员
2. 用 LiveKit API key/secret 签发 JWT
3. room = `team-{sessionId}`identity = userId
4. 返回 `{ token: "..." }`
## 前端
### 路由
`/group/:groupId/voice/:sessionId` — 独立语音房间页面
### 页面结构 (VoiceRoom.vue)
- 顶部:房间名 + 离开按钮
- 中部:成员头像网格,说话时绿圈动画
- 底部:麦克风开关、扬声器开关、成员列表
### 入口
GroupView 组队卡片上加"语音"按钮,recruiting/playing 状态时显示。
## 文件变更
### 新增
- `frontend/src/views/VoiceRoom.vue`
- `frontend/src/api/voice.ts`
- `frontend/src/composables/useVoiceRoom.ts`
- `frontend/src/components/voice/VoiceMemberGrid.vue`
- `frontend/src/components/voice/VoiceControls.vue`
- `backend/pb_hooks/voice-token.pb.js`
- `backend/pb_hooks/package.json`
### 修改
- `frontend/src/types/index.ts` — TeamSession 加字段
- `frontend/src/router/index.ts` — 加路由
- `frontend/src/views/GroupView.vue` — 加入口按钮
- `docker-compose.backend.yml` — 加 LiveKit 容器
- `docker-compose.uat.yml` — 同步加 LiveKit
## 部署
- PocketBase 启用 JS hooks
- LiveKit 端口:7880 (HTTP)、7881 (UDP/RTC)
- 局域网无需 TURN 服务器
@@ -0,0 +1,924 @@
# 语音房间功能 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。
**Architecture:** LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。
**Tech Stack:** LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)
---
### Task 1: Docker 基础设施 — 添加 LiveKit 容器
**Files:**
- Modify: `docker-compose.backend.yml`
- Modify: `docker-compose.uat.yml`
**Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务**
在 pocketbase 服务之后、networks 之前添加:
```yaml
livekit:
image: livekit/livekit-server:v1.7
container_name: gamegroup-livekit
ports:
- "7880:7880"
- "7881:7881/udp"
environment:
- LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
command: --dev
restart: unless-stopped
networks:
- gamegroup-net
```
**Step 2: 在 docker-compose.uat.yml 同步添加**
在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。
**Step 3: 启动验证**
Run: `docker compose -f docker-compose.backend.yml up -d livekit`
Expected: 容器正常启动,`docker logs gamegroup-livekit` 显示 LiveKit listening on :7880
**Step 4: Commit**
```bash
git add docker-compose.backend.yml docker-compose.uat.yml
git commit -m "feat(voice): add LiveKit server container to docker-compose"
```
---
### Task 2: 安装前端 LiveKit 依赖
**Files:**
- Modify: `frontend/package.json`
**Step 1: 安装依赖**
Run: `cd frontend && npm install livekit-client @livekit/components-vue`
**Step 2: 验证安装**
Run: `cd frontend && npm ls livekit-client @livekit/components-vue`
Expected: 两个包正常列出
**Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "feat(voice): add livekit-client and components-vue dependencies"
```
---
### Task 3: 前端 API 层 — voice.ts
**Files:**
- Create: `frontend/src/api/voice.ts`
**Step 1: 创建 voice.ts API 封装**
```typescript
// src/api/voice.ts
import { pb } from './pocketbase'
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
export function getLiveKitUrl(): string {
return LIVEKIT_URL
}
export async function fetchVoiceToken(sessionId: string): Promise<string> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 先验证用户是该 session 的成员
const session = await pb.collection('team_sessions').getOne(sessionId)
const members: string[] = (session as any).members || []
if (!members.includes(user.id)) {
throw new Error('你不是该小队的成员')
}
// 调用 PocketBase hook 获取 token
// 如果 hook 未部署,用前端 fallback 方案(仅开发用)
try {
const res = await pb.send(`/api/voice-token/${sessionId}`, {
method: 'POST',
})
return res.token
} catch {
// Hook 不可用时,提示用户
throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
}
}
```
**Step 2: 添加 .env 配置**
`frontend/.env.dev``frontend/.env.uat` 中添加:
```
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
```
**Step 3: Commit**
```bash
git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"
```
---
### Task 4: 类型更新 — TeamSession 加语音字段
**Files:**
- Modify: `frontend/src/types/index.ts:80-94`
**Step 1: 在 TeamSession interface 中添加字段**
`updated: string` 之后、`expand?` 之前添加:
```typescript
voiceRoom?: string
voiceActive?: boolean
```
**Step 2: Commit**
```bash
git add frontend/src/types/index.ts
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"
```
---
### Task 5: composable — useVoiceRoom.ts
**Files:**
- Create: `frontend/src/composables/useVoiceRoom.ts`
**Step 1: 创建 useVoiceRoom composable**
```typescript
// src/composables/useVoiceRoom.ts
import { ref, onUnmounted } from 'vue'
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
export interface VoiceParticipant {
identity: string
name: string
isSpeaking: boolean
isMuted: boolean
avatar?: string
}
export function useVoiceRoom() {
const room = ref<Room | null>(null)
const connected = ref(false)
const participants = ref<Map<string, VoiceParticipant>>(new Map())
const micEnabled = ref(true)
const speakerEnabled = ref(true)
const error = ref<string | null>(null)
async function connect(sessionId: string, userId: string) {
try {
error.value = null
const token = await fetchVoiceToken(sessionId)
const livekitUrl = getLiveKitUrl()
const newRoom = new Room()
newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
participants.value.delete(participant.identity)
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const speakerIds = new Set(speakers.map(s => s.identity))
for (const [id, p] of participants.value) {
p.isSpeaking = speakerIds.has(id)
}
participants.value = new Map(participants.value)
})
newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
updateParticipant(participant)
})
newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
updateParticipant(participant)
})
await newRoom.connect(livekitUrl, token)
// 添加本地参与者
updateParticipant(newRoom.localParticipant)
// 添加已有远端参与者
for (const p of newRoom.remoteParticipants.values()) {
updateParticipant(p)
}
// 发布本地麦克风
await newRoom.localParticipant.setMicrophoneEnabled(true)
room.value = newRoom
connected.value = true
} catch (e: any) {
error.value = e.message || '连接语音房间失败'
console.error('Voice room connect error:', e)
}
}
function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
const isLocal = participant instanceof LocalParticipant
const audioTrack = isLocal
? participant.audioTrackPublications.values().next().value
: participant.audioTrackPublications.values().next().value
const vp: VoiceParticipant = {
identity: participant.identity,
name: participant.name || participant.identity,
isSpeaking: participant.isSpeaking,
isMuted: audioTrack?.isMuted ?? true,
}
participants.value.set(participant.identity, vp)
participants.value = new Map(participants.value)
}
async function toggleMic() {
if (!room.value) return
const enabled = !micEnabled.value
await room.value.localParticipant.setMicrophoneEnabled(enabled)
micEnabled.value = enabled
updateParticipant(room.value.localParticipant)
}
async function toggleSpeaker() {
if (!room.value) return
speakerEnabled.value = !speakerEnabled.value
for (const p of room.value.remoteParticipants.values()) {
for (const pub of p.audioTrackPublications.values()) {
if (pub.track) {
pub.track.enabled = speakerEnabled.value
}
}
}
}
async function disconnect() {
if (room.value) {
await room.value.disconnect()
room.value = null
}
connected.value = false
participants.value = new Map()
micEnabled.value = true
speakerEnabled.value = true
}
return {
room,
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
}
}
```
**Step 2: Commit**
```bash
git add frontend/src/composables/useVoiceRoom.ts
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"
```
---
### Task 6: 语音组件 — VoiceMemberGrid + VoiceControls
**Files:**
- Create: `frontend/src/components/voice/VoiceMemberGrid.vue`
- Create: `frontend/src/components/voice/VoiceControls.vue`
**Step 1: 创建 VoiceMemberGrid.vue**
成员头像网格,说话时有绿圈呼吸动画。
```vue
<!-- src/components/voice/VoiceMemberGrid.vue -->
<script setup lang="ts">
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
import { displayName } from '@/types'
defineProps<{
participants: Map<string, VoiceParticipant>
}>()
</script>
<template>
<div class="voice-member-grid">
<div
v-for="[id, p] of participants"
:key="id"
class="voice-member"
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
>
<div class="avatar-ring">
<img
:src="p.avatar || '/default-avatar.svg'"
:alt="p.name"
class="avatar"
/>
</div>
<span class="name">{{ p.name }}</span>
<span v-if="p.isMuted" class="mic-icon muted">🎤</span>
<span v-else-if="p.isSpeaking" class="mic-icon active">🎤</span>
</div>
</div>
</template>
<style scoped>
.voice-member-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
padding: 24px;
}
.voice-member {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
padding: 3px;
border: 2px solid var(--gg-border);
transition: border-color 0.2s, box-shadow 0.3s;
}
.speaking .avatar-ring {
border-color: var(--gg-primary);
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
animation: pulse-ring 1.5s ease-in-out infinite;
}
.muted .avatar-ring {
opacity: 0.5;
}
@keyframes pulse-ring {
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
}
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
text-align: center;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mic-icon {
font-size: 11px;
}
.mic-icon.active {
color: var(--gg-primary);
}
.mic-icon.muted {
color: var(--gg-text-muted);
}
</style>
```
**Step 2: 创建 VoiceControls.vue**
底部控制栏。
```vue
<!-- src/components/voice/VoiceControls.vue -->
<script setup lang="ts">
defineProps<{
micEnabled: boolean
speakerEnabled: boolean
connected: boolean
}>()
const emit = defineEmits<{
toggleMic: []
toggleSpeaker: []
leave: []
}>()
</script>
<template>
<div class="voice-controls">
<button
class="ctrl-btn"
:class="{ active: micEnabled, off: !micEnabled }"
@click="emit('toggleMic')"
>
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
<span class="ctrl-label">麦克风</span>
</button>
<button
class="ctrl-btn"
:class="{ active: speakerEnabled, off: !speakerEnabled }"
@click="emit('toggleSpeaker')"
>
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
<span class="ctrl-label">扬声器</span>
</button>
<button class="ctrl-btn leave-btn" @click="emit('leave')">
<span class="ctrl-icon">🚪</span>
<span class="ctrl-label">离开</span>
</button>
</div>
</template>
<style scoped>
.voice-controls {
display: flex;
justify-content: center;
gap: 20px;
padding: 20px;
background: var(--gg-bg-card);
border-top: 1px solid var(--gg-border);
}
.ctrl-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 24px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg);
color: var(--gg-text);
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.ctrl-btn:hover {
border-color: var(--gg-primary);
}
.ctrl-btn.off {
background: var(--gg-bg);
opacity: 0.7;
}
.ctrl-btn.active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.1);
}
.ctrl-icon {
font-size: 22px;
}
.ctrl-label {
font-size: 12px;
font-weight: 500;
}
.leave-btn {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.leave-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: var(--gg-danger);
}
</style>
```
**Step 3: Commit**
```bash
git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"
```
---
### Task 7: 语音房间页面 — VoiceRoom.vue
**Files:**
- Create: `frontend/src/views/VoiceRoom.vue`
- Modify: `frontend/src/router/index.ts`
**Step 1: 创建 VoiceRoom.vue**
```vue
<!-- src/views/VoiceRoom.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useVoiceRoom } from '@/composables/useVoiceRoom'
import { pb } from '@/api/pocketbase'
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
import VoiceControls from '@/components/voice/VoiceControls.vue'
const route = useRoute()
const router = useRouter()
const teamStore = useTeamStore()
const groupStore = useGroupStore()
const sessionId = route.params.sessionId as string
const groupId = route.params.groupId as string
const session = computed(() => teamStore.currentSession)
const gameName = computed(() => session.value?.gameName || '语音房间')
const {
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
} = useVoiceRoom()
onMounted(async () => {
// 加载 session 数据
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
const userId = pb.authStore.model?.id
if (!userId) {
router.replace('/login')
return
}
await connect(sessionId, userId)
})
onUnmounted(async () => {
await disconnect()
})
async function handleLeave() {
await disconnect()
router.replace(`/group/${groupId}`)
}
</script>
<template>
<div class="voice-room">
<!-- 顶部栏 -->
<header class="voice-header">
<button class="back-btn" @click="handleLeave">
返回
</button>
<h1 class="room-title">{{ gameName }}</h1>
<div class="conn-status" :class="{ on: connected }">
{{ connected ? '已连接' : '连接中...' }}
</div>
</header>
<!-- 错误提示 -->
<div v-if="error" class="error-banner">
{{ error }}
</div>
<!-- 成员区域 -->
<main class="voice-body">
<VoiceMemberGrid :participants="participants" />
<div v-if="participants.size === 0" class="empty-hint">
正在连接语音...
</div>
</main>
<!-- 底部控制栏 -->
<VoiceControls
:mic-enabled="micEnabled"
:speaker-enabled="speakerEnabled"
:connected="connected"
@toggle-mic="toggleMic"
@toggle-speaker="toggleSpeaker"
@leave="handleLeave"
/>
</div>
</template>
<style scoped>
.voice-room {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--gg-bg);
}
.voice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--gg-bg-card);
border-bottom: 1px solid var(--gg-border);
}
.back-btn {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.room-title {
font-size: 18px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.conn-status {
font-size: 13px;
padding: 4px 12px;
border-radius: 12px;
background: var(--gg-bg);
color: var(--gg-text-muted);
}
.conn-status.on {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.error-banner {
margin: 16px 24px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--gg-danger);
border-radius: var(--gg-radius-sm);
color: var(--gg-danger);
font-size: 14px;
}
.voice-body {
flex: 1;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.empty-hint {
color: var(--gg-text-muted);
font-size: 15px;
}
</style>
```
**Step 2: 添加路由**
`frontend/src/router/index.ts` 中,在 `blacklist` 路由之后、`games` 路由之前添加:
```typescript
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: () => import('@/views/VoiceRoom.vue'),
props: true,
meta: { requiresAuth: true }
},
```
**Step 3: Commit**
```bash
git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
git commit -m "feat(voice): add VoiceRoom page and route"
```
---
### Task 8: 入口按钮 — TeamSessionPanel 加语音入口
**Files:**
- Modify: `frontend/src/components/team/TeamSessionPanel.vue:110-120`
**Step 1: 在 TeamSessionPanel 中添加语音按钮**
`end-game-btn` 按钮之后、`dissolve-btn` 之前(约第 116 行区域),添加语音房间入口:
`<script setup>` 中导入 useRoute
```typescript
import { useRoute } from 'vue-router'
const route = useRoute()
```
在 template 中,`start-game-btn` 按钮之后、`end-game-btn` 之前添加:
```html
<router-link
v-if="session.status === 'recruiting' || session.status === 'playing'"
:to="`/group/${route.params.id}/voice/${session.id}`"
class="voice-btn"
>
语音房间
</router-link>
```
添加对应样式:
```css
.voice-btn {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 8px;
border: 1px solid var(--gg-primary);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-primary);
font-size: 14px;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.voice-btn:hover {
background: rgba(5, 150, 105, 0.1);
}
```
**Step 2: Commit**
```bash
git add frontend/src/components/team/TeamSessionPanel.vue
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"
```
---
### Task 9: PocketBase hook — 签发 LiveKit token
**Files:**
- Modify: `backend/pb_hooks/main.js`
- Create: `backend/pb_hooks/package.json`
**注意:** PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。
**Step 1: 创建 package.json**
```json
{
"name": "gamegroup-pb-hooks",
"private": true,
"dependencies": {
"livekit-server-sdk": "^2.0"
}
}
```
Run: `cd backend/pb_hooks && npm install`
**Step 2: 更新 main.js — 添加 token 签发路由**
在现有注释之后添加:
```javascript
// LiveKit voice token endpoint
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
const sessionId = c.pathParam("sessionId")
const user = c.authRecord
if (!user) {
throw new BadRequestError("未登录")
}
// 验证用户是 session 成员
const session = $app.dao().findRecordById("team_sessions", sessionId)
const members = session.getStringSlice("members")
if (!members.includes(user.id)) {
throw new ForbiddenError("你不是该小队的成员")
}
// LiveKit token 签发
// 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
// 可以改用外部 API 或 PocketBase Go middleware
const { AccessToken } = require("livekit-server-sdk")
const apiKey = "APIyxZGQjM2"
const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
const at = new AccessToken(apiKey, apiSecret, {
identity: user.id,
name: user.getString("name") || user.getString("username"),
})
at.addGrant({
roomJoin: true,
room: `team-${sessionId}`,
canPublish: true,
canSubscribe: true,
})
const token = at.toJwt()
return c.json(200, { token: token })
}, $apis.requireAuth())
```
**Step 3: Commit**
```bash
git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"
```
---
### Task 10: 构建验证 + 部署测试
**Files:** 无新文件
**Step 1: 前端构建**
Run: `cd frontend && npm run build`
Expected: 构建成功,无 TypeScript 错误
**Step 2: 部署 Dev 环境**
Run: `./deploy-dev.sh`
Expected: 前端容器构建并启动在 7033 端口
**Step 3: 功能验证**
打开 `http://192.168.1.14:7033`
1. 登录 → 进入群组 → 创建/查看临时小组
2. 组队卡片中应显示"语音房间"按钮
3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
4. 启动 LiveKit 容器后重试验证完整流程
**Step 4: Commit**
```bash
git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"
```
+1
View File
@@ -0,0 +1 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
+72
View File
@@ -0,0 +1,72 @@
const { app, BrowserWindow, session } = require('electron')
const path = require('path')
const ENV_URLS = {
dev: 'http://192.168.1.14:7033',
uat: 'http://nas.wjl-work.top:7034',
}
function getWindowUrl() {
const envArg = process.argv.find(a => a.startsWith('--env='))
if (envArg) {
const env = envArg.split('=')[1]
return ENV_URLS[env] || ENV_URLS.dev
}
return ENV_URLS.dev
}
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
const insecureOrigins = Object.values(ENV_URLS).join(',')
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
title: 'Game Group',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
webSecurity: false,
allowRunningInsecureContent: true,
},
})
const url = getWindowUrl()
win.loadURL(url)
// 页面标题同步
win.on('page-title-updated', (event, title) => {
event.preventDefault()
win.setTitle(`Game Group - ${title}`)
})
}
app.whenReady().then(() => {
// 自动批准麦克风/摄像头权限请求,无需用户手动确认
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
callback(true)
} else {
callback(false)
}
})
// 绕过权限检查,确保 mediaDevices 可用
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
return true
}
return false
})
createWindow()
})
app.on('window-all-closed', () => {
app.quit()
})
+41
View File
@@ -0,0 +1,41 @@
{
"name": "gamegroup-electron",
"version": "0.3.2",
"description": "Game Group V2 桌面客户端",
"main": "main.js",
"scripts": {
"start": "electron .",
"start:dev": "electron . --env=dev",
"start:uat": "electron . --env=uat",
"build": "electron-builder --win",
"build:portable": "electron-builder --win portable"
},
"dependencies": {
"electron-store": "^8.2"
},
"devDependencies": {
"electron": "^35.0",
"electron-builder": "^26.0"
},
"build": {
"appId": "com.gamegroup.v2",
"productName": "GameGroup",
"directories": {
"output": "dist"
},
"win": {
"target": [
{
"target": "portable",
"arch": ["x64"]
}
],
"icon": "build/icon.ico"
},
"files": [
"main.js",
"preload.js",
"build/**/*"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
})
+2
View File
@@ -1,3 +1,5 @@
# Dev Environment # Dev Environment
VITE_PB_URL=http://192.168.1.14:8711 VITE_PB_URL=http://192.168.1.14:8711
VITE_PORT=7033 VITE_PORT=7033
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
+2
View File
@@ -1,3 +1,5 @@
# UAT Environment # UAT Environment
VITE_PB_URL=http://192.168.1.14:8711 VITE_PB_URL=http://192.168.1.14:8711
VITE_PORT=7034 VITE_PORT=7034
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
+3 -2
View File
@@ -22,8 +22,9 @@ FROM nginx:alpine
# 复制构建产物 # 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置 # 通过 NGINX_CONF 参数选择配置文件(默认 dev)
COPY nginx.conf /etc/nginx/conf.d/default.conf ARG NGINX_CONF=nginx.conf
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+30 -2
View File
@@ -10,10 +10,38 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SSE realtime 连接(必须在 /api/ 之前)
location /api/realtime {
proxy_pass http://192.168.1.14:8090;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
gzip off;
}
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# API 代理到局域网 PocketBase # API 代理到局域网 PocketBase
location /api/ { location /api/ {
client_max_body_size 500m;
proxy_pass http://192.168.1.14:8090; proxy_pass http://192.168.1.14:8090;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -25,8 +53,8 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# 静态资源缓存 # 静态资源缓存(排除 /api/ 路径)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|mp4|mkv|webm|mp3|ogg|pdf)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
+60
View File
@@ -0,0 +1,60 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 开启 gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SSE realtime 连接(必须在 /api/ 之前)
location /api/realtime {
proxy_pass http://192.168.1.14:8712;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
gzip off;
}
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# API 代理到局域网 PocketBase
location /api/ {
proxy_pass http://192.168.1.14:8712;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+11 -10
View File
@@ -10,20 +10,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"element-plus": "^2.6.3",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"pocketbase": "^0.21.1" "element-plus": "^2.6.3",
"livekit-client": "^2.18.3",
"pinia": "^2.1.7",
"pocketbase": "^0.21.1",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.5",
"vue-tsc": "^2.0.11",
"vite": "^5.2.8",
"tailwindcss": "^3.4.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38" "postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vue-tsc": "^2.0.11"
} }
} }
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
</svg>

After

Width:  |  Height:  |  Size: 232 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
<rect fill="#e8f5e9" width="200" height="260"/>
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
</svg>

After

Width:  |  Height:  |  Size: 327 B

+83
View File
@@ -0,0 +1,83 @@
import { pb } from './pocketbase'
import type { Asset } from '@/types'
export interface CreateAssetData {
group: string
name: string
type: string
description?: string
image?: File
}
export interface UpdateAssetData {
name?: string
type?: string
description?: string
}
export async function createAsset(data: CreateAssetData): Promise<Asset> {
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', data.group)
formData.append('creator', user?.id || '')
formData.append('name', data.name)
formData.append('type', data.type)
if (data.description) formData.append('description', data.description)
if (data.image) formData.append('image', data.image)
return pb.collection('assets').create(formData, { $autoCancel: false }) as Promise<Asset>
}
export async function listAssets(groupId: string): Promise<Asset[]> {
const result = await pb.collection('assets').getFullList({
filter: `group="${groupId}"`,
sort: 'created',
expand: 'creator,currentHolder',
$autoCancel: false
})
return result as unknown as Asset[]
}
export async function updateAsset(
assetId: string,
data: UpdateAssetData,
image?: File
): Promise<Asset> {
if (image) {
const formData = new FormData()
if (data.name) formData.append('name', data.name)
if (data.type) formData.append('type', data.type)
if (data.description !== undefined) formData.append('description', data.description)
formData.append('image', image)
return pb.collection('assets').update(assetId, formData, { $autoCancel: false }) as Promise<Asset>
}
return pb.collection('assets').update(assetId, data, { $autoCancel: false }) as Promise<Asset>
}
export async function transferAsset(assetId: string, userId: string): Promise<Asset> {
return pb.collection('assets').update(assetId, {
currentHolder: userId
}, { $autoCancel: false }) as Promise<Asset>
}
export async function deleteAsset(assetId: string): Promise<void> {
await pb.collection('assets').delete(assetId, { $autoCancel: false })
}
export function subscribeAssets(
groupId: string,
callback: (data: any) => void
): Promise<() => Promise<void>> {
return pb.collection('assets').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
const record = { id: assetId, image: filename }
return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined)
}
+218
View File
@@ -0,0 +1,218 @@
import { pb } from './pocketbase'
import type { Bet, BetOption, BetEntry } from '@/types'
export async function createBet(data: {
group: string
title: string
description?: string
options: string[]
minStake?: number
maxStake?: number
deadline: string
}): Promise<Bet> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const bet = await pb.collection('bets').create({
group: data.group,
creator: user.id,
title: data.title,
description: data.description || '',
minStake: data.minStake || 1,
maxStake: data.maxStake || 10,
status: 'open',
deadline: data.deadline,
})
for (let i = 0; i < data.options.length; i++) {
await pb.collection('bet_options').create({
bet: bet.id,
content: data.options[i],
order: i + 1,
})
}
return bet as unknown as Bet
}
export async function listBets(groupId: string, status?: string): Promise<Bet[]> {
let filter = `group="${groupId}"`
if (status) filter += ` && status="${status}"`
const result = await pb.collection('bets').getFullList({
filter,
sort: '-created',
expand: 'creator',
$autoCancel: false,
})
return result as unknown as Bet[]
}
export async function getBet(betId: string): Promise<Bet> {
const result = await pb.collection('bets').getOne(betId, {
expand: 'creator,resultOption',
$autoCancel: false,
})
return result as unknown as Bet
}
export async function getBetOptions(betId: string): Promise<BetOption[]> {
const result = await pb.collection('bet_options').getFullList({
filter: `bet="${betId}"`,
sort: 'order',
$autoCancel: false,
})
return result as unknown as BetOption[]
}
export async function getBetEntries(betId: string): Promise<BetEntry[]> {
const result = await pb.collection('bet_entries').getFullList({
filter: `bet="${betId}"`,
expand: 'user,option',
$autoCancel: false,
})
return result as unknown as BetEntry[]
}
export async function placeBet(betId: string, optionId: string, stake: number): Promise<BetEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 验证竞猜状态和下注范围 (H1)
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
if ((betData as any).status !== 'open') throw new Error('竞猜已关闭,无法下注')
if (stake < (betData as any).minStake || stake > (betData as any).maxStake) {
throw new Error(`下注积分需在 ${(betData as any).minStake}~${(betData as any).maxStake} 之间`)
}
// 防止重复下注
const existing = await pb.collection('bet_entries').getList(1, 1, {
filter: `bet="${betId}" && user="${user.id}"`,
$autoCancel: false,
})
if (existing.items.length > 0) throw new Error('你已经下注过了')
// 重新读取最新积分缓解 TOCTOU (C2)
const currentUser = await pb.collection('users').getOne(user.id, { $autoCancel: false })
const currentPoints = (currentUser as any).points || 0
if (currentPoints < stake) throw new Error('积分不足')
await pb.collection('users').update(user.id, {
points: currentPoints - stake,
})
const entry = await pb.collection('bet_entries').create({
bet: betId,
user: user.id,
option: optionId,
stake,
})
await pb.collection('point_logs').create({
user: user.id,
action: 'bet',
points: -stake,
relatedId: entry.id,
})
return entry as unknown as BetEntry
}
export async function closeBet(betId: string): Promise<Bet> {
const record = await pb.collection('bets').update(betId, { status: 'closed' })
return record as unknown as Bet
}
export async function settleBet(betId: string, resultOptionId: string): Promise<void> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 先标记为 settled 防止双重结算 (C1)
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
if ((betData as any).status === 'settled') throw new Error('竞猜已结算,请勿重复操作')
if ((betData as any).creator !== user.id) throw new Error('只有发起人可以开奖') // (H2)
const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + '.000Z'
await pb.collection('bets').update(betId, {
status: 'settled',
resultOption: resultOptionId,
settledAt: now,
})
const entries = await getBetEntries(betId)
const totalPool = entries.reduce((sum, e) => sum + e.stake, 0)
const winnerEntries = entries.filter(e => e.option === resultOptionId)
const loserEntries = entries.filter(e => e.option !== resultOptionId)
// 并行标记输赢
const markPromises: Promise<void>[] = []
if (winnerEntries.length === 0) {
// 无人猜中,退还所有积分
for (const entry of entries) {
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
await pb.collection('users').update(entry.user, {
points: ((entryUser as any).points || 0) + entry.stake,
})
await pb.collection('point_logs').create({
user: entry.user,
action: 'bet',
points: entry.stake,
relatedId: entry.id,
})
await pb.collection('bet_entries').update(entry.id, { won: false })
})())
}
} else {
const winnerTotalStake = winnerEntries.reduce((sum, e) => sum + e.stake, 0)
let distributed = 0
for (const entry of winnerEntries) {
const winAmount = Math.floor((entry.stake / winnerTotalStake) * totalPool)
distributed += winAmount
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
await pb.collection('users').update(entry.user, {
points: ((entryUser as any).points || 0) + winAmount,
})
await pb.collection('point_logs').create({
user: entry.user,
action: 'bet',
points: winAmount,
relatedId: entry.id,
})
await pb.collection('bet_entries').update(entry.id, { won: true })
})())
}
// 余数分给最后一个赢家而非庄家 (C3)
const remainder = totalPool - distributed
if (remainder > 0 && winnerEntries.length > 0) {
const lastWinner = winnerEntries[winnerEntries.length - 1]
markPromises.push((async () => {
const entryUser = await pb.collection('users').getOne(lastWinner.user, { $autoCancel: false })
await pb.collection('users').update(lastWinner.user, {
points: ((entryUser as any).points || 0) + remainder,
})
})())
}
for (const entry of loserEntries) {
markPromises.push(pb.collection('bet_entries').update(entry.id, { won: false }).then(() => {}))
}
}
await Promise.all(markPromises)
}
export async function subscribeBets(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('bets').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+57
View File
@@ -0,0 +1,57 @@
import { pb } from './pocketbase'
import type { BlacklistEntry } from '@/types'
export async function createBlacklistEntry(data: {
group: string
game?: string
gameName: string
reason: string
description: string
severity: string
}): Promise<BlacklistEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const payload: Record<string, any> = {
group: data.group,
reporter: user.id,
gameName: data.gameName,
reason: data.reason,
description: data.description,
severity: data.severity,
}
if (data.game) payload.game = data.game
const record = await pb.collection('game_blacklist').create(payload)
return record as unknown as BlacklistEntry
}
export async function listBlacklist(
groupId: string,
options?: { reason?: string; severity?: string }
): Promise<BlacklistEntry[]> {
let filter = `group="${groupId}"`
if (options?.reason) filter += ` && reason="${options.reason}"`
if (options?.severity) filter += ` && severity="${options.severity}"`
const result = await pb.collection('game_blacklist').getFullList({
filter,
sort: '-created',
expand: 'reporter,game',
$autoCancel: false,
})
return result as unknown as BlacklistEntry[]
}
export async function deleteBlacklistEntry(entryId: string): Promise<void> {
await pb.collection('game_blacklist').delete(entryId)
}
export async function subscribeBlacklist(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('game_blacklist').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+127 -64
View File
@@ -1,88 +1,132 @@
// src/api/games.ts
import pb from './pocketbase' import pb from './pocketbase'
import type { Game, GamePlatform } from '@/types' import type { Game, GamePlatform, GameComment } from '@/types'
// 获取游戏列表 // 获取群组的游戏列表
export async function getGames(options?: { export async function getGroupGames(groupId: string, options?: {
page?: number page?: number
limit?: number limit?: number
platform?: GamePlatform platform?: GamePlatform
search?: string search?: string
}): Promise<{ items: Game[], total: number }> { }): Promise<{ items: Game[], total: number }> {
const { page = 1, limit = 20, platform, search } = options || {} const { page = 1, limit = 50, platform, search } = options || {}
let filter = `group="${groupId}"`
let filter = '' if (platform) filter += ` && platform="${platform}"`
if (platform) { if (search) filter += ` && name ~ "${search}"`
filter = `platform="${platform}"`
}
if (search) {
const searchFilter = `name ~ "${search}"`
filter = filter ? `${filter} && ${searchFilter}` : searchFilter
}
const result = await pb.collection('games').getList(page, limit, { const result = await pb.collection('games').getList(page, limit, {
filter, filter,
sort: '-popularCount' sort: '-created',
$autoCancel: false
}) })
return { items: result.items as unknown as Game[], total: result.totalItems }
return {
items: result.items as unknown as Game[],
total: result.totalItems
}
} }
// 获取热门游戏 // 添加游戏到群组
export async function getPopularGames(limit = 10): Promise<Game[]> { export async function addGame(groupId: string, data: {
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 搜索游戏
export async function searchGames(query: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
const result = await pb.collection('games').getList(1, limit, {
filter: `name ~ "${query}"`,
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 添加游戏(需要管理员权限)
export async function addGame(data: {
name: string name: string
platform: GamePlatform platform?: GamePlatform
tags?: string[] tags?: string[]
cover?: string cover?: string
}) { }) {
return pb.collection('games').create(data) const user = pb.authStore.model
return pb.collection('games').create({
...data,
group: groupId,
addedBy: user?.id,
popularCount: 0
}, { $autoCancel: false })
} }
// 更新游戏热度 // 删除游戏
export async function incrementGamePopularity(gameId: string) { export async function deleteGame(gameId: string) {
const game = await pb.collection('games').getOne(gameId) return pb.collection('games').delete(gameId, { $autoCancel: false })
return pb.collection('games').update(gameId, { }
popularCount: (game.popularCount || 0) + 1
// 导入游戏(批量添加)
export async function importGames(groupId: string, games: Array<{
name: string
platform?: GamePlatform
tags?: string[]
cover?: string
}>) {
const results = []
for (const game of games) {
try {
const result = await addGame(groupId, game)
results.push({ success: true, data: result })
} catch (error: any) {
results.push({ success: false, error: error.message, data: game })
}
}
return results
}
// 导出游戏(获取全部数据)
export async function exportGames(groupId: string): Promise<Game[]> {
const result = await pb.collection('games').getFullList({
filter: `group="${groupId}"`,
sort: '-created',
$autoCancel: false
}) })
return result as unknown as Game[]
} }
// 获取游戏详情 // 收藏/取消收藏
export async function getGame(gameId: string): Promise<Game> { export async function toggleFavorite(gameId: string): Promise<boolean> {
return pb.collection('games').getOne(gameId) as unknown as Game const user = pb.authStore.model
} if (!user) throw new Error('未登录')
// 按平台获取游戏 const existing = await pb.collection('game_favorites').getList(1, 1, {
export async function getGamesByPlatform(platform: GamePlatform): Promise<Game[]> { filter: `game="${gameId}" && user="${user.id}"`,
const result = await pb.collection('games').getList(1, 50, { $autoCancel: false
filter: `platform="${platform}"`,
sort: '-popularCount'
}) })
return result.items as unknown as Game[] if (existing.items.length > 0) {
await pb.collection('game_favorites').delete(existing.items[0].id, { $autoCancel: false })
return false
} else {
await pb.collection('game_favorites').create({
game: gameId,
user: user.id
}, { $autoCancel: false })
return true
}
}
// 检查是否已收藏
export async function isFavorite(gameId: string): Promise<boolean> {
const user = pb.authStore.model
if (!user) return false
const result = await pb.collection('game_favorites').getList(1, 1, {
filter: `game="${gameId}" && user="${user.id}"`,
$autoCancel: false
})
return result.items.length > 0
}
// 添加评论
export async function addComment(gameId: string, content: string, rating?: number) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
return pb.collection('game_comments').create({
game: gameId,
author: user.id,
content,
rating
}, { $autoCancel: false })
}
// 获取游戏评论
export async function getGameComments(gameId: string): Promise<GameComment[]> {
const result = await pb.collection('game_comments').getList(1, 50, {
filter: `game="${gameId}"`,
sort: '-created',
expand: 'author',
$autoCancel: false
})
return result.items as unknown as GameComment[]
} }
// 获取所有平台 // 获取所有平台
@@ -90,9 +134,28 @@ export function getAllPlatforms(): GamePlatform[] {
return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile'] return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile']
} }
// 订阅游戏变更 // 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
export function subscribeGames(callback: (game: Game) => void) { export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
return pb.collection('games').subscribe('*', (payload) => { if (!query.trim()) return []
callback(payload.record as unknown as Game) let filter = `name ~ "${query}"`
if (groupId) filter += ` && group="${groupId}"`
const result = await pb.collection('games').getList(1, limit, {
filter,
sort: '-popularCount',
$autoCancel: false
}) })
return result.items as unknown as Game[]
}
// 获取热门游戏(全局,按 popularCount 排序)
export async function getPopularGames(limit = 10): Promise<Game[]> {
const user = pb.authStore.model
if (!user) return []
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount',
$autoCancel: false
})
return result.items as unknown as Game[]
} }
+117 -9
View File
@@ -1,6 +1,6 @@
// src/api/groups.ts // src/api/groups.ts
import pb from './pocketbase' import pb from './pocketbase'
import type { Group } from '@/types' import type { Group, JoinRequest } from '@/types'
// 创建群组 // 创建群组
export async function createGroup(data: { export async function createGroup(data: {
@@ -14,7 +14,8 @@ export async function createGroup(data: {
return pb.collection('groups').create({ return pb.collection('groups').create({
...data, ...data,
owner: user.id, owner: user.id,
members: [user.id] members: [user.id],
requireApproval: true
}) })
} }
@@ -23,20 +24,35 @@ export async function getUserGroups(): Promise<Group[]> {
const user = pb.authStore.model const user = pb.authStore.model
if (!user) return [] if (!user) return []
// 通过 members 字段过滤
return pb.collection('groups').getList(1, 50, { return pb.collection('groups').getList(1, 50, {
filter: `members ~ "${user.id}"` filter: `members ~ "${user.id}"`,
$autoCancel: false
}).then(res => res.items as unknown as Group[]) }).then(res => res.items as unknown as Group[])
} }
// 获取群组详情 // 获取群组详情
export async function getGroup(groupId: string): Promise<Group> { export async function getGroup(groupId: string): Promise<Group> {
return pb.collection('groups').getOne(groupId, { return pb.collection('groups').getOne(groupId, {
expand: 'members' expand: 'members',
$autoCancel: false
}) as unknown as Group }) as unknown as Group
} }
// 加入群组 // 按名称搜索群组
export async function searchGroups(keyword: string): Promise<Group[]> {
if (!keyword.trim()) return []
const user = pb.authStore.model
const filter = `name ~ "${keyword.trim()}" && id != "${user?.id}"`
const result = await pb.collection('groups').getList(1, 20, {
filter,
$autoCancel: false
})
return result.items as unknown as Group[]
}
// 直接加入群组(无需审核时调用)
export async function joinGroup(groupId: string) { export async function joinGroup(groupId: string) {
const user = pb.authStore.model const user = pb.authStore.model
if (!user) throw new Error('未登录') if (!user) throw new Error('未登录')
@@ -57,6 +73,82 @@ export async function joinGroup(groupId: string) {
}) })
} }
// 提交加入申请
export async function createJoinRequest(groupId: string): Promise<JoinRequest> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 检查是否已有待审核的申请
const existing = await pb.collection('join_requests').getList(1, 1, {
filter: `group="${groupId}" && user="${user.id}" && status="pending"`
})
if (existing.items.length > 0) {
throw new Error('已提交过申请,请等待审核')
}
return pb.collection('join_requests').create({
group: groupId,
user: user.id,
status: 'pending'
}) as unknown as JoinRequest
}
// 获取群组的待审核申请(群主用)
export async function getGroupJoinRequests(groupId: string): Promise<JoinRequest[]> {
const result = await pb.collection('join_requests').getList(1, 50, {
filter: `group="${groupId}" && status="pending"`,
sort: '-created',
expand: 'user',
$autoCancel: false
})
return result.items as unknown as JoinRequest[]
}
// 获取我作为群主的所有群组的待审核申请
export async function getMyGroupsJoinRequests(): Promise<JoinRequest[]> {
const user = pb.authStore.model
if (!user) return []
const result = await pb.collection('join_requests').getList(1, 50, {
filter: `group.owner="${user.id}" && status="pending"`,
sort: '-created',
expand: 'user,group',
$autoCancel: false
})
return result.items as unknown as JoinRequest[]
}
// 审批加入申请
export async function respondJoinRequest(
requestId: string,
status: 'approved' | 'rejected',
rejectReason?: string
) {
const updateData: Record<string, unknown> = { status }
if (status === 'rejected' && rejectReason) {
updateData.rejectReason = rejectReason
}
const request = await pb.collection('join_requests').update(requestId, updateData) as any
// 如果同意,将用户加入群组
if (status === 'approved') {
const group = await pb.collection('groups').getOne(request.group) as any
const members: string[] = group.members || []
if (!members.includes(request.user)) {
members.push(request.user)
await pb.collection('groups').update(request.group, { members })
}
}
return request
}
// 更新群组审核设置
export async function updateGroupApproval(groupId: string, requireApproval: boolean) {
return pb.collection('groups').update(groupId, { requireApproval })
}
// 退出群组 // 退出群组
export async function leaveGroup(groupId: string) { export async function leaveGroup(groupId: string) {
const user = pb.authStore.model const user = pb.authStore.model
@@ -97,14 +189,30 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void
}) })
} }
// 订阅加入申请变更
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
return pb.collection('join_requests').subscribe('*', (payload) => {
const record = payload.record as any
if (record.group === groupId) {
callback(record as unknown as JoinRequest)
}
})
}
// 获取群组成员 // 获取群组成员
export async function getGroupMembers(groupId: string) { export async function getGroupMembers(groupId: string) {
const group = await getGroup(groupId) const group = await getGroup(groupId)
const members = group.members as string[]
if (group.expand?.members) {
return group.expand.members
}
const members = group.members as string[]
if (!members || members.length === 0) return []
// 批量获取用户信息
const users = await pb.collection('users').getList(1, 50, { const users = await pb.collection('users').getList(1, 50, {
filter: members.map(id => `id="${id}"`).join(' || ') filter: members.map(id => `id="${id}"`).join(' || '),
$autoCancel: false
}) })
return users.items return users.items
+54 -1
View File
@@ -77,8 +77,61 @@ export async function respondInvitation(
updateData.rejectReason = rejectReason updateData.rejectReason = rejectReason
} }
// 后端 hook 会自动处理:加入 team members + 更新用户状态 // 接受邀请:先加入 team session,再更新邀请状态
if (response === 'accepted') {
const user = pb.authStore.model
if (!user) return
// 获取邀请详情以找到 team session
const invitation = await pb.collection('invitations').getOne(invitationId, {
$autoCancel: false
}) as any
const teamSessionId = invitation.teamSession
// 加入 team session
const session = await pb.collection('team_sessions').getOne(teamSessionId, {
$autoCancel: false
}) as any
const members: string[] = session.members || []
if (!members.includes(user.id)) {
members.push(user.id)
await pb.collection('team_sessions').update(teamSessionId, { members })
}
// 更新用户状态为 in_team
await pb.collection('users').update(user.id, { status: 'in_team' })
// 同步更新本地 userStore
const { useUserStore } = await import('@/stores/user')
const userStore = useUserStore()
if (userStore.user) {
userStore.user.status = 'in_team'
}
}
// 更新邀请状态
await pb.collection('invitations').update(invitationId, updateData) await pb.collection('invitations').update(invitationId, updateData)
// 通知邀请发起人
try {
const invitation = await pb.collection('invitations').getOne(invitationId, {
expand: 'teamSession',
$autoCancel: false,
}) as any
const { createNotification } = await import('./notifications')
await createNotification({
user: invitation.from,
type: response === 'rejected' ? 'team_invite' : 'team_invite',
title: response === 'rejected' ? '邀请被拒绝' : '邀请已接受',
content: response === 'rejected'
? (rejectReason || '对方拒绝了组队邀请')
: '对方已接受组队邀请',
relatedId: invitation.teamSession,
relatedType: 'team',
})
} catch {
// 通知失败不影响主流程
}
} }
// 订阅邀请变更 // 订阅邀请变更
+132
View File
@@ -0,0 +1,132 @@
// src/api/ledgers.ts
import { pb } from './pocketbase'
import type { Ledger } from '@/types'
interface CreateLedgerData {
group: string
type: string
amount: number
category: string
description?: string
relatedMembers?: string[]
occurredAt: string
}
interface ListLedgersOptions {
page?: number
limit?: number
type?: string
category?: string
month?: string
}
interface LedgerSummary {
totalIncome: number
totalExpense: number
balance: number
}
export async function createLedger(data: CreateLedgerData): Promise<Ledger> {
const user = pb.authStore.model
const record = await pb.collection('ledgers').create({
...data,
creator: user?.id || '',
relatedMembers: data.relatedMembers || []
}, { $autoCancel: false })
return record as unknown as Ledger
}
export async function listLedgers(
groupId: string,
options?: ListLedgersOptions
): Promise<{ items: Ledger[]; total: number }> {
const { page = 1, limit = 30, type, category, month } = options || {}
let filter = `group="${groupId}"`
if (type) filter += ` && type="${type}"`
if (category) filter += ` && category="${category}"`
if (month) {
const start = `${month}-01 00:00:00`
const year = parseInt(month.slice(0, 4))
const mon = parseInt(month.slice(5, 7))
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
const end = `${nextMonth}-01 00:00:00`
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
}
const result = await pb.collection('ledgers').getList(page, limit, {
filter,
sort: '-occurredAt',
expand: 'creator,relatedMembers',
$autoCancel: false
})
return { items: result.items as unknown as Ledger[], total: result.totalItems }
}
export async function updateLedger(
ledgerId: string,
data: Partial<CreateLedgerData>
): Promise<Ledger> {
const record = await pb.collection('ledgers').update(ledgerId, data, { $autoCancel: false })
return record as unknown as Ledger
}
export async function deleteLedger(ledgerId: string): Promise<void> {
await pb.collection('ledgers').delete(ledgerId, { $autoCancel: false })
}
export async function getLedgerSummary(
groupId: string,
month?: string
): Promise<LedgerSummary> {
let totalIncome = 0
let totalExpense = 0
let page = 1
const batchSize = 500
let hasMore = true
let filter = `group="${groupId}"`
if (month) {
const start = `${month}-01 00:00:00`
const year = parseInt(month.slice(0, 4))
const mon = parseInt(month.slice(5, 7))
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
const end = `${nextMonth}-01 00:00:00`
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
}
while (hasMore) {
const result = await pb.collection('ledgers').getList(page, batchSize, {
filter,
fields: 'type,amount',
$autoCancel: false
})
for (const item of result.items as any[]) {
if (item.type === 'income') {
totalIncome += item.amount || 0
} else if (item.type === 'expense') {
totalExpense += item.amount || 0
}
}
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return {
totalIncome,
totalExpense,
balance: totalIncome - totalExpense
}
}
export function subscribeLedgers(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('ledgers').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
+99
View File
@@ -0,0 +1,99 @@
import { pb } from './pocketbase'
import type { Memory } from '@/types'
export const GROUP_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024
export function detectFileType(file: File): 'image' | 'video' | 'audio' | 'document' | 'other' {
const mime = file.type || ''
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (
mime.startsWith('application/pdf') ||
mime.startsWith('application/msword') ||
mime.startsWith('application/vnd.') ||
mime.startsWith('text/')
) return 'document'
return 'other'
}
export async function uploadMemory(
groupId: string,
file: File,
meta?: { title?: string; description?: string; tags?: string[] }
) {
const used = await getGroupStorageUsed(groupId)
if (used + file.size > GROUP_STORAGE_LIMIT) {
throw new Error('群组存储空间不足,无法上传')
}
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', groupId)
formData.append('uploader', user?.id || '')
formData.append('file', file)
formData.append('fileType', detectFileType(file))
formData.append('size', file.size.toString())
formData.append('title', meta?.title || file.name)
if (meta?.description) formData.append('description', meta.description)
return pb.collection('memories').create(formData)
}
export async function listMemories(
groupId: string,
options?: {
page?: number
limit?: number
fileType?: string
}
): Promise<{ items: Memory[]; total: number }> {
const { page = 1, limit = 30, fileType } = options || {}
let filter = `group="${groupId}"`
if (fileType) filter += ` && fileType="${fileType}"`
const result = await pb.collection('memories').getList(page, limit, {
filter,
sort: '-created',
expand: 'uploader'
})
return { items: result.items as unknown as Memory[], total: result.totalItems }
}
export async function deleteMemory(memoryId: string) {
return pb.collection('memories').delete(memoryId)
}
export async function getGroupStorageUsed(groupId: string): Promise<number> {
let total = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
const result = await pb.collection('memories').getList(page, batchSize, {
filter: `group="${groupId}"`,
fields: 'size'
})
total += result.items.reduce((sum: number, r: any) => sum + (r.size || 0), 0)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return total
}
export function getGroupStorageLimit(): number {
return GROUP_STORAGE_LIMIT
}
export function subscribeMemories(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('memories').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
+89
View File
@@ -0,0 +1,89 @@
import { pb } from './pocketbase'
import type { AppNotification } from '@/types'
export async function listNotifications(options?: {
page?: number
limit?: number
unreadOnly?: boolean
}): Promise<{ items: AppNotification[], total: number }> {
const { page = 1, limit = 50, unreadOnly = false } = options || {}
const user = pb.authStore.model
if (!user) return { items: [], total: 0 }
let filter = `user="${user.id}"`
if (unreadOnly) filter += ' && read=false'
const result = await pb.collection('notifications').getList(page, limit, {
filter,
sort: '-created'
})
return { items: result.items as unknown as AppNotification[], total: result.totalItems }
}
export async function markAsRead(notificationId: string): Promise<AppNotification> {
return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise<AppNotification>
}
export async function markAllAsRead(): Promise<void> {
const user = pb.authStore.model
if (!user) return
let page = 1
const batchSize = 50
let hasMore = true
while (hasMore) {
const result = await pb.collection('notifications').getList(page, batchSize, {
filter: `user="${user.id}" && read=false`
})
if (result.items.length === 0) break
await Promise.all(
result.items.map(item => pb.collection('notifications').update(item.id, { read: true }))
)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
}
export async function deleteNotification(notificationId: string): Promise<void> {
await pb.collection('notifications').delete(notificationId)
}
export async function batchDelete(notificationIds: string[]): Promise<void> {
const batchSize = 20
for (let i = 0; i < notificationIds.length; i += batchSize) {
const batch = notificationIds.slice(i, i + batchSize)
await Promise.all(batch.map(id => pb.collection('notifications').delete(id)))
}
}
export async function createNotification(data: {
user: string
type: string
title: string
content?: string
relatedId?: string
relatedType?: string
}): Promise<AppNotification> {
return pb.collection('notifications').create(data) as unknown as Promise<AppNotification>
}
export async function subscribeNotifications(
callback: (data: { action: string, record: AppNotification }) => void
): Promise<() => void> {
const user = pb.authStore.model
if (!user) return () => {}
await pb.collection('notifications').subscribe('*', (e) => {
if (e.record?.user === user.id) {
callback({ action: e.action, record: e.record as unknown as AppNotification })
}
})
return () => {
pb.collection('notifications').unsubscribe('*')
}
}
+59
View File
@@ -0,0 +1,59 @@
import { pb } from './pocketbase'
import type { PlayerBlacklistEntry } from '@/types'
export async function createPlayerBlacklistEntry(data: {
group: string
playerId: string
platform: string
tags: string[]
customTag?: string
description: string
severity: string
}): Promise<PlayerBlacklistEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const payload: Record<string, any> = {
group: data.group,
reporter: user.id,
playerId: data.playerId,
platform: data.platform,
tags: data.tags,
description: data.description,
severity: data.severity,
}
if (data.customTag) payload.customTag = data.customTag
const record = await pb.collection('player_blacklist').create(payload)
return record as unknown as PlayerBlacklistEntry
}
export async function listPlayerBlacklist(
groupId: string,
options?: { tag?: string; severity?: string }
): Promise<PlayerBlacklistEntry[]> {
let filter = `group="${groupId}"`
if (options?.tag) filter += ` && tags~"${options.tag}"`
if (options?.severity) filter += ` && severity="${options.severity}"`
const result = await pb.collection('player_blacklist').getFullList({
filter,
sort: '-created',
expand: 'reporter',
$autoCancel: false,
})
return result as unknown as PlayerBlacklistEntry[]
}
export async function deletePlayerBlacklistEntry(entryId: string): Promise<void> {
await pb.collection('player_blacklist').delete(entryId)
}
export async function subscribePlayerBlacklist(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('player_blacklist').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
+1 -7
View File
@@ -5,13 +5,7 @@ const pbUrl = import.meta.env.VITE_PB_URL || window.location.origin
export const pb = new PocketBase(pbUrl) export const pb = new PocketBase(pbUrl)
// 认证状态持久化 // SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
pb.authStore.loadFromCookie(document.cookie)
// 保存认证状态到 cookie
pb.authStore.onChange(() => {
document.cookie = pb.authStore.exportToCookie({ httpOnly: false })
})
// 获取当前用户 // 获取当前用户
export function getCurrentUser() { export function getCurrentUser() {
+93
View File
@@ -0,0 +1,93 @@
import { pb } from './pocketbase'
import type { PointLog, PointAction } from '@/types'
const POINT_MAP: Record<PointAction, number> = {
vote: 1,
team: 2,
memory: 1,
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
}
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length > 0) return
const points = POINT_MAP[action]
try {
await pb.collection('point_logs').create({
user: user.id,
action,
points,
relatedId
})
} catch (error: any) {
if (error?.response?.data?.user || error?.message?.includes('unique')) {
return
}
throw error
}
const currentUser = await pb.collection('users').getOne(user.id)
await pb.collection('users').update(user.id, {
points: ((currentUser as any).points || 0) + points
})
}
export async function deductPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length === 0) return
const log = existing.items[0]
try {
await pb.collection('point_logs').delete(log.id)
} catch (error: any) {
if (error?.status !== 404) {
throw error
}
}
const pointsToDeduct = POINT_MAP[action]
const currentUser = await pb.collection('users').getOne(user.id)
const currentPoints = (currentUser as any).points || 0
const newPoints = Math.max(0, currentPoints - pointsToDeduct)
await pb.collection('users').update(user.id, {
points: newPoints
})
}
export async function getUserPointLogs(userId: string): Promise<PointLog[]> {
const result = await pb.collection('point_logs').getList(1, 100, {
filter: `user="${userId}"`,
sort: '-created',
$autoCancel: false
})
return result.items as unknown as PointLog[]
}
export async function getGroupMemberRanking(groupId: string, limit = 20) {
const group = await pb.collection('groups').getOne(groupId, {
expand: 'members',
$autoCancel: false
}) as any
const members: any[] = group.expand?.members || []
return members
.map((m: any) => ({ userId: m.id, points: m.points || 0, name: m.name || m.username }))
.sort((a: any, b: any) => b.points - a.points)
.slice(0, limit)
}
+242
View File
@@ -0,0 +1,242 @@
import { pb } from './pocketbase'
import { awardPoints, deductPoints } from './points'
import { createNotification } from './notifications'
import type { Poll, PollOption, PollVote } from '@/types'
export async function createPoll(data: {
group: string
title: string
type?: 'option' | 'rollcall'
anonymous?: boolean
deadline?: string
maxParticipants?: number
options: string[]
}): Promise<Poll> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').create({
group: data.group,
title: data.title,
type: data.type || 'option',
anonymous: data.anonymous || false,
deadline: data.deadline || '',
maxParticipants: data.type === 'rollcall' ? data.maxParticipants : null,
status: 'active',
creator: user.id,
})
for (let i = 0; i < data.options.length; i++) {
await pb.collection('poll_options').create({
poll: poll.id,
content: data.options[i],
order: i + 1,
})
}
// 给同群组其他成员发送通知
try {
const group = await pb.collection('groups').getOne(data.group)
const typeLabel = data.type === 'rollcall' ? '接龙报名' : '投票'
const otherMembers = (group.members || []).filter((id: string) => id !== user.id)
await Promise.all(
otherMembers.map((memberId: string) =>
createNotification({
user: memberId,
type: 'poll_new',
title: `${typeLabel}`,
content: `${data.title}`,
relatedId: poll.id,
relatedType: 'poll',
})
)
)
} catch {
// 通知发送失败不影响主流程
}
return poll as unknown as Poll
}
export async function listPolls(
groupId: string,
status?: string
): Promise<Poll[]> {
let filter = `group="${groupId}"`
if (status) filter += ` && status="${status}"`
const result = await pb.collection('polls').getFullList({
filter,
sort: '-created',
expand: 'creator',
$autoCancel: false,
})
return result as unknown as Poll[]
}
export async function getPoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').getOne(pollId, {
expand: 'creator',
})
return result as unknown as Poll
}
export async function getPollOptions(pollId: string): Promise<PollOption[]> {
const result = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: 'order',
$autoCancel: false,
})
return result as unknown as PollOption[]
}
export async function getPollVotes(pollId: string): Promise<PollVote[]> {
const result = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}"`,
expand: 'user,option',
$autoCancel: false,
})
return result as unknown as PollVote[]
}
export async function votePoll(
pollId: string,
optionId: string
): Promise<PollVote> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束')
}
const existing = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
})
if (existing.items.length > 0) {
throw new Error('你已经投过票了')
}
try {
const vote = await pb.collection('poll_votes').create({
poll: pollId,
option: optionId,
user: user.id,
})
await awardPoints('vote', pollId)
return vote as unknown as PollVote
} catch (error: any) {
if (error?.response?.data?.poll || error?.message?.includes('unique')) {
throw new Error('你已经投过票了')
}
throw error
}
}
export async function cancelVote(pollId: string): Promise<void> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束,无法取消')
}
const existing = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}" && user="${user.id}"`,
})
for (const vote of existing) {
await pb.collection('poll_votes').delete(vote.id)
}
await deductPoints('vote', pollId)
}
export async function settlePoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, {
status: 'settled',
settledAt: new Date().toISOString(),
})
return result as unknown as Poll
}
export async function updatePoll(pollId: string, data: {
title?: string
deadline?: string
maxParticipants?: number | null
}): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, data)
return result as unknown as Poll
}
export async function addPollOption(pollId: string, content: string): Promise<PollOption> {
const existing = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: '-order',
$autoCancel: false,
})
const maxOrder = existing.reduce((max: number, o: any) => Math.max(max, o.order || 0), 0)
const result = await pb.collection('poll_options').create({
poll: pollId,
content,
order: maxOrder + 1,
})
return result as unknown as PollOption
}
export async function updatePollOption(optionId: string, content: string): Promise<PollOption> {
const result = await pb.collection('poll_options').update(optionId, { content })
return result as unknown as PollOption
}
export async function deletePollOption(optionId: string): Promise<void> {
await pb.collection('poll_options').delete(optionId)
}
export async function getUserVote(pollId: string): Promise<PollVote | null> {
const user = pb.authStore.model
if (!user) return null
const result = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
expand: 'option',
$autoCancel: false,
})
return result.items.length > 0
? (result.items[0] as unknown as PollVote)
: null
}
export async function subscribePolls(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('polls').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
return () => {
pb.collection('polls').unsubscribe('*')
}
}
export async function subscribePollVotes(
pollId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('poll_votes').subscribe('*', (data) => {
if (data.record?.poll === pollId) {
callback(data)
}
})
return () => {
pb.collection('poll_votes').unsubscribe('*')
}
}
+15 -2
View File
@@ -49,9 +49,22 @@ export async function updateTeamStatus(sessionId: string, status: TeamStatus): P
return pb.collection('team_sessions').update(sessionId, updateData) as unknown as TeamSession return pb.collection('team_sessions').update(sessionId, updateData) as unknown as TeamSession
} }
// 结束游戏(解散临时小组) // 结束游戏(解散临时小组 + 重置成员状态
export async function endGame(sessionId: string) { export async function endGame(sessionId: string) {
return updateTeamStatus(sessionId, 'dissolved') const session = await pb.collection('team_sessions').getOne(sessionId, {
$autoCancel: false
}) as any
const members: string[] = session.members || []
// 解散临时小组
await updateTeamStatus(sessionId, 'dissolved')
// 重置所有成员状态为 idle
for (const memberId of members) {
try {
await pb.collection('users').update(memberId, { status: 'idle' })
} catch (_) {}
}
} }
// 加入临时小组 // 加入临时小组
+34
View File
@@ -0,0 +1,34 @@
// src/api/voice.ts
import { pb } from './pocketbase'
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
export function getLiveKitUrl(): string {
return LIVEKIT_URL
}
export async function fetchVoiceToken(sessionId: string): Promise<string> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const token = pb.authStore.token
console.log('[voice] fetching token for session:', sessionId, 'token prefix:', token?.slice(0, 20))
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
})
console.log('[voice] token service response status:', res.status)
if (!res.ok) {
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
console.log('[voice] token service error:', data)
throw new Error(data.error || data.detail || '语音服务暂不可用')
}
const data = await res.json()
return data.token
}
+4 -4
View File
@@ -4,9 +4,9 @@
--gg-primary-light: #10b981; --gg-primary-light: #10b981;
--gg-primary-dark: #047857; --gg-primary-dark: #047857;
/* 辅助色:深紫 */ /* 辅助色:翠绿 */
--gg-accent: #7c3aed; --gg-accent: #0d9488;
--gg-accent-light: #8b5cf6; --gg-accent-light: #14b8a6;
/* 背景色(亮色) */ /* 背景色(亮色) */
--gg-bg: #f0fdf4; --gg-bg: #f0fdf4;
@@ -40,7 +40,7 @@
--gg-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1); --gg-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
/* 渐变 */ /* 渐变 */
--gg-gradient: linear-gradient(135deg, #059669 0%, #7c3aed 100%); --gg-gradient: linear-gradient(135deg, #059669 0%, #0d9488 100%);
--gg-gradient-green: linear-gradient(135deg, #10b981 0%, #059669 100%); --gg-gradient-green: linear-gradient(135deg, #10b981 0%, #059669 100%);
--gg-gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); --gg-gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
--gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%); --gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { Edit, Delete, Box, Monitor, SetUp, Headset } from '@element-plus/icons-vue'
import { AssetTypeMap, displayName } from '@/types'
import { getAssetImageUrl } from '@/api/assets'
import { useAssetStore } from '@/stores/asset'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import type { Asset, AssetType } from '@/types'
const props = defineProps<{
asset: Asset
}>()
const emit = defineEmits<{
edit: [assetId: string]
transfer: [assetId: string]
delete: [assetId: string]
}>()
const assetStore = useAssetStore()
const groupStore = useGroupStore()
// 是否有图片
const hasImage = computed(() => !!props.asset.image)
// 图片 URL(缩略图)
const imageUrl = computed(() => {
if (!props.asset.image) return ''
return getAssetImageUrl(props.asset.id, props.asset.image, '200x200')
})
// 类型标签
const typeLabel = computed(() => {
return AssetTypeMap[props.asset.type as AssetType] || '其他'
})
// 当前持有者信息
const holder = computed(() => {
return props.asset.expand?.currentHolder
})
const holderName = computed(() => {
if (!props.asset.currentHolder) return '在库'
return displayName(holder.value)
})
const isInStock = computed(() => !props.asset.currentHolder)
// 类型图标
const typeIcon = computed(() => {
switch (props.asset.type) {
case 'game_account': return Monitor
case 'console': return SetUp
case 'equipment': return Monitor
case 'accessory': return Headset
default: return Box
}
})
// 当前用户是否为创建者或群主
const canManage = computed(() => {
const userId = pb.authStore.model?.id
if (!userId) return false
if (props.asset.creator === userId) return true
if (groupStore.currentGroup?.owner === userId) return true
return false
})
// 点击卡片
function handleCardClick() {
emit('transfer', props.asset.id)
}
// 编辑
function handleEdit(e: Event) {
e.stopPropagation()
emit('edit', props.asset.id)
}
// 删除
async function handleDelete(e: Event) {
e.stopPropagation()
try {
await ElMessageBox.confirm('确定要删除这个资产吗?', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
await assetStore.removeAsset(props.asset.id)
emit('delete', props.asset.id)
ElMessage.success('删除成功')
} catch {
// 用户取消
}
}
</script>
<template>
<div class="asset-card" @click="handleCardClick">
<!-- 图片区域 -->
<div class="asset-card__image">
<img
v-if="hasImage"
:src="imageUrl"
:alt="asset.name"
class="asset-card__img"
loading="lazy"
/>
<div v-else class="asset-card__placeholder">
<el-icon :size="32"><component :is="typeIcon" /></el-icon>
</div>
<!-- 操作按钮悬停显示 -->
<div v-if="canManage" class="asset-card__actions">
<button class="asset-card__action-btn" title="编辑" @click="handleEdit">
<el-icon><Edit /></el-icon>
</button>
<button class="asset-card__action-btn asset-card__action-btn--danger" title="删除" @click="handleDelete">
<el-icon><Delete /></el-icon>
</button>
</div>
</div>
<!-- 信息区域 -->
<div class="asset-card__info">
<!-- 名称 -->
<div class="asset-card__name" :title="asset.name">{{ asset.name }}</div>
<!-- 类型标签 -->
<div class="asset-card__meta">
<span class="asset-card__type-tag">{{ typeLabel }}</span>
<span v-if="isInStock" class="asset-card__stock-tag">在库</span>
</div>
<!-- 持有者 -->
<div class="asset-card__holder">
<template v-if="holder">
<div class="asset-card__avatar">
{{ holderName.charAt(0) }}
</div>
<span class="asset-card__holder-name">{{ holderName }}</span>
</template>
<template v-else>
<span class="asset-card__stock-text">在库</span>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.asset-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg);
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.asset-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 2px 16px rgba(5, 150, 105, 0.1);
transform: translateY(-2px);
}
/* 图片区域 */
.asset-card__image {
position: relative;
width: 100%;
height: 160px;
background: var(--gg-bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.asset-card__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-card__placeholder {
color: var(--gg-text-hint);
opacity: 0.6;
}
/* 操作按钮 */
.asset-card__actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.asset-card:hover .asset-card__actions {
opacity: 1;
}
.asset-card__action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.9);
color: var(--gg-text-secondary);
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.asset-card__action-btn:hover {
background: var(--gg-primary);
color: #fff;
}
.asset-card__action-btn--danger:hover {
background: var(--gg-danger);
color: #fff;
}
/* 信息区域 */
.asset-card__info {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.asset-card__name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-card__meta {
display: flex;
align-items: center;
gap: 6px;
}
.asset-card__type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(5, 150, 105, 0.1);
color: var(--gg-primary);
}
.asset-card__stock-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
/* 持有者 */
.asset-card__holder {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--gg-text-secondary);
}
.asset-card__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gg-gradient-green);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.asset-card__holder-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-card__stock-text {
color: var(--gg-success);
font-weight: 500;
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useAssetStore } from '@/stores/asset'
import { useGroupStore } from '@/stores/group'
import { AssetTypeMap } from '@/types'
import type { Asset, AssetType } from '@/types'
import AssetCard from './AssetCard.vue'
import CreateAssetDialog from './CreateAssetDialog.vue'
import TransferAssetDialog from './TransferAssetDialog.vue'
const assetStore = useAssetStore()
const groupStore = useGroupStore()
// 类型过滤
const typeFilter = ref<string>('')
// 类型选项
const typeOptions = computed(() => {
return [
{ value: '', label: '全部' },
...(Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
value,
label
}))
]
})
// 过滤后的资产列表
const filteredAssets = computed(() => {
if (!typeFilter.value) return assetStore.assets
return assetStore.assets.filter(a => a.type === typeFilter.value)
})
// 对话框状态
const showCreateDialog = ref(false)
const showTransferDialog = ref(false)
// 编辑/转移目标
const editingAsset = ref<Asset | undefined>(undefined)
const transferringAsset = ref<Asset | null>(null)
// 加载数据
async function loadData() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await assetStore.loadAssets(groupId)
}
// 订阅变更
async function setupSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await assetStore.startSubscription(groupId)
}
// 登记资产
function handleCreate() {
editingAsset.value = undefined
showCreateDialog.value = true
}
// 编辑资产
function handleEdit(assetId: string) {
const asset = assetStore.assets.find(a => a.id === assetId)
if (asset) {
editingAsset.value = asset
showCreateDialog.value = true
}
}
// 转移资产
function handleTransfer(assetId: string) {
const asset = assetStore.assets.find(a => a.id === assetId)
if (asset) {
transferringAsset.value = asset
showTransferDialog.value = true
}
}
// 资产删除后刷新
function handleDelete() {
// store 已经从列表中移除了,无需额外操作
}
// 保存成功回调
function handleSaved() {
showCreateDialog.value = false
editingAsset.value = undefined
}
// 转移成功回调
function handleTransferred() {
showTransferDialog.value = false
transferringAsset.value = null
}
onMounted(async () => {
if (groupStore.currentGroupId) {
await loadData()
await setupSubscription()
}
})
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
if (newId && newId !== oldId) {
await loadData()
await setupSubscription()
}
})
onUnmounted(() => {
assetStore.stopSubscription()
})
</script>
<template>
<div class="asset-list">
<!-- 顶部操作栏 -->
<div class="asset-list__header">
<h3 class="asset-list__title">资产库</h3>
<button class="asset-list__create-btn" @click="handleCreate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="asset-list__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
登记资产
</button>
</div>
<!-- 类型过滤 -->
<div class="asset-list__filter">
<el-select
v-model="typeFilter"
placeholder="筛选类型"
size="default"
style="width: 160px"
clearable
>
<el-option
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<span class="asset-list__count"> {{ filteredAssets.length }} </span>
</div>
<!-- 加载状态 -->
<div v-if="assetStore.loading" v-loading="true" class="asset-list__loading"></div>
<!-- 空状态 -->
<div
v-else-if="filteredAssets.length === 0"
class="asset-list__empty"
>
<svg class="asset-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
<p class="asset-list__empty-text">暂无资产</p>
<p class="asset-list__empty-hint">点击登记资产添加第一个资产</p>
</div>
<!-- 资产网格 -->
<div v-else class="asset-list__grid">
<AssetCard
v-for="asset in filteredAssets"
:key="asset.id"
:asset="asset"
@edit="handleEdit"
@transfer="handleTransfer"
@delete="handleDelete"
/>
</div>
<!-- 创建/编辑弹窗 -->
<CreateAssetDialog
v-model="showCreateDialog"
:group-id="groupStore.currentGroupId"
:edit-asset="editingAsset"
@saved="handleSaved"
/>
<!-- 转移弹窗 -->
<TransferAssetDialog
v-model="showTransferDialog"
:asset="transferringAsset"
:members="groupStore.currentMembers"
@transferred="handleTransferred"
/>
</div>
</template>
<style scoped>
.asset-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部操作栏 */
.asset-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.asset-list__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.asset-list__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.asset-list__create-btn:hover {
opacity: 0.85;
}
.asset-list__create-icon {
width: 16px;
height: 16px;
}
/* 过滤栏 */
.asset-list__filter {
display: flex;
align-items: center;
gap: 12px;
}
.asset-list__count {
font-size: 13px;
color: var(--gg-text-muted);
}
/* 加载状态 */
.asset-list__loading {
min-height: 200px;
}
/* 网格布局 */
.asset-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
/* 空状态 */
.asset-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.asset-list__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.asset-list__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.asset-list__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.asset-list__grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
}
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useAssetStore } from '@/stores/asset'
import { AssetTypeMap } from '@/types'
import type { Asset, AssetType } from '@/types'
const visible = defineModel<boolean>({ default: false })
const props = defineProps<{
groupId: string
editAsset?: Asset
}>()
const emit = defineEmits<{
saved: []
}>()
const assetStore = useAssetStore()
const isEditing = computed(() => !!props.editAsset)
const dialogTitle = computed(() => isEditing.value ? '编辑资产' : '登记资产')
const form = ref({
name: '',
type: 'other' as AssetType,
description: '',
})
const fileList = ref<UploadFile[]>([])
const selectedImage = ref<File | null>(null)
const loading = ref(false)
// 资产类型选项
const typeOptions = computed(() => {
return (Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
value,
label
}))
})
// 重置表单
function resetForm() {
form.value = {
name: '',
type: 'other',
description: '',
}
fileList.value = []
selectedImage.value = null
}
// 初始化编辑表单
function initForm() {
resetForm()
if (props.editAsset) {
form.value.name = props.editAsset.name
form.value.type = props.editAsset.type
form.value.description = props.editAsset.description || ''
}
}
// 文件选择变更
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
fileList.value = uploadFileList
if (_file.raw) {
selectedImage.value = _file.raw
}
}
// 移除文件
function handleFileRemove() {
fileList.value = []
selectedImage.value = null
}
// 超出限制
function handleExceed() {
ElMessage.warning('只能上传一张图片,请先移除已选图片')
}
// 提交表单
async function handleSubmit() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入资产名称')
return
}
loading.value = true
try {
if (isEditing.value && props.editAsset) {
await assetStore.editAsset(
props.editAsset.id,
{
name: form.value.name.trim(),
type: form.value.type,
description: form.value.description.trim() || undefined,
},
selectedImage.value || undefined
)
ElMessage.success('资产更新成功')
} else {
await assetStore.addAsset({
group: props.groupId,
name: form.value.name.trim(),
type: form.value.type,
description: form.value.description.trim() || undefined,
image: selectedImage.value || undefined,
})
ElMessage.success('资产登记成功')
}
visible.value = false
emit('saved')
} catch (error: any) {
ElMessage.error(error.message || (isEditing.value ? '更新资产失败' : '登记资产失败'))
} finally {
loading.value = false
}
}
// 打开时初始化
function handleOpen() {
initForm()
}
// 关闭时重置
function handleClose() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="480px"
@open="handleOpen"
@close="handleClose"
>
<div class="create-form">
<!-- 名称 -->
<div class="form-field">
<label>名称 <span class="required">*</span></label>
<el-input
v-model="form.name"
placeholder="请输入资产名称"
maxlength="100"
show-word-limit
/>
</div>
<!-- 类型 -->
<div class="form-field">
<label>类型</label>
<el-select v-model="form.type" placeholder="选择资产类型" style="width: 100%">
<el-option
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 描述 -->
<div class="form-field">
<label>描述</label>
<el-input
v-model="form.description"
type="textarea"
placeholder="简单描述一下(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</div>
<!-- 图片上传 -->
<div class="form-field">
<label>图片</label>
<el-upload
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
accept="image/*"
list-type="picture"
>
<div class="upload-trigger">
<el-icon class="upload-icon"><Plus /></el-icon>
<div>点击上传图片</div>
</div>
</el-upload>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '登记') }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--gg-text-secondary);
font-size: 13px;
padding: 8px 0;
}
.upload-icon {
font-size: 24px;
color: var(--gg-text-hint);
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,294 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useAssetStore } from '@/stores/asset'
import { displayName } from '@/types'
import type { Asset, User } from '@/types'
const visible = defineModel<boolean>({ default: false })
const props = defineProps<{
asset: Asset | null
members: User[]
}>()
const emit = defineEmits<{
transferred: []
}>()
const assetStore = useAssetStore()
const selectedUserId = ref<string | null>(null)
const loading = ref(false)
// 资产名称
const assetName = computed(() => props.asset?.name || '')
// 当前持有者
const currentHolderId = computed(() => props.asset?.currentHolder || '')
// 成员列表(排除当前持有者)
const availableMembers = computed(() => {
if (!props.asset) return []
return props.members.filter(m => m.id !== currentHolderId.value)
})
// 重置
function resetForm() {
selectedUserId.value = null
}
// 选择"放回在库"
function selectInStock() {
selectedUserId.value = null
}
// 选择成员
function selectMember(userId: string) {
selectedUserId.value = userId
}
// 提交转移
async function handleSubmit() {
if (!props.asset) return
loading.value = true
try {
// null 表示放回在库,传空字符串让 PocketBase 清空
const targetUserId = selectedUserId.value || ''
await assetStore.transfer(props.asset.id, targetUserId)
visible.value = false
ElMessage.success(selectedUserId.value ? '资产已转移' : '资产已放回在库')
emit('transferred')
} catch (error: any) {
ElMessage.error(error.message || '转移资产失败')
} finally {
loading.value = false
}
}
// 打开时重置
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="转移资产"
width="480px"
@open="handleOpen"
>
<div v-if="asset" class="transfer-form">
<!-- 资产名称 -->
<div class="transfer-asset-name">
<span class="transfer-label">资产</span>
<span class="transfer-value">{{ assetName }}</span>
</div>
<!-- 放回在库 -->
<div class="member-selection">
<div class="section-label">选择新持有者</div>
<div
class="member-item member-item--stock"
:class="{ 'member-item--selected': selectedUserId === null }"
@click="selectInStock"
>
<div class="member-item__check">
<span v-if="selectedUserId === null" class="check-dot"></span>
</div>
<div class="member-item__avatar member-item__avatar--stock">
</div>
<span class="member-item__name">放回在库</span>
<span class="member-item__tag">在库</span>
</div>
<!-- 成员列表 -->
<div
v-for="member in availableMembers"
:key="member.id"
class="member-item"
:class="{ 'member-item--selected': selectedUserId === member.id }"
@click="selectMember(member.id)"
>
<div class="member-item__check">
<span v-if="selectedUserId === member.id" class="check-dot"></span>
</div>
<div class="member-item__avatar">
{{ displayName(member).charAt(0) }}
</div>
<span class="member-item__name">{{ displayName(member) }}</span>
</div>
<!-- 无可用成员 -->
<div v-if="availableMembers.length === 0 && currentHolderId" class="no-members">
没有其他群成员可以转移
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '转移中...' : '确认转移' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.transfer-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.transfer-asset-name {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--gg-bg-secondary);
border-radius: 8px;
}
.transfer-label {
font-size: 13px;
color: var(--gg-text-secondary);
flex-shrink: 0;
}
.transfer-value {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-selection {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-label {
font-size: 13px;
font-weight: 500;
color: var(--gg-text-secondary);
margin-bottom: 4px;
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid var(--gg-border);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.member-item:hover {
border-color: var(--gg-primary-light);
background: rgba(5, 150, 105, 0.04);
}
.member-item--selected {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
}
.member-item__check {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--gg-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s;
}
.member-item--selected .member-item__check {
border-color: var(--gg-primary);
}
.check-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--gg-primary);
}
.member-item__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gg-gradient-green);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.member-item__avatar--stock {
background: var(--gg-success);
}
.member-item__name {
font-size: 14px;
color: var(--gg-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-item__tag {
font-size: 11px;
color: var(--gg-success);
background: rgba(16, 185, 129, 0.1);
padding: 2px 8px;
border-radius: 6px;
font-weight: 500;
}
.no-members {
text-align: center;
padding: 16px;
font-size: 13px;
color: var(--gg-text-hint);
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed } from 'vue'
import { displayName } from '@/types'
import type { Bet, BetOption, BetEntry } from '@/types'
const props = defineProps<{
bet: Bet
options: BetOption[]
entries: BetEntry[]
currentUserEntry: BetEntry | null
}>()
const emit = defineEmits<{
click: []
}>()
// 状态标签
const statusMap: Record<string, { label: string; cls: string }> = {
open: { label: '进行中', cls: 'active' },
closed: { label: '已关闭', cls: 'closed' },
settled: { label: '已开奖', cls: 'settled' },
}
const statusInfo = computed(() => statusMap[props.bet.status] || statusMap.open)
// 发起人名称
const creatorName = computed(() => displayName(props.bet.expand?.creator))
// 奖池总额
const totalPool = computed(() => props.entries.reduce((sum, e) => sum + e.stake, 0))
// 各选项的下注总额
const optionStakes = computed(() => {
const map: Record<string, number> = {}
for (const e of props.entries) {
map[e.option] = (map[e.option] || 0) + e.stake
}
return map
})
// 截止时间倒计时
const deadlineText = computed(() => {
if (!props.bet.deadline) return null
const deadline = new Date(props.bet.deadline).getTime()
const now = Date.now()
const diff = deadline - now
if (diff <= 0) return '已截止'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天后截止`
if (hours > 0) return `${hours}小时后截止`
if (minutes > 0) return `${minutes}分钟后截止`
return '即将截止'
})
</script>
<template>
<div class="bet-card" @click="emit('click')">
<!-- 顶部标签行 -->
<div class="bet-card__tags">
<span class="bet-card__status-tag" :class="`bet-card__status-tag--${statusInfo.cls}`">
{{ statusInfo.label }}
</span>
<span v-if="currentUserEntry" class="bet-card__voted-badge">
已下注
</span>
</div>
<!-- 标题 -->
<div class="bet-card__title">{{ bet.title }}</div>
<!-- 发起人 -->
<div class="bet-card__creator">
<svg class="bet-card__creator-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{{ creatorName }}</span>
</div>
<!-- 奖池总额 -->
<div class="bet-card__pool">
<svg class="bet-card__pool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span class="bet-card__pool-label">奖池</span>
<span class="bet-card__pool-value">{{ totalPool }} 积分</span>
</div>
<!-- 各选项进度条 -->
<div v-if="options.length > 0 && totalPool > 0" class="bet-card__options">
<div
v-for="option in options"
:key="option.id"
class="bet-card__option"
>
<div class="bet-card__option-header">
<span class="bet-card__option-name">{{ option.content }}</span>
<span class="bet-card__option-pct">
{{ totalPool > 0 ? Math.round((optionStakes[option.id] || 0) / totalPool * 100) : 0 }}%
</span>
</div>
<div class="bet-card__option-bar">
<div
class="bet-card__option-fill"
:style="{
width: totalPool > 0
? ((optionStakes[option.id] || 0) / totalPool * 100) + '%'
: '0%',
}"
/>
</div>
</div>
</div>
<!-- 底部截止时间 -->
<div v-if="deadlineText" class="bet-card__footer">
<svg class="bet-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span :class="{ 'bet-card__deadline--urgent': deadlineText === '即将截止' }">
{{ deadlineText }}
</span>
</div>
</div>
</template>
<style scoped>
.bet-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.bet-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
/* 标签行 */
.bet-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.bet-card__status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.bet-card__status-tag--active {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.bet-card__status-tag--closed {
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
}
.bet-card__status-tag--settled {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
.bet-card__voted-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
/* 标题 */
.bet-card__title {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
line-height: 1.5;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 发起人 */
.bet-card__creator {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-secondary);
margin-bottom: 8px;
}
.bet-card__creator-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 奖池 */
.bet-card__pool {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(5, 150, 105, 0.06);
border-radius: 6px;
margin-bottom: 10px;
}
.bet-card__pool-icon {
width: 16px;
height: 16px;
color: var(--gg-primary);
flex-shrink: 0;
}
.bet-card__pool-label {
font-size: 12px;
color: var(--gg-text-muted);
}
.bet-card__pool-value {
font-size: 14px;
font-weight: 700;
color: var(--gg-primary);
}
/* 选项进度 */
.bet-card__options {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.bet-card__option {
display: flex;
flex-direction: column;
gap: 3px;
}
.bet-card__option-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.bet-card__option-name {
font-size: 12px;
color: var(--gg-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bet-card__option-pct {
font-size: 11px;
color: var(--gg-text-muted);
flex-shrink: 0;
}
.bet-card__option-bar {
height: 4px;
background: var(--gg-bg-elevated);
border-radius: 2px;
overflow: hidden;
}
.bet-card__option-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 2px;
transition: width 0.3s ease;
}
/* 底部 */
.bet-card__footer {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-muted);
}
.bet-card__footer-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.bet-card__deadline--urgent {
color: var(--gg-danger);
font-weight: 600;
}
</style>
+650
View File
@@ -0,0 +1,650 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { getBet, getBetOptions, getBetEntries, placeBet, closeBet, subscribeBets } from '@/api/bets'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import type { Bet, BetOption, BetEntry } from '@/types'
import BetEntryList from './BetEntryList.vue'
import SettleBetDialog from './SettleBetDialog.vue'
const props = defineProps<{
betId: string
}>()
const emit = defineEmits<{
back: []
}>()
// 数据
const bet = ref<Bet | null>(null)
const options = ref<BetOption[]>([])
const entries = ref<BetEntry[]>([])
const loading = ref(false)
// 下注表单
const selectedOption = ref('')
const stakeAmount = ref(1)
// 结算弹窗
const showSettle = ref(false)
// 实时订阅
let unsubscribeFn: (() => void) | null = null
// 计算属性
const creatorName = computed(() => displayName(bet.value?.expand?.creator))
const totalPool = computed(() => entries.value.reduce((sum, e) => sum + e.stake, 0))
// 各选项的下注总额和人数
const optionStats = computed(() => {
const stats: Record<string, { totalStake: number; count: number }> = {}
for (const opt of options.value) {
stats[opt.id] = { totalStake: 0, count: 0 }
}
for (const e of entries.value) {
if (!stats[e.option]) {
stats[e.option] = { totalStake: 0, count: 0 }
}
stats[e.option].totalStake += e.stake
stats[e.option].count += 1
}
return stats
})
// 当前用户
const currentUserId = computed(() => pb.authStore.model?.id)
const isCreator = computed(() => bet.value?.creator === currentUserId.value)
// 当前用户的下注记录
const currentUserEntry = computed(() =>
entries.value.find((e) => e.user === currentUserId.value) || null
)
// 状态
const isOpen = computed(() => bet.value?.status === 'open')
const isClosed = computed(() => bet.value?.status === 'closed')
const isSettled = computed(() => bet.value?.status === 'settled')
// 结果选项ID
const resultOptionId = computed(() => bet.value?.resultOption || '')
// 加载数据
async function loadDetail() {
loading.value = true
try {
const [betData, optionsData, entriesData] = await Promise.all([
getBet(props.betId),
getBetOptions(props.betId),
getBetEntries(props.betId),
])
bet.value = betData
options.value = optionsData
entries.value = entriesData
} catch (error) {
console.error('加载竞猜详情失败:', error)
} finally {
loading.value = false
}
}
// 下注
async function handlePlaceBet() {
if (!selectedOption.value) {
ElMessage.warning('请选择一个选项')
return
}
if (!stakeAmount.value || stakeAmount.value < 1) {
ElMessage.warning('请输入有效的积分数')
return
}
try {
await placeBet(props.betId, selectedOption.value, stakeAmount.value)
ElMessage.success('下注成功')
await loadDetail()
} catch (error: any) {
ElMessage.error(error.message || '下注失败')
}
}
// 关闭竞猜
async function handleClose() {
try {
await ElMessageBox.confirm('确定要关闭竞猜吗?关闭后将不能再下注。', '关闭竞猜', {
confirmButtonText: '确定关闭',
cancelButtonText: '取消',
type: 'warning',
})
await closeBet(props.betId)
ElMessage.success('竞猜已关闭')
await loadDetail()
} catch {
// 用户取消
}
}
// 实时订阅
async function startSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
if (!bet.value) return
const groupId = bet.value.group
unsubscribeFn = await subscribeBets(groupId, () => {
loadDetail()
})
}
onMounted(async () => {
await loadDetail()
startSubscription()
})
onUnmounted(() => {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
})
</script>
<template>
<div class="bet-detail">
<!-- 顶部操作栏 -->
<div class="bet-detail__header">
<el-button text @click="emit('back')">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="bet-detail__actions">
<button
v-if="isCreator && isOpen"
class="action-btn action-btn--close"
@click="handleClose"
>
关闭竞猜
</button>
<button
v-if="isCreator && isClosed"
class="action-btn action-btn--settle"
@click="showSettle = true"
>
开奖
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="bet-detail__loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 竞猜内容 -->
<template v-else-if="bet">
<!-- 标题区域 -->
<div class="bet-detail__title-area">
<h2 class="bet-detail__title">{{ bet.title }}</h2>
<div class="bet-detail__meta">
<span class="bet-detail__meta-item">
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{{ creatorName }} 发起
</span>
<span class="bet-detail__meta-item">
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
奖池 {{ totalPool }} 积分
</span>
<span class="bet-detail__meta-item">
下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分
</span>
</div>
</div>
<!-- 描述 -->
<div v-if="bet.description" class="bet-detail__desc">
{{ bet.description }}
</div>
<!-- 选项列表 -->
<div class="bet-detail__options">
<div
v-for="option in options"
:key="option.id"
:class="[
'bet-detail__option',
{
'bet-detail__option--winner': isSettled && resultOptionId === option.id,
'bet-detail__option--loser': isSettled && resultOptionId !== option.id,
},
]"
>
<div class="bet-detail__option-header">
<span class="bet-detail__option-name">
{{ option.content }}
<span v-if="isSettled && resultOptionId === option.id" class="winner-badge">
正确答案
</span>
</span>
<div class="bet-detail__option-stats">
<span class="bet-detail__option-count">
{{ (optionStats[option.id]?.count || 0) }}
</span>
<span class="bet-detail__option-stake">
{{ optionStats[option.id]?.totalStake || 0 }} 积分
</span>
</div>
</div>
<div class="bet-detail__option-bar">
<div
class="bet-detail__option-fill"
:class="{ 'bet-detail__option-fill--winner': isSettled && resultOptionId === option.id }"
:style="{
width: totalPool > 0
? ((optionStats[option.id]?.totalStake || 0) / totalPool * 100) + '%'
: '0%',
}"
/>
</div>
</div>
</div>
<!-- 下注区域状态 open 且未下注时 -->
<div v-if="isOpen && !currentUserEntry" class="bet-detail__bet-area">
<h4 class="bet-detail__bet-title">下注</h4>
<div class="bet-detail__bet-form">
<div class="bet-detail__bet-option">
<label>选择选项</label>
<div class="bet-detail__option-pick">
<button
v-for="option in options"
:key="option.id"
type="button"
:class="['bet-detail__pick-card', { 'bet-detail__pick-card--active': selectedOption === option.id }]"
@click="selectedOption = option.id"
>
{{ option.content }}
</button>
</div>
</div>
<div class="bet-detail__bet-stake">
<label>下注积分</label>
<el-input-number
v-model="stakeAmount"
:min="bet.minStake"
:max="bet.maxStake"
:step="1"
/>
</div>
<button
class="bet-detail__bet-btn"
:disabled="!selectedOption"
@click="handlePlaceBet"
>
下注
</button>
</div>
</div>
<!-- 已下注提示 -->
<div v-if="isOpen && currentUserEntry" class="bet-detail__already-bet">
<svg class="bet-detail__already-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>
你已下注 <strong>{{ currentUserEntry.stake }}</strong> 积分选择{{ currentUserEntry.expand?.option?.content || '未知' }}
</span>
</div>
<!-- 下注记录 -->
<div class="bet-detail__entries">
<h4 class="bet-detail__entries-title">下注记录</h4>
<BetEntryList :entries="entries" :show-result="isSettled" />
</div>
</template>
<!-- 开奖弹窗 -->
<SettleBetDialog
v-if="bet"
v-model="showSettle"
:bet-id="betId"
:options="options"
@settled="loadDetail"
/>
</div>
</template>
<style scoped>
.bet-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部操作栏 */
.bet-detail__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.bet-detail__actions {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: none;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s, border-color 0.2s, color 0.2s;
}
.action-btn--close {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.action-btn--close:hover {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.action-btn--settle {
background: var(--gg-gradient);
color: #fff;
}
.action-btn--settle:hover {
opacity: 0.85;
}
/* 加载中 */
.bet-detail__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
color: var(--gg-text-secondary);
}
/* 标题区域 */
.bet-detail__title-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.bet-detail__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gg-text);
}
.bet-detail__meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.bet-detail__meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 描述 */
.bet-detail__desc {
font-size: 14px;
color: var(--gg-text-secondary);
line-height: 1.6;
padding: 10px 14px;
background: var(--gg-bg-elevated);
border-radius: var(--gg-radius-sm);
}
/* 选项列表 */
.bet-detail__options {
display: flex;
flex-direction: column;
gap: 10px;
}
.bet-detail__option {
padding: 12px 16px;
border: 1px solid var(--gg-border);
border-radius: 8px;
transition: all 0.2s;
}
.bet-detail__option--winner {
border-color: var(--gg-success);
background: rgba(16, 185, 129, 0.06);
}
.bet-detail__option--loser {
opacity: 0.5;
}
.bet-detail__option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.bet-detail__option-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
display: flex;
align-items: center;
gap: 6px;
}
.winner-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: rgba(16, 185, 129, 0.12);
color: var(--gg-success);
}
.bet-detail__option-stats {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.bet-detail__option-count {
font-size: 12px;
color: var(--gg-text-secondary);
}
.bet-detail__option-stake {
font-size: 12px;
font-weight: 600;
color: var(--gg-primary);
}
.bet-detail__option-bar {
height: 6px;
background: var(--gg-bg-elevated);
border-radius: 3px;
overflow: hidden;
}
.bet-detail__option-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.bet-detail__option-fill--winner {
background: var(--gg-success);
}
/* 下注区域 */
.bet-detail__bet-area {
padding: 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg-card);
}
.bet-detail__bet-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
}
.bet-detail__bet-form {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.bet-detail__bet-option,
.bet-detail__bet-stake {
display: flex;
flex-direction: column;
gap: 6px;
}
.bet-detail__bet-option label,
.bet-detail__bet-stake label {
font-size: 13px;
color: var(--gg-text-secondary);
}
.bet-detail__option-pick {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bet-detail__pick-card {
padding: 6px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card);
font-size: 13px;
color: var(--gg-text-secondary);
cursor: pointer;
transition: all 0.2s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.bet-detail__pick-card:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.bet-detail__pick-card--active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
font-weight: 500;
}
.bet-detail__bet-btn {
padding: 8px 24px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.bet-detail__bet-btn:hover {
opacity: 0.85;
}
.bet-detail__bet-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 已下注提示 */
.bet-detail__already-bet {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(5, 150, 105, 0.06);
border-radius: var(--gg-radius-sm);
font-size: 14px;
color: var(--gg-text-secondary);
}
.bet-detail__already-bet strong {
color: var(--gg-primary);
}
.bet-detail__already-icon {
width: 18px;
height: 18px;
color: var(--gg-success);
flex-shrink: 0;
}
/* 下注记录 */
.bet-detail__entries {
display: flex;
flex-direction: column;
gap: 10px;
}
.bet-detail__entries-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
}
/* 响应式 */
@media (max-width: 640px) {
.bet-detail__bet-form {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { displayName } from '@/types'
import type { BetEntry } from '@/types'
defineProps<{
entries: BetEntry[]
showResult?: boolean
}>()
</script>
<template>
<div class="entry-list">
<div v-if="entries.length === 0" class="entry-list__empty">
暂无下注记录
</div>
<div
v-for="entry in entries"
:key="entry.id"
class="entry-list__item"
>
<!-- 用户信息 -->
<div class="entry-list__user">
<div class="entry-list__avatar">
{{ displayName(entry.expand?.user).charAt(0) }}
</div>
<span class="entry-list__name">{{ displayName(entry.expand?.user) }}</span>
</div>
<!-- 选择 & 积分 -->
<div class="entry-list__info">
<span class="entry-list__option">
{{ entry.expand?.option?.content || '未知选项' }}
</span>
<span class="entry-list__stake">{{ entry.stake }} 积分</span>
</div>
<!-- 结果标签 -->
<div v-if="showResult" class="entry-list__result">
<span v-if="entry.won" class="entry-list__badge entry-list__badge--win"></span>
<span v-else class="entry-list__badge entry-list__badge--lose"></span>
</div>
</div>
</div>
</template>
<style scoped>
.entry-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.entry-list__empty {
text-align: center;
padding: 20px;
font-size: 13px;
color: var(--gg-text-muted);
}
.entry-list__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
gap: 12px;
}
.entry-list__user {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.entry-list__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--gg-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.entry-list__name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-list__info {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.entry-list__option {
font-size: 12px;
color: var(--gg-text-secondary);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-list__stake {
font-size: 13px;
font-weight: 600;
color: var(--gg-primary);
white-space: nowrap;
}
.entry-list__result {
flex-shrink: 0;
}
.entry-list__badge {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.entry-list__badge--win {
background: rgba(16, 185, 129, 0.12);
color: var(--gg-success);
}
.entry-list__badge--lose {
background: rgba(239, 68, 68, 0.08);
color: var(--gg-danger);
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { listBets, getBetOptions, getBetEntries, subscribeBets } from '@/api/bets'
import { pb } from '@/api/pocketbase'
import BetCard from './BetCard.vue'
import CreateBetDialog from './CreateBetDialog.vue'
import type { Bet, BetOption, BetEntry } from '@/types'
const emit = defineEmits<{
viewBet: [betId: string]
}>()
const groupStore = useGroupStore()
const showCreate = ref(false)
const loading = ref(false)
let unsubscribeFn: (() => void) | null = null
// 竞猜列表
const allBets = ref<Bet[]>([])
// 缓存每个 bet 的 options / entries
interface BetExtra {
options: BetOption[]
entries: BetEntry[]
currentUserEntry: BetEntry | null
}
const betExtras = ref<Record<string, BetExtra>>({})
// 分栏
const activeBets = computed(() =>
allBets.value.filter((b) => b.status === 'open' || b.status === 'closed')
)
const settledBets = computed(() =>
allBets.value.filter((b) => b.status === 'settled')
)
// 加载所有竞猜及详情
async function loadAll() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
const bets = await listBets(groupId)
allBets.value = bets
// 并行加载每个 bet 的 options / entries
const currentUserId = pb.authStore.model?.id
const results = await Promise.allSettled(
bets.map(async (bet) => {
const [options, entries] = await Promise.all([
getBetOptions(bet.id),
getBetEntries(bet.id),
])
const currentUserEntry = currentUserId
? entries.find((e) => e.user === currentUserId) || null
: null
return { betId: bet.id, options, entries, currentUserEntry }
})
)
const extras: Record<string, BetExtra> = {}
for (const r of results) {
if (r.status === 'fulfilled') {
extras[r.value.betId] = {
options: r.value.options,
entries: r.value.entries,
currentUserEntry: r.value.currentUserEntry,
}
}
}
betExtras.value = extras
} catch (error) {
console.error('加载竞猜列表失败:', error)
} finally {
loading.value = false
}
}
// 获取某个 bet 的缓存 extras
function getExtra(betId: string): BetExtra {
return betExtras.value[betId] || { options: [], entries: [], currentUserEntry: null }
}
// 实时订阅
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribeBets(groupId, () => {
loadAll()
})
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
onMounted(() => {
if (groupStore.currentGroupId) {
loadAll()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
loadAll()
if (!unsubscribeFn) startSubscription()
}
})
onUnmounted(() => {
stopSubscription()
})
</script>
<template>
<div class="bet-list">
<!-- 顶部操作栏 -->
<div class="bet-list__header">
<h3 class="bet-list__title">竞猜</h3>
<button class="bet-list__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bet-list__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
发起竞猜
</button>
</div>
<CreateBetDialog
v-model="showCreate"
@created="loadAll(); showCreate = false"
/>
<!-- 进行中 -->
<section v-if="activeBets.length > 0" class="bet-list__section">
<div class="bet-list__section-header">
<span class="bet-list__section-dot bet-list__section-dot--active"></span>
<span class="bet-list__section-label">进行中</span>
<span class="bet-list__section-count">{{ activeBets.length }}</span>
</div>
<div class="bet-list__grid">
<BetCard
v-for="bet in activeBets"
:key="bet.id"
:bet="bet"
:options="getExtra(bet.id).options"
:entries="getExtra(bet.id).entries"
:current-user-entry="getExtra(bet.id).currentUserEntry"
@click="emit('viewBet', bet.id)"
/>
</div>
</section>
<!-- 已结束 -->
<section v-if="settledBets.length > 0" class="bet-list__section">
<div class="bet-list__section-header">
<span class="bet-list__section-dot bet-list__section-dot--settled"></span>
<span class="bet-list__section-label">已结束</span>
<span class="bet-list__section-count">{{ settledBets.length }}</span>
</div>
<div class="bet-list__grid">
<BetCard
v-for="bet in settledBets"
:key="bet.id"
:bet="bet"
:options="getExtra(bet.id).options"
:entries="getExtra(bet.id).entries"
:current-user-entry="getExtra(bet.id).currentUserEntry"
@click="emit('viewBet', bet.id)"
/>
</div>
</section>
<!-- 加载中 -->
<div v-if="loading && allBets.length === 0" class="bet-list__loading">
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div
v-if="activeBets.length === 0 && settledBets.length === 0 && !loading"
class="bet-list__empty"
>
<svg class="bet-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<p class="bet-list__empty-text">暂无竞猜</p>
<p class="bet-list__empty-hint">群组内还没有发起过竞猜</p>
</div>
</div>
</template>
<style scoped>
.bet-list {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部操作栏 */
.bet-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.bet-list__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.bet-list__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.bet-list__create-btn:hover {
opacity: 0.85;
}
.bet-list__create-icon {
width: 16px;
height: 16px;
}
/* 分栏 */
.bet-list__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.bet-list__section-header {
display: flex;
align-items: center;
gap: 6px;
}
.bet-list__section-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.bet-list__section-dot--active {
background: var(--gg-success);
box-shadow: 0 0 6px var(--gg-success);
}
.bet-list__section-dot--settled {
background: var(--gg-text-muted);
}
.bet-list__section-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text-secondary);
}
.bet-list__section-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 7px;
border-radius: 10px;
}
/* 网格布局 */
.bet-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
/* 加载中 */
.bet-list__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
font-size: 14px;
color: var(--gg-text-secondary);
}
/* 空状态 */
.bet-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.bet-list__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.bet-list__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.bet-list__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.bet-list__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createBet } from '@/api/bets'
import { useGroupStore } from '@/stores/group'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
const form = ref({
title: '',
description: '',
options: ['', ''],
deadline: '' as string | Date,
minStake: 1,
maxStake: 10,
})
const loading = ref(false)
// 是否可以添加选项
const canAddOption = computed(() => form.value.options.length < 10)
function addOption() {
if (canAddOption.value) {
form.value.options.push('')
}
}
function removeOption(index: number) {
form.value.options.splice(index, 1)
}
function resetForm() {
form.value = {
title: '',
description: '',
options: ['', ''],
deadline: '',
minStake: 1,
maxStake: 10,
}
}
async function handleSubmit() {
// 标题必填
if (!form.value.title.trim()) {
ElMessage.warning('请输入竞猜标题')
return
}
// 至少2个非空选项
const nonEmpty = form.value.options.filter((o) => o.trim())
if (nonEmpty.length < 2) {
ElMessage.warning('至少需要2个非空选项')
return
}
// 截止时间必填
if (!form.value.deadline) {
ElMessage.warning('请选择截止时间')
return
}
// 积分范围校验
if (form.value.minStake < 1 || form.value.maxStake < form.value.minStake) {
ElMessage.warning('请设置正确的积分范围')
return
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('请先选择群组')
return
}
loading.value = true
try {
await createBet({
group: groupId,
title: form.value.title.trim(),
description: form.value.description.trim() || undefined,
options: nonEmpty,
minStake: form.value.minStake,
maxStake: form.value.maxStake,
deadline: new Date(String(form.value.deadline)).toISOString(),
})
visible.value = false
resetForm()
ElMessage.success('竞猜创建成功')
emit('created')
} catch (error: any) {
ElMessage.error(error.message || '创建竞猜失败')
} finally {
loading.value = false
}
}
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="发起竞猜"
width="520px"
@open="handleOpen"
>
<div class="create-form">
<!-- 标题 -->
<div class="form-field">
<label>标题 <span class="required">*</span></label>
<el-input
v-model="form.title"
placeholder="今晚谁 MVP"
maxlength="100"
show-word-limit
/>
</div>
<!-- 描述 -->
<div class="form-field">
<label>描述</label>
<el-input
v-model="form.description"
type="textarea"
placeholder="补充说明(可选)"
:rows="2"
maxlength="500"
show-word-limit
/>
</div>
<!-- 选项管理 -->
<div class="form-field">
<label>竞猜选项</label>
<div class="options-list">
<div
v-for="(_, index) in form.options"
:key="index"
class="option-item"
>
<el-input
v-model="form.options[index]"
:placeholder="`选项 ${index + 1}`"
maxlength="100"
/>
<el-button
v-if="form.options.length > 2"
type="danger"
text
@click="removeOption(index)"
>
删除
</el-button>
</div>
<button
v-if="canAddOption"
class="add-option-btn"
@click="addOption"
>
<el-icon><Plus /></el-icon> 添加选项
</button>
</div>
</div>
<!-- 截止时间 -->
<div class="form-field">
<label>截止时间 <span class="required">*</span></label>
<el-date-picker
v-model="form.deadline"
type="datetime"
placeholder="选择截止时间"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</div>
<!-- 积分范围 -->
<div class="form-field">
<label>下注积分范围</label>
<div class="stake-range">
<div class="stake-range__item">
<span class="stake-range__label">最小</span>
<el-input-number
v-model="form.minStake"
:min="1"
:max="100"
:step="1"
size="small"
/>
</div>
<span class="stake-range__separator">~</span>
<div class="stake-range__item">
<span class="stake-range__label">最大</span>
<el-input-number
v-model="form.maxStake"
:min="form.minStake"
:max="100"
:step="1"
size="small"
/>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button
class="submit-btn"
:disabled="loading"
@click="handleSubmit"
>
{{ loading ? '创建中...' : '发起竞猜' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item .el-input {
flex: 1;
}
.add-option-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.add-option-btn:hover {
opacity: 0.85;
}
.stake-range {
display: flex;
align-items: center;
gap: 12px;
}
.stake-range__item {
display: flex;
align-items: center;
gap: 8px;
}
.stake-range__label {
font-size: 13px;
color: var(--gg-text-secondary);
white-space: nowrap;
}
.stake-range__separator {
font-size: 16px;
color: var(--gg-text-muted);
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { settleBet } from '@/api/bets'
import type { BetOption } from '@/types'
const visible = defineModel<boolean>({ default: false })
const props = defineProps<{
betId: string
options: BetOption[]
}>()
const emit = defineEmits<{
settled: []
}>()
const selectedOption = ref('')
const loading = ref(false)
function handleOpen() {
selectedOption.value = ''
}
async function handleSettle() {
if (!selectedOption.value) {
ElMessage.warning('请选择正确选项')
return
}
loading.value = true
try {
await settleBet(props.betId, selectedOption.value)
visible.value = false
ElMessage.success('开奖成功')
emit('settled')
} catch (error: any) {
ElMessage.error(error.message || '开奖失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
title="开奖"
width="420px"
@open="handleOpen"
>
<div class="settle-form">
<p class="settle-form__hint">请选择正确的选项来结算竞猜</p>
<el-radio-group v-model="selectedOption" class="settle-form__options">
<div
v-for="option in options"
:key="option.id"
class="settle-form__option"
>
<el-radio :value="option.id">
{{ option.content }}
</el-radio>
</div>
</el-radio-group>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button
class="settle-form__submit"
:disabled="loading || !selectedOption"
@click="handleSettle"
>
{{ loading ? '结算中...' : '确认开奖' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.settle-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.settle-form__hint {
margin: 0;
font-size: 14px;
color: var(--gg-text-secondary);
}
.settle-form__options {
display: flex;
flex-direction: column;
gap: 10px;
}
.settle-form__option {
padding: 10px 14px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
transition: border-color 0.2s;
}
.settle-form__option:hover {
border-color: var(--gg-primary-light);
}
.settle-form__submit {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.settle-form__submit:hover {
opacity: 0.85;
}
.settle-form__submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -1,17 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '@/stores/notification' import { useNotificationStore } from '@/stores/notification'
import InvitationCard from '@/components/team/InvitationCard.vue' import InvitationCard from '@/components/team/InvitationCard.vue'
import JoinRequestCard from '@/components/group/JoinRequestCard.vue'
import { pb } from '@/api/pocketbase'
const store = useNotificationStore() const store = useNotificationStore()
const router = useRouter()
onMounted(() => { onMounted(() => {
store.loadPendingInvitations() store.loadPendingInvitations()
store.loadAppNotifications()
}) })
function onInvitationResponded(id: string, _accepted: boolean) { function onInvitationResponded(id: string, _accepted: boolean) {
store.removeInvitation(id) store.removeInvitation(id)
} }
function onJoinRequestResponded(id: string) {
store.removeJoinRequest(id)
}
async function handleNotificationClick(n: { id: string; read: boolean; relatedType?: string; relatedId?: string }) {
if (!n.read) store.markRead(n.id)
if (n.relatedType === 'poll' && n.relatedId) {
try {
const poll = await pb.collection('polls').getOne(n.relatedId, { $autoCancel: false })
store.showPanel = false
router.push({ name: 'GroupView', params: { id: poll.group }, query: { tab: 'polls' } })
} catch { /* ignore */ }
} else if (n.relatedType === 'team' && n.relatedId) {
try {
const session = await pb.collection('team_sessions').getOne(n.relatedId, { $autoCancel: false })
store.showPanel = false
router.push({ name: 'GroupView', params: { id: session.sourceGroup } })
} catch { /* ignore */ }
} else if (n.relatedType === 'group' && n.relatedId) {
store.showPanel = false
router.push({ name: 'GroupView', params: { id: n.relatedId } })
}
}
</script> </script>
<template> <template>
@@ -22,17 +52,59 @@ function onInvitationResponded(id: string, _accepted: boolean) {
size="380px" size="380px"
> >
<div class="notification-panel"> <div class="notification-panel">
<div v-if="store.pendingInvitations.length === 0" class="empty"> <div v-if="store.unreadCount === 0" class="empty">
暂无新通知 暂无新通知
</div> </div>
<div v-else class="invitation-list"> <template v-else>
<InvitationCard <!-- 加入申请 -->
v-for="invitation in store.pendingInvitations" <div v-if="store.pendingJoinRequests.length > 0" class="section">
:key="invitation.id" <h4 class="section-title">加入申请 ({{ store.pendingJoinRequests.length }})</h4>
:invitation="invitation" <div class="list">
@responded="onInvitationResponded" <JoinRequestCard
/> v-for="req in store.pendingJoinRequests"
</div> :key="req.id"
v-bind="req"
@responded="onJoinRequestResponded(req.id)"
/>
</div>
</div>
<!-- 组队邀请 -->
<div v-if="store.pendingInvitations.length > 0" class="section">
<h4 class="section-title">组队邀请 ({{ store.pendingInvitations.length }})</h4>
<div class="list">
<InvitationCard
v-for="invitation in store.pendingInvitations"
:key="invitation.id"
:invitation="invitation"
@responded="onInvitationResponded"
/>
</div>
</div>
<!-- 站内通知 -->
<div v-if="store.appNotifications.length > 0" class="section">
<div class="section-header">
<h4 class="section-title">消息通知</h4>
<button v-if="store.appUnreadCount > 0" class="mark-all-btn" @click="store.markAllRead">
全部已读
</button>
</div>
<div class="list">
<div
v-for="n in store.appNotifications"
:key="n.id"
class="app-notification"
:class="{ unread: !n.read }"
@click="handleNotificationClick(n)"
>
<div class="notif-title">{{ n.title }}</div>
<div v-if="n.content" class="notif-content">{{ n.content }}</div>
<div class="notif-time">{{ new Date(n.created).toLocaleString('zh-CN') }}</div>
</div>
</div>
</div>
</template>
</div> </div>
</el-drawer> </el-drawer>
</template> </template>
@@ -48,9 +120,74 @@ function onInvitationResponded(id: string, _accepted: boolean) {
padding: 48px 0; padding: 48px 0;
} }
.invitation-list { .section {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0;
}
.mark-all-btn {
border: none;
background: none;
color: var(--gg-primary);
font-size: 12px;
cursor: pointer;
}
.mark-all-btn:hover {
opacity: 0.8;
}
.list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
}
.app-notification {
padding: 12px 14px;
border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
cursor: pointer;
transition: background 0.2s;
}
.app-notification:hover {
background: var(--gg-bg-hover);
}
.app-notification.unread {
border-left: 3px solid var(--gg-primary);
background: rgba(5, 150, 105, 0.04);
}
.notif-title {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.notif-content {
font-size: 13px;
color: var(--gg-text-secondary);
margin-top: 4px;
}
.notif-time {
font-size: 11px;
color: var(--gg-text-muted);
margin-top: 6px;
} }
</style> </style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addGame } from '@/api/games'
import type { GamePlatform } from '@/types'
import { ElMessage } from 'element-plus'
import { getAllPlatforms } from '@/api/games'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const name = ref('')
const platform = ref<GamePlatform | ''>('')
const tagsInput = ref('')
const cover = ref('')
const loading = ref(false)
const platforms = getAllPlatforms()
async function handleSubmit() {
if (!name.value.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
try {
loading.value = true
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
await addGame(props.groupId, {
name: name.value.trim(),
platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
cover: cover.value.trim() || undefined
})
ElMessage.success('添加成功')
name.value = ''
platform.value = ''
tagsInput.value = ''
cover.value = ''
emit('created')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="添加游戏" 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-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>封面图 URL</label>
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">添加</el-button>
</template>
</el-dialog>
</template>
<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); }
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGameComments, addComment } from '@/api/games'
import type { GameComment } from '@/types'
import { ElMessage } from 'element-plus'
import { StarFilled } from '@element-plus/icons-vue'
const props = defineProps<{ gameId: string }>()
const comments = ref<GameComment[]>([])
const newContent = ref('')
const newRating = ref(0)
const loading = ref(false)
const submitting = ref(false)
onMounted(async () => {
await loadComments()
})
async function loadComments() {
try {
loading.value = true
comments.value = await getGameComments(props.gameId)
} catch { /* ignore */ } finally { loading.value = false }
}
async function handleSubmit() {
if (!newContent.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
try {
submitting.value = true
await addComment(props.gameId, newContent.value.trim(), newRating.value || undefined)
newContent.value = ''
newRating.value = 0
await loadComments()
ElMessage.success('评论成功')
} catch (error: any) {
ElMessage.error(error.message || '评论失败')
} finally { submitting.value = false }
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="comments-section">
<h4 class="comments-title">评论 ({{ comments.length }})</h4>
<div class="comment-form">
<div class="rating-row">
<span class="rating-label">评分:</span>
<span
v-for="star in 5" :key="star"
class="star" :class="{ active: star <= newRating }"
@click="newRating = star"
><el-icon :size="20"><StarFilled /></el-icon></span>
<span v-if="newRating > 0" class="rating-clear" @click="newRating = 0">清除</span>
</div>
<div class="input-row">
<el-input v-model="newContent" placeholder="写下你的评论..." maxlength="1000" show-word-limit type="textarea" :rows="2" />
<el-button type="primary" :loading="submitting" @click="handleSubmit">发表</el-button>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="comments.length === 0" class="empty">暂无评论</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ c.expand?.author?.name || c.expand?.author?.username || '用户' }}</span>
<span v-if="c.rating" class="comment-rating">
<el-icon v-for="i in c.rating" :key="i" :size="12"><StarFilled /></el-icon>
</span>
<span class="comment-date">{{ formatDate(c.created) }}</span>
</div>
<p class="comment-content">{{ c.content }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.comments-section { margin-top: 20px; }
.comments-title { font-size: 15px; font-weight: 600; margin: 0 0 16px; color: var(--gg-text); }
.comment-form { margin-bottom: 20px; }
.rating-row { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; }
.rating-label { font-size: 13px; color: var(--gg-text-secondary); }
.star { font-size: 20px; cursor: pointer; color: var(--gg-border); transition: color 0.15s; }
.star.active { color: #f59e0b; }
.rating-clear { font-size: 12px; color: var(--gg-text-muted); cursor: pointer; margin-left: 8px; }
.input-row { display: flex; gap: 10px; align-items: flex-end; }
.input-row .el-input { flex: 1; }
.loading, .empty { text-align: center; padding: 16px; color: var(--gg-text-muted); font-size: 13px; }
.comment-list { display: flex; flex-direction: column; gap: 12px; }
.comment-item {
padding: 12px 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.comment-author { font-size: 13px; font-weight: 600; color: var(--gg-text); }
.comment-rating { font-size: 12px; color: #f59e0b; }
.comment-date { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
.comment-content { font-size: 14px; color: var(--gg-text-secondary); margin: 0; line-height: 1.6; }
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import type { Game } from '@/types'
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
import { ElMessage, ElMessageBox } from 'element-plus'
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
import GameComments from './GameComments.vue'
const props = defineProps<{
modelValue: boolean
game: Game | null
groupId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'create-team': [gameName: string]
'deleted': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const favorited = ref(false)
onMounted(async () => {
if (props.game) {
favorited.value = await isFavorite(props.game.id)
}
})
async function handleFavorite() {
if (!props.game) return
favorited.value = await toggleFavorite(props.game.id)
}
async function handleDelete() {
if (!props.game) return
try {
await ElMessageBox.confirm(`确定要删除「${props.game.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteGame(props.game.id)
ElMessage.success('删除成功')
visible.value = false
emit('deleted')
} catch { /* 用户取消 */ }
}
function handleCreateTeam() {
if (props.game) {
emit('create-team', props.game.name)
visible.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" width="480px" :show-close="true">
<template v-if="game">
<div class="game-detail">
<div class="detail-cover-wrap">
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<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>
</div>
<div v-if="game.tags?.length" class="detail-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="detail-actions">
<button class="fav-btn" :class="{ active: favorited }" @click="handleFavorite">
<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>
</div>
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
快速组队
</el-button>
<GameComments v-if="game.id" :game-id="game.id" />
</div>
</template>
</el-dialog>
</template>
<style scoped>
.game-detail { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 8px 0; }
.detail-cover-wrap { width: 200px; height: 260px; border-radius: var(--gg-radius-md); overflow: hidden; background: var(--gg-bg-elevated); border: 1px solid var(--gg-border); }
.detail-cover { width: 100%; height: 100%; object-fit: cover; }
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
.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); }
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.tag { padding: 4px 12px; background: var(--gg-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--gg-primary); font-weight: 500; }
.detail-actions { display: flex; gap: 12px; width: 100%; justify-content: center; }
.fav-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;
}
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
.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;
}
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
.team-btn { width: 100%; margin-top: 4px; }
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { importGames } from '@/api/games'
import { ElMessage } from 'element-plus'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'imported': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const previewData = ref<any[]>([])
const importing = ref(false)
function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const text = e.target?.result as string
if (file.name.endsWith('.csv')) {
parseCSV(text)
} else {
const json = JSON.parse(text)
previewData.value = Array.isArray(json) ? json : [json]
}
} catch {
ElMessage.error('文件解析失败')
}
}
reader.readAsText(file)
}
function parseCSV(text: string) {
const lines = text.split('\n').filter(l => l.trim())
if (lines.length < 2) { previewData.value = []; return }
const headers = lines[0].split(',').map(h => h.trim().toLowerCase())
const data = []
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim())
const obj: any = {}
headers.forEach((h, idx) => {
if (h === 'tags' && values[idx]) {
obj[h] = values[idx].split(';').map((t: string) => t.trim())
} else {
obj[h] = values[idx] || undefined
}
})
if (obj.name) data.push(obj)
}
previewData.value = data
}
async function handleImport() {
if (previewData.value.length === 0) {
ElMessage.warning('没有数据可导入')
return
}
try {
importing.value = true
const results = await importGames(props.groupId, previewData.value)
const success = results.filter(r => r.success).length
ElMessage.success(`成功导入 ${success}/${results.length} 个游戏`)
previewData.value = []
emit('imported')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '导入失败')
} finally {
importing.value = false
}
}
function reset() {
previewData.value = []
}
</script>
<template>
<el-dialog v-model="visible" title="导入游戏" width="500px" @close="reset">
<div class="import-content">
<div class="upload-area">
<p class="upload-hint">上传 JSON CSV 文件</p>
<input type="file" accept=".json,.csv" class="file-input" @change="handleFileUpload" />
</div>
<div class="format-hint">
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
<p><strong>CSV 格式:</strong> name,platform,tags,covertags 用分号分隔</p>
</div>
<div v-if="previewData.length > 0" class="preview">
<h4>预览 ({{ previewData.length }} 个游戏)</h4>
<div class="preview-list">
<div v-for="(item, idx) in previewData" :key="idx" class="preview-item">
{{ item.name }} <span v-if="item.platform" class="preview-platform">{{ item.platform }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="importing" :disabled="previewData.length === 0" @click="handleImport">
确认导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.import-content { display: flex; flex-direction: column; gap: 16px; }
.upload-area {
border: 2px dashed var(--gg-border); border-radius: var(--gg-radius-md);
padding: 32px; text-align: center;
}
.upload-hint { margin: 0 0 12px; color: var(--gg-text-secondary); font-size: 14px; }
.file-input { font-size: 14px; }
.format-hint { font-size: 12px; color: var(--gg-text-muted); }
.format-hint p { margin: 4px 0; }
.preview h4 { margin: 0 0 8px; font-size: 14px; color: var(--gg-text); }
.preview-list { max-height: 200px; overflow-y: auto; }
.preview-item {
padding: 8px 12px; border-bottom: 1px solid var(--gg-border);
font-size: 13px; color: var(--gg-text-secondary);
}
.preview-platform {
font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--gg-bg-elevated); color: var(--gg-accent); margin-left: 8px;
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { deleteBlacklistEntry } from '@/api/gameBlacklist'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
defineProps<{
entries: BlacklistEntry[]
}>()
const emit = defineEmits<{
deleted: []
}>()
const groupStore = useGroupStore()
// 当前用户 ID
const currentUserId = computed(() => pb.authStore.model?.id)
// 是否为群主
const isOwner = computed(() => groupStore.isGroupOwner)
// 是否可以删除(举报人本人或群主)
function canDelete(entry: BlacklistEntry): boolean {
return entry.reporter === currentUserId.value || isOwner.value
}
// 格式化时间
function formatTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 30) {
return `${date.getMonth() + 1}${date.getDate()}`
} else if (diffDays > 0) {
return `${diffDays}天前`
} else if (diffHours > 0) {
return `${diffHours}小时前`
} else if (diffMinutes > 0) {
return `${diffMinutes}分钟前`
}
return '刚刚'
}
// 获取举报人显示名称
function reporterName(entry: BlacklistEntry): string {
if (entry.expand?.reporter) {
return displayName(entry.expand.reporter)
}
return '未知用户'
}
// 严重程度 CSS class
function severityClass(severity: BlacklistSeverity): string {
return `entry__severity--${severity}`
}
// 删除记录
async function handleDelete(entryId: string) {
try {
await deleteBlacklistEntry(entryId)
ElMessage.success('已删除')
emit('deleted')
} catch (error: any) {
ElMessage.error(error.message || '删除失败')
}
}
</script>
<template>
<div class="entry-list">
<div
v-for="(entry, index) in entries"
:key="entry.id"
:class="['entry', { 'entry--bordered': index > 0 }]"
>
<!-- 顶部举报人 + 时间 + 删除 -->
<div class="entry__header">
<div class="entry__reporter">
<div class="entry__avatar">
{{ reporterName(entry).charAt(0) }}
</div>
<span class="entry__name">{{ reporterName(entry) }}</span>
</div>
<div class="entry__header-right">
<span class="entry__time">{{ formatTime(entry.created) }}</span>
<el-popconfirm
v-if="canDelete(entry)"
title="确定删除这条黑名单记录?"
confirm-button-text="删除"
cancel-button-text="取消"
@confirm="handleDelete(entry.id)"
>
<template #reference>
<button class="entry__delete-btn" title="删除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</template>
</el-popconfirm>
</div>
</div>
<!-- 标签行 -->
<div class="entry__tags">
<span class="entry__reason-tag">
{{ BlacklistReasonMap[entry.reason] }}
</span>
<span class="entry__severity" :class="severityClass(entry.severity)">
{{ BlacklistSeverityMap[entry.severity] }}
</span>
</div>
<!-- 描述 -->
<div class="entry__desc">
{{ entry.description }}
</div>
</div>
</div>
</template>
<style scoped>
.entry-list {
display: flex;
flex-direction: column;
}
.entry {
padding: 12px 0;
}
.entry--bordered {
border-top: 1px solid var(--gg-border);
}
/* 顶部行 */
.entry__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.entry__reporter {
display: flex;
align-items: center;
gap: 8px;
}
.entry__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--gg-primary);
color: #ffffff;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.entry__name {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
}
.entry__header-right {
display: flex;
align-items: center;
gap: 8px;
}
.entry__time {
font-size: 12px;
color: var(--gg-text-muted);
}
.entry__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-muted);
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.entry__delete-btn:hover {
color: var(--gg-danger);
background: rgba(239, 68, 68, 0.08);
}
.entry__delete-icon {
width: 16px;
height: 16px;
}
/* 标签行 */
.entry__tags {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.entry__reason-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.entry__severity {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.entry__severity--mild {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.entry__severity--medium {
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.entry__severity--severe {
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
/* 描述 */
.entry__desc {
font-size: 13px;
color: var(--gg-text-secondary);
line-height: 1.6;
}
</style>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
const props = defineProps<{
gameName: string
entries: BlacklistEntry[]
}>()
const emit = defineEmits<{
click: []
}>()
// 被标记次数
const reportCount = computed(() => props.entries.length)
// 最新一条记录
const latestEntry = computed(() => props.entries[0])
// 最近标记原因
const latestReason = computed(() => {
const entry = latestEntry.value
if (!entry) return ''
return BlacklistReasonMap[entry.reason]
})
// 严重程度排序:severe > medium > mild
const severityOrder: Record<BlacklistSeverity, number> = {
mild: 1,
medium: 2,
severe: 3,
}
// 严重程度最高的一条
const maxSeverity = computed(() => {
let max: BlacklistSeverity = 'mild'
for (const entry of props.entries) {
if (severityOrder[entry.severity] > severityOrder[max]) {
max = entry.severity
}
}
return max
})
const maxSeverityLabel = computed(() => BlacklistSeverityMap[maxSeverity.value])
// 严重程度对应的 CSS class
const severityClass = computed(() => `game-card__severity--${maxSeverity.value}`)
// 第一条记录描述预览
const previewDesc = computed(() => {
const entry = latestEntry.value
if (!entry) return ''
return entry.description
})
</script>
<template>
<div class="game-card" @click="emit('click')">
<!-- 左侧游戏信息 -->
<div class="game-card__info">
<div class="game-card__name-row">
<span class="game-card__name">{{ gameName }}</span>
<span class="game-card__badge">{{ reportCount }} 次标记</span>
</div>
<!-- 标签行 -->
<div class="game-card__tags">
<span class="game-card__reason-tag">
{{ latestReason }}
</span>
<span class="game-card__severity" :class="severityClass">
{{ maxSeverityLabel }}
</span>
</div>
<!-- 描述预览 -->
<div v-if="previewDesc" class="game-card__desc">
{{ previewDesc }}
</div>
</div>
<!-- 右侧展开指示 -->
<div class="game-card__arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="game-card__arrow-icon">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</template>
<style scoped>
.game-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.game-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
/* 左侧信息 */
.game-card__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 游戏名 + 次数 */
.game-card__name-row {
display: flex;
align-items: center;
gap: 10px;
}
.game-card__name {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card__badge {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
/* 标签行 */
.game-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.game-card__reason-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.game-card__severity {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.game-card__severity--mild {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.game-card__severity--medium {
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.game-card__severity--severe {
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
/* 描述预览 */
.game-card__desc {
font-size: 13px;
color: var(--gg-text-secondary);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 展开箭头 */
.game-card__arrow {
flex-shrink: 0;
display: flex;
align-items: center;
}
.game-card__arrow-icon {
width: 18px;
height: 18px;
color: var(--gg-text-muted);
transition: transform 0.2s;
}
.game-card:hover .game-card__arrow-icon {
color: var(--gg-primary);
}
</style>
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { listBlacklist, subscribeBlacklist } from '@/api/gameBlacklist'
import BlacklistGameCard from './BlacklistGameCard.vue'
import BlacklistEntryList from './BlacklistEntryList.vue'
import CreateBlacklistDialog from './CreateBlacklistDialog.vue'
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
import type { BlacklistEntry, BlacklistReason, BlacklistSeverity } from '@/types'
const groupStore = useGroupStore()
const allEntries = ref<BlacklistEntry[]>([])
const loading = ref(false)
const showCreate = ref(false)
const expandedGame = ref<string | null>(null)
// 筛选条件
const filterReason = ref<BlacklistReason | ''>('')
const filterSeverity = ref<BlacklistSeverity | ''>('')
let unsubscribeFn: (() => void) | null = null
// 按游戏名聚合
const groupedByGame = computed(() => {
const map = new Map<string, BlacklistEntry[]>()
for (const entry of allEntries.value) {
const key = entry.gameName
if (!map.has(key)) {
map.set(key, [])
}
map.get(key)!.push(entry)
}
// 每个游戏内的条目按时间倒序
for (const entries of map.values()) {
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
}
return map
})
// 游戏列表(按被标记次数降序)
const gameGroups = computed(() => {
const groups: { gameName: string; entries: BlacklistEntry[] }[] = []
for (const [gameName, entries] of groupedByGame.value) {
groups.push({ gameName, entries })
}
groups.sort((a, b) => b.entries.length - a.entries.length)
return groups
})
async function loadEntries() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
const options: { reason?: string; severity?: string } = {}
if (filterReason.value) options.reason = filterReason.value
if (filterSeverity.value) options.severity = filterSeverity.value
allEntries.value = await listBlacklist(groupId, options)
} catch (error) {
console.error('加载黑名单失败:', error)
} finally {
loading.value = false
}
}
// 实时订阅
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribeBlacklist(groupId, () => {
loadEntries()
})
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
// 展开/收起某个游戏的详细记录
function toggleGame(gameName: string) {
expandedGame.value = expandedGame.value === gameName ? null : gameName
}
function handleCreated() {
showCreate.value = false
loadEntries()
}
onMounted(() => {
if (groupStore.currentGroupId) {
loadEntries()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
expandedGame.value = null
stopSubscription()
loadEntries()
startSubscription()
}
})
// 筛选条件变化时重新加载
watch([filterReason, filterSeverity], () => {
loadEntries()
})
onUnmounted(() => {
stopSubscription()
})
</script>
<template>
<div class="blacklist-main">
<!-- 顶部操作栏 -->
<div class="blacklist-main__header">
<h3 class="blacklist-main__title">游戏黑名单</h3>
<div class="blacklist-main__actions">
<div class="blacklist-main__filters">
<el-select
v-model="filterReason"
placeholder="原因筛选"
clearable
size="small"
class="blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in BlacklistReasonMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
<el-select
v-model="filterSeverity"
placeholder="严重程度"
clearable
size="small"
class="blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in BlacklistSeverityMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<button class="blacklist-main__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-main__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
标记游戏
</button>
</div>
</div>
<CreateBlacklistDialog
v-model="showCreate"
@created="handleCreated"
/>
<!-- 游戏卡片列表 -->
<div v-if="gameGroups.length > 0" class="blacklist-main__content">
<div v-for="group in gameGroups" :key="group.gameName" class="blacklist-main__game-group">
<BlacklistGameCard
:game-name="group.gameName"
:entries="group.entries"
@click="toggleGame(group.gameName)"
/>
<!-- 展开的详细记录 -->
<div v-if="expandedGame === group.gameName" class="blacklist-main__expanded">
<BlacklistEntryList :entries="group.entries" @deleted="loadEntries" />
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="loading" class="blacklist-main__loading">
<p class="blacklist-main__loading-text">加载中...</p>
</div>
<!-- 空状态 -->
<div v-else class="blacklist-main__empty">
<svg class="blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<p class="blacklist-main__empty-text">暂无黑名单记录</p>
<p class="blacklist-main__empty-hint">标记体验差的游戏提醒队友避开</p>
</div>
</div>
</template>
<style scoped>
.blacklist-main {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部操作栏 */
.blacklist-main__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.blacklist-main__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.blacklist-main__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.blacklist-main__filters {
display: flex;
align-items: center;
gap: 8px;
}
.blacklist-main__filter-select {
width: 120px;
}
.blacklist-main__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-primary);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.blacklist-main__create-btn:hover {
opacity: 0.85;
}
.blacklist-main__create-icon {
width: 16px;
height: 16px;
}
/* 内容区域 */
.blacklist-main__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.blacklist-main__game-group {
display: flex;
flex-direction: column;
gap: 0;
}
.blacklist-main__expanded {
padding: 0 16px;
background: var(--gg-bg-elevated);
border: 1px solid var(--gg-border);
border-top: none;
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
}
/* 加载状态 */
.blacklist-main__loading {
display: flex;
justify-content: center;
padding: 40px 20px;
}
.blacklist-main__loading-text {
font-size: 14px;
color: var(--gg-text-muted);
}
/* 空状态 */
.blacklist-main__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.blacklist-main__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.blacklist-main__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.blacklist-main__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.blacklist-main__header {
flex-direction: column;
align-items: flex-start;
}
.blacklist-main__filter-select {
width: 100px;
}
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createBlacklistEntry } from '@/api/gameBlacklist'
import { useGroupStore } from '@/stores/group'
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
import type { BlacklistReason, BlacklistSeverity } from '@/types'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
const form = ref({
gameName: '',
reason: '' as BlacklistReason | '',
severity: '' as BlacklistSeverity | '',
description: '',
})
const loading = ref(false)
function resetForm() {
form.value = {
gameName: '',
reason: '',
severity: '',
description: '',
}
}
async function handleSubmit() {
// 校验必填字段
if (!form.value.gameName.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
if (!form.value.reason) {
ElMessage.warning('请选择原因')
return
}
if (!form.value.severity) {
ElMessage.warning('请选择严重程度')
return
}
if (!form.value.description.trim()) {
ElMessage.warning('请填写描述')
return
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('请先选择群组')
return
}
loading.value = true
try {
await createBlacklistEntry({
group: groupId,
gameName: form.value.gameName.trim(),
reason: form.value.reason,
severity: form.value.severity,
description: form.value.description.trim(),
})
visible.value = false
resetForm()
ElMessage.success('标记成功')
emit('created')
} catch (error: any) {
ElMessage.error(error.message || '标记失败')
} finally {
loading.value = false
}
}
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="标记坑游戏"
width="460px"
@open="handleOpen"
>
<div class="create-form">
<!-- 游戏名称 -->
<div class="form-field">
<label>游戏名称 <span class="required">*</span></label>
<el-input
v-model="form.gameName"
placeholder="输入游戏名称"
maxlength="100"
show-word-limit
/>
</div>
<!-- 原因选择 -->
<div class="form-field">
<label>原因 <span class="required">*</span></label>
<el-select
v-model="form.reason"
placeholder="选择原因"
style="width: 100%"
>
<el-option
v-for="(label, key) in BlacklistReasonMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<!-- 严重程度 -->
<div class="form-field">
<label>严重程度 <span class="required">*</span></label>
<el-select
v-model="form.severity"
placeholder="选择严重程度"
style="width: 100%"
>
<el-option
v-for="(label, key) in BlacklistSeverityMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<!-- 描述 -->
<div class="form-field">
<label>描述 <span class="required">*</span></label>
<el-input
v-model="form.description"
type="textarea"
placeholder="描述一下为什么这游戏坑"
:rows="3"
maxlength="500"
show-word-limit
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '提交中...' : '提交' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { createGroup } from '@/api/groups' import { createGroup } from '@/api/groups'
import pb from '@/api/pocketbase'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -33,7 +34,20 @@ async function handleSubmit() {
ElMessage.warning('请输入群组名称') ElMessage.warning('请输入群组名称')
return return
} }
// 检查群组名是否重复
loading.value = true loading.value = true
try {
const existing = await pb.collection('groups').getList(1, 1, {
filter: `name="${form.value.name.trim()}"`
})
if (existing.items.length > 0) {
ElMessage.warning('该群组名称已存在,请换一个')
loading.value = false
return
}
} catch { /* ignore */ }
try { try {
const group = await createGroup({ const group = await createGroup({
name: form.value.name.trim(), name: form.value.name.trim(),
@@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
import { ElSwitch } from 'element-plus'
import type { JoinRequest } from '@/types'
import JoinRequestCard from './JoinRequestCard.vue'
const groupStore = useGroupStore() const groupStore = useGroupStore()
const userStore = useUserStore() const userStore = useUserStore()
@@ -11,6 +15,29 @@ const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers) const members = computed(() => groupStore.currentMembers)
const isOwner = computed(() => group.value?.owner === userStore.userId) const isOwner = computed(() => group.value?.owner === userStore.userId)
const joinRequests = ref<JoinRequest[]>([])
const approvalLoading = ref(false)
onMounted(async () => {
if (group.value && isOwner.value) {
await loadJoinRequests()
subscribeJoinRequests(group.value.id, () => {
loadJoinRequests()
})
}
})
onUnmounted(() => {
// cleanup handled by PocketBase
})
async function loadJoinRequests() {
if (!group.value) return
try {
joinRequests.value = await getGroupJoinRequests(group.value.id)
} catch { /* ignore */ }
}
function copyGroupId() { function copyGroupId() {
if (group.value?.id) { if (group.value?.id) {
navigator.clipboard.writeText(group.value.id) navigator.clipboard.writeText(group.value.id)
@@ -22,18 +49,38 @@ async function removeMember(userId: string, username: string) {
if (!isOwner.value) return if (!isOwner.value) return
try { try {
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' }) await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
const group = groupStore.currentGroup const grp = groupStore.currentGroup
if (!group) return if (!grp) return
// 直接更新群组的 members 列表
const { pb } = await import('@/api/pocketbase') const { pb } = await import('@/api/pocketbase')
const newMembers = group.members.filter(id => id !== userId) const newMembers = grp.members.filter(id => id !== userId)
await pb.collection('groups').update(group.id, { members: newMembers }) await pb.collection('groups').update(grp.id, { members: newMembers })
await groupStore.setCurrentGroup(group.id) await groupStore.setCurrentGroup(grp.id)
ElMessage.success('已移除成员') ElMessage.success('已移除成员')
} catch { } catch {
// 用户取消 // 用户取消
} }
} }
async function handleApprovalChange(val: string | number | boolean) {
if (!group.value) return
approvalLoading.value = true
try {
await updateGroupApproval(group.value.id, !!val)
await groupStore.setCurrentGroup(group.value.id)
ElMessage.success(val ? '已开启加入审核' : '已关闭加入审核')
} catch {
ElMessage.error('更新设置失败')
} finally {
approvalLoading.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)
}
}
</script> </script>
<template> <template>
@@ -51,22 +98,45 @@ async function removeMember(userId: string, username: string) {
</div> </div>
</div> </div>
<!-- 审核开关仅群主可见 -->
<div v-if="isOwner" class="approval-row">
<span class="info-label">加入需审核</span>
<el-switch
:model-value="group.requireApproval"
:loading="approvalLoading"
@change="handleApprovalChange"
/>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-label">成员</span> <span class="info-label">成员</span>
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span> <span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
</div> </div>
<!-- 待审核申请仅群主可见 -->
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
<div class="requests-list">
<JoinRequestCard
v-for="req in joinRequests"
:key="req.id"
v-bind="req"
@responded="onJoinRequestResponded(req.id)"
/>
</div>
</div>
<div class="members-list"> <div class="members-list">
<div v-for="member in members" :key="member.id" class="member-row"> <div v-for="member in members" :key="member.id" class="member-row">
<img :src="member.avatar || '/default-avatar.png'" class="member-avatar" alt="" /> <img :src="member.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info"> <div class="member-info">
<span class="member-name">{{ member.username }}</span> <span class="member-name">{{ member.name || member.username }}</span>
<span v-if="member.id === group.owner" class="owner-badge">群主</span> <span v-if="member.id === group.owner" class="owner-badge">群主</span>
</div> </div>
<button <button
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId" v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
class="remove-btn" class="remove-btn"
@click="removeMember(member.id, member.username)" @click="removeMember(member.id, member.name || member.username)"
> >
移除 移除
</button> </button>
@@ -137,6 +207,13 @@ async function removeMember(userId: string, username: string) {
opacity: 0.9; opacity: 0.9;
} }
.approval-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.info-row { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -155,6 +232,27 @@ async function removeMember(userId: string, username: string) {
font-weight: 500; font-weight: 500;
} }
.requests-section {
margin-bottom: 16px;
padding: 12px;
background: rgba(245, 158, 11, 0.06);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--gg-radius-sm);
}
.requests-title {
font-size: 13px;
font-weight: 600;
color: #f59e0b;
margin: 0 0 10px;
}
.requests-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.members-list { .members-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+289 -31
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getGroup, joinGroup, createJoinRequest, searchGroups } from '@/api/groups'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { getGroup, joinGroup } from '@/api/groups' import type { Group } from '@/types'
import { Search, Link } from '@element-plus/icons-vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -19,71 +21,195 @@ const visible = computed({
set: (val) => emit('update:modelValue', val) set: (val) => emit('update:modelValue', val)
}) })
const searchKeyword = ref('')
const searchResults = ref<Group[]>([])
const groupId = ref('') const groupId = ref('')
const groupInfo = ref<any>(null) const selectedGroup = ref<Group | null>(null)
const loading = ref(false) const loading = ref(false)
const joining = ref(false) const joining = ref(false)
const mode = ref<'search' | 'id'>('search')
async function searchGroup() { // 已加入的群组 ID 集合
const joinedGroupIds = computed(() => new Set(groupStore.groups.map(g => g.id)))
async function handleSearch() {
const kw = searchKeyword.value.trim()
if (!kw) {
ElMessage.warning('请输入群组名称')
return
}
loading.value = true
try {
searchResults.value = await searchGroups(kw)
if (searchResults.value.length === 0) {
ElMessage.info('未找到匹配的群组')
}
} catch {
ElMessage.error('搜索失败')
} finally {
loading.value = false
}
}
async function searchById() {
if (!groupId.value.trim()) { if (!groupId.value.trim()) {
ElMessage.warning('请输入群组 ID') ElMessage.warning('请输入群组 ID')
return return
} }
loading.value = true loading.value = true
try { try {
groupInfo.value = await getGroup(groupId.value.trim()) selectedGroup.value = await getGroup(groupId.value.trim())
} catch (error) { } catch {
groupInfo.value = null selectedGroup.value = null
ElMessage.error('未找到该群组') ElMessage.error('未找到该群组')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function selectFromResults(group: Group) {
selectedGroup.value = group
}
async function handleJoin() { async function handleJoin() {
if (!groupInfo.value) return if (!selectedGroup.value) return
joining.value = true joining.value = true
try { try {
await joinGroup(groupInfo.value.id) if (selectedGroup.value.requireApproval) {
await groupStore.loadGroups() await createJoinRequest(selectedGroup.value.id)
ElMessage.success('已提交加入申请,等待群主审核')
} else {
await joinGroup(selectedGroup.value.id)
ElMessage.success('已成功加入群组')
}
visible.value = false visible.value = false
groupId.value = '' reset()
groupInfo.value = null
ElMessage.success('已申请加入群组,等待群主审核')
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '加入群组失败') ElMessage.error(error.message || '操作失败')
} finally { } finally {
joining.value = false joining.value = false
} }
} }
function reset() { function reset() {
searchKeyword.value = ''
searchResults.value = []
groupId.value = '' groupId.value = ''
groupInfo.value = null selectedGroup.value = null
} }
</script> </script>
<template> <template>
<el-dialog v-model="visible" title="加入群组" width="440px" @close="reset"> <el-dialog v-model="visible" title="加入群组" width="480px" @close="reset">
<div class="join-form"> <div class="join-form">
<div class="form-field"> <!-- 切换模式 -->
<label>群组 ID</label> <div class="mode-tabs">
<div class="search-row"> <button
<el-input v-model="groupId" placeholder="输入群主分享的群组 ID" /> class="mode-tab"
<el-button type="primary" :loading="loading" @click="searchGroup">查找</el-button> :class="{ 'mode-tab--active': mode === 'search' }"
</div> @click="mode = 'search'; selectedGroup = null"
>
<el-icon><Search /></el-icon> 按名称搜索
</button>
<button
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'id' }"
@click="mode = 'id'; selectedGroup = null"
>
<el-icon><Link /></el-icon> ID 查找
</button>
</div> </div>
<div v-if="groupInfo" class="group-preview"> <!-- 搜索模式 -->
<div class="preview-header"> <template v-if="mode === 'search'">
<h3 class="preview-name">{{ groupInfo.name }}</h3> <div class="form-field">
<span class="preview-members">{{ groupInfo.members?.length || 0 }} / {{ groupInfo.maxMembers }} </span> <div class="search-row">
<el-input
v-model="searchKeyword"
placeholder="输入群组名称搜索..."
@keyup.enter="handleSearch"
/>
<el-button type="primary" :loading="loading" @click="handleSearch">搜索</el-button>
</div>
</div> </div>
<p v-if="groupInfo.description" class="preview-desc">{{ groupInfo.description }}</p>
<el-button type="primary" :loading="joining" @click="handleJoin" style="width:100%; margin-top: 12px;"> <!-- 搜索结果列表 -->
申请加入 <div v-if="searchResults.length > 0 && !selectedGroup" class="results-list">
</el-button> <div
</div> v-for="group in searchResults"
:key="group.id"
class="result-item"
@click="selectFromResults(group)"
>
<div class="result-info">
<span class="result-name">{{ group.name }}</span>
<span class="result-meta">{{ group.members?.length || 0 }} / {{ group.maxMembers }} </span>
</div>
<div class="result-tags">
<span v-if="joinedGroupIds.has(group.id)" class="tag-joined">已加入</span>
<span v-else-if="group.requireApproval" class="tag-approval">需审核</span>
<span v-else class="tag-direct">可直接加入</span>
</div>
</div>
</div>
<!-- 已选中群组预览 -->
<div v-if="selectedGroup && mode === 'search'" class="group-preview">
<div class="preview-header">
<h3 class="preview-name">{{ selectedGroup.name }}</h3>
<span class="preview-members">{{ selectedGroup.members?.length || 0 }} / {{ selectedGroup.maxMembers }} </span>
</div>
<p v-if="selectedGroup.description" class="preview-desc">{{ selectedGroup.description }}</p>
<div class="approval-tag">
<span v-if="joinedGroupIds.has(selectedGroup.id)" class="tag-joined">已加入</span>
<span v-else-if="selectedGroup.requireApproval" class="tag-approval">需要审核</span>
<span v-else class="tag-direct">直接加入</span>
</div>
<el-button
v-if="!joinedGroupIds.has(selectedGroup.id)"
type="primary"
:loading="joining"
@click="handleJoin"
style="width: 100%; margin-top: 12px;"
>
{{ selectedGroup.requireApproval ? '申请加入' : '加入群组' }}
</el-button>
<button class="back-btn" @click="selectedGroup = null">返回搜索结果</button>
</div>
</template>
<!-- ID 查找模式 -->
<template v-if="mode === 'id'">
<div class="form-field">
<label>群组 ID</label>
<div class="search-row">
<el-input v-model="groupId" placeholder="输入群主分享的群组 ID" />
<el-button type="primary" :loading="loading" @click="searchById">查找</el-button>
</div>
</div>
<div v-if="selectedGroup && mode === 'id'" class="group-preview">
<div class="preview-header">
<h3 class="preview-name">{{ selectedGroup.name }}</h3>
<span class="preview-members">{{ selectedGroup.members?.length || 0 }} / {{ selectedGroup.maxMembers }} </span>
</div>
<p v-if="selectedGroup.description" class="preview-desc">{{ selectedGroup.description }}</p>
<div class="approval-tag">
<span v-if="joinedGroupIds.has(selectedGroup.id)" class="tag-joined">已加入</span>
<span v-else-if="selectedGroup.requireApproval" class="tag-approval">需要审核</span>
<span v-else class="tag-direct">直接加入</span>
</div>
<el-button
v-if="!joinedGroupIds.has(selectedGroup.id)"
type="primary"
:loading="joining"
@click="handleJoin"
style="width: 100%; margin-top: 12px;"
>
{{ selectedGroup.requireApproval ? '申请加入' : '加入群组' }}
</el-button>
</div>
</template>
</div> </div>
</el-dialog> </el-dialog>
</template> </template>
@@ -92,7 +218,39 @@ function reset() {
.join-form { .join-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
}
/* ── 模式切换 ── */
.mode-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
}
.mode-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gg-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab--active {
background: var(--gg-bg-card);
color: var(--gg-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
} }
.form-field { .form-field {
@@ -115,6 +273,54 @@ function reset() {
flex: 1; flex: 1;
} }
/* ── 搜索结果列表 ── */
.results-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--gg-bg);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
cursor: pointer;
transition: all 0.2s;
}
.result-item:hover {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.04);
}
.result-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.result-name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.result-meta {
font-size: 12px;
color: var(--gg-text-muted);
}
.result-tags {
flex-shrink: 0;
}
/* ── 群组预览 ── */
.group-preview { .group-preview {
padding: 16px; padding: 16px;
background: var(--gg-bg-card); background: var(--gg-bg-card);
@@ -144,4 +350,56 @@ function reset() {
font-size: 14px; font-size: 14px;
color: var(--gg-text-secondary); color: var(--gg-text-secondary);
} }
.approval-tag {
margin-top: 10px;
}
.tag-approval {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.tag-direct {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary);
}
.tag-joined {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
}
.back-btn {
display: block;
width: 100%;
margin-top: 8px;
padding: 8px;
border: none;
background: transparent;
color: var(--gg-text-muted);
font-size: 13px;
cursor: pointer;
border-radius: var(--gg-radius-sm);
transition: color 0.2s;
}
.back-btn:hover {
color: var(--gg-text);
}
</style> </style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { respondJoinRequest } from '@/api/groups'
import type { JoinRequest } from '@/types'
const props = defineProps<JoinRequest>()
const emit = defineEmits<{ responded: [] }>()
const loading = ref(false)
async function handleApprove() {
loading.value = true
try {
await respondJoinRequest(props.id, 'approved')
ElMessage.success('已通过申请')
emit('responded')
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
loading.value = false
}
}
async function handleReject() {
try {
const { value } = await ElMessageBox.prompt('拒绝原因(可选)', '拒绝申请', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '输入拒绝原因...'
})
loading.value = true
await respondJoinRequest(props.id, 'rejected', value || undefined)
ElMessage.success('已拒绝申请')
emit('responded')
} catch {
// 用户取消
} finally {
loading.value = false
}
}
</script>
<template>
<div class="join-request-card">
<div class="request-info">
<img
:src="(props as any).expand?.user?.avatar || '/default-avatar.svg'"
:alt="(props as any).expand?.user?.name || (props as any).expand?.user?.username"
class="avatar"
/>
<div class="info-text">
<span class="username">{{ (props as any).expand?.user?.name || (props as any).expand?.user?.username || '用户' }}</span>
<span class="group-name">申请加入{{ (props as any).expand?.group?.name || '群组' }}</span>
</div>
</div>
<div class="request-actions">
<button class="approve-btn" :disabled="loading" @click="handleApprove">同意</button>
<button class="reject-btn" :disabled="loading" @click="handleReject">拒绝</button>
</div>
</div>
</template>
<style scoped>
.join-request-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--gg-bg);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
gap: 12px;
}
.request-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--gg-border);
flex-shrink: 0;
}
.info-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.username {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.group-name {
font-size: 12px;
color: var(--gg-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.approve-btn {
padding: 5px 14px;
border: none;
border-radius: 6px;
background: var(--gg-gradient-green);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.approve-btn:hover:not(:disabled) { opacity: 0.85; }
.approve-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.reject-btn {
padding: 5px 14px;
border: 1px solid var(--gg-border);
border-radius: 6px;
background: transparent;
color: var(--gg-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.reject-btn:hover:not(:disabled) {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.reject-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useLedgerStore } from '@/stores/ledger'
import { useGroupStore } from '@/stores/group'
import type { Ledger, LedgerType, LedgerCategory } from '@/types'
import { LedgerCategoryMap } from '@/types'
import { displayName } from '@/types'
const props = defineProps<{
groupId: string
editLedger?: Ledger
}>()
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
saved: []
}>()
const ledgerStore = useLedgerStore()
const groupStore = useGroupStore()
const loading = ref(false)
const form = ref({
type: 'expense' as LedgerType,
amount: 0,
category: 'other' as LedgerCategory,
description: '',
relatedMembers: [] as string[],
occurredAt: '' as string | Date,
})
const isEditing = computed(() => !!props.editLedger)
const dialogTitle = computed(() => (isEditing.value ? '编辑账目' : '新建账目'))
// 分类选项
const categoryOptions = Object.entries(LedgerCategoryMap).map(([value, label]) => ({
label,
value,
}))
// 群组成员选项
const memberOptions = computed(() => {
return groupStore.currentMembers.map((m) => ({
label: displayName(m),
value: m.id,
}))
})
// 格式化今天日期
function getTodayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// 重置表单
function resetForm() {
form.value = {
type: 'expense',
amount: 0,
category: 'other',
description: '',
relatedMembers: [],
occurredAt: getTodayStr(),
}
}
// 填充编辑数据
function fillForm(ledger: Ledger) {
form.value = {
type: ledger.type,
amount: ledger.amount,
category: ledger.category,
description: ledger.description || '',
relatedMembers: [...(ledger.relatedMembers || [])],
occurredAt: ledger.occurredAt?.slice(0, 10) || getTodayStr(),
}
}
// 对话框打开
function handleOpen() {
if (props.editLedger) {
fillForm(props.editLedger)
} else {
resetForm()
}
}
// 提交
async function handleSubmit() {
if (!props.groupId) {
ElMessage.error('缺少群组信息')
return
}
if (form.value.amount <= 0) {
ElMessage.warning('金额必须大于0')
return
}
loading.value = true
try {
const occurredAt = form.value.occurredAt
? new Date(String(form.value.occurredAt)).toISOString()
: new Date().toISOString()
const submitData = {
type: form.value.type,
amount: form.value.amount,
category: form.value.category,
description: form.value.description || '',
relatedMembers: form.value.relatedMembers,
occurredAt,
}
if (isEditing.value && props.editLedger) {
await ledgerStore.editLedger(props.editLedger.id, submitData)
ElMessage.success('账目更新成功')
} else {
await ledgerStore.addLedger({
group: props.groupId,
...submitData,
})
ElMessage.success('账目创建成功')
}
visible.value = false
emit('saved')
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="480px"
@open="handleOpen"
>
<div class="create-form">
<!-- 类型 -->
<div class="form-field">
<label>类型 <span class="required">*</span></label>
<div class="type-switch">
<button
:class="['type-btn', { active: form.type === 'income' }]"
@click="form.type = 'income'"
>
收入
</button>
<button
:class="['type-btn', { active: form.type === 'expense' }]"
@click="form.type = 'expense'"
>
支出
</button>
</div>
</div>
<!-- 金额 -->
<div class="form-field">
<label>金额 <span class="required">*</span></label>
<el-input
v-model.number="form.amount"
type="number"
:min="0.01"
:step="0.01"
placeholder="请输入金额"
>
<template #prefix>
<span class="amount-prefix">¥</span>
</template>
</el-input>
</div>
<!-- 分类 -->
<div class="form-field">
<label>分类 <span class="required">*</span></label>
<el-select
v-model="form.category"
placeholder="请选择分类"
style="width: 100%"
>
<el-option
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 描述 -->
<div class="form-field">
<label>描述</label>
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入账目描述(可选)"
maxlength="200"
show-word-limit
/>
</div>
<!-- 关联成员 -->
<div class="form-field">
<label>关联成员</label>
<el-select
v-model="form.relatedMembers"
multiple
placeholder="选择关联成员(可选)"
style="width: 100%"
collapse-tags
collapse-tags-tooltip
>
<el-option
v-for="opt in memberOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 发生日期 -->
<div class="form-field">
<label>发生日期</label>
<el-date-picker
v-model="form.occurredAt"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '保存中...' : '保存' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-switch {
display: flex;
gap: 8px;
}
.type-btn {
flex: 1;
padding: 8px 16px;
border: 1px solid var(--gg-border, #dcdfe6);
border-radius: 6px;
background: var(--gg-bg, #fff);
color: var(--gg-text-secondary, #909399);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.type-btn:hover {
border-color: var(--gg-primary, #67c23a);
color: var(--gg-primary, #67c23a);
}
.type-btn.active {
background: var(--gg-primary, #67c23a);
border-color: var(--gg-primary, #67c23a);
color: #fff;
}
.amount-prefix {
color: var(--gg-text-secondary);
font-weight: 500;
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
import type { Ledger } from '@/types'
import { LedgerTypeMap, LedgerCategoryMap, displayName } from '@/types'
import { pb } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
const props = defineProps<{
ledger: Ledger
}>()
const emit = defineEmits<{
edit: [ledgerId: string]
delete: [ledgerId: string]
}>()
const groupStore = useGroupStore()
const isCreator = computed(() => {
return props.ledger.creator === pb.authStore.model?.id
})
const isGroupOwner = computed(() => {
return pb.authStore.model?.id === groupStore.currentGroup?.owner
})
const typeLabel = computed(() => LedgerTypeMap[props.ledger.type])
const categoryLabel = computed(() => LedgerCategoryMap[props.ledger.category])
const isIncome = computed(() => props.ledger.type === 'income')
const formattedAmount = computed(() => {
const prefix = isIncome.value ? '+' : '-'
return `${prefix}¥${props.ledger.amount.toFixed(2)}`
})
const formattedDate = computed(() => {
if (!props.ledger.occurredAt) return ''
return props.ledger.occurredAt.slice(0, 10)
})
const creatorName = computed(() => {
return displayName(props.ledger.expand?.creator)
})
const relatedMemberNames = computed(() => {
const members = props.ledger.expand?.relatedMembers
if (!members || members.length === 0) return []
return members.map((m) => displayName(m))
})
</script>
<template>
<div class="ledger-card">
<div class="ledger-card__main">
<!-- 左侧类型标签 + 金额 -->
<div class="ledger-card__left">
<span
class="ledger-card__type-tag"
:class="isIncome ? 'ledger-card__type-tag--income' : 'ledger-card__type-tag--expense'"
>
{{ typeLabel }}
</span>
<span
class="ledger-card__amount"
:class="isIncome ? 'ledger-card__amount--income' : 'ledger-card__amount--expense'"
>
{{ formattedAmount }}
</span>
</div>
<!-- 中间描述分类日期 -->
<div class="ledger-card__center">
<div class="ledger-card__desc">{{ ledger.description || '无备注' }}</div>
<div class="ledger-card__meta">
<span class="ledger-card__category-tag">{{ categoryLabel }}</span>
<span class="ledger-card__date">{{ formattedDate }}</span>
</div>
</div>
<!-- 右侧关联成员 + 创建者 -->
<div class="ledger-card__right">
<div v-if="relatedMemberNames.length > 0" class="ledger-card__members">
<span
v-for="(name, index) in relatedMemberNames.slice(0, 3)"
:key="index"
class="ledger-card__member"
>
{{ name }}
</span>
<span v-if="relatedMemberNames.length > 3" class="ledger-card__member ledger-card__member--more">
+{{ relatedMemberNames.length - 3 }}
</span>
</div>
<div class="ledger-card__creator">
{{ creatorName }}
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="isCreator || isGroupOwner" class="ledger-card__actions">
<button v-if="isCreator" class="ledger-card__action-btn" @click.stop="emit('edit', ledger.id)">
<el-icon><Edit /></el-icon>
编辑
</button>
<button class="ledger-card__action-btn ledger-card__action-btn--danger" @click.stop="emit('delete', ledger.id)">
<el-icon><Delete /></el-icon>
删除
</button>
</div>
</div>
</template>
<style scoped>
.ledger-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md, 8px);
padding: 14px 18px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ledger-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
}
.ledger-card__main {
display: flex;
align-items: flex-start;
gap: 16px;
}
/* 左侧 */
.ledger-card__left {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 80px;
}
.ledger-card__type-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.ledger-card__type-tag--income {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.ledger-card__type-tag--expense {
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
.ledger-card__amount {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
.ledger-card__amount--income {
color: var(--gg-success);
}
.ledger-card__amount--expense {
color: var(--gg-danger);
}
/* 中间 */
.ledger-card__center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ledger-card__desc {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.ledger-card__category-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
}
.ledger-card__date {
font-size: 12px;
color: var(--gg-text-muted);
}
/* 右侧 */
.ledger-card__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 0;
}
.ledger-card__members {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
.ledger-card__member {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: var(--gg-bg-elevated);
color: var(--gg-text-secondary);
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-card__member--more {
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
max-width: none;
}
.ledger-card__creator {
font-size: 12px;
color: var(--gg-text-muted);
}
/* 操作按钮 */
.ledger-card__actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--gg-border);
}
.ledger-card__action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gg-text-secondary);
font-size: 12px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.ledger-card__action-btn:hover {
color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
}
.ledger-card__action-btn--danger:hover {
color: var(--gg-danger);
background: rgba(239, 68, 68, 0.08);
}
@media (max-width: 640px) {
.ledger-card__main {
flex-direction: column;
gap: 10px;
}
.ledger-card__left {
flex-direction: row;
align-items: center;
justify-content: space-between;
min-width: auto;
width: 100%;
}
.ledger-card__right {
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,402 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useLedgerStore } from '@/stores/ledger'
import { useGroupStore } from '@/stores/group'
import type { LedgerType, LedgerCategory } from '@/types'
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
import LedgerSummary from './LedgerSummary.vue'
import LedgerCard from './LedgerCard.vue'
import CreateLedgerDialog from './CreateLedgerDialog.vue'
const ledgerStore = useLedgerStore()
const groupStore = useGroupStore()
const showCreate = ref(false)
const editingLedger = ref<any>(undefined)
const filterType = ref<LedgerType | ''>('')
const filterCategory = ref<LedgerCategory | ''>('')
// 月份选择器值
const selectedMonth = ref(new Date())
// 类型筛选选项
const typeOptions = computed(() => [
{ label: '全部', value: '' },
...Object.entries(LedgerTypeMap).map(([value, label]) => ({ label, value }))
])
// 分类筛选选项
const categoryOptions = computed(() => [
{ label: '全部', value: '' },
...Object.entries(LedgerCategoryMap).map(([value, label]) => ({ label, value }))
])
// 获取月份字符串
function getMonthString(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
// 加载数据
async function loadData() {
const groupId = groupStore.currentGroupId
if (!groupId) return
const month = getMonthString(selectedMonth.value)
await ledgerStore.loadLedgers(groupId, month)
}
// 筛选后的账目列表
const filteredLedgers = computed(() => {
let list = [...ledgerStore.ledgers]
if (filterType.value) {
list = list.filter((l) => l.type === filterType.value)
}
if (filterCategory.value) {
list = list.filter((l) => l.category === filterCategory.value)
}
return list
})
// 按日期分组
const groupedByDate = computed(() => {
const groups: Record<string, typeof filteredLedgers.value> = {}
for (const ledger of filteredLedgers.value) {
const date = ledger.occurredAt?.slice(0, 10) || '未知日期'
if (!groups[date]) {
groups[date] = []
}
groups[date].push(ledger)
}
// 按日期降序排列
const sortedKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a))
return sortedKeys.map((date) => ({ date, items: groups[date] }))
})
// 月份变更
function handleMonthChange(val: Date) {
selectedMonth.value = val
loadData()
}
// 筛选变更
function handleFilterChange() {
// 筛选在前端完成,无需重新加载
}
// 新建
function handleCreate() {
editingLedger.value = undefined
showCreate.value = true
}
// 编辑
function handleEdit(ledgerId: string) {
const ledger = ledgerStore.ledgers.find((l) => l.id === ledgerId)
if (ledger) {
editingLedger.value = ledger
showCreate.value = true
}
}
// 删除
async function handleDelete(ledgerId: string) {
try {
await ElMessageBox.confirm('确定删除这条账目记录吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await ledgerStore.removeLedger(ledgerId)
ElMessage.success('删除成功')
loadData()
} catch {
// 用户取消
}
}
// 保存成功
function handleSaved() {
showCreate.value = false
editingLedger.value = undefined
loadData()
}
// 实时订阅(store 内部管理生命周期)
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await ledgerStore.startSubscription(groupId)
}
onMounted(() => {
if (groupStore.currentGroupId) {
loadData()
startSubscription()
}
})
onUnmounted(() => {
ledgerStore.stopSubscription()
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
loadData()
startSubscription()
}
})
</script>
<template>
<div class="ledger-list">
<!-- 顶部操作栏 -->
<div class="ledger-list__header">
<h3 class="ledger-list__title">账本</h3>
<button class="ledger-list__create-btn" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建账目
</button>
</div>
<!-- 筛选栏 -->
<div class="ledger-list__filters">
<el-date-picker
:model-value="selectedMonth"
type="month"
placeholder="选择月份"
format="YYYY年MM月"
value-format="YYYY-MM"
@update:model-value="handleMonthChange"
class="ledger-list__month-picker"
/>
<el-select
v-model="filterType"
placeholder="类型"
clearable
@change="handleFilterChange"
class="ledger-list__filter-select"
>
<el-option
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filterCategory"
placeholder="分类"
clearable
@change="handleFilterChange"
class="ledger-list__filter-select"
>
<el-option
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 汇总 -->
<LedgerSummary />
<!-- 账目列表 -->
<div v-loading="ledgerStore.loading" class="ledger-list__content">
<section
v-for="group in groupedByDate"
:key="group.date"
class="ledger-list__date-group"
>
<div class="ledger-list__date-header">
<span class="ledger-list__date-dot"></span>
<span class="ledger-list__date-label">{{ group.date }}</span>
<span class="ledger-list__date-count">{{ group.items.length }} </span>
</div>
<div class="ledger-list__items">
<LedgerCard
v-for="ledger in group.items"
:key="ledger.id"
:ledger="ledger"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
</section>
<!-- 空状态 -->
<div
v-if="groupedByDate.length === 0 && !ledgerStore.loading"
class="ledger-list__empty"
>
<svg class="ledger-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z" />
</svg>
<p class="ledger-list__empty-text">暂无账目</p>
<p class="ledger-list__empty-hint">该月份还没有账目记录</p>
</div>
</div>
<!-- 新建/编辑对话框 -->
<CreateLedgerDialog
v-model="showCreate"
:group-id="groupStore.currentGroupId"
:edit-ledger="editingLedger"
@saved="handleSaved"
/>
</div>
</template>
<style scoped>
.ledger-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部操作栏 */
.ledger-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ledger-list__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.ledger-list__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.ledger-list__create-btn:hover {
opacity: 0.85;
}
/* 筛选栏 */
.ledger-list__filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.ledger-list__month-picker {
width: 160px;
}
.ledger-list__filter-select {
width: 110px;
}
/* 内容区 */
.ledger-list__content {
min-height: 200px;
}
/* 日期分组 */
.ledger-list__date-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.ledger-list__date-header {
display: flex;
align-items: center;
gap: 6px;
padding-top: 4px;
}
.ledger-list__date-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--gg-primary);
}
.ledger-list__date-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text-secondary);
}
.ledger-list__date-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 7px;
border-radius: 10px;
}
.ledger-list__items {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 空状态 */
.ledger-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.ledger-list__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.ledger-list__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.ledger-list__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
@media (max-width: 640px) {
.ledger-list__filters {
flex-direction: column;
align-items: stretch;
}
.ledger-list__month-picker,
.ledger-list__filter-select {
width: 100%;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More