Files
gamegroup2/docs/plans/2026-04-18-phase3-implementation.md
T
congsh c5413644f9 feat: phase 3 - ledger and asset management
Add group expense tracking (ledger) and public asset inventory (asset) features.
Ledger supports income/expense recording with monthly summary. Asset tracks
group equipment with free-form holder transfer. Both are independent pages
accessible from GroupView navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:42:04 +08:00

921 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3: Ledger + Asset Management 实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为群组添加账目流水记录和公共资产清单功能,两个独立页面。
**Architecture:** 新增两个 PocketBase Collectionledgers、assets),对应 API 层、类型定义、Pinia Store、Vue 组件和路由。通过 GroupView 操作入口跳转到独立页面。纯前端实现,无自定义后端逻辑。
**Tech Stack:** Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + PocketBase JS SDK
---
## Task 1: PocketBase 数据库迁移
**Files:**
- Create: `backend/pb_migrations/1776510001_created_ledgers.js`
- Create: `backend/pb_migrations/1776510002_created_assets.js`
**Step 1: 创建 ledgers 迁移文件**
```javascript
// backend/pb_migrations/1776510001_created_ledgers.js
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "ledgers_col",
"name": "ledgers",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_type",
"name": "type",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["income", "expense"]
}
},
{
"system": false,
"id": "sf_amount",
"name": "amount",
"type": "number",
"required": true,
"options": {
"min": 0.01,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "sf_category",
"name": "category",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["gaming", "food", "equipment", "transport", "other"]
}
},
{
"system": false,
"id": "sf_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "sf_members",
"name": "relatedMembers",
"type": "relation",
"required": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
},
{
"system": false,
"id": "sf_occurred",
"name": "occurredAt",
"type": "date",
"required": true,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("ledgers_col");
return dao.deleteCollection(collection);
})
```
**Step 2: 创建 assets 迁移文件**
```javascript
// backend/pb_migrations/1776510002_created_assets.js
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "assets_col",
"name": "assets",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_name",
"name": "name",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "sf_type",
"name": "type",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["game_account", "console", "equipment", "accessory", "other"]
}
},
{
"system": false,
"id": "sf_desc",
"name": "description",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "sf_holder",
"name": "currentHolder",
"type": "relation",
"required": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_image",
"name": "image",
"type": "file",
"required": false,
"options": {
"mimeTypes": ["image/*"],
"thumbs": ["200x200"],
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("assets_col");
return dao.deleteCollection(collection);
})
```
**Step 3: 部署后端并验证迁移**
Run: `./deploy-backend.sh`(或在 UAT 环境部署后检查 PocketBase 管理面板,确认 ledgers 和 assets collections 已创建)
**Step 4: Commit**
```bash
git add backend/pb_migrations/1776510001_created_ledgers.js backend/pb_migrations/1776510002_created_assets.js
git commit -m "feat(phase3): add ledgers and assets PocketBase migrations"
```
---
## Task 2: TypeScript 类型定义
**Files:**
- Modify: `frontend/src/types/index.ts`
**Step 1: 在 types/index.ts 末尾(`displayName` 函数之前)添加账目和资产类型**
```typescript
// 账目类型
export type LedgerType = 'income' | 'expense'
export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other'
export const LedgerTypeMap: Record<LedgerType, string> = {
income: '收入',
expense: '支出'
}
export const LedgerCategoryMap: Record<LedgerCategory, string> = {
gaming: '游戏',
food: '聚餐',
equipment: '设备',
transport: '交通',
other: '其他'
}
export interface Ledger {
id: string
group: string
creator: string
type: LedgerType
amount: number
category: LedgerCategory
description: string
relatedMembers: string[]
occurredAt: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
relatedMembers?: User[]
}
}
// 资产类型
export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other'
export const AssetTypeMap: Record<AssetType, string> = {
game_account: '游戏账号',
console: '主机',
equipment: '设备',
accessory: '配件',
other: '其他'
}
export interface Asset {
id: string
group: string
creator: string
name: string
type: AssetType
description?: string
currentHolder?: string
image?: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
currentHolder?: User
}
}
```
**Step 2: Commit**
```bash
git add frontend/src/types/index.ts
git commit -m "feat(phase3): add Ledger and Asset type definitions"
```
---
## Task 3: API 层 — 账目
**Files:**
- Create: `frontend/src/api/ledgers.ts`
**Step 1: 创建 ledgers API**
遵循 `api/memories.ts` 的模式:导入 pb 实例、类型、async 函数、expand 关联数据、subscribe 实时订阅。
```typescript
// frontend/src/api/ledgers.ts
import { pb } from './pocketbase'
import type { Ledger } from '@/types'
export async function createLedger(data: {
group: string
type: 'income' | 'expense'
amount: number
category: string
description: string
relatedMembers?: string[]
occurredAt: string
}): Promise<Ledger> {
const user = pb.authStore.model
const record = await pb.collection('ledgers').create({
group: data.group,
creator: user?.id,
type: data.type,
amount: data.amount,
category: data.category,
description: data.description,
relatedMembers: data.relatedMembers || [],
occurredAt: data.occurredAt,
})
return record as unknown as Ledger
}
export async function listLedgers(
groupId: string,
options?: {
page?: number
limit?: number
type?: string
category?: string
month?: string // "2026-04" 格式
}
): Promise<{ items: Ledger[]; total: number }> {
const { page = 1, limit = 30, type, category, month } = options || {}
const filters = [`group="${groupId}"`]
if (type) filters.push(`type="${type}"`)
if (category) filters.push(`category="${category}"`)
if (month) {
const [y, m] = month.split('-').map(Number)
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
const end = new Date(y, m, 1).toISOString().slice(0, 10)
filters.push(`occurredAt >= "${start}"`)
filters.push(`occurredAt < "${end}"`)
}
const result = await pb.collection('ledgers').getList(page, limit, {
filter: filters.join(' && '),
sort: '-occurredAt',
expand: 'creator,relatedMembers'
})
return { items: result.items as unknown as Ledger[], total: result.totalItems }
}
export async function updateLedger(
ledgerId: string,
data: Partial<Pick<Ledger, 'type' | 'amount' | 'category' | 'description' | 'relatedMembers' | 'occurredAt'>>
): Promise<Ledger> {
const record = await pb.collection('ledgers').update(ledgerId, data)
return record as unknown as Ledger
}
export async function deleteLedger(ledgerId: string): Promise<void> {
await pb.collection('ledgers').delete(ledgerId)
}
export async function getLedgerSummary(groupId: string, month?: string): Promise<{
totalIncome: number
totalExpense: number
balance: number
}> {
const filters = [`group="${groupId}"`]
if (month) {
const [y, m] = month.split('-').map(Number)
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
const end = new Date(y, m, 1).toISOString().slice(0, 10)
filters.push(`occurredAt >= "${start}"`)
filters.push(`occurredAt < "${end}"`)
}
let totalIncome = 0
let totalExpense = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
const result = await pb.collection('ledgers').getList(page, batchSize, {
filter: filters.join(' && '),
fields: 'type,amount'
})
for (const item of result.items as any[]) {
if (item.type === 'income') totalIncome += item.amount || 0
else totalExpense += item.amount || 0
}
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return { totalIncome, totalExpense, balance: totalIncome - totalExpense }
}
export function subscribeLedgers(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('ledgers').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
```
**Step 2: Commit**
```bash
git add frontend/src/api/ledgers.ts
git commit -m "feat(phase3): add ledgers API layer"
```
---
## Task 4: API 层 — 资产
**Files:**
- Create: `frontend/src/api/assets.ts`
**Step 1: 创建 assets API**
```typescript
// frontend/src/api/assets.ts
import { pb } from './pocketbase'
import type { Asset } from '@/types'
export async function createAsset(data: {
group: string
name: string
type: string
description?: string
image?: File
}): Promise<Asset> {
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', data.group)
formData.append('creator', user?.id || '')
formData.append('name', data.name)
formData.append('type', data.type)
if (data.description) formData.append('description', data.description)
if (data.image) formData.append('image', data.image)
const record = await pb.collection('assets').create(formData)
return record as unknown as Asset
}
export async function listAssets(groupId: string): Promise<Asset[]> {
const result = await pb.collection('assets').getFullList({
filter: `group="${groupId}"`,
sort: 'created',
expand: 'creator,currentHolder'
})
return result as unknown as Asset[]
}
export async function updateAsset(
assetId: string,
data: Partial<Pick<Asset, 'name' | 'type' | 'description'>>,
image?: File
): Promise<Asset> {
const formData = new FormData()
if (data.name) formData.append('name', data.name)
if (data.type) formData.append('type', data.type)
if (data.description !== undefined) formData.append('description', data.description)
if (image) formData.append('image', image)
const record = await pb.collection('assets').update(assetId, formData)
return record as unknown as Asset
}
export async function transferAsset(assetId: string, userId: string | null): Promise<Asset> {
const record = await pb.collection('assets').update(assetId, {
currentHolder: userId || ''
})
return record as unknown as Asset
}
export async function deleteAsset(assetId: string): Promise<void> {
await pb.collection('assets').delete(assetId)
}
export function subscribeAssets(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('assets').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
return pb.files.getURL({ id: assetId, collectionName: 'assets' }, filename, { thumb })
}
```
**Step 2: Commit**
```bash
git add frontend/src/api/assets.ts
git commit -m "feat(phase3): add assets API layer"
```
---
## Task 5: Pinia Store — 账目
**Files:**
- Create: `frontend/src/stores/ledger.ts`
**Step 1: 创建 ledger store**
遵循 `stores/poll.ts` 的模式。
```typescript
// frontend/src/stores/ledger.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Ledger } from '@/types'
import { listLedgers, getLedgerSummary, subscribeLedgers, createLedger, updateLedger, deleteLedger } from '@/api/ledgers'
export const useLedgerStore = defineStore('ledger', () => {
const ledgers = ref<Ledger[]>([])
const loading = ref(false)
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const currentMonth = ref(new Date().toISOString().slice(0, 7))
async function loadLedgers(groupId: string, month?: string) {
try {
loading.value = true
const m = month || currentMonth.value
const result = await listLedgers(groupId, { month: m, limit: 200 })
ledgers.value = result.items
} catch (error) {
console.error('加载账目列表失败:', error)
} finally {
loading.value = false
}
}
async function loadSummary(groupId: string, month?: string) {
try {
const m = month || currentMonth.value
summary.value = await getLedgerSummary(groupId, m)
} catch (error) {
console.error('加载账目汇总失败:', error)
}
}
async function addLedger(data: Parameters<typeof createLedger>[0]) {
const ledger = await createLedger(data)
return ledger
}
async function editLedger(ledgerId: string, data: Parameters<typeof updateLedger>[1]) {
return updateLedger(ledgerId, data)
}
async function removeLedger(ledgerId: string) {
await deleteLedger(ledgerId)
}
async function startSubscription(groupId: string) {
return subscribeLedgers(groupId, () => {
loadLedgers(groupId)
loadSummary(groupId)
})
}
return {
ledgers, loading, summary, currentMonth,
loadLedgers, loadSummary, addLedger, editLedger, removeLedger, startSubscription
}
})
```
**Step 2: Commit**
```bash
git add frontend/src/stores/ledger.ts
git commit -m "feat(phase3): add ledger Pinia store"
```
---
## Task 6: Pinia Store — 资产
**Files:**
- Create: `frontend/src/stores/asset.ts`
**Step 1: 创建 asset store**
```typescript
// frontend/src/stores/asset.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Asset } from '@/types'
import { listAssets, createAsset, updateAsset, transferAsset, deleteAsset as deleteAssetApi, subscribeAssets } from '@/api/assets'
export const useAssetStore = defineStore('asset', () => {
const assets = ref<Asset[]>([])
const loading = ref(false)
async function loadAssets(groupId: string) {
try {
loading.value = true
assets.value = await listAssets(groupId)
} catch (error) {
console.error('加载资产列表失败:', error)
} finally {
loading.value = false
}
}
async function addAsset(data: Parameters<typeof createAsset>[0]) {
return createAsset(data)
}
async function editAsset(assetId: string, data: Parameters<typeof updateAsset>[1], image?: File) {
return updateAsset(assetId, data, image)
}
async function transfer(assetId: string, userId: string | null) {
return transferAsset(assetId, userId)
}
async function removeAsset(assetId: string) {
await deleteAssetApi(assetId)
}
async function startSubscription(groupId: string) {
return subscribeAssets(groupId, () => {
loadAssets(groupId)
})
}
return {
assets, loading,
loadAssets, addAsset, editAsset, transfer, removeAsset, startSubscription
}
})
```
**Step 2: Commit**
```bash
git add frontend/src/stores/asset.ts
git commit -m "feat(phase3): add asset Pinia store"
```
---
## Task 7: 账目组件
**Files:**
- Create: `frontend/src/components/ledger/LedgerSummary.vue`
- Create: `frontend/src/components/ledger/LedgerCard.vue`
- Create: `frontend/src/components/ledger/LedgerList.vue`
- Create: `frontend/src/components/ledger/CreateLedgerDialog.vue`
**Step 1: 创建 LedgerSummary 组件**
汇总面板:显示当月总收入、总支出、余额。Element Plus 的 `el-statistic` 或自定义卡片。
参考项目中 `GroupStatsPanel.vue` 的卡片样式,使用 `var(--gg-*)` CSS 变量。
**Step 2: 创建 LedgerCard 组件**
单条账目卡片:类型标签(收入绿/支出红)、金额、分类、描述、相关成员头像、发生日期、操作按钮(编辑/删除,仅记录人可见)。
**Step 3: 创建 LedgerList 组件**
账目列表:顶部月份选择器 + 类型/分类筛选 + 新建按钮;下方按日期分组展示 LedgerCard 列表。
**Step 4: 创建 CreateLedgerDialog 组件**
新建/编辑弹窗:类型选择、金额输入、分类选择、描述输入、相关成员多选、日期选择器。使用 `el-dialog` + `el-form`
**Step 5: Commit**
```bash
git add frontend/src/components/ledger/
git commit -m "feat(phase3): add ledger components"
```
---
## Task 8: 资产组件
**Files:**
- Create: `frontend/src/components/asset/AssetCard.vue`
- Create: `frontend/src/components/asset/AssetList.vue`
- Create: `frontend/src/components/asset/CreateAssetDialog.vue`
- Create: `frontend/src/components/asset/TransferAssetDialog.vue`
**Step 1: 创建 AssetCard 组件**
资产卡片(网格布局):资产图片(有则显示缩略图,无则显示类型图标)、名称、类型标签、当前持有人头像和名称、点击可操作。
**Step 2: 创建 AssetList 组件**
资产列表:顶部类型筛选 + 新建按钮;下方网格展示 AssetCard。
**Step 3: 创建 CreateAssetDialog 组件**
登记/编辑弹窗:名称输入、类型选择、描述输入、图片上传。使用 `el-dialog` + `el-form` + `el-upload`
**Step 4: 创建 TransferAssetDialog 组件**
转移持有人弹窗:显示资产名称,选择群组成员作为新持有人(含"放回在库"选项)。使用 `el-dialog` + 群成员列表。
**Step 5: Commit**
```bash
git add frontend/src/components/asset/
git commit -m "feat(phase3): add asset components"
```
---
## Task 9: 页面与路由
**Files:**
- Create: `frontend/src/views/LedgerView.vue`
- Create: `frontend/src/views/AssetView.vue`
- Modify: `frontend/src/router/index.ts` — 新增两条路由
**Step 1: 创建 LedgerView.vue**
独立页面:顶部返回群组按钮 + 群组名;LedgerSummary 汇总面板;LedgerList 账目列表。
加载数据、订阅实时更新、页面卸载时取消订阅。参考 GroupView.vue 的订阅管理模式。
```typescript
// 核心结构
const route = useRoute()
const groupId = route.params.groupId as string
const groupStore = useGroupStore()
const ledgerStore = useLedgerStore()
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await Promise.all([
ledgerStore.loadLedgers(groupId),
ledgerStore.loadSummary(groupId)
])
unsubFns.push(await ledgerStore.startSubscription(groupId))
})
```
**Step 2: 创建 AssetView.vue**
独立页面:顶部返回群组按钮 + 群组名;AssetList 资产列表。
**Step 3: 在 router/index.ts 中添加路由**
在 Layout children 数组中添加:
```typescript
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: () => import('@/views/LedgerView.vue'),
props: true,
meta: { requiresAuth: true }
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: () => import('@/views/AssetView.vue'),
props: true,
meta: { requiresAuth: true }
}
```
**Step 4: Commit**
```bash
git add frontend/src/views/LedgerView.vue frontend/src/views/AssetView.vue frontend/src/router/index.ts
git commit -m "feat(phase3): add ledger and asset pages with routes"
```
---
## Task 10: GroupView 导航入口
**Files:**
- Modify: `frontend/src/views/GroupView.vue` — 添加账目/资产入口按钮
**Step 1: 在 GroupView 的 header-meta 区域添加入口按钮**
在群组信息条中添加"账目"和"资产"两个链接按钮,点击跳转到对应页面。使用 `router.push` 跳转。
参考现有样式,使用 Element Plus 的 `Wallet``Box` 图标(或 `Coin``Present` 等)。
```html
<!-- 在 header-meta 中添加 -->
<span class="meta-divider">|</span>
<router-link :to="`/group/${groupId}/ledger`" class="meta-link">
<el-icon><Wallet /></el-icon> 账目
</router-link>
<span class="meta-divider">|</span>
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
<el-icon><Box /></el-icon> 资产
</router-link>
```
**Step 2: Commit**
```bash
git add frontend/src/views/GroupView.vue
git commit -m "feat(phase3): add ledger and asset navigation in GroupView"
```
---
## Task 11: 构建验证与部署
**Step 1: 构建前端验证无报错**
Run: `cd frontend && npm run build`
**Step 2: 部署到 Dev 环境测试**
Run: `./deploy-dev.sh`
**Step 3: 在 Dev 环境手动测试**
访问 `http://192.168.1.14:7033`,测试:
- 群组页面能看到"账目"和"资产"入口
- 点击进入账目页面,创建收入/支出记录,查看汇总
- 点击进入资产页面,登记资产,转移持有人
- 其他成员能看到记录变化(实时订阅)
**Step 4: 最终 Commit**
如有修复则提交。