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:
congsh
2026-04-18 19:42:04 +08:00
parent 625d0baf7d
commit c5413644f9
21 changed files with 4407 additions and 1 deletions
+82
View File
@@ -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)
}
+132
View File
@@ -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)
}
})
}
+309
View File
@@ -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>
+303
View File
@@ -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>
+14
View File
@@ -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',
+111
View File
@@ -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
}
})
+99
View File
@@ -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
}
})
+65
View File
@@ -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 || '未知'
+110
View File
@@ -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>
+24 -1
View File
@@ -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;
+105
View File
@@ -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>