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,151 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ledgers_col",
|
||||
"created": "2026-04-18 10:00:01.000Z",
|
||||
"updated": "2026-04-18 10:00:01.000Z",
|
||||
"name": "ledgers",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"income",
|
||||
"expense"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_amount",
|
||||
"name": "amount",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 0.01,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_category",
|
||||
"name": "category",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"gaming",
|
||||
"food",
|
||||
"equipment",
|
||||
"transport",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_members",
|
||||
"name": "relatedMembers",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_occurred",
|
||||
"name": "occurredAt",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "creator = @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "assets_col",
|
||||
"created": "2026-04-18 10:00:02.000Z",
|
||||
"updated": "2026-04-18 10:00:02.000Z",
|
||||
"name": "assets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"game_account",
|
||||
"console",
|
||||
"equipment",
|
||||
"accessory",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_holder",
|
||||
"name": "currentHolder",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_image",
|
||||
"name": "image",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"mimeTypes": null,
|
||||
"thumbs": ["200x200"],
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"protected": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("assets_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -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**
|
||||
|
||||
如有修复则提交。
|
||||
@@ -0,0 +1,82 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export interface CreateAssetData {
|
||||
group: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
export interface UpdateAssetData {
|
||||
name?: string
|
||||
type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function createAsset(data: CreateAssetData): Promise<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)
|
||||
|
||||
return pb.collection('assets').create(formData) as Promise<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: UpdateAssetData,
|
||||
image?: File
|
||||
): Promise<Asset> {
|
||||
if (image) {
|
||||
const formData = new FormData()
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.description !== undefined) formData.append('description', data.description)
|
||||
formData.append('image', image)
|
||||
|
||||
return pb.collection('assets').update(assetId, formData) as Promise<Asset>
|
||||
}
|
||||
|
||||
return pb.collection('assets').update(assetId, data) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function transferAsset(assetId: string, userId: string): Promise<Asset> {
|
||||
return pb.collection('assets').update(assetId, {
|
||||
currentHolder: userId
|
||||
}) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
await pb.collection('assets').delete(assetId)
|
||||
}
|
||||
|
||||
export function subscribeAssets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
return pb.collection('assets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
|
||||
const record = { id: assetId, image: filename }
|
||||
return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/api/ledgers.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Ledger } from '@/types'
|
||||
|
||||
interface CreateLedgerData {
|
||||
group: string
|
||||
type: string
|
||||
amount: number
|
||||
category: string
|
||||
description: string
|
||||
relatedMembers?: string[]
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
interface ListLedgersOptions {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
category?: string
|
||||
month?: string
|
||||
}
|
||||
|
||||
interface LedgerSummary {
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
balance: number
|
||||
}
|
||||
|
||||
export async function createLedger(data: CreateLedgerData): Promise<Ledger> {
|
||||
const user = pb.authStore.model
|
||||
const record = await pb.collection('ledgers').create({
|
||||
...data,
|
||||
creator: user?.id || '',
|
||||
relatedMembers: data.relatedMembers || []
|
||||
})
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function listLedgers(
|
||||
groupId: string,
|
||||
options?: ListLedgersOptions
|
||||
): Promise<{ items: Ledger[]; total: number }> {
|
||||
const { page = 1, limit = 30, type, category, month } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (type) filter += ` && type="${type}"`
|
||||
if (category) filter += ` && category="${category}"`
|
||||
if (month) {
|
||||
// month format: "2026-04"
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
// 下个月的第一天
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-occurredAt',
|
||||
expand: 'creator,relatedMembers'
|
||||
})
|
||||
return { items: result.items as unknown as Ledger[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function updateLedger(
|
||||
ledgerId: string,
|
||||
data: Partial<CreateLedgerData>
|
||||
): 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<LedgerSummary> {
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
let filter = `group="${groupId}"`
|
||||
if (month) {
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, batchSize, {
|
||||
filter,
|
||||
fields: 'type,amount'
|
||||
})
|
||||
|
||||
for (const item of result.items as any[]) {
|
||||
if (item.type === 'income') {
|
||||
totalIncome += item.amount || 0
|
||||
} else if (item.type === 'expense') {
|
||||
totalExpense += item.amount || 0
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLedgers(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('ledgers').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Edit, Delete, Box, Monitor, SetUp, Headset } from '@element-plus/icons-vue'
|
||||
import { AssetTypeMap, displayName } from '@/types'
|
||||
import { getAssetImageUrl } from '@/api/assets'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
asset: Asset
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [assetId: string]
|
||||
transfer: [assetId: string]
|
||||
delete: [assetId: string]
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 是否有图片
|
||||
const hasImage = computed(() => !!props.asset.image)
|
||||
|
||||
// 图片 URL(缩略图)
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.asset.image) return ''
|
||||
return getAssetImageUrl(props.asset.id, props.asset.image, '200x200')
|
||||
})
|
||||
|
||||
// 类型标签
|
||||
const typeLabel = computed(() => {
|
||||
return AssetTypeMap[props.asset.type as AssetType] || '其他'
|
||||
})
|
||||
|
||||
// 当前持有者信息
|
||||
const holder = computed(() => {
|
||||
return props.asset.expand?.currentHolder
|
||||
})
|
||||
|
||||
const holderName = computed(() => {
|
||||
if (!props.asset.currentHolder) return '在库'
|
||||
return displayName(holder.value)
|
||||
})
|
||||
|
||||
const isInStock = computed(() => !props.asset.currentHolder)
|
||||
|
||||
// 类型图标
|
||||
const typeIcon = computed(() => {
|
||||
switch (props.asset.type) {
|
||||
case 'game_account': return Monitor
|
||||
case 'console': return SetUp
|
||||
case 'equipment': return Monitor
|
||||
case 'accessory': return Headset
|
||||
default: return Box
|
||||
}
|
||||
})
|
||||
|
||||
// 当前用户是否为创建者或群主
|
||||
const canManage = computed(() => {
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
if (props.asset.creator === userId) return true
|
||||
if (groupStore.currentGroup?.owner === userId) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 点击卡片
|
||||
function handleCardClick() {
|
||||
emit('transfer', props.asset.id)
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('edit', props.asset.id)
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(e: Event) {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个资产吗?', '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await assetStore.removeAsset(props.asset.id)
|
||||
emit('delete', props.asset.id)
|
||||
ElMessage.success('删除成功')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-card" @click="handleCardClick">
|
||||
<!-- 图片区域 -->
|
||||
<div class="asset-card__image">
|
||||
<img
|
||||
v-if="hasImage"
|
||||
:src="imageUrl"
|
||||
:alt="asset.name"
|
||||
class="asset-card__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="asset-card__placeholder">
|
||||
<el-icon :size="32"><component :is="typeIcon" /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮(悬停显示) -->
|
||||
<div v-if="canManage" class="asset-card__actions">
|
||||
<button class="asset-card__action-btn" title="编辑" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</button>
|
||||
<button class="asset-card__action-btn asset-card__action-btn--danger" title="删除" @click="handleDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="asset-card__info">
|
||||
<!-- 名称 -->
|
||||
<div class="asset-card__name" :title="asset.name">{{ asset.name }}</div>
|
||||
|
||||
<!-- 类型标签 -->
|
||||
<div class="asset-card__meta">
|
||||
<span class="asset-card__type-tag">{{ typeLabel }}</span>
|
||||
<span v-if="isInStock" class="asset-card__stock-tag">在库</span>
|
||||
</div>
|
||||
|
||||
<!-- 持有者 -->
|
||||
<div class="asset-card__holder">
|
||||
<template v-if="holder">
|
||||
<div class="asset-card__avatar">
|
||||
{{ holderName.charAt(0) }}
|
||||
</div>
|
||||
<span class="asset-card__holder-name">{{ holderName }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="asset-card__stock-text">在库</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.asset-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 2px 16px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 图片区域 */
|
||||
.asset-card__image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: var(--gg-bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-card__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-card__placeholder {
|
||||
color: var(--gg-text-hint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.asset-card__actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.asset-card:hover .asset-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asset-card__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.asset-card__action-btn:hover {
|
||||
background: var(--gg-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.asset-card__action-btn--danger:hover {
|
||||
background: var(--gg-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 信息区域 */
|
||||
.asset-card__info {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.asset-card__type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.asset-card__stock-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
/* 持有者 */
|
||||
.asset-card__holder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.asset-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-card__holder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-card__stock-text {
|
||||
color: var(--gg-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
import AssetCard from './AssetCard.vue'
|
||||
import CreateAssetDialog from './CreateAssetDialog.vue'
|
||||
import TransferAssetDialog from './TransferAssetDialog.vue'
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 类型过滤
|
||||
const typeFilter = ref<string>('')
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = computed(() => {
|
||||
return [
|
||||
{ value: '', label: '全部' },
|
||||
...(Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// 过滤后的资产列表
|
||||
const filteredAssets = computed(() => {
|
||||
if (!typeFilter.value) return assetStore.assets
|
||||
return assetStore.assets.filter(a => a.type === typeFilter.value)
|
||||
})
|
||||
|
||||
// 对话框状态
|
||||
const showCreateDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
|
||||
// 编辑/转移目标
|
||||
const editingAsset = ref<Asset | undefined>(undefined)
|
||||
const transferringAsset = ref<Asset | null>(null)
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.loadAssets(groupId)
|
||||
}
|
||||
|
||||
// 订阅变更
|
||||
async function setupSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.startSubscription(groupId)
|
||||
}
|
||||
|
||||
// 登记资产
|
||||
function handleCreate() {
|
||||
editingAsset.value = undefined
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑资产
|
||||
function handleEdit(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
editingAsset.value = asset
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 转移资产
|
||||
function handleTransfer(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
transferringAsset.value = asset
|
||||
showTransferDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 资产删除后刷新
|
||||
function handleDelete() {
|
||||
// store 已经从列表中移除了,无需额外操作
|
||||
}
|
||||
|
||||
// 保存成功回调
|
||||
function handleSaved() {
|
||||
showCreateDialog.value = false
|
||||
editingAsset.value = undefined
|
||||
}
|
||||
|
||||
// 转移成功回调
|
||||
function handleTransferred() {
|
||||
showTransferDialog.value = false
|
||||
transferringAsset.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupStore.currentGroupId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
assetStore.stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="asset-list__header">
|
||||
<h3 class="asset-list__title">资产库</h3>
|
||||
<button class="asset-list__create-btn" @click="handleCreate">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="asset-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
登记资产
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型过滤 -->
|
||||
<div class="asset-list__filter">
|
||||
<el-select
|
||||
v-model="typeFilter"
|
||||
placeholder="筛选类型"
|
||||
size="default"
|
||||
style="width: 160px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="asset-list__count">共 {{ filteredAssets.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="assetStore.loading" v-loading="true" class="asset-list__loading"></div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="filteredAssets.length === 0"
|
||||
class="asset-list__empty"
|
||||
>
|
||||
<svg class="asset-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
<p class="asset-list__empty-text">暂无资产</p>
|
||||
<p class="asset-list__empty-hint">点击「登记资产」添加第一个资产</p>
|
||||
</div>
|
||||
|
||||
<!-- 资产网格 -->
|
||||
<div v-else class="asset-list__grid">
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
@edit="handleEdit"
|
||||
@transfer="handleTransfer"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑弹窗 -->
|
||||
<CreateAssetDialog
|
||||
v-model="showCreateDialog"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
:edit-asset="editingAsset"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<!-- 转移弹窗 -->
|
||||
<TransferAssetDialog
|
||||
v-model="showTransferDialog"
|
||||
:asset="transferringAsset"
|
||||
:members="groupStore.currentMembers"
|
||||
@transferred="handleTransferred"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.asset-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.asset-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.asset-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.asset-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 过滤栏 */
|
||||
.asset-list__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-list__count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.asset-list__loading {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.asset-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.asset-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.asset-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.asset-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.asset-list__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: string
|
||||
editAsset?: Asset
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
|
||||
const isEditing = computed(() => !!props.editAsset)
|
||||
|
||||
const dialogTitle = computed(() => isEditing.value ? '编辑资产' : '登记资产')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
type: 'other' as AssetType,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const selectedImage = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 资产类型选项
|
||||
const typeOptions = computed(() => {
|
||||
return (Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}))
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
type: 'other',
|
||||
description: '',
|
||||
}
|
||||
fileList.value = []
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
// 初始化编辑表单
|
||||
function initForm() {
|
||||
resetForm()
|
||||
if (props.editAsset) {
|
||||
form.value.name = props.editAsset.name
|
||||
form.value.type = props.editAsset.type
|
||||
form.value.description = props.editAsset.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 文件选择变更
|
||||
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
|
||||
fileList.value = uploadFileList
|
||||
if (_file.raw) {
|
||||
selectedImage.value = _file.raw
|
||||
}
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function handleFileRemove() {
|
||||
fileList.value = []
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
// 超出限制
|
||||
function handleExceed() {
|
||||
ElMessage.warning('只能上传一张图片,请先移除已选图片')
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入资产名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEditing.value && props.editAsset) {
|
||||
await assetStore.editAsset(
|
||||
props.editAsset.id,
|
||||
{
|
||||
name: form.value.name.trim(),
|
||||
type: form.value.type,
|
||||
description: form.value.description.trim() || undefined,
|
||||
},
|
||||
selectedImage.value || undefined
|
||||
)
|
||||
ElMessage.success('资产更新成功')
|
||||
} else {
|
||||
await assetStore.addAsset({
|
||||
group: props.groupId,
|
||||
name: form.value.name.trim(),
|
||||
type: form.value.type,
|
||||
description: form.value.description.trim() || undefined,
|
||||
image: selectedImage.value || undefined,
|
||||
})
|
||||
ElMessage.success('资产登记成功')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || (isEditing.value ? '更新资产失败' : '登记资产失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开时初始化
|
||||
function handleOpen() {
|
||||
initForm()
|
||||
}
|
||||
|
||||
// 关闭时重置
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 名称 -->
|
||||
<div class="form-field">
|
||||
<label>名称 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入资产名称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 类型 -->
|
||||
<div class="form-field">
|
||||
<label>类型</label>
|
||||
<el-select v-model="form.type" placeholder="选择资产类型" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="简单描述一下(可选)"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div class="form-field">
|
||||
<label>图片</label>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="fileList"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:on-exceed="handleExceed"
|
||||
accept="image/*"
|
||||
list-type="picture"
|
||||
>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div>点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '登记') }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { displayName } from '@/types'
|
||||
import type { Asset, User } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
asset: Asset | null
|
||||
members: User[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
transferred: []
|
||||
}>()
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 资产名称
|
||||
const assetName = computed(() => props.asset?.name || '')
|
||||
|
||||
// 当前持有者
|
||||
const currentHolderId = computed(() => props.asset?.currentHolder || '')
|
||||
|
||||
// 成员列表(排除当前持有者)
|
||||
const availableMembers = computed(() => {
|
||||
if (!props.asset) return []
|
||||
return props.members.filter(m => m.id !== currentHolderId.value)
|
||||
})
|
||||
|
||||
// 重置
|
||||
function resetForm() {
|
||||
selectedUserId.value = null
|
||||
}
|
||||
|
||||
// 选择"放回在库"
|
||||
function selectInStock() {
|
||||
selectedUserId.value = null
|
||||
}
|
||||
|
||||
// 选择成员
|
||||
function selectMember(userId: string) {
|
||||
selectedUserId.value = userId
|
||||
}
|
||||
|
||||
// 提交转移
|
||||
async function handleSubmit() {
|
||||
if (!props.asset) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// null 表示放回在库,传空字符串让 PocketBase 清空
|
||||
const targetUserId = selectedUserId.value || ''
|
||||
await assetStore.transfer(props.asset.id, targetUserId)
|
||||
|
||||
visible.value = false
|
||||
ElMessage.success(selectedUserId.value ? '资产已转移' : '资产已放回在库')
|
||||
emit('transferred')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '转移资产失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开时重置
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="转移资产"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div v-if="asset" class="transfer-form">
|
||||
<!-- 资产名称 -->
|
||||
<div class="transfer-asset-name">
|
||||
<span class="transfer-label">资产</span>
|
||||
<span class="transfer-value">{{ assetName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 放回在库 -->
|
||||
<div class="member-selection">
|
||||
<div class="section-label">选择新持有者</div>
|
||||
|
||||
<div
|
||||
class="member-item member-item--stock"
|
||||
:class="{ 'member-item--selected': selectedUserId === null }"
|
||||
@click="selectInStock"
|
||||
>
|
||||
<div class="member-item__check">
|
||||
<span v-if="selectedUserId === null" class="check-dot"></span>
|
||||
</div>
|
||||
<div class="member-item__avatar member-item__avatar--stock">
|
||||
库
|
||||
</div>
|
||||
<span class="member-item__name">放回在库</span>
|
||||
<span class="member-item__tag">在库</span>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div
|
||||
v-for="member in availableMembers"
|
||||
:key="member.id"
|
||||
class="member-item"
|
||||
:class="{ 'member-item--selected': selectedUserId === member.id }"
|
||||
@click="selectMember(member.id)"
|
||||
>
|
||||
<div class="member-item__check">
|
||||
<span v-if="selectedUserId === member.id" class="check-dot"></span>
|
||||
</div>
|
||||
<div class="member-item__avatar">
|
||||
{{ displayName(member).charAt(0) }}
|
||||
</div>
|
||||
<span class="member-item__name">{{ displayName(member) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 无可用成员 -->
|
||||
<div v-if="availableMembers.length === 0 && currentHolderId" class="no-members">
|
||||
没有其他群成员可以转移
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '转移中...' : '确认转移' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.transfer-asset-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--gg-bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.transfer-label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transfer-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
background: rgba(5, 150, 105, 0.04);
|
||||
}
|
||||
|
||||
.member-item--selected {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.member-item__check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--gg-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.member-item--selected .member-item__check {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.check-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
}
|
||||
|
||||
.member-item__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-item__avatar--stock {
|
||||
background: var(--gg-success);
|
||||
}
|
||||
|
||||
.member-item__name {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-item__tag {
|
||||
font-size: 11px;
|
||||
color: var(--gg-success);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-members {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-hint);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { Ledger, LedgerType, LedgerCategory } from '@/types'
|
||||
import { LedgerCategoryMap } from '@/types'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: string
|
||||
editLedger?: Ledger
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
type: 'expense' as LedgerType,
|
||||
amount: 0,
|
||||
category: 'other' as LedgerCategory,
|
||||
description: '',
|
||||
relatedMembers: [] as string[],
|
||||
occurredAt: '' as string | Date,
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.editLedger)
|
||||
const dialogTitle = computed(() => (isEditing.value ? '编辑账目' : '新建账目'))
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = Object.entries(LedgerCategoryMap).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))
|
||||
|
||||
// 群组成员选项
|
||||
const memberOptions = computed(() => {
|
||||
return groupStore.currentMembers.map((m) => ({
|
||||
label: displayName(m),
|
||||
value: m.id,
|
||||
}))
|
||||
})
|
||||
|
||||
// 格式化今天日期
|
||||
function getTodayStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
type: 'expense',
|
||||
amount: 0,
|
||||
category: 'other',
|
||||
description: '',
|
||||
relatedMembers: [],
|
||||
occurredAt: getTodayStr(),
|
||||
}
|
||||
}
|
||||
|
||||
// 填充编辑数据
|
||||
function fillForm(ledger: Ledger) {
|
||||
form.value = {
|
||||
type: ledger.type,
|
||||
amount: ledger.amount,
|
||||
category: ledger.category,
|
||||
description: ledger.description || '',
|
||||
relatedMembers: [...(ledger.relatedMembers || [])],
|
||||
occurredAt: ledger.occurredAt?.slice(0, 10) || getTodayStr(),
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框打开
|
||||
function handleOpen() {
|
||||
if (props.editLedger) {
|
||||
fillForm(props.editLedger)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
async function handleSubmit() {
|
||||
if (!props.groupId) {
|
||||
ElMessage.error('缺少群组信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.amount <= 0) {
|
||||
ElMessage.warning('金额必须大于0')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const occurredAt = form.value.occurredAt
|
||||
? new Date(String(form.value.occurredAt)).toISOString()
|
||||
: new Date().toISOString()
|
||||
|
||||
if (isEditing.value && props.editLedger) {
|
||||
await ledgerStore.editLedger(props.editLedger.id, {
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
category: form.value.category,
|
||||
description: form.value.description,
|
||||
relatedMembers: form.value.relatedMembers,
|
||||
occurredAt,
|
||||
})
|
||||
ElMessage.success('账目更新成功')
|
||||
} else {
|
||||
await ledgerStore.addLedger({
|
||||
group: props.groupId,
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
category: form.value.category,
|
||||
description: form.value.description,
|
||||
relatedMembers: form.value.relatedMembers,
|
||||
occurredAt,
|
||||
})
|
||||
ElMessage.success('账目创建成功')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="480px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 类型 -->
|
||||
<div class="form-field">
|
||||
<label>类型 <span class="required">*</span></label>
|
||||
<div class="type-switch">
|
||||
<button
|
||||
:class="['type-btn', { active: form.type === 'income' }]"
|
||||
@click="form.type = 'income'"
|
||||
>
|
||||
收入
|
||||
</button>
|
||||
<button
|
||||
:class="['type-btn', { active: form.type === 'expense' }]"
|
||||
@click="form.type = 'expense'"
|
||||
>
|
||||
支出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金额 -->
|
||||
<div class="form-field">
|
||||
<label>金额 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
:min="0.01"
|
||||
:step="0.01"
|
||||
placeholder="请输入金额"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="amount-prefix">¥</span>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 分类 -->
|
||||
<div class="form-field">
|
||||
<label>分类 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.category"
|
||||
placeholder="请选择分类"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in categoryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入账目描述(可选)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 关联成员 -->
|
||||
<div class="form-field">
|
||||
<label>关联成员</label>
|
||||
<el-select
|
||||
v-model="form.relatedMembers"
|
||||
multiple
|
||||
placeholder="选择关联成员(可选)"
|
||||
style="width: 100%"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in memberOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 发生日期 -->
|
||||
<div class="form-field">
|
||||
<label>发生日期</label>
|
||||
<el-date-picker
|
||||
v-model="form.occurredAt"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.type-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
background: var(--gg-bg, #fff);
|
||||
color: var(--gg-text-secondary, #909399);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: var(--gg-primary, #67c23a);
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: var(--gg-primary, #67c23a);
|
||||
border-color: var(--gg-primary, #67c23a);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.amount-prefix {
|
||||
color: var(--gg-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Edit, Delete } from '@element-plus/icons-vue'
|
||||
import type { Ledger } from '@/types'
|
||||
import { LedgerTypeMap, LedgerCategoryMap, displayName } from '@/types'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const props = defineProps<{
|
||||
ledger: Ledger
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [ledgerId: string]
|
||||
delete: [ledgerId: string]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const isCreator = computed(() => {
|
||||
return props.ledger.creator === pb.authStore.model?.id
|
||||
})
|
||||
|
||||
const isGroupOwner = computed(() => {
|
||||
return pb.authStore.model?.id === groupStore.currentGroup?.owner
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => LedgerTypeMap[props.ledger.type])
|
||||
const categoryLabel = computed(() => LedgerCategoryMap[props.ledger.category])
|
||||
const isIncome = computed(() => props.ledger.type === 'income')
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
const prefix = isIncome.value ? '+' : '-'
|
||||
return `${prefix}¥${props.ledger.amount.toFixed(2)}`
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.ledger.occurredAt) return ''
|
||||
return props.ledger.occurredAt.slice(0, 10)
|
||||
})
|
||||
|
||||
const creatorName = computed(() => {
|
||||
return displayName(props.ledger.expand?.creator)
|
||||
})
|
||||
|
||||
const relatedMemberNames = computed(() => {
|
||||
const members = props.ledger.expand?.relatedMembers
|
||||
if (!members || members.length === 0) return []
|
||||
return members.map((m) => displayName(m))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-card">
|
||||
<div class="ledger-card__main">
|
||||
<!-- 左侧:类型标签 + 金额 -->
|
||||
<div class="ledger-card__left">
|
||||
<span
|
||||
class="ledger-card__type-tag"
|
||||
:class="isIncome ? 'ledger-card__type-tag--income' : 'ledger-card__type-tag--expense'"
|
||||
>
|
||||
{{ typeLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="ledger-card__amount"
|
||||
:class="isIncome ? 'ledger-card__amount--income' : 'ledger-card__amount--expense'"
|
||||
>
|
||||
{{ formattedAmount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:描述、分类、日期 -->
|
||||
<div class="ledger-card__center">
|
||||
<div class="ledger-card__desc">{{ ledger.description || '无备注' }}</div>
|
||||
<div class="ledger-card__meta">
|
||||
<span class="ledger-card__category-tag">{{ categoryLabel }}</span>
|
||||
<span class="ledger-card__date">{{ formattedDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:关联成员 + 创建者 -->
|
||||
<div class="ledger-card__right">
|
||||
<div v-if="relatedMemberNames.length > 0" class="ledger-card__members">
|
||||
<span
|
||||
v-for="(name, index) in relatedMemberNames.slice(0, 3)"
|
||||
:key="index"
|
||||
class="ledger-card__member"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
<span v-if="relatedMemberNames.length > 3" class="ledger-card__member ledger-card__member--more">
|
||||
+{{ relatedMemberNames.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-card__creator">
|
||||
{{ creatorName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="isCreator || isGroupOwner" class="ledger-card__actions">
|
||||
<button v-if="isCreator" class="ledger-card__action-btn" @click.stop="emit('edit', ledger.id)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</button>
|
||||
<button class="ledger-card__action-btn ledger-card__action-btn--danger" @click.stop="emit('delete', ledger.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md, 8px);
|
||||
padding: 14px 18px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ledger-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.ledger-card__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.ledger-card__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ledger-card__type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ledger-card__type-tag--income {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-card__type-tag--expense {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.ledger-card__amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__amount--income {
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-card__amount--expense {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 中间 */
|
||||
.ledger-card__center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ledger-card__desc {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ledger-card__category-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ledger-card__date {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.ledger-card__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-card__members {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ledger-card__member {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-secondary);
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ledger-card__member--more {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ledger-card__creator {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.ledger-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ledger-card__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.ledger-card__action-btn:hover {
|
||||
color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.ledger-card__action-btn--danger:hover {
|
||||
color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-card__main {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ledger-card__left {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ledger-card__right {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { LedgerType, LedgerCategory } from '@/types'
|
||||
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
||||
import LedgerSummary from './LedgerSummary.vue'
|
||||
import LedgerCard from './LedgerCard.vue'
|
||||
import CreateLedgerDialog from './CreateLedgerDialog.vue'
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const editingLedger = ref<any>(undefined)
|
||||
const filterType = ref<LedgerType | ''>('')
|
||||
const filterCategory = ref<LedgerCategory | ''>('')
|
||||
|
||||
// 月份选择器值
|
||||
const selectedMonth = ref(new Date())
|
||||
|
||||
// 类型筛选选项
|
||||
const typeOptions = computed(() => [
|
||||
{ label: '全部', value: '' },
|
||||
...Object.entries(LedgerTypeMap).map(([value, label]) => ({ label, value }))
|
||||
])
|
||||
|
||||
// 分类筛选选项
|
||||
const categoryOptions = computed(() => [
|
||||
{ label: '全部', value: '' },
|
||||
...Object.entries(LedgerCategoryMap).map(([value, label]) => ({ label, value }))
|
||||
])
|
||||
|
||||
// 获取月份字符串
|
||||
function getMonthString(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
const month = getMonthString(selectedMonth.value)
|
||||
await ledgerStore.loadLedgers(groupId, month)
|
||||
}
|
||||
|
||||
// 筛选后的账目列表
|
||||
const filteredLedgers = computed(() => {
|
||||
let list = [...ledgerStore.ledgers]
|
||||
if (filterType.value) {
|
||||
list = list.filter((l) => l.type === filterType.value)
|
||||
}
|
||||
if (filterCategory.value) {
|
||||
list = list.filter((l) => l.category === filterCategory.value)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// 按日期分组
|
||||
const groupedByDate = computed(() => {
|
||||
const groups: Record<string, typeof filteredLedgers.value> = {}
|
||||
for (const ledger of filteredLedgers.value) {
|
||||
const date = ledger.occurredAt?.slice(0, 10) || '未知日期'
|
||||
if (!groups[date]) {
|
||||
groups[date] = []
|
||||
}
|
||||
groups[date].push(ledger)
|
||||
}
|
||||
// 按日期降序排列
|
||||
const sortedKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a))
|
||||
return sortedKeys.map((date) => ({ date, items: groups[date] }))
|
||||
})
|
||||
|
||||
// 月份变更
|
||||
function handleMonthChange(val: Date) {
|
||||
selectedMonth.value = val
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 筛选变更
|
||||
function handleFilterChange() {
|
||||
// 筛选在前端完成,无需重新加载
|
||||
}
|
||||
|
||||
// 新建
|
||||
function handleCreate() {
|
||||
editingLedger.value = undefined
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(ledgerId: string) {
|
||||
const ledger = ledgerStore.ledgers.find((l) => l.id === ledgerId)
|
||||
if (ledger) {
|
||||
editingLedger.value = ledger
|
||||
showCreate.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(ledgerId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条账目记录吗?删除后不可恢复。', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await ledgerStore.removeLedger(ledgerId)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 保存成功
|
||||
function handleSaved() {
|
||||
showCreate.value = false
|
||||
editingLedger.value = undefined
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 实时订阅(store 内部管理生命周期)
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await ledgerStore.startSubscription(groupId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadData()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadData()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="ledger-list__header">
|
||||
<h3 class="ledger-list__title">账本</h3>
|
||||
<button class="ledger-list__create-btn" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建账目
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="ledger-list__filters">
|
||||
<el-date-picker
|
||||
:model-value="selectedMonth"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
format="YYYY年MM月"
|
||||
value-format="YYYY-MM"
|
||||
@update:model-value="handleMonthChange"
|
||||
class="ledger-list__month-picker"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="类型"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="ledger-list__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterCategory"
|
||||
placeholder="分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="ledger-list__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in categoryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 汇总 -->
|
||||
<LedgerSummary />
|
||||
|
||||
<!-- 账目列表 -->
|
||||
<div v-loading="ledgerStore.loading" class="ledger-list__content">
|
||||
<section
|
||||
v-for="group in groupedByDate"
|
||||
:key="group.date"
|
||||
class="ledger-list__date-group"
|
||||
>
|
||||
<div class="ledger-list__date-header">
|
||||
<span class="ledger-list__date-dot"></span>
|
||||
<span class="ledger-list__date-label">{{ group.date }}</span>
|
||||
<span class="ledger-list__date-count">{{ group.items.length }} 条</span>
|
||||
</div>
|
||||
<div class="ledger-list__items">
|
||||
<LedgerCard
|
||||
v-for="ledger in group.items"
|
||||
:key="ledger.id"
|
||||
:ledger="ledger"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="groupedByDate.length === 0 && !ledgerStore.loading"
|
||||
class="ledger-list__empty"
|
||||
>
|
||||
<svg class="ledger-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z" />
|
||||
</svg>
|
||||
<p class="ledger-list__empty-text">暂无账目</p>
|
||||
<p class="ledger-list__empty-hint">该月份还没有账目记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<CreateLedgerDialog
|
||||
v-model="showCreate"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
:edit-ledger="editingLedger"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.ledger-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ledger-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ledger-list__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ledger-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.ledger-list__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ledger-list__month-picker {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.ledger-list__filter-select {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.ledger-list__content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 日期分组 */
|
||||
.ledger-list__date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ledger-list__date-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ledger-list__date-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ledger-list__date-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.ledger-list__date-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ledger-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.ledger-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ledger-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ledger-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.ledger-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-list__filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ledger-list__month-picker,
|
||||
.ledger-list__filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
|
||||
const totalIncome = computed(() => ledgerStore.summary.totalIncome)
|
||||
const totalExpense = computed(() => ledgerStore.summary.totalExpense)
|
||||
const balance = computed(() => ledgerStore.summary.balance)
|
||||
|
||||
function formatAmount(value: number): string {
|
||||
return '¥' + value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-summary__stat ledger-summary__stat--income">
|
||||
<span class="ledger-summary__label">总收入</span>
|
||||
<span class="ledger-summary__amount ledger-summary__amount--income">
|
||||
{{ formatAmount(totalIncome) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-summary__stat ledger-summary__stat--expense">
|
||||
<span class="ledger-summary__label">总支出</span>
|
||||
<span class="ledger-summary__amount ledger-summary__amount--expense">
|
||||
{{ formatAmount(totalExpense) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ledger-summary__stat ledger-summary__stat--balance">
|
||||
<span class="ledger-summary__label">余额</span>
|
||||
<span
|
||||
class="ledger-summary__amount"
|
||||
:class="balance >= 0 ? 'ledger-summary__amount--income' : 'ledger-summary__amount--expense'"
|
||||
>
|
||||
{{ formatAmount(balance) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ledger-summary__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg, 12px);
|
||||
}
|
||||
|
||||
.ledger-summary__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.ledger-summary__amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ledger-summary__amount--income {
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.ledger-summary__amount--expense {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ledger-summary {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ledger-summary__stat {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,20 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/GroupView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: () => import('@/views/LedgerView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: () => import('@/views/AssetView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Asset } from '@/types'
|
||||
import {
|
||||
listAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
transferAsset,
|
||||
deleteAsset,
|
||||
subscribeAssets
|
||||
} from '@/api/assets'
|
||||
import type { CreateAssetData, UpdateAssetData } from '@/api/assets'
|
||||
|
||||
export const useAssetStore = defineStore('asset', () => {
|
||||
const assets = ref<Asset[]>([])
|
||||
const loading = ref(false)
|
||||
let unsubFn: (() => Promise<void> | void) | null = null
|
||||
|
||||
async function loadAssets(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
assets.value = await listAssets(groupId)
|
||||
} catch (error) {
|
||||
console.error('加载资产列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addAsset(data: CreateAssetData) {
|
||||
try {
|
||||
loading.value = true
|
||||
await createAsset(data)
|
||||
await loadAssets(data.group)
|
||||
} catch (error) {
|
||||
console.error('创建资产失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function editAsset(assetId: string, data: UpdateAssetData, image?: File) {
|
||||
try {
|
||||
loading.value = true
|
||||
await updateAsset(assetId, data, image)
|
||||
// 找到当前资产的 groupId 以刷新列表
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
await loadAssets(asset.group)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新资产失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function transfer(assetId: string, userId: string) {
|
||||
try {
|
||||
await transferAsset(assetId, userId)
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
await loadAssets(asset.group)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转移资产失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAsset(assetId: string) {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
const asset = assets.value.find(a => a.id === assetId)
|
||||
assets.value = assets.value.filter(a => a.id !== assetId)
|
||||
// 返回 groupId 以便调用方需要时使用
|
||||
return asset?.group
|
||||
} catch (error) {
|
||||
console.error('删除资产失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
stopSubscription()
|
||||
unsubFn = await subscribeAssets(groupId, () => {
|
||||
loadAssets(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubFn) {
|
||||
unsubFn()
|
||||
unsubFn = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assets,
|
||||
loading,
|
||||
loadAssets,
|
||||
addAsset,
|
||||
editAsset,
|
||||
transfer,
|
||||
removeAsset,
|
||||
startSubscription,
|
||||
stopSubscription
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
// src/stores/ledger.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Ledger } from '@/types'
|
||||
import {
|
||||
listLedgers,
|
||||
createLedger,
|
||||
updateLedger,
|
||||
deleteLedger,
|
||||
getLedgerSummary,
|
||||
subscribeLedgers
|
||||
} from '@/api/ledgers'
|
||||
|
||||
export const useLedgerStore = defineStore('ledger', () => {
|
||||
const ledgers = ref<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 filterMonth = month || currentMonth.value
|
||||
const [listResult, summaryResult] = await Promise.all([
|
||||
listLedgers(groupId, { month: filterMonth }),
|
||||
getLedgerSummary(groupId, filterMonth)
|
||||
])
|
||||
ledgers.value = listResult.items
|
||||
summary.value = summaryResult
|
||||
if (month) currentMonth.value = month
|
||||
} catch (error) {
|
||||
console.error('加载账目列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 只加载汇总
|
||||
async function loadSummary(groupId: string, month?: string) {
|
||||
try {
|
||||
const filterMonth = month || currentMonth.value
|
||||
summary.value = await getLedgerSummary(groupId, filterMonth)
|
||||
} catch (error) {
|
||||
console.error('加载账目汇总失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增账目
|
||||
async function addLedger(data: Parameters<typeof createLedger>[0]) {
|
||||
try {
|
||||
await createLedger(data)
|
||||
} catch (error) {
|
||||
console.error('新增账目失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑账目
|
||||
async function editLedger(ledgerId: string, data: Parameters<typeof updateLedger>[1]) {
|
||||
try {
|
||||
await updateLedger(ledgerId, data)
|
||||
} catch (error) {
|
||||
console.error('编辑账目失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账目
|
||||
async function removeLedger(ledgerId: string) {
|
||||
try {
|
||||
await deleteLedger(ledgerId)
|
||||
} catch (error) {
|
||||
console.error('删除账目失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅实时更新
|
||||
async function startSubscription(groupId: string) {
|
||||
await subscribeLedgers(groupId, () => {
|
||||
// 收到变更后重新加载当前月份的数据
|
||||
loadLedgers(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ledgers,
|
||||
loading,
|
||||
summary,
|
||||
currentMonth,
|
||||
loadLedgers,
|
||||
loadSummary,
|
||||
addLedger,
|
||||
editLedger,
|
||||
removeLedger,
|
||||
startSubscription
|
||||
}
|
||||
})
|
||||
@@ -269,6 +269,71 @@ export interface Memory {
|
||||
}
|
||||
}
|
||||
|
||||
// 账目类型
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户显示名称(优先 name,回退 username)
|
||||
export function displayName(user?: { name?: string; username: string } | null): string {
|
||||
return user?.name || user?.username || '未知'
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- src/views/AssetView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import AssetList from '@/components/asset/AssetList.vue'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const assetStore = useAssetStore()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await assetStore.loadAssets(groupId)
|
||||
await assetStore.startSubscription(groupId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
assetStore.stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-view">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<router-link :to="`/group/${groupId}`" class="back-link">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回群组
|
||||
</router-link>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">资产管理</h1>
|
||||
<span class="group-badge">{{ group?.name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 资产列表 -->
|
||||
<AssetList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gg-gradient);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--gg-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@ import PollList from '@/components/poll/PollList.vue'
|
||||
import PollDetail from '@/components/poll/PollDetail.vue'
|
||||
import MemoryGrid from '@/components/memory/MemoryGrid.vue'
|
||||
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
|
||||
import { Promotion, Opportunity, UserFilled } from '@element-plus/icons-vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box } from '@element-plus/icons-vue'
|
||||
import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -154,6 +154,14 @@ async function checkExpiredPolls() {
|
||||
<span class="meta-label">容量:</span>
|
||||
<span class="meta-value">{{ members.length }} / {{ group?.maxMembers || '-' }}</span>
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -331,6 +339,21 @@ async function checkExpiredPolls() {
|
||||
color: var(--gg-border);
|
||||
}
|
||||
|
||||
.meta-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--gg-primary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.meta-link:hover {
|
||||
color: var(--gg-primary-light);
|
||||
}
|
||||
|
||||
/* ── 主体双栏 ── */
|
||||
.body-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- src/views/LedgerView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import LedgerList from '@/components/ledger/LedgerList.vue'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await ledgerStore.loadLedgers(groupId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-view">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<router-link :to="`/group/${groupId}`" class="back-link">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回群组
|
||||
</router-link>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">账目管理</h1>
|
||||
<span class="group-badge">{{ group?.name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 账目列表 -->
|
||||
<LedgerList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gg-gradient);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--gg-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user