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>
This commit is contained in:
@@ -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. 资产页面(组件 + 路由 + 导航入口)
|
||||
@@ -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
|
||||
/// <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**
|
||||
|
||||
如有修复则提交。
|
||||
Reference in New Issue
Block a user