# 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** 如有修复则提交。