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

24 KiB
Raw Blame History

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 迁移文件

// 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 迁移文件

// 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

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 函数之前)添加账目和资产类型

// 账目类型
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

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 实时订阅。

// 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

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

// 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

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 的模式。

// 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

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

// 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

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

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

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 的订阅管理模式。

// 核心结构
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 数组中添加:

{
  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

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 的 WalletBox 图标(或 CoinPresent 等)。

<!-- 在 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

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

如有修复则提交。