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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端开发(默认连接 localhost:8090 的 PocketBase)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# 前端开发环境(连接远程后端 192.168.1.14:8711,端口 7033)
|
||||||
|
cd frontend && npm run dev:dev
|
||||||
|
|
||||||
|
# 前端 UAT 环境(端口 7034)
|
||||||
|
cd frontend && npm run dev:uat
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
# 启动后端(PocketBase,端口 8711)
|
||||||
|
cd backend && docker-compose up -d
|
||||||
|
|
||||||
|
# 部署脚本(根目录)
|
||||||
|
./deploy-backend.sh # 部署后端
|
||||||
|
./deploy-dev.sh # 部署 Dev 前端
|
||||||
|
./deploy-uat.sh # 部署 UAT 前端
|
||||||
|
./stop-all.sh # 停止所有服务
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: PocketBase 0.22.4 (Docker, `ghcr.io/muchobien/pocketbase`) — 无自定义 JS hooks,业务逻辑全在前端
|
||||||
|
- **前端**: Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + Vite
|
||||||
|
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),cookie 认证
|
||||||
|
- **实时通信**: PocketBase realtime subscriptions
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
### 前端目录结构 (`frontend/src/`)
|
||||||
|
|
||||||
|
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 PocketBase CRUD 和过滤逻辑。`pocketbase.ts` 初始化客户端并导出认证工具函数
|
||||||
|
- **`stores/`** — Pinia stores(`user`, `group`, `team`, `notification`),组合式 API 风格(`defineStore('name', () => {...})`)
|
||||||
|
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||||
|
- **`views/`** — 页面级组件,路由懒加载
|
||||||
|
- **`components/`** — 按领域分子目录:`common/`, `game/`, `group/`, `layout/`, `team/`
|
||||||
|
- **`types/index.ts`** — 所有 TypeScript 接口和类型定义集中在一个文件
|
||||||
|
|
||||||
|
### 数据模型(PocketBase Collections)
|
||||||
|
|
||||||
|
- **users** — 用户,含状态(idle/working/in_team/away)、工作时间设定、积分
|
||||||
|
- **groups** — 群组,owner + members 关系,支持审核加入(requireApproval)
|
||||||
|
- **team_sessions** — 临时组队,关联 sourceGroup,状态流转:recruiting → playing → finished/dissolved
|
||||||
|
- **invitations** — 组队邀请,from/to 用户,pending/accepted/rejected
|
||||||
|
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||||
|
- **game_comments** / **game_favorites** — 游戏评论和收藏
|
||||||
|
- **join_requests** — 入群申请,pending/approved/rejected
|
||||||
|
|
||||||
|
### 关键模式
|
||||||
|
|
||||||
|
- **Vite 代理**: 开发时 `/api` 代理到 PocketBase,路径重写去掉 `/api` 前缀(`vite.config.ts`)
|
||||||
|
- **认证流程**: `pocketbase.ts` → cookie 持久化 → 路由守卫检查 `isAuthenticated()` → 未登录跳转 `/login`
|
||||||
|
- **实时订阅**: 通过 `useRealtime` composable 和 `subscribe*` 函数订阅 PocketBase 变更事件,各 store 自行刷新数据
|
||||||
|
- **样式系统**: 自定义 CSS 变量(`design.css`,`--gg-*` 前缀)+ Tailwind + Element Plus,主题色为绿色系
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- PocketBase 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成
|
||||||
|
- `backend/pb_hooks/main.js` 为占位文件,当前镜像不支持 JS VM
|
||||||
|
- 所有业务逻辑(如入群审批、组队邀请)在前端 API 层实现
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
前端通过 `.env` 文件配置:
|
||||||
|
- `VITE_PB_URL` — PocketBase 地址(默认 `window.location.origin`)
|
||||||
|
- `VITE_PORT` — 开发服务器端口
|
||||||
@@ -87,7 +87,7 @@ migrate((db) => {
|
|||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\"",
|
||||||
"viewRule": "@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,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)
|
||||||
|
})
|
||||||
@@ -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 服务器
|
|||||||
|
|
||||||
### 第二期
|
### 第二期
|
||||||
|
|
||||||
**目标**: 预约 + 积分 + 荣誉
|
**目标**: 预约 + 积分 + 荣誉 + 记忆
|
||||||
|
|
||||||
- [ ] 预约系统(简化投票)
|
- [ ] 预约系统(简化投票)
|
||||||
- [ ] 积分系统(获取/记录)
|
- [ ] 积分系统(获取/记录)
|
||||||
- [ ] 荣誉墙(自动授予)
|
- [ ] 荣誉墙(自动授予)
|
||||||
|
- [ ] 多媒体记忆(支持音视频等多媒体文件的上传、下载与在线预览)
|
||||||
|
|
||||||
### 第三期
|
### 第三期
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
# API 代理到局域网 PocketBase
|
# API 代理到局域网 PocketBase
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import type { UserStatus } from '@/types'
|
import type { UserStatus } from '@/types'
|
||||||
import { sendInvitation } from '@/api/invitations'
|
import { sendInvitation } from '@/api/invitations'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -15,9 +16,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const idleMembers = computed(() =>
|
const idleMembers = computed(() =>
|
||||||
groupStore.currentMembers.filter(m => m.status === props.status)
|
groupStore.currentMembers.filter(m => m.status === props.status && m.id !== userStore.userId)
|
||||||
)
|
)
|
||||||
|
|
||||||
async function inviteMember(userId: string, username: string) {
|
async function inviteMember(userId: string, username: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Group, User } from '@/types'
|
import type { Group, User } from '@/types'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
import { getUserGroups, getGroup, getGroupMembers } from '@/api/groups'
|
import { getUserGroups, getGroup, getGroupMembers } from '@/api/groups'
|
||||||
|
|
||||||
export const useGroupStore = defineStore('group', () => {
|
export const useGroupStore = defineStore('group', () => {
|
||||||
@@ -14,8 +15,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
// 计算属性
|
// 计算属性
|
||||||
const currentGroupId = computed(() => currentGroup.value?.id || '')
|
const currentGroupId = computed(() => currentGroup.value?.id || '')
|
||||||
const isGroupOwner = computed(() => {
|
const isGroupOwner = computed(() => {
|
||||||
const userId = localStorage.getItem('userId')
|
return currentGroup.value?.owner === pb.authStore.model?.id
|
||||||
return currentGroup.value?.owner === userId
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载用户的群组列表
|
// 加载用户的群组列表
|
||||||
|
|||||||
@@ -44,8 +44,15 @@ export const useTeamStore = defineStore('team', () => {
|
|||||||
const session = await createTeamSession(data)
|
const session = await createTeamSession(data)
|
||||||
currentSession.value = session
|
currentSession.value = session
|
||||||
|
|
||||||
// 发送邀请(排除创建者自己)
|
// 设置创建者状态为组队中
|
||||||
const currentUserId = pb.authStore.model?.id
|
const currentUserId = pb.authStore.model?.id
|
||||||
|
if (currentUserId) {
|
||||||
|
try {
|
||||||
|
await pb.collection('users').update(currentUserId, { status: 'in_team' })
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邀请(排除创建者自己)
|
||||||
const others = data.members.filter(id => id !== currentUserId)
|
const others = data.members.filter(id => id !== currentUserId)
|
||||||
if (others.length > 0) {
|
if (others.length > 0) {
|
||||||
await sendBulkInvitations(others, session.id)
|
await sendBulkInvitations(others, session.id)
|
||||||
|
|||||||
@@ -170,3 +170,24 @@ export interface JoinRequest {
|
|||||||
user?: User
|
user?: User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多媒体记忆类型 (二期规划)
|
||||||
|
export type MemoryFileType = 'image' | 'video' | 'audio' | 'other'
|
||||||
|
|
||||||
|
// 多媒体记忆 (二期规划)
|
||||||
|
export interface Memory {
|
||||||
|
id: string
|
||||||
|
groupId: string
|
||||||
|
uploader: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
file: string
|
||||||
|
fileType: MemoryFileType
|
||||||
|
size: number
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
expand?: {
|
||||||
|
uploader?: User
|
||||||
|
group?: Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ const membersByStatus = computed(() => {
|
|||||||
away: []
|
away: []
|
||||||
}
|
}
|
||||||
for (const m of members.value) {
|
for (const m of members.value) {
|
||||||
if (groups[m.status]) {
|
const s = m.status || 'idle'
|
||||||
groups[m.status].push(m)
|
if (groups[s]) {
|
||||||
|
groups[s].push(m)
|
||||||
} else {
|
} else {
|
||||||
groups.away.push(m)
|
groups.away.push(m)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user