diff --git a/backend/pb_migrations/1776510001_created_ledgers.js b/backend/pb_migrations/1776510001_created_ledgers.js new file mode 100644 index 0000000..fb5826b --- /dev/null +++ b/backend/pb_migrations/1776510001_created_ledgers.js @@ -0,0 +1,151 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "ledgers_col", + "created": "2026-04-18 10:00:01.000Z", + "updated": "2026-04-18 10:00:01.000Z", + "name": "ledgers", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "lgr_group", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "lgr_creator", + "name": "creator", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "lgr_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "income", + "expense" + ] + } + }, + { + "system": false, + "id": "lgr_amount", + "name": "amount", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 0.01, + "max": null, + "noDecimal": false + } + }, + { + "system": false, + "id": "lgr_category", + "name": "category", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "gaming", + "food", + "equipment", + "transport", + "other" + ] + } + }, + { + "system": false, + "id": "lgr_desc", + "name": "description", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "lgr_members", + "name": "relatedMembers", + "type": "relation", + "required": false, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": null, + "displayFields": null + } + }, + { + "system": false, + "id": "lgr_occurred", + "name": "occurredAt", + "type": "date", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("ledgers_col"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776510002_created_assets.js b/backend/pb_migrations/1776510002_created_assets.js new file mode 100644 index 0000000..4720384 --- /dev/null +++ b/backend/pb_migrations/1776510002_created_assets.js @@ -0,0 +1,138 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "assets_col", + "created": "2026-04-18 10:00:02.000Z", + "updated": "2026-04-18 10:00:02.000Z", + "name": "assets", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "ast_group", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "ast_creator", + "name": "creator", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "ast_name", + "name": "name", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 100, + "pattern": "" + } + }, + { + "system": false, + "id": "ast_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "game_account", + "console", + "equipment", + "accessory", + "other" + ] + } + }, + { + "system": false, + "id": "ast_desc", + "name": "description", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "ast_holder", + "name": "currentHolder", + "type": "relation", + "required": false, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "ast_image", + "name": "image", + "type": "file", + "required": false, + "presentable": false, + "unique": false, + "options": { + "mimeTypes": null, + "thumbs": ["200x200"], + "maxSelect": 1, + "maxSize": 5242880, + "protected": false + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("assets_col"); + + return dao.deleteCollection(collection); +}) diff --git a/docs/plans/2026-04-18-phase3-features-design.md b/docs/plans/2026-04-18-phase3-features-design.md new file mode 100644 index 0000000..cdfbdc5 --- /dev/null +++ b/docs/plans/2026-04-18-phase3-features-design.md @@ -0,0 +1,146 @@ +# Game Group V2 — 三期功能设计 + +## 功能总览 + +| 优先级 | 功能 | 说明 | +|--------|------|------| +| P0 | 账目管理 | 群组费用流水记录,纯记录不分摊 | +| P0 | 资产管理 | 群组公共资产清单,自由标记持有人 | + +--- + +## P0: 账目管理 + +### 定位 + +群组内费用流水记录。成员记录每笔收入/支出(聚餐、游戏充值、设备采购等),纯记录,不做分摊和结算。 + +### 数据模型 + +**`ledgers` Collection:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| group | relation → groups | 所属群组 | +| creator | relation → users | 记录人 | +| type | select: `income` / `expense` | 收入/支出 | +| amount | number | 金额 | +| category | select: `gaming` / `food` / `equipment` / `transport` / `other` | 分类 | +| description | text | 描述 | +| relatedMembers | relation → users (multiple) | 相关成员 | +| occurredAt | date | 发生时间 | + +### 业务规则 + +- 所有群成员可记录和查看 +- 只有记录人可编辑/删除 +- 收入用绿色,支出用红色,直观区分 + +### 前端页面 + +路由:`/group/:groupId/ledger` + +``` +components/ledger/ +├── LedgerList.vue # 账目列表(收入/支出分色显示) +├── LedgerCard.vue # 单条账目卡片 +├── CreateLedgerDialog.vue # 新建账目弹窗 +└── LedgerSummary.vue # 汇总面板(总收入/总支出/余额) +``` + +### API 层 (`api/ledgers.ts`) + +- `createLedger(groupId, data)` — 新建记录 +- `listLedgers(groupId, filter?)` — 列表(支持按月筛选) +- `updateLedger(ledgerId, data)` — 更新(仅记录人) +- `deleteLedger(ledgerId)` — 删除(仅记录人) +- `getLedgerSummary(groupId)` — 汇总统计 + +--- + +## P0: 资产管理 + +### 定位 + +群组公共资产清单。登记游戏账号、主机、手柄等物品,任何成员可自由标记当前持有人。空持有人表示资产在库。 + +### 数据模型 + +**`assets` Collection:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| group | relation → groups | 所属群组 | +| creator | relation → users | 登记人 | +| name | text | 资产名称 | +| type | select: `game_account` / `console` / `equipment` / `accessory` / `other` | 类型 | +| description | text, 可选 | 描述备注 | +| currentHolder | relation → users, 可选 | 当前持有人(空=在库) | +| image | file, 可选 | 资产照片 | + +### 业务规则 + +- 所有群成员可登记和查看 +- 任何成员可更新持有人(标记自己拿走/放回) +- 只有登记人可编辑/删除资产信息 + +### 前端页面 + +路由:`/group/:groupId/assets` + +``` +components/asset/ +├── AssetList.vue # 资产列表(卡片网格) +├── AssetCard.vue # 资产卡片(显示持有人头像) +├── CreateAssetDialog.vue # 登记资产弹窗 +└── TransferAssetDialog.vue # 转移持有人弹窗(选择成员) +``` + +### API 层 (`api/assets.ts`) + +- `createAsset(groupId, data)` — 登记资产 +- `listAssets(groupId)` — 资产列表 +- `updateAsset(assetId, data)` — 更新信息 +- `deleteAsset(assetId)` — 删除 +- `transferAsset(assetId, userId?)` — 更新持有人(null=放回在库) + +--- + +## 导航入口 + +GroupView 群组操作菜单中添加: +- "账目"按钮 → 跳转 `/group/:groupId/ledger` +- "资产"按钮 → 跳转 `/group/:groupId/assets` + +--- + +## 实现文件变更总览 + +### 新增文件 + +| 文件 | 说明 | +|------|------| +| `frontend/src/api/ledgers.ts` | 账目 API | +| `frontend/src/api/assets.ts` | 资产 API | +| `frontend/src/components/ledger/*.vue` | 账目组件 (4 个) | +| `frontend/src/components/asset/*.vue` | 资产组件 (4 个) | +| `frontend/src/views/LedgerView.vue` | 账目页面 | +| `frontend/src/views/AssetView.vue` | 资产页面 | +| `backend/pb_migrations/xxxx_create_ledgers.js` | 账目表迁移 | +| `backend/pb_migrations/xxxx_create_assets.js` | 资产表迁移 | + +### 修改文件 + +| 文件 | 变更 | +|------|------| +| `frontend/src/types/index.ts` | 新增 Ledger、Asset 类型定义 | +| `frontend/src/router/index.ts` | 新增 ledger、asset 路由 | +| `frontend/src/views/GroupView.vue` | 添加账目/资产入口按钮 | + +### 建议实现顺序 + +1. 数据库迁移(ledgers → assets) +2. 类型定义 (types/index.ts) +3. API 层 (ledgers.ts → assets.ts) +4. 账目页面(组件 + 路由 + 导航入口) +5. 资产页面(组件 + 路由 + 导航入口) diff --git a/docs/plans/2026-04-18-phase3-implementation.md b/docs/plans/2026-04-18-phase3-implementation.md new file mode 100644 index 0000000..2ccc8eb --- /dev/null +++ b/docs/plans/2026-04-18-phase3-implementation.md @@ -0,0 +1,920 @@ +# Phase 3: Ledger + Asset Management 实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为群组添加账目流水记录和公共资产清单功能,两个独立页面。 + +**Architecture:** 新增两个 PocketBase Collection(ledgers、assets),对应 API 层、类型定义、Pinia Store、Vue 组件和路由。通过 GroupView 操作入口跳转到独立页面。纯前端实现,无自定义后端逻辑。 + +**Tech Stack:** Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + PocketBase JS SDK + +--- + +## Task 1: PocketBase 数据库迁移 + +**Files:** +- Create: `backend/pb_migrations/1776510001_created_ledgers.js` +- Create: `backend/pb_migrations/1776510002_created_assets.js` + +**Step 1: 创建 ledgers 迁移文件** + +```javascript +// backend/pb_migrations/1776510001_created_ledgers.js +/// +migrate((db) => { + const collection = new Collection({ + "id": "ledgers_col", + "name": "ledgers", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_group", + "name": "group", + "type": "relation", + "required": true, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_creator", + "name": "creator", + "type": "relation", + "required": true, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_type", + "name": "type", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["income", "expense"] + } + }, + { + "system": false, + "id": "sf_amount", + "name": "amount", + "type": "number", + "required": true, + "options": { + "min": 0.01, + "max": null, + "noDecimal": false + } + }, + { + "system": false, + "id": "sf_category", + "name": "category", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["gaming", "food", "equipment", "transport", "other"] + } + }, + { + "system": false, + "id": "sf_desc", + "name": "description", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_members", + "name": "relatedMembers", + "type": "relation", + "required": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": null, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_occurred", + "name": "occurredAt", + "type": "date", + "required": true, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("ledgers_col"); + return dao.deleteCollection(collection); +}) +``` + +**Step 2: 创建 assets 迁移文件** + +```javascript +// backend/pb_migrations/1776510002_created_assets.js +/// +migrate((db) => { + const collection = new Collection({ + "id": "assets_col", + "name": "assets", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_group", + "name": "group", + "type": "relation", + "required": true, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_creator", + "name": "creator", + "type": "relation", + "required": true, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_name", + "name": "name", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 100, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_type", + "name": "type", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["game_account", "console", "equipment", "accessory", "other"] + } + }, + { + "system": false, + "id": "sf_desc", + "name": "description", + "type": "text", + "required": false, + "options": { + "min": null, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_holder", + "name": "currentHolder", + "type": "relation", + "required": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_image", + "name": "image", + "type": "file", + "required": false, + "options": { + "mimeTypes": ["image/*"], + "thumbs": ["200x200"], + "maxSelect": 1, + "maxSize": 5242880, + "protected": false + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("assets_col"); + return dao.deleteCollection(collection); +}) +``` + +**Step 3: 部署后端并验证迁移** + +Run: `./deploy-backend.sh`(或在 UAT 环境部署后检查 PocketBase 管理面板,确认 ledgers 和 assets collections 已创建) + +**Step 4: Commit** + +```bash +git add backend/pb_migrations/1776510001_created_ledgers.js backend/pb_migrations/1776510002_created_assets.js +git commit -m "feat(phase3): add ledgers and assets PocketBase migrations" +``` + +--- + +## Task 2: TypeScript 类型定义 + +**Files:** +- Modify: `frontend/src/types/index.ts` + +**Step 1: 在 types/index.ts 末尾(`displayName` 函数之前)添加账目和资产类型** + +```typescript +// 账目类型 +export type LedgerType = 'income' | 'expense' +export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other' + +export const LedgerTypeMap: Record = { + income: '收入', + expense: '支出' +} + +export const LedgerCategoryMap: Record = { + gaming: '游戏', + food: '聚餐', + equipment: '设备', + transport: '交通', + other: '其他' +} + +export interface Ledger { + id: string + group: string + creator: string + type: LedgerType + amount: number + category: LedgerCategory + description: string + relatedMembers: string[] + occurredAt: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + relatedMembers?: User[] + } +} + +// 资产类型 +export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other' + +export const AssetTypeMap: Record = { + game_account: '游戏账号', + console: '主机', + equipment: '设备', + accessory: '配件', + other: '其他' +} + +export interface Asset { + id: string + group: string + creator: string + name: string + type: AssetType + description?: string + currentHolder?: string + image?: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + currentHolder?: User + } +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/types/index.ts +git commit -m "feat(phase3): add Ledger and Asset type definitions" +``` + +--- + +## Task 3: API 层 — 账目 + +**Files:** +- Create: `frontend/src/api/ledgers.ts` + +**Step 1: 创建 ledgers API** + +遵循 `api/memories.ts` 的模式:导入 pb 实例、类型、async 函数、expand 关联数据、subscribe 实时订阅。 + +```typescript +// frontend/src/api/ledgers.ts +import { pb } from './pocketbase' +import type { Ledger } from '@/types' + +export async function createLedger(data: { + group: string + type: 'income' | 'expense' + amount: number + category: string + description: string + relatedMembers?: string[] + occurredAt: string +}): Promise { + const user = pb.authStore.model + const record = await pb.collection('ledgers').create({ + group: data.group, + creator: user?.id, + type: data.type, + amount: data.amount, + category: data.category, + description: data.description, + relatedMembers: data.relatedMembers || [], + occurredAt: data.occurredAt, + }) + return record as unknown as Ledger +} + +export async function listLedgers( + groupId: string, + options?: { + page?: number + limit?: number + type?: string + category?: string + month?: string // "2026-04" 格式 + } +): Promise<{ items: Ledger[]; total: number }> { + const { page = 1, limit = 30, type, category, month } = options || {} + const filters = [`group="${groupId}"`] + if (type) filters.push(`type="${type}"`) + if (category) filters.push(`category="${category}"`) + if (month) { + const [y, m] = month.split('-').map(Number) + const start = new Date(y, m - 1, 1).toISOString().slice(0, 10) + const end = new Date(y, m, 1).toISOString().slice(0, 10) + filters.push(`occurredAt >= "${start}"`) + filters.push(`occurredAt < "${end}"`) + } + + const result = await pb.collection('ledgers').getList(page, limit, { + filter: filters.join(' && '), + sort: '-occurredAt', + expand: 'creator,relatedMembers' + }) + return { items: result.items as unknown as Ledger[], total: result.totalItems } +} + +export async function updateLedger( + ledgerId: string, + data: Partial> +): Promise { + const record = await pb.collection('ledgers').update(ledgerId, data) + return record as unknown as Ledger +} + +export async function deleteLedger(ledgerId: string): Promise { + await pb.collection('ledgers').delete(ledgerId) +} + +export async function getLedgerSummary(groupId: string, month?: string): Promise<{ + totalIncome: number + totalExpense: number + balance: number +}> { + const filters = [`group="${groupId}"`] + if (month) { + const [y, m] = month.split('-').map(Number) + const start = new Date(y, m - 1, 1).toISOString().slice(0, 10) + const end = new Date(y, m, 1).toISOString().slice(0, 10) + filters.push(`occurredAt >= "${start}"`) + filters.push(`occurredAt < "${end}"`) + } + + let totalIncome = 0 + let totalExpense = 0 + let page = 1 + const batchSize = 500 + let hasMore = true + + while (hasMore) { + const result = await pb.collection('ledgers').getList(page, batchSize, { + filter: filters.join(' && '), + fields: 'type,amount' + }) + for (const item of result.items as any[]) { + if (item.type === 'income') totalIncome += item.amount || 0 + else totalExpense += item.amount || 0 + } + hasMore = result.items.length === batchSize && page * batchSize < result.totalItems + page++ + } + + return { totalIncome, totalExpense, balance: totalIncome - totalExpense } +} + +export function subscribeLedgers( + groupId: string, + callback: (data: any) => void +) { + return pb.collection('ledgers').subscribe('*', (data) => { + if (data.record?.group === groupId) callback(data) + }) +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/api/ledgers.ts +git commit -m "feat(phase3): add ledgers API layer" +``` + +--- + +## Task 4: API 层 — 资产 + +**Files:** +- Create: `frontend/src/api/assets.ts` + +**Step 1: 创建 assets API** + +```typescript +// frontend/src/api/assets.ts +import { pb } from './pocketbase' +import type { Asset } from '@/types' + +export async function createAsset(data: { + group: string + name: string + type: string + description?: string + image?: File +}): Promise { + const user = pb.authStore.model + const formData = new FormData() + formData.append('group', data.group) + formData.append('creator', user?.id || '') + formData.append('name', data.name) + formData.append('type', data.type) + if (data.description) formData.append('description', data.description) + if (data.image) formData.append('image', data.image) + + const record = await pb.collection('assets').create(formData) + return record as unknown as Asset +} + +export async function listAssets(groupId: string): Promise { + const result = await pb.collection('assets').getFullList({ + filter: `group="${groupId}"`, + sort: 'created', + expand: 'creator,currentHolder' + }) + return result as unknown as Asset[] +} + +export async function updateAsset( + assetId: string, + data: Partial>, + image?: File +): Promise { + const formData = new FormData() + if (data.name) formData.append('name', data.name) + if (data.type) formData.append('type', data.type) + if (data.description !== undefined) formData.append('description', data.description) + if (image) formData.append('image', image) + + const record = await pb.collection('assets').update(assetId, formData) + return record as unknown as Asset +} + +export async function transferAsset(assetId: string, userId: string | null): Promise { + const record = await pb.collection('assets').update(assetId, { + currentHolder: userId || '' + }) + return record as unknown as Asset +} + +export async function deleteAsset(assetId: string): Promise { + await pb.collection('assets').delete(assetId) +} + +export function subscribeAssets( + groupId: string, + callback: (data: any) => void +) { + return pb.collection('assets').subscribe('*', (data) => { + if (data.record?.group === groupId) callback(data) + }) +} + +export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string { + return pb.files.getURL({ id: assetId, collectionName: 'assets' }, filename, { thumb }) +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/api/assets.ts +git commit -m "feat(phase3): add assets API layer" +``` + +--- + +## Task 5: Pinia Store — 账目 + +**Files:** +- Create: `frontend/src/stores/ledger.ts` + +**Step 1: 创建 ledger store** + +遵循 `stores/poll.ts` 的模式。 + +```typescript +// frontend/src/stores/ledger.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Ledger } from '@/types' +import { listLedgers, getLedgerSummary, subscribeLedgers, createLedger, updateLedger, deleteLedger } from '@/api/ledgers' + +export const useLedgerStore = defineStore('ledger', () => { + const ledgers = ref([]) + const loading = ref(false) + const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 }) + const currentMonth = ref(new Date().toISOString().slice(0, 7)) + + async function loadLedgers(groupId: string, month?: string) { + try { + loading.value = true + const m = month || currentMonth.value + const result = await listLedgers(groupId, { month: m, limit: 200 }) + ledgers.value = result.items + } catch (error) { + console.error('加载账目列表失败:', error) + } finally { + loading.value = false + } + } + + async function loadSummary(groupId: string, month?: string) { + try { + const m = month || currentMonth.value + summary.value = await getLedgerSummary(groupId, m) + } catch (error) { + console.error('加载账目汇总失败:', error) + } + } + + async function addLedger(data: Parameters[0]) { + const ledger = await createLedger(data) + return ledger + } + + async function editLedger(ledgerId: string, data: Parameters[1]) { + return updateLedger(ledgerId, data) + } + + async function removeLedger(ledgerId: string) { + await deleteLedger(ledgerId) + } + + async function startSubscription(groupId: string) { + return subscribeLedgers(groupId, () => { + loadLedgers(groupId) + loadSummary(groupId) + }) + } + + return { + ledgers, loading, summary, currentMonth, + loadLedgers, loadSummary, addLedger, editLedger, removeLedger, startSubscription + } +}) +``` + +**Step 2: Commit** + +```bash +git add frontend/src/stores/ledger.ts +git commit -m "feat(phase3): add ledger Pinia store" +``` + +--- + +## Task 6: Pinia Store — 资产 + +**Files:** +- Create: `frontend/src/stores/asset.ts` + +**Step 1: 创建 asset store** + +```typescript +// frontend/src/stores/asset.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Asset } from '@/types' +import { listAssets, createAsset, updateAsset, transferAsset, deleteAsset as deleteAssetApi, subscribeAssets } from '@/api/assets' + +export const useAssetStore = defineStore('asset', () => { + const assets = ref([]) + const loading = ref(false) + + async function loadAssets(groupId: string) { + try { + loading.value = true + assets.value = await listAssets(groupId) + } catch (error) { + console.error('加载资产列表失败:', error) + } finally { + loading.value = false + } + } + + async function addAsset(data: Parameters[0]) { + return createAsset(data) + } + + async function editAsset(assetId: string, data: Parameters[1], image?: File) { + return updateAsset(assetId, data, image) + } + + async function transfer(assetId: string, userId: string | null) { + return transferAsset(assetId, userId) + } + + async function removeAsset(assetId: string) { + await deleteAssetApi(assetId) + } + + async function startSubscription(groupId: string) { + return subscribeAssets(groupId, () => { + loadAssets(groupId) + }) + } + + return { + assets, loading, + loadAssets, addAsset, editAsset, transfer, removeAsset, startSubscription + } +}) +``` + +**Step 2: Commit** + +```bash +git add frontend/src/stores/asset.ts +git commit -m "feat(phase3): add asset Pinia store" +``` + +--- + +## Task 7: 账目组件 + +**Files:** +- Create: `frontend/src/components/ledger/LedgerSummary.vue` +- Create: `frontend/src/components/ledger/LedgerCard.vue` +- Create: `frontend/src/components/ledger/LedgerList.vue` +- Create: `frontend/src/components/ledger/CreateLedgerDialog.vue` + +**Step 1: 创建 LedgerSummary 组件** + +汇总面板:显示当月总收入、总支出、余额。Element Plus 的 `el-statistic` 或自定义卡片。 + +参考项目中 `GroupStatsPanel.vue` 的卡片样式,使用 `var(--gg-*)` CSS 变量。 + +**Step 2: 创建 LedgerCard 组件** + +单条账目卡片:类型标签(收入绿/支出红)、金额、分类、描述、相关成员头像、发生日期、操作按钮(编辑/删除,仅记录人可见)。 + +**Step 3: 创建 LedgerList 组件** + +账目列表:顶部月份选择器 + 类型/分类筛选 + 新建按钮;下方按日期分组展示 LedgerCard 列表。 + +**Step 4: 创建 CreateLedgerDialog 组件** + +新建/编辑弹窗:类型选择、金额输入、分类选择、描述输入、相关成员多选、日期选择器。使用 `el-dialog` + `el-form`。 + +**Step 5: Commit** + +```bash +git add frontend/src/components/ledger/ +git commit -m "feat(phase3): add ledger components" +``` + +--- + +## Task 8: 资产组件 + +**Files:** +- Create: `frontend/src/components/asset/AssetCard.vue` +- Create: `frontend/src/components/asset/AssetList.vue` +- Create: `frontend/src/components/asset/CreateAssetDialog.vue` +- Create: `frontend/src/components/asset/TransferAssetDialog.vue` + +**Step 1: 创建 AssetCard 组件** + +资产卡片(网格布局):资产图片(有则显示缩略图,无则显示类型图标)、名称、类型标签、当前持有人头像和名称、点击可操作。 + +**Step 2: 创建 AssetList 组件** + +资产列表:顶部类型筛选 + 新建按钮;下方网格展示 AssetCard。 + +**Step 3: 创建 CreateAssetDialog 组件** + +登记/编辑弹窗:名称输入、类型选择、描述输入、图片上传。使用 `el-dialog` + `el-form` + `el-upload`。 + +**Step 4: 创建 TransferAssetDialog 组件** + +转移持有人弹窗:显示资产名称,选择群组成员作为新持有人(含"放回在库"选项)。使用 `el-dialog` + 群成员列表。 + +**Step 5: Commit** + +```bash +git add frontend/src/components/asset/ +git commit -m "feat(phase3): add asset components" +``` + +--- + +## Task 9: 页面与路由 + +**Files:** +- Create: `frontend/src/views/LedgerView.vue` +- Create: `frontend/src/views/AssetView.vue` +- Modify: `frontend/src/router/index.ts` — 新增两条路由 + +**Step 1: 创建 LedgerView.vue** + +独立页面:顶部返回群组按钮 + 群组名;LedgerSummary 汇总面板;LedgerList 账目列表。 + +加载数据、订阅实时更新、页面卸载时取消订阅。参考 GroupView.vue 的订阅管理模式。 + +```typescript +// 核心结构 +const route = useRoute() +const groupId = route.params.groupId as string +const groupStore = useGroupStore() +const ledgerStore = useLedgerStore() + +onMounted(async () => { + await groupStore.setCurrentGroup(groupId) + await Promise.all([ + ledgerStore.loadLedgers(groupId), + ledgerStore.loadSummary(groupId) + ]) + unsubFns.push(await ledgerStore.startSubscription(groupId)) +}) +``` + +**Step 2: 创建 AssetView.vue** + +独立页面:顶部返回群组按钮 + 群组名;AssetList 资产列表。 + +**Step 3: 在 router/index.ts 中添加路由** + +在 Layout children 数组中添加: + +```typescript +{ + path: 'group/:groupId/ledger', + name: 'LedgerView', + component: () => import('@/views/LedgerView.vue'), + props: true, + meta: { requiresAuth: true } +}, +{ + path: 'group/:groupId/assets', + name: 'AssetView', + component: () => import('@/views/AssetView.vue'), + props: true, + meta: { requiresAuth: true } +} +``` + +**Step 4: Commit** + +```bash +git add frontend/src/views/LedgerView.vue frontend/src/views/AssetView.vue frontend/src/router/index.ts +git commit -m "feat(phase3): add ledger and asset pages with routes" +``` + +--- + +## Task 10: GroupView 导航入口 + +**Files:** +- Modify: `frontend/src/views/GroupView.vue` — 添加账目/资产入口按钮 + +**Step 1: 在 GroupView 的 header-meta 区域添加入口按钮** + +在群组信息条中添加"账目"和"资产"两个链接按钮,点击跳转到对应页面。使用 `router.push` 跳转。 + +参考现有样式,使用 Element Plus 的 `Wallet` 和 `Box` 图标(或 `Coin`、`Present` 等)。 + +```html + +| + + 账目 + +| + + 资产 + +``` + +**Step 2: Commit** + +```bash +git add frontend/src/views/GroupView.vue +git commit -m "feat(phase3): add ledger and asset navigation in GroupView" +``` + +--- + +## Task 11: 构建验证与部署 + +**Step 1: 构建前端验证无报错** + +Run: `cd frontend && npm run build` + +**Step 2: 部署到 Dev 环境测试** + +Run: `./deploy-dev.sh` + +**Step 3: 在 Dev 环境手动测试** + +访问 `http://192.168.1.14:7033`,测试: +- 群组页面能看到"账目"和"资产"入口 +- 点击进入账目页面,创建收入/支出记录,查看汇总 +- 点击进入资产页面,登记资产,转移持有人 +- 其他成员能看到记录变化(实时订阅) + +**Step 4: 最终 Commit** + +如有修复则提交。 diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts new file mode 100644 index 0000000..314fc16 --- /dev/null +++ b/frontend/src/api/assets.ts @@ -0,0 +1,82 @@ +import { pb } from './pocketbase' +import type { Asset } from '@/types' + +export interface CreateAssetData { + group: string + name: string + type: string + description?: string + image?: File +} + +export interface UpdateAssetData { + name?: string + type?: string + description?: string +} + +export async function createAsset(data: CreateAssetData): Promise { + const user = pb.authStore.model + const formData = new FormData() + formData.append('group', data.group) + formData.append('creator', user?.id || '') + formData.append('name', data.name) + formData.append('type', data.type) + if (data.description) formData.append('description', data.description) + if (data.image) formData.append('image', data.image) + + return pb.collection('assets').create(formData) as Promise +} + +export async function listAssets(groupId: string): Promise { + const result = await pb.collection('assets').getFullList({ + filter: `group="${groupId}"`, + sort: 'created', + expand: 'creator,currentHolder' + }) + return result as unknown as Asset[] +} + +export async function updateAsset( + assetId: string, + data: UpdateAssetData, + image?: File +): Promise { + if (image) { + const formData = new FormData() + if (data.name) formData.append('name', data.name) + if (data.type) formData.append('type', data.type) + if (data.description !== undefined) formData.append('description', data.description) + formData.append('image', image) + + return pb.collection('assets').update(assetId, formData) as Promise + } + + return pb.collection('assets').update(assetId, data) as Promise +} + +export async function transferAsset(assetId: string, userId: string): Promise { + return pb.collection('assets').update(assetId, { + currentHolder: userId + }) as Promise +} + +export async function deleteAsset(assetId: string): Promise { + await pb.collection('assets').delete(assetId) +} + +export function subscribeAssets( + groupId: string, + callback: (data: any) => void +): Promise<() => Promise> { + return pb.collection('assets').subscribe('*', (data) => { + if (data.record?.group === groupId) { + callback(data) + } + }) +} + +export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string { + const record = { id: assetId, image: filename } + return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined) +} diff --git a/frontend/src/api/ledgers.ts b/frontend/src/api/ledgers.ts new file mode 100644 index 0000000..0435e54 --- /dev/null +++ b/frontend/src/api/ledgers.ts @@ -0,0 +1,132 @@ +// src/api/ledgers.ts +import { pb } from './pocketbase' +import type { Ledger } from '@/types' + +interface CreateLedgerData { + group: string + type: string + amount: number + category: string + description: string + relatedMembers?: string[] + occurredAt: string +} + +interface ListLedgersOptions { + page?: number + limit?: number + type?: string + category?: string + month?: string +} + +interface LedgerSummary { + totalIncome: number + totalExpense: number + balance: number +} + +export async function createLedger(data: CreateLedgerData): Promise { + const user = pb.authStore.model + const record = await pb.collection('ledgers').create({ + ...data, + creator: user?.id || '', + relatedMembers: data.relatedMembers || [] + }) + return record as unknown as Ledger +} + +export async function listLedgers( + groupId: string, + options?: ListLedgersOptions +): Promise<{ items: Ledger[]; total: number }> { + const { page = 1, limit = 30, type, category, month } = options || {} + let filter = `group="${groupId}"` + if (type) filter += ` && type="${type}"` + if (category) filter += ` && category="${category}"` + if (month) { + // month format: "2026-04" + const start = `${month}-01 00:00:00` + const year = parseInt(month.slice(0, 4)) + const mon = parseInt(month.slice(5, 7)) + // 下个月的第一天 + const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}` + const end = `${nextMonth}-01 00:00:00` + filter += ` && occurredAt>="${start}" && occurredAt<"${end}"` + } + + const result = await pb.collection('ledgers').getList(page, limit, { + filter, + sort: '-occurredAt', + expand: 'creator,relatedMembers' + }) + return { items: result.items as unknown as Ledger[], total: result.totalItems } +} + +export async function updateLedger( + ledgerId: string, + data: Partial +): Promise { + const record = await pb.collection('ledgers').update(ledgerId, data) + return record as unknown as Ledger +} + +export async function deleteLedger(ledgerId: string): Promise { + await pb.collection('ledgers').delete(ledgerId) +} + +export async function getLedgerSummary( + groupId: string, + month?: string +): Promise { + let totalIncome = 0 + let totalExpense = 0 + let page = 1 + const batchSize = 500 + let hasMore = true + + while (hasMore) { + let filter = `group="${groupId}"` + if (month) { + const start = `${month}-01 00:00:00` + const year = parseInt(month.slice(0, 4)) + const mon = parseInt(month.slice(5, 7)) + const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}` + const end = `${nextMonth}-01 00:00:00` + filter += ` && occurredAt>="${start}" && occurredAt<"${end}"` + } + + const result = await pb.collection('ledgers').getList(page, batchSize, { + filter, + fields: 'type,amount' + }) + + for (const item of result.items as any[]) { + if (item.type === 'income') { + totalIncome += item.amount || 0 + } else if (item.type === 'expense') { + totalExpense += item.amount || 0 + } + } + + hasMore = result.items.length === batchSize && page * batchSize < result.totalItems + page++ + } + + return { + totalIncome, + totalExpense, + balance: totalIncome - totalExpense + } +} + +export function subscribeLedgers( + groupId: string, + callback: (data: any) => void +) { + return pb.collection('ledgers').subscribe('*', (data) => { + if (data.record?.group === groupId) { + callback(data) + } + }) +} diff --git a/frontend/src/components/asset/AssetCard.vue b/frontend/src/components/asset/AssetCard.vue new file mode 100644 index 0000000..93cf373 --- /dev/null +++ b/frontend/src/components/asset/AssetCard.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/frontend/src/components/asset/AssetList.vue b/frontend/src/components/asset/AssetList.vue new file mode 100644 index 0000000..feba267 --- /dev/null +++ b/frontend/src/components/asset/AssetList.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/frontend/src/components/asset/CreateAssetDialog.vue b/frontend/src/components/asset/CreateAssetDialog.vue new file mode 100644 index 0000000..f1c57d1 --- /dev/null +++ b/frontend/src/components/asset/CreateAssetDialog.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/components/asset/TransferAssetDialog.vue b/frontend/src/components/asset/TransferAssetDialog.vue new file mode 100644 index 0000000..658e7a4 --- /dev/null +++ b/frontend/src/components/asset/TransferAssetDialog.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/frontend/src/components/ledger/CreateLedgerDialog.vue b/frontend/src/components/ledger/CreateLedgerDialog.vue new file mode 100644 index 0000000..983700a --- /dev/null +++ b/frontend/src/components/ledger/CreateLedgerDialog.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/frontend/src/components/ledger/LedgerCard.vue b/frontend/src/components/ledger/LedgerCard.vue new file mode 100644 index 0000000..66af352 --- /dev/null +++ b/frontend/src/components/ledger/LedgerCard.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/frontend/src/components/ledger/LedgerList.vue b/frontend/src/components/ledger/LedgerList.vue new file mode 100644 index 0000000..7bbb909 --- /dev/null +++ b/frontend/src/components/ledger/LedgerList.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/frontend/src/components/ledger/LedgerSummary.vue b/frontend/src/components/ledger/LedgerSummary.vue new file mode 100644 index 0000000..84e7af0 --- /dev/null +++ b/frontend/src/components/ledger/LedgerSummary.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7bfea80..b95ff47 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -33,6 +33,20 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/GroupView.vue'), props: true }, + { + path: 'group/:groupId/ledger', + name: 'LedgerView', + component: () => import('@/views/LedgerView.vue'), + props: true, + meta: { requiresAuth: true } + }, + { + path: 'group/:groupId/assets', + name: 'AssetView', + component: () => import('@/views/AssetView.vue'), + props: true, + meta: { requiresAuth: true } + }, { path: 'games', name: 'GamesLibrary', diff --git a/frontend/src/stores/asset.ts b/frontend/src/stores/asset.ts new file mode 100644 index 0000000..024ad54 --- /dev/null +++ b/frontend/src/stores/asset.ts @@ -0,0 +1,111 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Asset } from '@/types' +import { + listAssets, + createAsset, + updateAsset, + transferAsset, + deleteAsset, + subscribeAssets +} from '@/api/assets' +import type { CreateAssetData, UpdateAssetData } from '@/api/assets' + +export const useAssetStore = defineStore('asset', () => { + const assets = ref([]) + const loading = ref(false) + let unsubFn: (() => Promise | void) | null = null + + async function loadAssets(groupId: string) { + try { + loading.value = true + assets.value = await listAssets(groupId) + } catch (error) { + console.error('加载资产列表失败:', error) + } finally { + loading.value = false + } + } + + async function addAsset(data: CreateAssetData) { + try { + loading.value = true + await createAsset(data) + await loadAssets(data.group) + } catch (error) { + console.error('创建资产失败:', error) + throw error + } finally { + loading.value = false + } + } + + async function editAsset(assetId: string, data: UpdateAssetData, image?: File) { + try { + loading.value = true + await updateAsset(assetId, data, image) + // 找到当前资产的 groupId 以刷新列表 + const asset = assets.value.find(a => a.id === assetId) + if (asset) { + await loadAssets(asset.group) + } + } catch (error) { + console.error('更新资产失败:', error) + throw error + } finally { + loading.value = false + } + } + + async function transfer(assetId: string, userId: string) { + try { + await transferAsset(assetId, userId) + const asset = assets.value.find(a => a.id === assetId) + if (asset) { + await loadAssets(asset.group) + } + } catch (error) { + console.error('转移资产失败:', error) + throw error + } + } + + async function removeAsset(assetId: string) { + try { + await deleteAsset(assetId) + const asset = assets.value.find(a => a.id === assetId) + assets.value = assets.value.filter(a => a.id !== assetId) + // 返回 groupId 以便调用方需要时使用 + return asset?.group + } catch (error) { + console.error('删除资产失败:', error) + throw error + } + } + + async function startSubscription(groupId: string) { + stopSubscription() + unsubFn = await subscribeAssets(groupId, () => { + loadAssets(groupId) + }) + } + + function stopSubscription() { + if (unsubFn) { + unsubFn() + unsubFn = null + } + } + + return { + assets, + loading, + loadAssets, + addAsset, + editAsset, + transfer, + removeAsset, + startSubscription, + stopSubscription + } +}) diff --git a/frontend/src/stores/ledger.ts b/frontend/src/stores/ledger.ts new file mode 100644 index 0000000..f25885a --- /dev/null +++ b/frontend/src/stores/ledger.ts @@ -0,0 +1,99 @@ +// src/stores/ledger.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Ledger } from '@/types' +import { + listLedgers, + createLedger, + updateLedger, + deleteLedger, + getLedgerSummary, + subscribeLedgers +} from '@/api/ledgers' + +export const useLedgerStore = defineStore('ledger', () => { + const ledgers = ref([]) + const loading = ref(false) + const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 }) + const currentMonth = ref(new Date().toISOString().slice(0, 7)) + + // 加载账目列表和汇总 + async function loadLedgers(groupId: string, month?: string) { + try { + loading.value = true + const filterMonth = month || currentMonth.value + const [listResult, summaryResult] = await Promise.all([ + listLedgers(groupId, { month: filterMonth }), + getLedgerSummary(groupId, filterMonth) + ]) + ledgers.value = listResult.items + summary.value = summaryResult + if (month) currentMonth.value = month + } catch (error) { + console.error('加载账目列表失败:', error) + } finally { + loading.value = false + } + } + + // 只加载汇总 + async function loadSummary(groupId: string, month?: string) { + try { + const filterMonth = month || currentMonth.value + summary.value = await getLedgerSummary(groupId, filterMonth) + } catch (error) { + console.error('加载账目汇总失败:', error) + } + } + + // 新增账目 + async function addLedger(data: Parameters[0]) { + try { + await createLedger(data) + } catch (error) { + console.error('新增账目失败:', error) + throw error + } + } + + // 编辑账目 + async function editLedger(ledgerId: string, data: Parameters[1]) { + try { + await updateLedger(ledgerId, data) + } catch (error) { + console.error('编辑账目失败:', error) + throw error + } + } + + // 删除账目 + async function removeLedger(ledgerId: string) { + try { + await deleteLedger(ledgerId) + } catch (error) { + console.error('删除账目失败:', error) + throw error + } + } + + // 订阅实时更新 + async function startSubscription(groupId: string) { + await subscribeLedgers(groupId, () => { + // 收到变更后重新加载当前月份的数据 + loadLedgers(groupId) + }) + } + + return { + ledgers, + loading, + summary, + currentMonth, + loadLedgers, + loadSummary, + addLedger, + editLedger, + removeLedger, + startSubscription + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7254ea2..8195a2e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -269,6 +269,71 @@ export interface Memory { } } +// 账目类型 +export type LedgerType = 'income' | 'expense' +export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other' + +export const LedgerTypeMap: Record = { + income: '收入', + expense: '支出' +} + +export const LedgerCategoryMap: Record = { + gaming: '游戏', + food: '聚餐', + equipment: '设备', + transport: '交通', + other: '其他' +} + +export interface Ledger { + id: string + group: string + creator: string + type: LedgerType + amount: number + category: LedgerCategory + description: string + relatedMembers: string[] + occurredAt: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + relatedMembers?: User[] + } +} + +// 资产类型 +export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other' + +export const AssetTypeMap: Record = { + game_account: '游戏账号', + console: '主机', + equipment: '设备', + accessory: '配件', + other: '其他' +} + +export interface Asset { + id: string + group: string + creator: string + name: string + type: AssetType + description?: string + currentHolder?: string + image?: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + currentHolder?: User + } +} + // 获取用户显示名称(优先 name,回退 username) export function displayName(user?: { name?: string; username: string } | null): string { return user?.name || user?.username || '未知' diff --git a/frontend/src/views/AssetView.vue b/frontend/src/views/AssetView.vue new file mode 100644 index 0000000..08c0b9e --- /dev/null +++ b/frontend/src/views/AssetView.vue @@ -0,0 +1,110 @@ + + + + + + diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue index d3cdb0e..2b72927 100644 --- a/frontend/src/views/GroupView.vue +++ b/frontend/src/views/GroupView.vue @@ -13,7 +13,7 @@ import PollList from '@/components/poll/PollList.vue' import PollDetail from '@/components/poll/PollDetail.vue' import MemoryGrid from '@/components/memory/MemoryGrid.vue' import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue' -import { Promotion, Opportunity, UserFilled } from '@element-plus/icons-vue' +import { Promotion, Opportunity, UserFilled, Wallet, Box } from '@element-plus/icons-vue' import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue' const route = useRoute() @@ -154,6 +154,14 @@ async function checkExpiredPolls() { 容量: {{ members.length }} / {{ group?.maxMembers || '-' }} + | + + 账目 + + | + + 资产 + @@ -331,6 +339,21 @@ async function checkExpiredPolls() { color: var(--gg-border); } +.meta-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--gg-primary); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: color 0.2s; +} + +.meta-link:hover { + color: var(--gg-primary-light); +} + /* ── 主体双栏 ── */ .body-grid { display: grid; diff --git a/frontend/src/views/LedgerView.vue b/frontend/src/views/LedgerView.vue new file mode 100644 index 0000000..1d1cfc3 --- /dev/null +++ b/frontend/src/views/LedgerView.vue @@ -0,0 +1,105 @@ + + + + + +