feat(mobile): stage 8 - ledger + assets + blacklist
- migrate LedgerMobile.vue (monthly summary + list + add + swipe-delete) - migrate AssetMobile.vue (list + add with image + transfer + delete) - migrate BlacklistMobile.vue (game/player dual tabs + add + delete) - router: wire LedgerView/AssetView/BlacklistView mobile views - verified: ledger/asset stores + assets/gameBlacklist/playerBlacklist APIs + types maps all match uat build verified: vue-tsc + vite build pass
This commit is contained in:
@@ -89,7 +89,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LedgerView',
|
||||
component: view(
|
||||
() => import('@/views/LedgerView.vue'),
|
||||
mobilePlaceholder
|
||||
() => import('@/views-mobile/LedgerMobile.vue')
|
||||
),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
@@ -99,7 +99,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'AssetView',
|
||||
component: view(
|
||||
() => import('@/views/AssetView.vue'),
|
||||
mobilePlaceholder
|
||||
() => import('@/views-mobile/AssetMobile.vue')
|
||||
),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
@@ -109,7 +109,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'BlacklistView',
|
||||
component: view(
|
||||
() => import('@/views/BlacklistView.vue'),
|
||||
mobilePlaceholder
|
||||
() => import('@/views-mobile/BlacklistMobile.vue')
|
||||
),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<!-- src/views-mobile/AssetMobile.vue -->
|
||||
<!-- 手机端资产:列表 + 添加 + 转移 + 删除 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { getAssetImageUrl } from '@/api/assets'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { AssetType } from '@/types'
|
||||
import { displayName } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const route = useRoute()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const assets = computed(() => assetStore.assets)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await assetStore.loadAssets(groupId)
|
||||
await assetStore.startSubscription(groupId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
assetStore.stopSubscription()
|
||||
})
|
||||
|
||||
function imageUrl(assetId: string, filename: string): string {
|
||||
if (!filename) return ''
|
||||
return getAssetImageUrl(assetId, filename, '200x200')
|
||||
}
|
||||
|
||||
// 添加
|
||||
const showAdd = ref(false)
|
||||
const addForm = ref({ name: '', type: 'other' as AssetType, description: '' })
|
||||
const addImage = ref<File | null>(null)
|
||||
const addLoading = ref(false)
|
||||
|
||||
function onImageSelect(items: any) {
|
||||
const arr = Array.isArray(items) ? items : [items]
|
||||
addImage.value = arr[0]?.file || null
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!addForm.value.name.trim()) {
|
||||
showFailToast('请输入名称')
|
||||
return
|
||||
}
|
||||
addLoading.value = true
|
||||
try {
|
||||
await assetStore.addAsset({
|
||||
group: groupId,
|
||||
name: addForm.value.name.trim(),
|
||||
type: addForm.value.type,
|
||||
description: addForm.value.description.trim(),
|
||||
image: addImage.value || undefined
|
||||
})
|
||||
showSuccessToast('添加成功')
|
||||
showAdd.value = false
|
||||
addForm.value = { name: '', type: 'other', description: '' }
|
||||
addImage.value = null
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '添加失败')
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 转移
|
||||
const showTransfer = ref(false)
|
||||
const transferAssetId = ref('')
|
||||
const transferUserId = ref('')
|
||||
|
||||
function openTransfer(assetId: string) {
|
||||
transferAssetId.value = assetId
|
||||
transferUserId.value = ''
|
||||
showTransfer.value = true
|
||||
}
|
||||
|
||||
async function handleTransfer() {
|
||||
if (!transferUserId.value) {
|
||||
showFailToast('请选择成员')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assetStore.transfer(transferAssetId.value, transferUserId.value)
|
||||
showSuccessToast('已转移')
|
||||
showTransfer.value = false
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '转移失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(assetId: string) {
|
||||
showConfirmDialog({ title: '删除', message: '确定删除这个资产吗?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await assetStore.removeAsset(assetId)
|
||||
showSuccessToast('已删除')
|
||||
} catch (e: any) {
|
||||
showFailToast('删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const assetTypes = Object.keys(AssetTypeMap) as AssetType[]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-mobile">
|
||||
<div v-if="assets.length === 0" class="empty">
|
||||
<van-empty description="暂无资产" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else class="asset-list">
|
||||
<div v-for="a in assets" :key="a.id" class="asset-card">
|
||||
<div class="asset-main">
|
||||
<img
|
||||
v-if="a.image"
|
||||
:src="imageUrl(a.id, a.image)"
|
||||
class="asset-img"
|
||||
alt=""
|
||||
/>
|
||||
<div v-else class="asset-img-placeholder">
|
||||
<van-icon name="gift-o" size="28" />
|
||||
</div>
|
||||
<div class="asset-info">
|
||||
<div class="asset-name">{{ a.name }}</div>
|
||||
<div class="asset-meta">
|
||||
<van-tag plain size="medium">{{ AssetTypeMap[a.type] }}</van-tag>
|
||||
</div>
|
||||
<div v-if="a.expand?.currentHolder" class="asset-holder">
|
||||
持有: {{ displayName(a.expand.currentHolder) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<van-button size="mini" plain round @click="openTransfer(a.id)">转移</van-button>
|
||||
<van-button size="mini" plain type="danger" round @click="handleDelete(a.id)">删除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浮动添加 -->
|
||||
<div class="fab" @click="showAdd = true">
|
||||
<van-icon name="plus" size="24" />
|
||||
</div>
|
||||
|
||||
<!-- 添加弹层 -->
|
||||
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">添加资产</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="addForm.name" label="名称" placeholder="资产名称" required />
|
||||
<van-field name="select" label="类型">
|
||||
<template #input>
|
||||
<select v-model="addForm.type" class="type-select">
|
||||
<option v-for="t in assetTypes" :key="t" :value="t">{{ AssetTypeMap[t] }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="addForm.description" label="描述" placeholder="可选" />
|
||||
</van-cell-group>
|
||||
<div class="upload-area">
|
||||
<van-uploader :after-read="onImageSelect" :max-count="1" accept="image/*" />
|
||||
</div>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 转移弹层 -->
|
||||
<van-action-sheet v-model:show="showTransfer" title="转移给">
|
||||
<div class="transfer-list">
|
||||
<div
|
||||
v-for="m in members"
|
||||
:key="m.id"
|
||||
class="transfer-item"
|
||||
:class="{ active: transferUserId === m.id }"
|
||||
@click="transferUserId = m.id"
|
||||
>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="transfer-avatar" alt="" />
|
||||
<span>{{ displayName(m) }}</span>
|
||||
<van-icon v-if="transferUserId === m.id" name="success" class="transfer-check" />
|
||||
</div>
|
||||
<div class="transfer-actions">
|
||||
<van-button type="primary" block round @click="handleTransfer">确认转移</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-mobile { padding: 12px; min-height: 60vh; }
|
||||
.empty { padding: 30px 0; }
|
||||
|
||||
.asset-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.asset-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
|
||||
.asset-main { display: flex; gap: 12px; }
|
||||
.asset-img { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||
.asset-img-placeholder { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); background: var(--gg-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--gg-text-muted); flex-shrink: 0; }
|
||||
.asset-info { flex: 1; min-width: 0; }
|
||||
.asset-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
|
||||
.asset-meta { margin-top: 6px; }
|
||||
.asset-holder { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
|
||||
.asset-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
|
||||
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
|
||||
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.type-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||
.upload-area { padding: 16px; }
|
||||
.popup-actions { padding: 16px; }
|
||||
|
||||
.transfer-list { padding: 8px 0; }
|
||||
.transfer-item { display: flex; align-items: center; gap: 10px; padding: 12px 24px; }
|
||||
.transfer-item.active { background: rgba(5,150,105,0.06); color: var(--gg-primary); }
|
||||
.transfer-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
|
||||
.transfer-check { margin-left: auto; color: var(--gg-primary); }
|
||||
.transfer-actions { padding: 16px 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- src/views-mobile/BlacklistMobile.vue -->
|
||||
<!-- 手机端黑名单:游戏/玩家双 Tab + 添加 + 删除 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { listBlacklist, createBlacklistEntry, deleteBlacklistEntry } from '@/api/gameBlacklist'
|
||||
import { listPlayerBlacklist, createPlayerBlacklistEntry, deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap, PlayerTagMap } from '@/types'
|
||||
import type { BlacklistReason, BlacklistSeverity, PlayerTag, BlacklistEntry, PlayerBlacklistEntry } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const route = useRoute()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const activeTab = ref<'game' | 'player'>('game')
|
||||
|
||||
const gameList = ref<BlacklistEntry[]>([])
|
||||
const playerList = ref<PlayerBlacklistEntry[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [games, players] = await Promise.all([
|
||||
listBlacklist(groupId),
|
||||
listPlayerBlacklist(groupId)
|
||||
])
|
||||
gameList.value = games
|
||||
playerList.value = players
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
|
||||
const showAddGame = ref(false)
|
||||
const gameForm = ref({
|
||||
gameName: '',
|
||||
reason: 'behavior' as BlacklistReason,
|
||||
severity: 'medium' as BlacklistSeverity,
|
||||
description: ''
|
||||
})
|
||||
|
||||
async function handleAddGame() {
|
||||
if (!gameForm.value.gameName.trim()) {
|
||||
showFailToast('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createBlacklistEntry({
|
||||
group: groupId,
|
||||
gameName: gameForm.value.gameName.trim(),
|
||||
reason: gameForm.value.reason,
|
||||
severity: gameForm.value.severity,
|
||||
description: gameForm.value.description.trim()
|
||||
})
|
||||
showSuccessToast('已添加')
|
||||
showAddGame.value = false
|
||||
gameForm.value = { gameName: '', reason: 'behavior', severity: 'medium', description: '' }
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showAddPlayer = ref(false)
|
||||
const playerForm = ref({
|
||||
playerId: '',
|
||||
platform: '',
|
||||
tags: [] as PlayerTag[],
|
||||
severity: 'medium' as BlacklistSeverity,
|
||||
description: ''
|
||||
})
|
||||
|
||||
function toggleTag(tag: PlayerTag) {
|
||||
const idx = playerForm.value.tags.indexOf(tag)
|
||||
if (idx === -1) playerForm.value.tags.push(tag)
|
||||
else playerForm.value.tags.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function handleAddPlayer() {
|
||||
if (!playerForm.value.playerId.trim()) {
|
||||
showFailToast('请输入玩家 ID')
|
||||
return
|
||||
}
|
||||
if (playerForm.value.tags.length === 0) {
|
||||
showFailToast('请至少选一个标签')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createPlayerBlacklistEntry({
|
||||
group: groupId,
|
||||
playerId: playerForm.value.playerId.trim(),
|
||||
platform: playerForm.value.platform.trim(),
|
||||
tags: playerForm.value.tags,
|
||||
severity: playerForm.value.severity,
|
||||
description: playerForm.value.description.trim()
|
||||
})
|
||||
showSuccessToast('已添加')
|
||||
showAddPlayer.value = false
|
||||
playerForm.value = { playerId: '', platform: '', tags: [], severity: 'medium', description: '' }
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGame(id: string) {
|
||||
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteBlacklistEntry(id)
|
||||
showSuccessToast('已删除')
|
||||
await loadAll()
|
||||
} catch { showFailToast('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function deletePlayer(id: string) {
|
||||
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await deletePlayerBlacklistEntry(id)
|
||||
showSuccessToast('已删除')
|
||||
await loadAll()
|
||||
} catch { showFailToast('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const reasonKeys = Object.keys(BlacklistReasonMap) as BlacklistReason[]
|
||||
const severityKeys = Object.keys(BlacklistSeverityMap) as BlacklistSeverity[]
|
||||
const tagKeys = Object.keys(PlayerTagMap) as PlayerTag[]
|
||||
|
||||
const severityType = (s: string): any => s === 'severe' ? 'danger' : s === 'medium' ? 'warning' : 'default'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="blacklist-mobile">
|
||||
<van-tabs v-model:active="activeTab" sticky>
|
||||
<van-tab title="游戏黑名单" name="game">
|
||||
<div class="list-header">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">添加</van-button>
|
||||
</div>
|
||||
<div v-if="gameList.length === 0" class="empty">
|
||||
<van-empty description="暂无游戏黑名单" image-size="100" />
|
||||
</div>
|
||||
<div v-else class="entry-list">
|
||||
<van-swipe-cell v-for="g in gameList" :key="g.id">
|
||||
<div class="entry-card">
|
||||
<div class="entry-top">
|
||||
<span class="entry-name">{{ g.gameName }}</span>
|
||||
<van-tag :type="severityType(g.severity)" size="medium">{{ BlacklistSeverityMap[g.severity] }}</van-tag>
|
||||
</div>
|
||||
<van-tag plain size="medium">{{ BlacklistReasonMap[g.reason] }}</van-tag>
|
||||
<p v-if="g.description" class="entry-desc">{{ g.description }}</p>
|
||||
</div>
|
||||
<template #right>
|
||||
<van-button square type="danger" text="删除" class="delete-btn" @click="deleteGame(g.id)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</div>
|
||||
</van-tab>
|
||||
<van-tab title="玩家黑名单" name="player">
|
||||
<div class="list-header">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showAddPlayer = true">添加</van-button>
|
||||
</div>
|
||||
<div v-if="playerList.length === 0" class="empty">
|
||||
<van-empty description="暂无玩家黑名单" image-size="100" />
|
||||
</div>
|
||||
<div v-else class="entry-list">
|
||||
<van-swipe-cell v-for="p in playerList" :key="p.id">
|
||||
<div class="entry-card">
|
||||
<div class="entry-top">
|
||||
<span class="entry-name">{{ p.playerId }}</span>
|
||||
<van-tag :type="severityType(p.severity)" size="medium">{{ BlacklistSeverityMap[p.severity] }}</van-tag>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<van-tag v-for="t in p.tags" :key="t" type="danger" plain size="medium">{{ PlayerTagMap[t] }}</van-tag>
|
||||
<van-tag v-if="p.customTag" type="danger" plain size="medium">{{ p.customTag }}</van-tag>
|
||||
</div>
|
||||
<p v-if="p.description" class="entry-desc">{{ p.description }}</p>
|
||||
<div v-if="p.platform" class="entry-platform">平台: {{ p.platform }}</div>
|
||||
</div>
|
||||
<template #right>
|
||||
<van-button square type="danger" text="删除" class="delete-btn" @click="deletePlayer(p.id)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
|
||||
<van-popup v-model:show="showAddGame" position="bottom" round closeable :style="{ height: '70%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">添加游戏黑名单</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="gameForm.gameName" label="游戏名" placeholder="游戏名称" required />
|
||||
<van-field name="select" label="原因">
|
||||
<template #input>
|
||||
<select v-model="gameForm.reason" class="sel">
|
||||
<option v-for="r in reasonKeys" :key="r" :value="r">{{ BlacklistReasonMap[r] }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field name="select" label="严重度">
|
||||
<template #input>
|
||||
<select v-model="gameForm.severity" class="sel">
|
||||
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="gameForm.description" type="textarea" label="说明" placeholder="详细描述" rows="2" />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round @click="handleAddGame">添加</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showAddPlayer" position="bottom" round closeable :style="{ height: '75%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">添加玩家黑名单</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="playerForm.playerId" label="玩家 ID" placeholder="游戏内 ID" required />
|
||||
<van-field v-model="playerForm.platform" label="平台" placeholder="如 Steam/PSN" />
|
||||
<van-field name="select" label="严重度">
|
||||
<template #input>
|
||||
<select v-model="playerForm.severity" class="sel">
|
||||
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
<div class="tag-select-title">标签(多选)</div>
|
||||
<div class="tag-select-area">
|
||||
<van-tag
|
||||
v-for="t in tagKeys"
|
||||
:key="t"
|
||||
:type="playerForm.tags.includes(t) ? 'primary' : 'default'"
|
||||
size="large"
|
||||
round
|
||||
class="tag-opt"
|
||||
@click="toggleTag(t)"
|
||||
>{{ PlayerTagMap[t] }}</van-tag>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="playerForm.description" type="textarea" label="说明" placeholder="可选" rows="2" />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round @click="handleAddPlayer">添加</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blacklist-mobile { min-height: 60vh; }
|
||||
.list-header { display: flex; justify-content: flex-end; padding: 12px; }
|
||||
.empty { padding: 30px 0; }
|
||||
.entry-list { padding: 0 12px; display: flex; flex-direction: column; gap: 1px; }
|
||||
.entry-card { background: var(--gg-bg-card); padding: 12px 14px; }
|
||||
.entry-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.entry-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||
.entry-desc { font-size: 13px; color: var(--gg-text-secondary); margin: 6px 0 0; line-height: 1.5; }
|
||||
.entry-platform { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
|
||||
.delete-btn { height: 100%; }
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.sel { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||
.tag-select-title { padding: 16px 16px 8px; font-size: 14px; font-weight: 600; }
|
||||
.tag-select-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 16px; }
|
||||
.tag-opt { user-select: none; }
|
||||
.popup-actions { padding: 20px 16px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<!-- src/views-mobile/LedgerMobile.vue -->
|
||||
<!-- 手机端账本:月度汇总 + 列表 + 添加 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useLedgerStore } from '@/stores/ledger'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
||||
import type { LedgerType, LedgerCategory } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const route = useRoute()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const ledgerStore = useLedgerStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const ledgers = computed(() => ledgerStore.ledgers)
|
||||
const summary = computed(() => ledgerStore.summary)
|
||||
|
||||
const showMonthPicker = ref(false)
|
||||
const currentMonth = ref(ledgerStore.currentMonth || new Date().toISOString().slice(0, 7))
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||
await ledgerStore.startSubscription(groupId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ledgerStore.stopSubscription()
|
||||
})
|
||||
|
||||
async function changeMonth(month: string) {
|
||||
currentMonth.value = month
|
||||
showMonthPicker.value = false
|
||||
await ledgerStore.loadLedgers(groupId, month)
|
||||
}
|
||||
|
||||
// 月份选择器
|
||||
const months = computed(() => {
|
||||
const now = new Date()
|
||||
const list = []
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
list.push({
|
||||
value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
|
||||
label: `${d.getFullYear()}年${d.getMonth() + 1}月`
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// 添加
|
||||
const showAdd = ref(false)
|
||||
const addForm = ref({
|
||||
type: 'expense' as LedgerType,
|
||||
amount: 0,
|
||||
category: 'gaming' as LedgerCategory,
|
||||
description: '',
|
||||
occurredAt: new Date().toISOString().slice(0, 10)
|
||||
})
|
||||
const addLoading = ref(false)
|
||||
|
||||
async function handleAdd() {
|
||||
if (addForm.value.amount <= 0) {
|
||||
showFailToast('请输入金额')
|
||||
return
|
||||
}
|
||||
addLoading.value = true
|
||||
try {
|
||||
await ledgerStore.addLedger({
|
||||
group: groupId,
|
||||
type: addForm.value.type,
|
||||
amount: addForm.value.amount,
|
||||
category: addForm.value.category,
|
||||
description: addForm.value.description.trim(),
|
||||
occurredAt: addForm.value.occurredAt
|
||||
})
|
||||
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||
showSuccessToast('添加成功')
|
||||
showAdd.value = false
|
||||
addForm.value = { type: 'expense', amount: 0, category: 'gaming', description: '', occurredAt: new Date().toISOString().slice(0, 10) }
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '添加失败')
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(id: string) {
|
||||
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await ledgerStore.removeLedger(id)
|
||||
await ledgerStore.loadLedgers(groupId, currentMonth.value)
|
||||
showSuccessToast('已删除')
|
||||
} catch (e: any) {
|
||||
showFailToast('删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const typeColors: Record<string, 'success' | 'danger' | 'warning' | 'primary' | 'default'> = { income: 'success', expense: 'danger' }
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-mobile">
|
||||
<!-- 月度汇总 -->
|
||||
<div class="summary-card">
|
||||
<div class="month-bar" @click="showMonthPicker = true">
|
||||
<span class="month-label">{{ months.find(m => m.value === currentMonth)?.label || currentMonth }}</span>
|
||||
<van-icon name="arrow-down" />
|
||||
</div>
|
||||
<div class="balance-row">
|
||||
<div class="balance-item">
|
||||
<div class="balance-label">收入</div>
|
||||
<div class="balance-num income">{{ summary.totalIncome.toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="balance-divider" />
|
||||
<div class="balance-item">
|
||||
<div class="balance-label">支出</div>
|
||||
<div class="balance-num expense">{{ summary.totalExpense.toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="balance-divider" />
|
||||
<div class="balance-item">
|
||||
<div class="balance-label">结余</div>
|
||||
<div class="balance-num" :class="summary.balance >= 0 ? 'income' : 'expense'">
|
||||
{{ summary.balance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div v-if="ledgers.length === 0" class="empty">
|
||||
<van-empty description="本月暂无记录" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else class="ledger-list">
|
||||
<van-swipe-cell v-for="l in ledgers" :key="l.id">
|
||||
<div class="ledger-item">
|
||||
<div class="ledger-left">
|
||||
<van-tag :type="typeColors[l.type]" size="medium">{{ LedgerTypeMap[l.type] }}</van-tag>
|
||||
<div class="ledger-info">
|
||||
<div class="ledger-desc">{{ l.description || LedgerCategoryMap[l.category] }}</div>
|
||||
<div class="ledger-meta">
|
||||
{{ LedgerCategoryMap[l.category] }} · {{ formatDate(l.occurredAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledger-amount" :class="l.type">
|
||||
{{ l.type === 'income' ? '+' : '-' }}{{ l.amount.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<template #right>
|
||||
<van-button square type="danger" text="删除" class="delete-btn" @click="handleDelete(l.id)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</div>
|
||||
|
||||
<!-- 浮动添加按钮 -->
|
||||
<div class="fab" @click="showAdd = true">
|
||||
<van-icon name="plus" size="24" />
|
||||
</div>
|
||||
|
||||
<!-- 月份选择 -->
|
||||
<van-action-sheet v-model:show="showMonthPicker" title="选择月份">
|
||||
<div class="month-list">
|
||||
<div
|
||||
v-for="m in months"
|
||||
:key="m.value"
|
||||
class="month-option"
|
||||
:class="{ active: m.value === currentMonth }"
|
||||
@click="changeMonth(m.value)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 添加弹层 -->
|
||||
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">添加账目</div>
|
||||
<van-cell-group inset>
|
||||
<van-field name="radio" label="类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="addForm.type" direction="horizontal">
|
||||
<van-radio name="expense">支出</van-radio>
|
||||
<van-radio name="income">收入</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field name="stepper" label="金额">
|
||||
<template #input>
|
||||
<van-stepper v-model="addForm.amount" min="0" step="0.01" allow-empty />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field name="radio" label="分类">
|
||||
<template #input>
|
||||
<select v-model="addForm.category" class="category-select">
|
||||
<option v-for="(label, key) in LedgerCategoryMap" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="addForm.description" label="备注" placeholder="可选" />
|
||||
<van-field v-model="addForm.occurredAt" label="日期" placeholder="YYYY-MM-DD" />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-mobile { padding: 12px; min-height: 60vh; }
|
||||
|
||||
.summary-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 16px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
|
||||
.month-bar { display: flex; align-items: center; gap: 4px; font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 14px; }
|
||||
.balance-row { display: flex; align-items: center; }
|
||||
.balance-item { flex: 1; text-align: center; }
|
||||
.balance-label { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.balance-num { font-size: 18px; font-weight: 700; margin-top: 4px; }
|
||||
.balance-num.income { color: var(--gg-success); }
|
||||
.balance-num.expense { color: var(--gg-danger); }
|
||||
.balance-divider { width: 1px; height: 32px; background: var(--gg-border); }
|
||||
|
||||
.empty { padding: 30px 0; }
|
||||
|
||||
.ledger-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.ledger-item { display: flex; align-items: center; justify-content: space-between; background: var(--gg-bg-card); padding: 12px 14px; }
|
||||
.ledger-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||
.ledger-info { min-width: 0; }
|
||||
.ledger-desc { font-size: 14px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ledger-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
|
||||
.ledger-amount { font-size: 15px; font-weight: 600; flex-shrink: 0; }
|
||||
.ledger-amount.income { color: var(--gg-success); }
|
||||
.ledger-amount.expense { color: var(--gg-danger); }
|
||||
.delete-btn { height: 100%; }
|
||||
|
||||
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
|
||||
.fab:active { transform: scale(0.92); }
|
||||
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.category-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
|
||||
.popup-actions { padding: 20px 16px 0; }
|
||||
|
||||
.month-list { padding: 8px 0; }
|
||||
.month-option { padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
|
||||
.month-option.active { color: var(--gg-primary); font-weight: 600; background: rgba(5,150,105,0.06); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user