Compare commits
15 Commits
89d8ecec82
...
71742da600
| Author | SHA1 | Date | |
|---|---|---|---|
| 71742da600 | |||
| 3173525a2e | |||
| 0a7dcbb6b8 | |||
| 5cec2101af | |||
| 262f946a4e | |||
| cfdbaf1095 | |||
| 277a484f60 | |||
| 7299128a34 | |||
| 12b2cdbc02 | |||
| 8d3cce814a | |||
| 3ae141ba56 | |||
| 4dac4bc751 | |||
| 6b684f8600 | |||
| 9405406c47 | |||
| c76346294a |
+1
-1
@@ -35,4 +35,4 @@ backend/pb_migrations.bak/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.cache/
|
||||
.cache/.playwright-mcp/
|
||||
|
||||
@@ -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 != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": "owner = @request.auth.id",
|
||||
"updateRule": "@request.auth.id != \"\"",
|
||||
"deleteRule": "owner = @request.auth.id",
|
||||
"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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -1,14 +1,39 @@
|
||||
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
|
||||
|
||||
frontend-uat:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NGINX_CONF: nginx.uat.conf
|
||||
container_name: gamegroup-frontend-uat
|
||||
ports:
|
||||
- "7034:80"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-uat
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
|
||||
@@ -159,6 +159,19 @@ NAS 服务器
|
||||
icon: string
|
||||
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 三期数据集合
|
||||
@@ -398,11 +411,12 @@ NAS 服务器
|
||||
|
||||
### 第二期
|
||||
|
||||
**目标**: 预约 + 积分 + 荣誉
|
||||
**目标**: 预约 + 积分 + 荣誉 + 记忆
|
||||
|
||||
- [ ] 预约系统(简化投票)
|
||||
- [ ] 积分系统(获取/记录)
|
||||
- [ ] 荣誉墙(自动授予)
|
||||
- [ ] 多媒体记忆(支持音视频等多媒体文件的上传、下载与在线预览)
|
||||
|
||||
### 第三期
|
||||
|
||||
|
||||
+3
-2
@@ -22,8 +22,9 @@ FROM nginx:alpine
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 通过 NGINX_CONF 参数选择配置文件(默认 dev)
|
||||
ARG NGINX_CONF=nginx.conf
|
||||
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
@@ -10,6 +10,24 @@ server {
|
||||
|
||||
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: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;
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
|
||||
# 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";
|
||||
}
|
||||
}
|
||||
+117
-9
@@ -1,6 +1,6 @@
|
||||
// src/api/groups.ts
|
||||
import pb from './pocketbase'
|
||||
import type { Group } from '@/types'
|
||||
import type { Group, JoinRequest } from '@/types'
|
||||
|
||||
// 创建群组
|
||||
export async function createGroup(data: {
|
||||
@@ -14,7 +14,8 @@ export async function createGroup(data: {
|
||||
return pb.collection('groups').create({
|
||||
...data,
|
||||
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
|
||||
if (!user) return []
|
||||
|
||||
// 通过 members 字段过滤
|
||||
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[])
|
||||
}
|
||||
|
||||
// 获取群组详情
|
||||
export async function getGroup(groupId: string): Promise<Group> {
|
||||
return pb.collection('groups').getOne(groupId, {
|
||||
expand: 'members'
|
||||
expand: 'members',
|
||||
$autoCancel: false
|
||||
}) 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) {
|
||||
const user = pb.authStore.model
|
||||
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) {
|
||||
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) {
|
||||
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, {
|
||||
filter: members.map(id => `id="${id}"`).join(' || ')
|
||||
filter: members.map(id => `id="${id}"`).join(' || '),
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
return users.items
|
||||
|
||||
@@ -77,20 +77,21 @@ export async function respondInvitation(
|
||||
updateData.rejectReason = rejectReason
|
||||
}
|
||||
|
||||
// 更新邀请状态
|
||||
await pb.collection('invitations').update(invitationId, updateData)
|
||||
|
||||
// 接受邀请:前端处理加入 team session + 更新用户状态
|
||||
// 接受邀请:先加入 team session,再更新邀请状态
|
||||
if (response === 'accepted') {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return
|
||||
|
||||
// 获取邀请详情以找到 team session
|
||||
const invitation = await pb.collection('invitations').getOne(invitationId) as any
|
||||
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) as any
|
||||
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)
|
||||
@@ -99,7 +100,17 @@ export async function respondInvitation(
|
||||
|
||||
// 更新用户状态为 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)
|
||||
}
|
||||
|
||||
// 订阅邀请变更
|
||||
|
||||
@@ -5,13 +5,7 @@ const pbUrl = import.meta.env.VITE_PB_URL || window.location.origin
|
||||
|
||||
export const pb = new PocketBase(pbUrl)
|
||||
|
||||
// 认证状态持久化
|
||||
pb.authStore.loadFromCookie(document.cookie)
|
||||
|
||||
// 保存认证状态到 cookie
|
||||
pb.authStore.onChange(() => {
|
||||
document.cookie = pb.authStore.exportToCookie({ httpOnly: false })
|
||||
})
|
||||
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
||||
|
||||
// 获取当前用户
|
||||
export function getCurrentUser() {
|
||||
|
||||
@@ -51,7 +51,9 @@ export async function updateTeamStatus(sessionId: string, status: TeamStatus): P
|
||||
|
||||
// 结束游戏(解散临时小组 + 重置成员状态)
|
||||
export async function endGame(sessionId: string) {
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId) as any
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId, {
|
||||
$autoCancel: false
|
||||
}) as any
|
||||
const members: string[] = session.members || []
|
||||
|
||||
// 解散临时小组
|
||||
@@ -60,10 +62,7 @@ export async function endGame(sessionId: string) {
|
||||
// 重置所有成员状态为 idle
|
||||
for (const memberId of members) {
|
||||
try {
|
||||
const user = await pb.collection('users').getOne(memberId) as any
|
||||
if (user && user.status === 'in_team') {
|
||||
await pb.collection('users').update(memberId, { status: 'idle' })
|
||||
}
|
||||
await pb.collection('users').update(memberId, { status: 'idle' })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
--gg-primary-light: #10b981;
|
||||
--gg-primary-dark: #047857;
|
||||
|
||||
/* 辅助色:深紫 */
|
||||
--gg-accent: #7c3aed;
|
||||
--gg-accent-light: #8b5cf6;
|
||||
/* 辅助色:翠绿 */
|
||||
--gg-accent: #0d9488;
|
||||
--gg-accent-light: #14b8a6;
|
||||
|
||||
/* 背景色(亮色) */
|
||||
--gg-bg: #f0fdf4;
|
||||
@@ -40,7 +40,7 @@
|
||||
--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-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
--gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import InvitationCard from '@/components/team/InvitationCard.vue'
|
||||
import JoinRequestCard from '@/components/group/JoinRequestCard.vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
|
||||
@@ -12,6 +13,10 @@ onMounted(() => {
|
||||
function onInvitationResponded(id: string, _accepted: boolean) {
|
||||
store.removeInvitation(id)
|
||||
}
|
||||
|
||||
function onJoinRequestResponded(id: string) {
|
||||
store.removeJoinRequest(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,17 +27,36 @@ function onInvitationResponded(id: string, _accepted: boolean) {
|
||||
size="380px"
|
||||
>
|
||||
<div class="notification-panel">
|
||||
<div v-if="store.pendingInvitations.length === 0" class="empty">
|
||||
<div v-if="store.unreadCount === 0" class="empty">
|
||||
暂无新通知
|
||||
</div>
|
||||
<div v-else class="invitation-list">
|
||||
<InvitationCard
|
||||
v-for="invitation in store.pendingInvitations"
|
||||
:key="invitation.id"
|
||||
:invitation="invitation"
|
||||
@responded="onInvitationResponded"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 加入申请 -->
|
||||
<div v-if="store.pendingJoinRequests.length > 0" class="section">
|
||||
<h4 class="section-title">加入申请 ({{ store.pendingJoinRequests.length }})</h4>
|
||||
<div class="list">
|
||||
<JoinRequestCard
|
||||
v-for="req in store.pendingJoinRequests"
|
||||
: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>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -48,9 +72,20 @@ function onInvitationResponded(id: string, _accepted: boolean) {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.invitation-list {
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,7 +71,7 @@ function formatDate(dateStr: string) {
|
||||
<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?.username || '用户' }}</span>
|
||||
<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>
|
||||
|
||||
@@ -103,7 +103,7 @@ function handleCreateTeam() {
|
||||
.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(168, 85, 247, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
||||
|
||||
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { createGroup } from '@/api/groups'
|
||||
import pb from '@/api/pocketbase'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -33,7 +34,20 @@ async function handleSubmit() {
|
||||
ElMessage.warning('请输入群组名称')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查群组名是否重复
|
||||
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 {
|
||||
const group = await createGroup({
|
||||
name: form.value.name.trim(),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
|
||||
import { ElSwitch } from 'element-plus'
|
||||
import type { JoinRequest } from '@/types'
|
||||
import JoinRequestCard from './JoinRequestCard.vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -11,6 +15,29 @@ const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
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() {
|
||||
if (group.value?.id) {
|
||||
navigator.clipboard.writeText(group.value.id)
|
||||
@@ -22,18 +49,38 @@ async function removeMember(userId: string, username: string) {
|
||||
if (!isOwner.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
||||
const group = groupStore.currentGroup
|
||||
if (!group) return
|
||||
// 直接更新群组的 members 列表
|
||||
const grp = groupStore.currentGroup
|
||||
if (!grp) return
|
||||
const { pb } = await import('@/api/pocketbase')
|
||||
const newMembers = group.members.filter(id => id !== userId)
|
||||
await pb.collection('groups').update(group.id, { members: newMembers })
|
||||
await groupStore.setCurrentGroup(group.id)
|
||||
const newMembers = grp.members.filter(id => id !== userId)
|
||||
await pb.collection('groups').update(grp.id, { members: newMembers })
|
||||
await groupStore.setCurrentGroup(grp.id)
|
||||
ElMessage.success('已移除成员')
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
@@ -51,22 +98,45 @@ async function removeMember(userId: string, username: string) {
|
||||
</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">
|
||||
<span class="info-label">成员</span>
|
||||
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 待审核申请(仅群主可见) -->
|
||||
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
|
||||
<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 v-for="member in members" :key="member.id" class="member-row">
|
||||
<img :src="member.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
|
||||
class="remove-btn"
|
||||
@click="removeMember(member.id, member.username)"
|
||||
@click="removeMember(member.id, member.name || member.username)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
@@ -137,6 +207,13 @@ async function removeMember(userId: string, username: string) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.approval-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -155,6 +232,27 @@ async function removeMember(userId: string, username: string) {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGroup, joinGroup, createJoinRequest, searchGroups } from '@/api/groups'
|
||||
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<{
|
||||
modelValue: boolean
|
||||
@@ -19,71 +21,195 @@ const visible = computed({
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref<Group[]>([])
|
||||
const groupId = ref('')
|
||||
const groupInfo = ref<any>(null)
|
||||
const selectedGroup = ref<Group | null>(null)
|
||||
const loading = 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()) {
|
||||
ElMessage.warning('请输入群组 ID')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
groupInfo.value = await getGroup(groupId.value.trim())
|
||||
} catch (error) {
|
||||
groupInfo.value = null
|
||||
selectedGroup.value = await getGroup(groupId.value.trim())
|
||||
} catch {
|
||||
selectedGroup.value = null
|
||||
ElMessage.error('未找到该群组')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromResults(group: Group) {
|
||||
selectedGroup.value = group
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (!groupInfo.value) return
|
||||
if (!selectedGroup.value) return
|
||||
joining.value = true
|
||||
try {
|
||||
await joinGroup(groupInfo.value.id)
|
||||
await groupStore.loadGroups()
|
||||
if (selectedGroup.value.requireApproval) {
|
||||
await createJoinRequest(selectedGroup.value.id)
|
||||
ElMessage.success('已提交加入申请,等待群主审核')
|
||||
} else {
|
||||
await joinGroup(selectedGroup.value.id)
|
||||
ElMessage.success('已成功加入群组')
|
||||
}
|
||||
visible.value = false
|
||||
groupId.value = ''
|
||||
groupInfo.value = null
|
||||
ElMessage.success('已申请加入群组,等待群主审核')
|
||||
reset()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加入群组失败')
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
searchKeyword.value = ''
|
||||
searchResults.value = []
|
||||
groupId.value = ''
|
||||
groupInfo.value = null
|
||||
selectedGroup.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<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="form-field">
|
||||
<label>群组 ID</label>
|
||||
<div class="search-row">
|
||||
<el-input v-model="groupId" placeholder="输入群主分享的群组 ID" />
|
||||
<el-button type="primary" :loading="loading" @click="searchGroup">查找</el-button>
|
||||
</div>
|
||||
<!-- 切换模式 -->
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ 'mode-tab--active': mode === 'search' }"
|
||||
@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 v-if="groupInfo" class="group-preview">
|
||||
<div class="preview-header">
|
||||
<h3 class="preview-name">{{ groupInfo.name }}</h3>
|
||||
<span class="preview-members">{{ groupInfo.members?.length || 0 }} / {{ groupInfo.maxMembers }} 人</span>
|
||||
<!-- 搜索模式 -->
|
||||
<template v-if="mode === 'search'">
|
||||
<div class="form-field">
|
||||
<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>
|
||||
<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;">
|
||||
申请加入
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-if="searchResults.length > 0 && !selectedGroup" class="results-list">
|
||||
<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>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -92,7 +218,39 @@ function reset() {
|
||||
.join-form {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -115,6 +273,54 @@ function reset() {
|
||||
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 {
|
||||
padding: 16px;
|
||||
background: var(--gg-bg-card);
|
||||
@@ -144,4 +350,56 @@ function reset() {
|
||||
font-size: 14px;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { UserStatus } from '@/types'
|
||||
import { sendInvitation } from '@/api/invitations'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -15,9 +16,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
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) {
|
||||
@@ -60,17 +62,17 @@ async function inviteMember(userId: string, username: string) {
|
||||
>
|
||||
<img
|
||||
:src="member.avatar || '/default-avatar.svg'"
|
||||
:alt="member.username"
|
||||
:alt="member.name || member.username"
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="member-info">
|
||||
<span class="username">{{ member.username }}</span>
|
||||
<span class="username">{{ member.name || member.username }}</span>
|
||||
<span v-if="member.statusNote" class="status-note">{{ member.statusNote }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="status === 'idle'"
|
||||
class="invite-btn"
|
||||
@click="inviteMember(member.id, member.username)"
|
||||
@click="inviteMember(member.id, member.name || member.username)"
|
||||
>
|
||||
邀请
|
||||
</button>
|
||||
@@ -171,6 +173,6 @@ async function inviteMember(userId: string, username: string) {
|
||||
|
||||
.invite-btn:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 0 12px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,11 +50,11 @@ async function rejectInvitation() {
|
||||
<div class="invitation-header">
|
||||
<img
|
||||
:src="invitation.expand?.from?.avatar || '/default-avatar.svg'"
|
||||
:alt="invitation.expand?.from?.username"
|
||||
:alt="invitation.expand?.from?.name || invitation.expand?.from?.username"
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="invitation-info">
|
||||
<span class="inviter-name">{{ invitation.expand?.from?.username }}</span>
|
||||
<span class="inviter-name">{{ invitation.expand?.from?.name || invitation.expand?.from?.username }}</span>
|
||||
<span class="invitation-text">邀请你加入</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ async function rejectInvitation() {
|
||||
|
||||
.invitation-card:hover {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.12);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.12);
|
||||
}
|
||||
|
||||
.invitation-header {
|
||||
@@ -211,6 +211,6 @@ async function rejectInvitation() {
|
||||
.reject-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 8px rgba(99, 102, 241, 0.15);
|
||||
box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ async function handleGameSelect(gameName: string) {
|
||||
try {
|
||||
const session = await createTeamSession({
|
||||
sourceGroup: groupStore.currentGroupId,
|
||||
name: `${userStore.user?.username || ''}的车队`,
|
||||
name: `${userStore.user?.name || userStore.user?.username || ''}的车队`,
|
||||
gameName,
|
||||
members: [userStore.userId]
|
||||
})
|
||||
|
||||
@@ -100,10 +100,10 @@ async function handleGameSelected(gameName: string) {
|
||||
>
|
||||
<img
|
||||
:src="member.avatar || '/default-avatar.svg'"
|
||||
:alt="member.username"
|
||||
:alt="member.name || member.username"
|
||||
class="avatar"
|
||||
/>
|
||||
<span class="username">{{ member.username }}</span>
|
||||
<span class="username">{{ member.name || member.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ async function handleGameSelected(gameName: string) {
|
||||
.team-session-panel {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.15);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.15);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue')
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'Changelog',
|
||||
component: () => import('@/views/Changelog.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Group, User } from '@/types'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { getUserGroups, getGroup, getGroupMembers } from '@/api/groups'
|
||||
|
||||
export const useGroupStore = defineStore('group', () => {
|
||||
@@ -14,8 +15,7 @@ export const useGroupStore = defineStore('group', () => {
|
||||
// 计算属性
|
||||
const currentGroupId = computed(() => currentGroup.value?.id || '')
|
||||
const isGroupOwner = computed(() => {
|
||||
const userId = localStorage.getItem('userId')
|
||||
return currentGroup.value?.owner === userId
|
||||
return currentGroup.value?.owner === pb.authStore.model?.id
|
||||
})
|
||||
|
||||
// 加载用户的群组列表
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Invitation } from '@/types'
|
||||
import type { Invitation, JoinRequest } from '@/types'
|
||||
import { getPendingInvitations, subscribeInvitations } from '@/api/invitations'
|
||||
import { getMyGroupsJoinRequests } from '@/api/groups'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
const pendingInvitations = ref<Invitation[]>([])
|
||||
const pendingJoinRequests = ref<JoinRequest[]>([])
|
||||
const loading = ref(false)
|
||||
const showPanel = ref(false)
|
||||
let unsubFn: (() => Promise<void> | void) | null = null
|
||||
let unsubJoinFn: (() => Promise<void> | void) | null = null
|
||||
|
||||
const unreadCount = computed(() => pendingInvitations.value.length)
|
||||
const unreadCount = computed(() =>
|
||||
pendingInvitations.value.length + pendingJoinRequests.value.length
|
||||
)
|
||||
|
||||
async function loadPendingInvitations() {
|
||||
try {
|
||||
loading.value = true
|
||||
pendingInvitations.value = await getPendingInvitations()
|
||||
pendingJoinRequests.value = await getMyGroupsJoinRequests()
|
||||
} catch (error) {
|
||||
console.error('加载待处理邀请失败:', error)
|
||||
console.error('加载通知失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -30,6 +36,10 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
unsubFn = await subscribeInvitations(() => {
|
||||
loadPendingInvitations()
|
||||
})
|
||||
|
||||
unsubJoinFn = await pb.collection('join_requests').subscribe('*', () => {
|
||||
loadPendingInvitations()
|
||||
})
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
@@ -37,18 +47,27 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
unsubFn()
|
||||
unsubFn = null
|
||||
}
|
||||
if (unsubJoinFn) {
|
||||
unsubJoinFn()
|
||||
unsubJoinFn = null
|
||||
}
|
||||
}
|
||||
|
||||
function removeInvitation(invitationId: string) {
|
||||
pendingInvitations.value = pendingInvitations.value.filter(i => i.id !== invitationId)
|
||||
}
|
||||
|
||||
function removeJoinRequest(requestId: string) {
|
||||
pendingJoinRequests.value = pendingJoinRequests.value.filter(r => r.id !== requestId)
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
showPanel.value = !showPanel.value
|
||||
}
|
||||
|
||||
return {
|
||||
pendingInvitations,
|
||||
pendingJoinRequests,
|
||||
loading,
|
||||
showPanel,
|
||||
unreadCount,
|
||||
@@ -56,6 +75,7 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
startListening,
|
||||
stopListening,
|
||||
removeInvitation,
|
||||
removeJoinRequest,
|
||||
togglePanel
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TeamSession, TeamStatus } from '@/types'
|
||||
import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
export const useTeamStore = defineStore('team', () => {
|
||||
// 状态
|
||||
@@ -44,8 +45,15 @@ export const useTeamStore = defineStore('team', () => {
|
||||
const session = await createTeamSession(data)
|
||||
currentSession.value = session
|
||||
|
||||
// 发送邀请(排除创建者自己)
|
||||
// 设置创建者状态为组队中
|
||||
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)
|
||||
if (others.length > 0) {
|
||||
await sendBulkInvitations(others, session.id)
|
||||
@@ -67,6 +75,15 @@ export const useTeamStore = defineStore('team', () => {
|
||||
try {
|
||||
const updated = await updateTeamStatus(currentSession.value.id, status)
|
||||
currentSession.value = updated
|
||||
|
||||
// 同步更新创建者状态
|
||||
const userStore = useUserStore()
|
||||
if (status === 'playing') {
|
||||
await userStore.setStatus('in_team')
|
||||
} else if (status === 'dissolved' || status === 'finished') {
|
||||
await userStore.setStatus('idle')
|
||||
}
|
||||
|
||||
return updated
|
||||
} catch (error: any) {
|
||||
console.error('更新小组状态失败:', error)
|
||||
@@ -82,6 +99,10 @@ export const useTeamStore = defineStore('team', () => {
|
||||
loading.value = true
|
||||
await endGame(currentSession.value.id)
|
||||
currentSession.value = null
|
||||
|
||||
// 重置本地用户状态
|
||||
const userStore = useUserStore()
|
||||
await userStore.setStatus('idle')
|
||||
} catch (error: any) {
|
||||
console.error('结束游戏失败:', error)
|
||||
throw error
|
||||
|
||||
@@ -12,7 +12,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => isAuthenticated() && user.value !== null)
|
||||
const userStatus = computed(() => user.value?.status || 'away')
|
||||
const userStatus = computed(() => user.value?.status || 'idle')
|
||||
const userId = computed(() => user.value?.id || '')
|
||||
|
||||
// 初始化用户信息
|
||||
@@ -41,6 +41,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
loading.value = true
|
||||
await pb.collection('users').authWithPassword(email, password)
|
||||
await initUser()
|
||||
// 首次登录设置默认状态为空闲
|
||||
if (user.value && !user.value.status) {
|
||||
await setStatus('idle')
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error('邮箱或密码错误')
|
||||
} finally {
|
||||
@@ -49,7 +53,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
// 注册
|
||||
async function register(data: { email: string; password: string; passwordConfirm: string; username: string }) {
|
||||
async function register(data: { email: string; password: string; passwordConfirm: string; username: string; name?: string }) {
|
||||
try {
|
||||
loading.value = true
|
||||
await pb.collection('users').create(data)
|
||||
|
||||
@@ -45,6 +45,7 @@ export type GamePlatform = 'PC' | 'PS5' | 'Xbox' | 'Switch' | 'Mobile'
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
name?: string
|
||||
email: string
|
||||
avatar?: string
|
||||
status: UserStatus
|
||||
@@ -66,6 +67,7 @@ export interface Group {
|
||||
owner: string
|
||||
members: string[]
|
||||
maxMembers: number
|
||||
requireApproval: boolean
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
@@ -151,3 +153,47 @@ export interface GameFavorite {
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
// 加入申请状态
|
||||
export type JoinRequestStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
// 加入申请
|
||||
export interface JoinRequest {
|
||||
id: string
|
||||
group: string
|
||||
user: string
|
||||
status: JoinRequestStatus
|
||||
rejectReason?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
group?: Group
|
||||
user?: User
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户显示名称(优先 name,回退 username)
|
||||
export function displayName(user?: { name?: string; username: string } | null): string {
|
||||
return user?.name || user?.username || '未知'
|
||||
}
|
||||
|
||||
// 多媒体记忆类型 (二期规划)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<!-- src/views/Changelog.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface LogEntry {
|
||||
version: string
|
||||
date: string
|
||||
title: string
|
||||
items: { type: 'feat' | 'fix' | 'refactor' | 'style'; text: string }[]
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([
|
||||
{
|
||||
version: 'v0.0.3',
|
||||
date: '2026-04-18',
|
||||
title: '优化登录和注册',
|
||||
items: [
|
||||
{ type: 'feat', text: '支持中文昵称登录:输入昵称即可登录,无需记忆邮箱' },
|
||||
{ type: 'feat', text: '注册时昵称唯一性检查:失焦自动检测,实时反馈是否可用' },
|
||||
{ type: 'feat', text: '注册支持中文昵称,自动生成系统用户名' },
|
||||
{ type: 'fix', text: '密码规则清晰展示:四项规则标签 + 密码强度指示条' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.0.2',
|
||||
date: '2026-04-18',
|
||||
title: '优化页面元素和游戏库筛选',
|
||||
items: [
|
||||
{ type: 'style', text: '统一色彩体系,将混用的蓝/紫色调全部替换为绿色主题' },
|
||||
{ type: 'feat', text: '侧边栏「创建群组」「加入群组」按钮添加文字标签,提升可发现性' },
|
||||
{ type: 'feat', text: '顶部 Header 增加快捷操作入口(创建群组、加入群组、通知)' },
|
||||
{ type: 'feat', text: '移动端适配:添加汉堡菜单,侧边栏滑动展开' },
|
||||
{ type: 'feat', text: '首页欢迎条增加 CTA 按钮,无群组时显示引导卡片' },
|
||||
{ type: 'feat', text: '加入群组弹窗新增按名称搜索模式,保留 ID 查找作为备选' },
|
||||
{ type: 'feat', text: '游戏库页面内置群组下拉选择,不再依赖外部选择' },
|
||||
{ type: 'fix', text: '热门游戏空状态优化提示文案' },
|
||||
{ type: 'refactor', text: '首页无临时小组时自动折叠空闲成员区域' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.0.1',
|
||||
date: '2026-04-17',
|
||||
title: '项目初始化',
|
||||
items: [
|
||||
{ type: 'feat', text: '搭建项目基础架构:Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS' },
|
||||
{ type: 'feat', text: '集成 PocketBase 后端服务,完成用户认证(注册/登录/Cookie 持久化)' },
|
||||
{ type: 'feat', text: '实现群组管理:创建群组、加入群组(ID 查找)、解散群组' },
|
||||
{ type: 'feat', text: '实现入群审批流程:审核开关、提交申请、群主审批' },
|
||||
{ type: 'feat', text: '实现临时小组:创建小队、邀请成员、开始游戏、结束解散' },
|
||||
{ type: 'feat', text: '实现组队邀请:发送邀请、接受/拒绝、实时通知' },
|
||||
{ type: 'feat', text: '实现游戏库:添加游戏、导入导出、评论收藏、热门排行' },
|
||||
{ type: 'feat', text: '用户状态管理:空闲/工作中/组队中/离开,工作时间自动切换' },
|
||||
{ type: 'feat', text: '实时数据同步:PocketBase Realtime 订阅群组、成员、邀请变更' },
|
||||
{ type: 'feat', text: 'Docker 部署方案:Dev/UAT 环境分离,独立 PocketBase 实例' },
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const typeMap: Record<string, { label: string; color: string }> = {
|
||||
feat: { label: '新功能', color: 'var(--gg-primary)' },
|
||||
fix: { label: '修复', color: 'var(--gg-warning)' },
|
||||
refactor: { label: '优化', color: 'var(--gg-info)' },
|
||||
style: { label: '样式', color: 'var(--gg-accent)' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="changelog-page">
|
||||
<h1 class="page-title">更新日志</h1>
|
||||
|
||||
<div class="timeline">
|
||||
<section v-for="log in logs" :key="log.version" class="version-block">
|
||||
<div class="version-header">
|
||||
<span class="version-tag">{{ log.version }}</span>
|
||||
<span class="version-date">{{ log.date }}</span>
|
||||
</div>
|
||||
<h2 class="version-title">{{ log.title }}</h2>
|
||||
<ul class="change-list">
|
||||
<li v-for="(item, i) in log.items" :key="i" class="change-item">
|
||||
<span class="change-type" :style="{ background: typeMap[item.type].color + '18', color: typeMap[item.type].color }">
|
||||
{{ typeMap[item.type].label }}
|
||||
</span>
|
||||
<span class="change-text">{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.changelog-page {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 28px;
|
||||
background: var(--gg-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.version-block {
|
||||
position: relative;
|
||||
padding: 20px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.version-block:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.version-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -29px;
|
||||
top: 28px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
border: 3px solid var(--gg-bg);
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.version-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.change-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.change-type {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.change-text {
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- src/views/GamesLibrary.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
|
||||
import type { Game, GamePlatform } from '@/types'
|
||||
@@ -12,6 +12,7 @@ import { Close } from '@element-plus/icons-vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const selectedGroupId = ref('')
|
||||
const games = ref<Game[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -22,19 +23,35 @@ const showDetail = ref(false)
|
||||
const showAddGame = ref(false)
|
||||
const showImport = ref(false)
|
||||
|
||||
const currentGroupId = computed(() => groupStore.currentGroupId)
|
||||
const groupOptions = computed(() => groupStore.groups)
|
||||
const hasGroups = computed(() => groupOptions.value.length > 0)
|
||||
|
||||
onMounted(async () => {
|
||||
if (currentGroupId.value) {
|
||||
if (groupStore.groups.length === 0) {
|
||||
await groupStore.loadGroups()
|
||||
}
|
||||
// 优先使用当前群组,否则选第一个
|
||||
if (groupStore.currentGroupId) {
|
||||
selectedGroupId.value = groupStore.currentGroupId
|
||||
} else if (groupOptions.value.length > 0) {
|
||||
selectedGroupId.value = groupOptions.value[0].id
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
await loadGames()
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedGroupId, () => {
|
||||
searchQuery.value = ''
|
||||
selectedPlatform.value = ''
|
||||
loadGames()
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
if (!currentGroupId.value) return
|
||||
if (!selectedGroupId.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await getGroupGames(currentGroupId.value, {
|
||||
const result = await getGroupGames(selectedGroupId.value, {
|
||||
limit: 100,
|
||||
search: searchQuery.value || undefined,
|
||||
platform: selectedPlatform.value || undefined
|
||||
@@ -66,8 +83,8 @@ async function handleDeleteGame(game: Game) {
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!currentGroupId.value) return
|
||||
exportGames(currentGroupId.value).then(data => {
|
||||
if (!selectedGroupId.value) return
|
||||
exportGames(selectedGroupId.value).then(data => {
|
||||
const json = JSON.stringify(data.map(g => ({
|
||||
name: g.name,
|
||||
platform: g.platform,
|
||||
@@ -78,7 +95,7 @@ function handleExport() {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `games-export-${currentGroupId.value}.json`
|
||||
a.download = `games-export-${selectedGroupId.value}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${data.length} 个游戏`)
|
||||
@@ -98,35 +115,56 @@ async function handleImportComplete() {
|
||||
|
||||
<template>
|
||||
<div class="games-library">
|
||||
<!-- 无群组提示 -->
|
||||
<div v-if="!currentGroupId" class="no-group">
|
||||
<p class="no-group-text">请先选择一个群组</p>
|
||||
<p class="no-group-hint">在左侧选择或创建群组后,即可管理游戏库</p>
|
||||
<!-- 无群组引导 -->
|
||||
<div v-if="!hasGroups" class="no-group">
|
||||
<div class="no-group-icon">🎮</div>
|
||||
<p class="no-group-text">还没有群组</p>
|
||||
<p class="no-group-hint">先创建或加入一个群组,然后就可以管理游戏库了</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 顶部栏:群组选择 + 工具栏 -->
|
||||
<div class="page-header">
|
||||
<h1>游戏库</h1>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索游戏..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@input="loadGames"
|
||||
/>
|
||||
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 120px" @change="loadGames">
|
||||
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||
<div class="header-row">
|
||||
<el-select
|
||||
v-model="selectedGroupId"
|
||||
placeholder="选择群组"
|
||||
class="group-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="g in groupOptions"
|
||||
:key="g.id"
|
||||
:label="g.name"
|
||||
:value="g.id"
|
||||
/>
|
||||
</el-select>
|
||||
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button>
|
||||
<button class="tool-btn" @click="showImport = true">导入</button>
|
||||
<button class="tool-btn" @click="handleExport">导出</button>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索游戏..."
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="loadGames"
|
||||
/>
|
||||
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 110px" @change="loadGames">
|
||||
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<span class="game-count" v-if="!loading">{{ games.length }} 个游戏</span>
|
||||
<div class="action-btns">
|
||||
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button>
|
||||
<button class="tool-btn" @click="showImport = true">导入</button>
|
||||
<button class="tool-btn" @click="handleExport">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<div v-else-if="games.length === 0" class="empty">
|
||||
<div class="empty-icon">📦</div>
|
||||
<p class="empty-text">暂无游戏</p>
|
||||
<p class="empty-hint">点击「添加游戏」或「导入」开始管理游戏库</p>
|
||||
</div>
|
||||
@@ -145,9 +183,9 @@ async function handleImportComplete() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" />
|
||||
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" />
|
||||
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" />
|
||||
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
|
||||
<AddGameDialog v-model="showAddGame" :group-id="selectedGroupId" @created="handleGameAdded" />
|
||||
<ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -156,70 +194,262 @@ async function handleImportComplete() {
|
||||
.games-library { width: 100%; }
|
||||
|
||||
.no-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 32px;
|
||||
text-align: center;
|
||||
padding: 120px 32px;
|
||||
}
|
||||
.no-group-text { font-size: 18px; font-weight: 600; color: var(--gg-text-secondary); margin: 0 0 8px; }
|
||||
.no-group-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 {
|
||||
font-size: 28px; font-weight: 700; margin: 0 0 16px;
|
||||
background: var(--gg-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.no-group-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.no-group-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.no-group-hint {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 页面头部 ── */
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-select {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.game-count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
padding: 8px 18px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card); color: var(--gg-text-secondary); font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; transition: all 0.2s; white-space: nowrap;
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
|
||||
|
||||
.tool-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.tool-btn.primary {
|
||||
background: var(--gg-gradient-green); border-color: transparent; color: white;
|
||||
background: var(--gg-gradient-green);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
.tool-btn.primary:hover { opacity: 0.9; }
|
||||
|
||||
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); }
|
||||
.tool-btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.empty { text-align: center; padding: 80px 32px; }
|
||||
.empty-text { font-size: 16px; font-weight: 500; color: var(--gg-text-secondary); margin: 0 0 8px; }
|
||||
.empty-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 游戏网格 ── */
|
||||
.games-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
position: relative; border-radius: var(--gg-radius-md); overflow: hidden; cursor: pointer;
|
||||
background: var(--gg-bg-card); border: 1px solid var(--gg-border);
|
||||
position: relative;
|
||||
border-radius: var(--gg-radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); }
|
||||
|
||||
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; }
|
||||
.game-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.12);
|
||||
}
|
||||
|
||||
.game-info { padding: 14px; }
|
||||
.game-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.game-name {
|
||||
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-platform {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.game-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.game-platform { font-size: 13px; color: var(--gg-text-secondary); margin: 0 0 8px; }
|
||||
|
||||
.game-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px;
|
||||
font-size: 11px; color: var(--gg-primary); font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--gg-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
|
||||
border: none; border-radius: 50%; background: rgba(0,0,0,0.5); color: white;
|
||||
font-size: 12px; cursor: pointer; opacity: 0; transition: opacity 0.2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.game-card:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* ── 移动端 ── */
|
||||
@media (max-width: 768px) {
|
||||
.header-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.group-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar .el-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
.game-card:hover .delete-btn { opacity: 1; }
|
||||
.delete-btn:hover { background: var(--gg-danger); }
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const members = computed(() => groupStore.currentMembers)
|
||||
|
||||
const ownerName = computed(() => {
|
||||
const owner = members.value.find(m => m.id === group.value?.owner)
|
||||
return owner?.username || '未知'
|
||||
return owner?.name || owner?.username || '未知'
|
||||
})
|
||||
|
||||
const membersByStatus = computed(() => {
|
||||
@@ -34,8 +34,9 @@ const membersByStatus = computed(() => {
|
||||
away: []
|
||||
}
|
||||
for (const m of members.value) {
|
||||
if (groups[m.status]) {
|
||||
groups[m.status].push(m)
|
||||
const s = m.status || 'idle'
|
||||
if (groups[s]) {
|
||||
groups[s].push(m)
|
||||
} else {
|
||||
groups.away.push(m)
|
||||
}
|
||||
@@ -154,8 +155,8 @@ async function refreshMembers() {
|
||||
<div v-if="list.length === 0" class="empty-row">暂无</div>
|
||||
<div v-else class="member-row-list">
|
||||
<div v-for="m in list" :key="m.id" class="member-row">
|
||||
<img :src="m.avatar || '/default-avatar.svg'" :alt="m.username" class="avatar" />
|
||||
<span class="member-name">{{ m.username }}</span>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" :alt="m.name || m.username" class="avatar" />
|
||||
<span class="member-name">{{ m.name || m.username }}</span>
|
||||
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +219,7 @@ async function refreshMembers() {
|
||||
font-size: 13px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
+257
-86
@@ -4,17 +4,24 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { getPopularGames } from '@/api/games'
|
||||
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
||||
import { UserStatusMap } from '@/types'
|
||||
import type { Game } from '@/types'
|
||||
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
|
||||
import IdleMembersList from '@/components/team/IdleMembersList.vue'
|
||||
import { User, Promotion, TrendCharts } from '@element-plus/icons-vue'
|
||||
import { Plus, Search, User, Promotion, TrendCharts } from '@element-plus/icons-vue'
|
||||
import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue'
|
||||
import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
const teamStore = useTeamStore()
|
||||
|
||||
const showCreateGroup = ref(false)
|
||||
const showJoinGroup = ref(false)
|
||||
|
||||
const popularGames = ref<Game[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -28,6 +35,9 @@ const statusDotClass = computed(() => {
|
||||
|
||||
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
|
||||
|
||||
const hasNoGroup = computed(() => groupStore.groups.length === 0)
|
||||
const hasNoSession = computed(() => !teamStore.currentSession)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPopularGames()
|
||||
})
|
||||
@@ -56,33 +66,54 @@ function openGameDetail(game: Game) {
|
||||
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 欢迎区 -->
|
||||
<section class="welcome-section">
|
||||
<h1 class="welcome-title">
|
||||
欢迎回来, <span class="gg-text-gradient">{{ userStore.user?.username || '玩家' }}</span>!
|
||||
</h1>
|
||||
<div class="status-line">
|
||||
<span :class="statusDotClass" />
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
|
||||
<!-- 欢迎 + 状态条 -->
|
||||
<section class="welcome-bar">
|
||||
<div class="welcome-left">
|
||||
<h1 class="welcome-title">
|
||||
欢迎回来, <span class="gg-text-gradient">{{ userStore.user?.name || userStore.user?.username || '玩家' }}</span>
|
||||
</h1>
|
||||
<div class="status-line">
|
||||
<span :class="statusDotClass" />
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button class="cta-btn cta-btn--primary" @click="showCreateGroup = true">
|
||||
<el-icon><Plus /></el-icon> 创建群组
|
||||
</button>
|
||||
<button class="cta-btn cta-btn--secondary" @click="showJoinGroup = true">
|
||||
<el-icon><Search /></el-icon> 加入群组
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 无群组引导 -->
|
||||
<section v-if="hasNoGroup" class="onboarding-section">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-icon"><el-icon :size="48"><User /></el-icon></div>
|
||||
<h2 class="onboarding-title">开始你的组队之旅</h2>
|
||||
<p class="onboarding-desc">创建一个群组邀请好友,或搜索加入已有群组</p>
|
||||
<div class="onboarding-actions">
|
||||
<button class="onboarding-btn onboarding-btn--primary" @click="showCreateGroup = true">
|
||||
<el-icon><Plus /></el-icon> 创建群组
|
||||
</button>
|
||||
<button class="onboarding-btn onboarding-btn--outline" @click="showJoinGroup = true">
|
||||
<el-icon><Search /></el-icon> 加入群组
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主内容双栏 -->
|
||||
<div class="content-grid">
|
||||
<div v-else class="content-grid">
|
||||
<!-- 左列: 我的群组 -->
|
||||
<section class="section groups-section">
|
||||
<h2 class="section-title">
|
||||
<el-icon class="section-icon"><User /></el-icon> 我的群组
|
||||
</h2>
|
||||
|
||||
<div v-if="groupStore.groups.length === 0" class="empty-state">
|
||||
<div class="empty-icon"><el-icon :size="40"><User /></el-icon></div>
|
||||
<p class="empty-text">暂无群组</p>
|
||||
<p class="empty-hint">创建或加入一个群组,开始组队冒险吧</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="group-grid">
|
||||
<div class="group-grid">
|
||||
<div
|
||||
v-for="group in groupStore.groups"
|
||||
:key="group.id"
|
||||
@@ -107,7 +138,7 @@ function openGameDetail(game: Game) {
|
||||
<TeamSessionPanel />
|
||||
</div>
|
||||
|
||||
<div class="idle-section">
|
||||
<div v-if="!hasNoSession" class="idle-section">
|
||||
<IdleMembersList status="idle" />
|
||||
</div>
|
||||
</section>
|
||||
@@ -124,9 +155,8 @@ function openGameDetail(game: Game) {
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="popularGames.length === 0" class="empty-state">
|
||||
<div class="empty-icon"><el-icon :size="40"><TrendCharts /></el-icon></div>
|
||||
<p class="empty-text">暂无热门游戏</p>
|
||||
<div v-else-if="popularGames.length === 0" class="empty-games">
|
||||
<p>暂无热门游戏,去群组中添加游戏吧</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="games-scroll">
|
||||
@@ -152,6 +182,8 @@ function openGameDetail(game: Game) {
|
||||
</section>
|
||||
|
||||
<GameDetailDialog v-model="showGameDetail" :game="selectedGame" @create-team="() => {}" />
|
||||
<CreateGroupDialog v-model="showCreateGroup" />
|
||||
<JoinGroupDialog v-model="showJoinGroup" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,12 +192,15 @@ function openGameDetail(game: Game) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ── 欢迎区 ── */
|
||||
.welcome-section {
|
||||
padding: 28px 32px;
|
||||
/* ── 欢迎条 ── */
|
||||
.welcome-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 28px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
@@ -173,7 +208,7 @@ function openGameDetail(game: Game) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-section::before {
|
||||
.welcome-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -183,30 +218,149 @@ function openGameDetail(game: Game) {
|
||||
background: var(--gg-gradient);
|
||||
}
|
||||
|
||||
.welcome-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cta-btn--primary {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cta-btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 12px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.cta-btn--secondary {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.cta-btn--secondary:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* ── 新手引导 ── */
|
||||
.onboarding-section {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 32px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 2px dashed var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.onboarding-icon {
|
||||
font-size: 48px;
|
||||
color: var(--gg-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.onboarding-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.onboarding-desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.onboarding-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.onboarding-btn--primary {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.onboarding-btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.onboarding-btn--outline {
|
||||
background: var(--gg-bg-card);
|
||||
border: 2px solid var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.onboarding-btn--outline:hover {
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
}
|
||||
|
||||
/* ── 通用 section ── */
|
||||
.section {
|
||||
display: flex;
|
||||
@@ -214,9 +368,9 @@ function openGameDetail(game: Game) {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
margin: 0 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -224,47 +378,47 @@ function openGameDetail(game: Game) {
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ── 双栏内容 ── */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 28px;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ── 群组区 ── */
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 18px 20px;
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s, box-shadow 0.25s, transform 0.2s;
|
||||
}
|
||||
|
||||
.group-card:hover {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 24px rgba(99, 102, 241, 0.18);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.group-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
@@ -274,10 +428,10 @@ function openGameDetail(game: Game) {
|
||||
|
||||
.member-badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
background: rgba(5, 150, 105, 0.12);
|
||||
color: var(--gg-primary);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -297,7 +451,7 @@ function openGameDetail(game: Game) {
|
||||
.session-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.session-card,
|
||||
@@ -313,6 +467,16 @@ function openGameDetail(game: Game) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.empty-games {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 14px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
}
|
||||
|
||||
.games-scroll {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -332,22 +496,22 @@ function openGameDetail(game: Game) {
|
||||
|
||||
.game-card {
|
||||
flex-shrink: 0;
|
||||
width: 160px;
|
||||
width: 150px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.game-card:hover .game-cover-wrap {
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.2);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
|
||||
.game-cover-wrap {
|
||||
width: 160px;
|
||||
height: 200px;
|
||||
width: 150px;
|
||||
height: 190px;
|
||||
border-radius: var(--gg-radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--gg-bg-elevated);
|
||||
@@ -365,12 +529,12 @@ function openGameDetail(game: Game) {
|
||||
.game-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.game-name {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
@@ -384,40 +548,11 @@ function openGameDetail(game: Game) {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--gg-accent-light);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px dashed var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 15px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
@@ -441,4 +576,40 @@ function openGameDetail(game: Game) {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── 移动端适配 ── */
|
||||
@media (max-width: 768px) {
|
||||
.welcome-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+238
-39
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
@@ -10,7 +10,8 @@ import WorkScheduleModal from '@/components/team/WorkScheduleModal.vue'
|
||||
import NotificationPanel from '@/components/common/NotificationPanel.vue'
|
||||
import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue'
|
||||
import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue'
|
||||
import { Monitor, HomeFilled, Grid, Link, AlarmClock, SwitchButton, Bell, Plus } from '@element-plus/icons-vue'
|
||||
import { Monitor, HomeFilled, Grid, Plus, Search, Bell, AlarmClock, SwitchButton, Document } from '@element-plus/icons-vue'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -22,6 +23,9 @@ const notificationStore = useNotificationStore()
|
||||
const showScheduleModal = ref(false)
|
||||
const showCreateGroup = ref(false)
|
||||
const showJoinGroup = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.initUser()
|
||||
@@ -29,10 +33,19 @@ onMounted(async () => {
|
||||
await teamStore.loadActiveSession()
|
||||
await notificationStore.loadPendingInvitations()
|
||||
await notificationStore.startListening()
|
||||
|
||||
refreshTimer = setInterval(async () => {
|
||||
await groupStore.loadGroups()
|
||||
await teamStore.loadActiveSession()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
notificationStore.stopListening()
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -43,17 +56,31 @@ function handleLogout() {
|
||||
function selectGroup(groupId: string) {
|
||||
groupStore.setCurrentGroup(groupId)
|
||||
router.push({ name: 'GroupView', params: { id: groupId } })
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'Home' })
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (route.name === 'GroupView') return groupStore.currentGroup?.name
|
||||
if (route.name === 'GamesLibrary') return '游戏库'
|
||||
if (route.name === 'Profile') return '个人中心'
|
||||
if (route.name === 'Settings') return '设置'
|
||||
if (route.name === 'Changelog') return '更新日志'
|
||||
return '首页'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false" />
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" :class="{ 'sidebar--open': sidebarOpen }">
|
||||
<div class="sidebar-top">
|
||||
<div class="logo" @click="goHome">
|
||||
<el-icon class="logo-icon"><Monitor /></el-icon>
|
||||
@@ -70,20 +97,34 @@ function goHome() {
|
||||
<el-icon class="nav-icon"><Grid /></el-icon>
|
||||
<span>游戏库</span>
|
||||
</router-link>
|
||||
<router-link to="/changelog" class="nav-item" active-class="nav-item--active">
|
||||
<el-icon class="nav-icon"><Document /></el-icon>
|
||||
<span>更新日志</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-divider" />
|
||||
|
||||
<!-- 群组操作区 -->
|
||||
<div class="sidebar-group-actions">
|
||||
<button class="group-action-btn group-action-btn--create" @click="showCreateGroup = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>创建群组</span>
|
||||
</button>
|
||||
<button class="group-action-btn group-action-btn--join" @click="showJoinGroup = true">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>加入群组</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 群组列表 -->
|
||||
<div class="sidebar-groups">
|
||||
<div class="section-header">
|
||||
<span class="section-title">我的群组</span>
|
||||
<div class="section-actions">
|
||||
<button class="section-btn" @click="showCreateGroup = true" title="创建群组"><el-icon><Plus /></el-icon></button>
|
||||
<button class="section-btn" @click="showJoinGroup = true" title="加入群组"><el-icon><Link /></el-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="groupStore.groups.length === 0" class="empty-groups">
|
||||
暂无群组
|
||||
<p>还没有群组</p>
|
||||
<p class="empty-hint">点击上方按钮创建或加入</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="group in groupStore.groups"
|
||||
@@ -111,7 +152,7 @@ function goHome() {
|
||||
class="user-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<span class="user-name">{{ userStore.user?.username }}</span>
|
||||
<span class="user-name">{{ displayName(userStore.user) }}</span>
|
||||
<button class="logout-btn" @click="handleLogout" title="退出登录">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
</button>
|
||||
@@ -122,10 +163,24 @@ function goHome() {
|
||||
<!-- 右侧 -->
|
||||
<div class="main-wrapper">
|
||||
<header class="top-header">
|
||||
<h2 class="page-title">
|
||||
{{ $route.name === 'GroupView' ? groupStore.currentGroup?.name : $route.name === 'GamesLibrary' ? '游戏库' : $route.name === 'Profile' ? '个人中心' : $route.name === 'Settings' ? '设置' : '首页' }}
|
||||
</h2>
|
||||
<!-- 移动端汉堡按钮 -->
|
||||
<button class="hamburger-btn" @click="sidebarOpen = !sidebarOpen">
|
||||
<span class="hamburger-line" />
|
||||
<span class="hamburger-line" />
|
||||
<span class="hamburger-line" />
|
||||
</button>
|
||||
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="header-action-btn" @click="showCreateGroup = true" title="创建群组">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span class="header-action-label">创建群组</span>
|
||||
</button>
|
||||
<button class="header-action-btn" @click="showJoinGroup = true" title="加入群组">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span class="header-action-label">加入群组</span>
|
||||
</button>
|
||||
<button class="icon-btn" @click="notificationStore.togglePanel()">
|
||||
<span v-if="notificationStore.unreadCount > 0" class="badge">
|
||||
{{ notificationStore.unreadCount }}
|
||||
@@ -235,6 +290,50 @@ function goHome() {
|
||||
margin: 12px 20px;
|
||||
}
|
||||
|
||||
/* ── 群组操作按钮 ── */
|
||||
.sidebar-group-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.group-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.group-action-btn--create {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.group-action-btn--create:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 12px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.group-action-btn--join {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.group-action-btn--join:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* ── 群组列表 ── */
|
||||
.sidebar-groups {
|
||||
flex: 1;
|
||||
@@ -246,44 +345,32 @@ function goHome() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 14px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 14px 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.section-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.empty-groups {
|
||||
text-align: center;
|
||||
color: var(--gg-text-muted);
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.empty-groups p {
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px !important;
|
||||
margin-top: 4px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
@@ -407,6 +494,7 @@ function goHome() {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -414,12 +502,43 @@ function goHome() {
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card);
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-action-btn:first-child {
|
||||
background: var(--gg-gradient-green);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.header-action-btn:first-child:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.header-action-btn:not(:first-child):hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -457,4 +576,84 @@ function goHome() {
|
||||
flex: 1;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
/* ── 汉堡按钮 ── */
|
||||
.hamburger-btn {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 8px 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--gg-text);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ── 移动端遮罩 ── */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
/* ── 移动端适配 ── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar--open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-action-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.header-action-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,27 +4,41 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import PasswordInput from '@/components/common/PasswordInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const email = ref('')
|
||||
const identity = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email.value || !password.value) {
|
||||
ElMessage.warning('请输入邮箱和密码')
|
||||
if (!identity.value || !password.value) {
|
||||
ElMessage.warning('请输入昵称/邮箱和密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await userStore.login(email.value, password.value)
|
||||
let loginIdentity = identity.value.trim()
|
||||
|
||||
const redirect = '/'
|
||||
router.push(redirect)
|
||||
// 如果不包含 @,按昵称或用户名查找对应 username 用于认证
|
||||
if (!loginIdentity.includes('@')) {
|
||||
const result = await pb.collection('users').getList(1, 1, {
|
||||
filter: `name="${loginIdentity}" || username="${loginIdentity}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
if (result.items.length === 0) {
|
||||
ElMessage.error('用户不存在')
|
||||
return
|
||||
}
|
||||
loginIdentity = (result.items[0] as any).username
|
||||
}
|
||||
|
||||
await userStore.login(loginIdentity, password.value)
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
} finally {
|
||||
@@ -45,12 +59,11 @@ async function handleLogin() {
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<label for="identity">昵称 / 邮箱</label>
|
||||
<el-input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
id="identity"
|
||||
v-model="identity"
|
||||
placeholder="请输入昵称或邮箱"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ function handleAvatarUpload() {
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.25);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.25);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
||||
+192
-55
@@ -1,49 +1,94 @@
|
||||
<!-- src/views/Register.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import PasswordInput from '@/components/common/PasswordInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const username = ref('')
|
||||
const nickname = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const loading = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
// ── 实时校验 ──
|
||||
const nicknameOk = computed(() => nickname.value.length >= 2 && nickname.value.length <= 16)
|
||||
const nicknameTaken = ref(false)
|
||||
const nicknameChecking = ref(false)
|
||||
|
||||
async function checkNickname() {
|
||||
const val = nickname.value.trim()
|
||||
if (!val || val.length < 2) { nicknameTaken.value = false; return }
|
||||
try {
|
||||
nicknameChecking.value = true
|
||||
const result = await pb.collection('users').getList(1, 1, {
|
||||
filter: `name="${val}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
nicknameTaken.value = result.items.length > 0
|
||||
} catch { /* ignore */ } finally { nicknameChecking.value = false }
|
||||
}
|
||||
|
||||
const hasLetter = computed(() => /[a-zA-Z]/.test(password.value))
|
||||
const hasDigit = computed(() => /[0-9]/.test(password.value))
|
||||
const hasSpecial = computed(() => /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password.value))
|
||||
const pwLenOk = computed(() => password.value.length >= 6)
|
||||
const pwTypeCount = computed(() => [hasLetter.value, hasDigit.value, hasSpecial.value].filter(Boolean).length)
|
||||
const passwordOk = computed(() => pwLenOk.value && pwTypeCount.value >= 2)
|
||||
const matchOk = computed(() => password.value === passwordConfirm.value && password.value !== '')
|
||||
|
||||
const passwordStrength = computed(() => {
|
||||
if (!pwLenOk.value) return 0
|
||||
if (pwTypeCount.value >= 3 && password.value.length >= 8) return 3
|
||||
if (pwTypeCount.value >= 2) return 2
|
||||
return 1
|
||||
})
|
||||
const strengthLabels = ['', '弱', '中', '强']
|
||||
const strengthColors = ['', '#ef4444', '#f59e0b', '#10b981']
|
||||
|
||||
async function handleRegister() {
|
||||
if (!username.value || !email.value || !password.value) {
|
||||
ElMessage.warning('请填写所有必填项')
|
||||
formError.value = ''
|
||||
|
||||
if (!nickname.value || !email.value || !password.value) {
|
||||
formError.value = '请填写所有必填项'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
if (!nicknameOk.value) {
|
||||
formError.value = '昵称需 2-16 个字符'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value.length < 6) {
|
||||
ElMessage.warning('密码长度至少为 6 位')
|
||||
if (nicknameTaken.value) {
|
||||
formError.value = '该昵称已被使用'
|
||||
return
|
||||
}
|
||||
if (!passwordOk.value) {
|
||||
formError.value = '密码不符合要求'
|
||||
return
|
||||
}
|
||||
if (!matchOk.value) {
|
||||
formError.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
// 自动生成合法 username(PocketBase 要求英文字母数字),昵称存到 name 字段
|
||||
const autoUsername = 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
await userStore.register({
|
||||
username: username.value,
|
||||
username: autoUsername,
|
||||
name: nickname.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
passwordConfirm: passwordConfirm.value
|
||||
})
|
||||
|
||||
ElMessage.success('注册成功')
|
||||
router.push({ name: 'Home' })
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '注册失败')
|
||||
formError.value = error.message || '注册失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -61,49 +106,55 @@ async function handleRegister() {
|
||||
</div>
|
||||
|
||||
<form class="register-form" @submit.prevent="handleRegister">
|
||||
<!-- 昵称 -->
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<el-input
|
||||
id="username"
|
||||
v-model="username"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
<label for="nickname">昵称</label>
|
||||
<el-input id="nickname" v-model="nickname" placeholder="2-16 个字符,支持中文" required @blur="checkNickname" />
|
||||
<span v-if="nicknameChecking" class="field-hint">检查中...</span>
|
||||
<span v-else-if="nicknameTaken && nickname.length >= 2" class="field-hint error">该昵称已被使用</span>
|
||||
<span v-else-if="nicknameOk && nickname" class="field-hint ok">昵称可用</span>
|
||||
<div class="field-rules">
|
||||
<span class="rule" :class="{ ok: nicknameOk && nickname }">2-16 个字符,支持中英文</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<el-input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
<el-input id="email" v-model="email" type="email" placeholder="请输入邮箱" required />
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
required
|
||||
/>
|
||||
<PasswordInput id="password" v-model="password" placeholder="设置密码" required />
|
||||
<div class="pw-rules">
|
||||
<span class="rule" :class="{ ok: pwLenOk }">至少 6 位</span>
|
||||
<span class="rule" :class="{ ok: hasLetter }">包含字母</span>
|
||||
<span class="rule" :class="{ ok: hasDigit }">包含数字</span>
|
||||
<span class="rule" :class="{ ok: hasSpecial }">包含特殊字符</span>
|
||||
</div>
|
||||
<div class="pw-note">需满足以上四项中的任意两项</div>
|
||||
<div v-if="password" class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div class="strength-fill" :style="{ width: passwordStrength * 33 + '%', background: strengthColors[passwordStrength] }" />
|
||||
</div>
|
||||
<span class="strength-label" :style="{ color: strengthColors[passwordStrength] }">{{ strengthLabels[passwordStrength] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<div class="form-group">
|
||||
<label for="passwordConfirm">确认密码</label>
|
||||
<PasswordInput
|
||||
id="passwordConfirm"
|
||||
v-model="passwordConfirm"
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
/>
|
||||
<PasswordInput id="passwordConfirm" v-model="passwordConfirm" placeholder="请再次输入密码" required />
|
||||
<span v-if="passwordConfirm && !matchOk" class="field-hint error">密码不一致</span>
|
||||
</div>
|
||||
|
||||
<!-- 表单级错误 -->
|
||||
<div v-if="formError" class="form-error">{{ formError }}</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="loading">
|
||||
<span v-if="loading" class="btn-loader"></span>
|
||||
<span v-if="loading" class="btn-loader" />
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -123,15 +174,14 @@ async function handleRegister() {
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 30%, #f5f3ff 100%);
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 30%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
/* ── 卡片 ── */
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 44px 40px 40px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--gg-border);
|
||||
@@ -139,15 +189,12 @@ async function handleRegister() {
|
||||
box-shadow: 0 8px 40px rgba(5, 150, 105, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ── 头部品牌 ── */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.brand { margin: 0 0 8px; }
|
||||
|
||||
.brand-text {
|
||||
font-size: 32px;
|
||||
@@ -163,27 +210,117 @@ async function handleRegister() {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 表单 ── */
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── 规则提示 ── */
|
||||
.field-rules {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pw-rules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rule::before {
|
||||
content: '○';
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.rule.ok {
|
||||
color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.rule.ok::before {
|
||||
content: '●';
|
||||
}
|
||||
|
||||
.pw-note {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-hint.error {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.field-hint.ok {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* ── 密码强度 ── */
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 表单错误 ── */
|
||||
.form-error {
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: var(--gg-danger);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 提交按钮 ── */
|
||||
@@ -220,7 +357,7 @@ async function handleRegister() {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 8px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
|
||||
@@ -81,7 +81,7 @@ const showScheduleModal = ref(false)
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.1);
|
||||
box-shadow: 0 0 12px rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
@@ -115,7 +115,7 @@ const showScheduleModal = ref(false)
|
||||
|
||||
.setting-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 0 12px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.setting-btn:disabled {
|
||||
|
||||
Reference in New Issue
Block a user