Compare commits
1 Commits
fac83eb09a
...
statistics
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2871cb775 |
@@ -71,6 +71,12 @@ const router = createRouter({
|
||||
component: () => import('../views/StatisticsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/statistics-v2',
|
||||
name: 'statistics-v2',
|
||||
component: () => import('../views/statisticsV2/Index.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/bill-analysis',
|
||||
name: 'bill-analysis',
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
<div class="bottom-spacer" />
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
|
||||
<!-- 交易详情弹窗 -->
|
||||
<TransactionDetailSheet
|
||||
v-model:show="showTransactionDetail"
|
||||
:transaction="currentTransaction"
|
||||
@save="handleTransactionSave"
|
||||
@delete="handleTransactionDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,6 +83,8 @@ import { showToast } from 'vant'
|
||||
import CalendarModule from './modules/Calendar.vue'
|
||||
import StatsModule from './modules/Stats.vue'
|
||||
import TransactionListModule from './modules/TransactionList.vue'
|
||||
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
|
||||
import { getTransactionDetail } from '@/api/transactionRecord'
|
||||
|
||||
// 定义组件名称(keep-alive 需要通过 name 识别)
|
||||
defineOptions({
|
||||
@@ -135,12 +145,35 @@ const onDayClick = async (day) => {
|
||||
selectedDate.value = clickedDate
|
||||
}
|
||||
|
||||
// 点击交易卡片 - 跳转到详情页
|
||||
const onTransactionClick = (txn) => {
|
||||
router.push({
|
||||
path: '/transaction-detail',
|
||||
query: { id: txn.id }
|
||||
})
|
||||
// 交易详情弹窗相关
|
||||
const showTransactionDetail = ref(false)
|
||||
const currentTransaction = ref(null)
|
||||
|
||||
// 点击交易卡片 - 打开详情弹窗
|
||||
const onTransactionClick = async (txn) => {
|
||||
try {
|
||||
// 获取完整的交易详情
|
||||
const response = await getTransactionDetail(txn.id)
|
||||
if (response.success && response.data) {
|
||||
currentTransaction.value = response.data
|
||||
showTransactionDetail.value = true
|
||||
} else {
|
||||
showToast('获取交易详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取交易详情失败:', error)
|
||||
showToast('获取交易详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存交易后刷新列表
|
||||
const handleTransactionSave = () => {
|
||||
handleTransactionsChanged()
|
||||
}
|
||||
|
||||
// 删除交易后刷新列表
|
||||
const handleTransactionDelete = () => {
|
||||
handleTransactionsChanged()
|
||||
}
|
||||
|
||||
// 点击通知按钮
|
||||
|
||||
172
Web/src/views/statisticsV2/Index.vue
Normal file
172
Web/src/views/statisticsV2/Index.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="statistics-v2">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar placeholder>
|
||||
<template #title>
|
||||
<div
|
||||
class="nav-title"
|
||||
@click="showYearPicker = true"
|
||||
>
|
||||
{{ currentYear }}年
|
||||
<van-icon name="arrow-down" />
|
||||
</div>
|
||||
</template>
|
||||
<template #right>
|
||||
<van-icon
|
||||
name="bell-o"
|
||||
size="20"
|
||||
class="notification-icon"
|
||||
@click="goToNotifications"
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<!-- 可滚动内容 -->
|
||||
<div class="scroll-content">
|
||||
<!-- 周期选择模块 -->
|
||||
<PeriodSelector
|
||||
:current-period="currentPeriod"
|
||||
@update:period="handlePeriodChange"
|
||||
/>
|
||||
|
||||
<!-- 核心指标模块 -->
|
||||
<MetricsCards
|
||||
:year="currentYear"
|
||||
:period="currentPeriod"
|
||||
/>
|
||||
|
||||
<!-- 分类支出模块 -->
|
||||
<CategorySection
|
||||
:year="currentYear"
|
||||
:month="currentMonth"
|
||||
/>
|
||||
|
||||
<!-- 支出趋势模块 -->
|
||||
<TrendSection
|
||||
:year="currentYear"
|
||||
:period="currentPeriod"
|
||||
/>
|
||||
|
||||
<!-- 预算使用模块 -->
|
||||
<BudgetSection
|
||||
:year="currentYear"
|
||||
:month="currentMonth"
|
||||
/>
|
||||
|
||||
<!-- 底部安全距离 -->
|
||||
<div class="safe-area-bottom" />
|
||||
</div>
|
||||
|
||||
<!-- 年份选择器 -->
|
||||
<van-popup
|
||||
v-model:show="showYearPicker"
|
||||
position="bottom"
|
||||
round
|
||||
>
|
||||
<van-picker
|
||||
:columns="yearColumns"
|
||||
:default-index="yearDefaultIndex"
|
||||
@confirm="onYearConfirm"
|
||||
@cancel="showYearPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PeriodSelector from './modules/PeriodSelector.vue'
|
||||
import MetricsCards from './modules/MetricsCards.vue'
|
||||
import CategorySection from './modules/CategorySection.vue'
|
||||
import TrendSection from './modules/TrendSection.vue'
|
||||
import BudgetSection from './modules/BudgetSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 状态管理
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const currentMonth = ref(new Date().getMonth() + 1)
|
||||
const currentPeriod = ref('month') // 'week' | 'month' | 'year'
|
||||
const showYearPicker = ref(false)
|
||||
|
||||
// 年份选择器配置
|
||||
const yearColumns = computed(() => {
|
||||
const startYear = 2020
|
||||
const endYear = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
years.push({ text: `${y}年`, value: y })
|
||||
}
|
||||
return years.reverse()
|
||||
})
|
||||
|
||||
const yearDefaultIndex = computed(() => {
|
||||
const index = yearColumns.value.findIndex(item => item.value === currentYear.value)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handlePeriodChange = (period) => {
|
||||
currentPeriod.value = period
|
||||
}
|
||||
|
||||
const onYearConfirm = ({ selectedOptions }) => {
|
||||
currentYear.value = selectedOptions[0].value
|
||||
showYearPicker.value = false
|
||||
}
|
||||
|
||||
const goToNotifications = () => {
|
||||
router.push({ path: '/balance', query: { tab: 'message' } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化逻辑(如需要)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-v2 {
|
||||
min-height: 100vh;
|
||||
background: var(--van-background-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'DM Sans', -apple-system, sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--van-text-color);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
cursor: pointer;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
height: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
:deep(.van-nav-bar) {
|
||||
background: var(--van-background-2);
|
||||
}
|
||||
</style>
|
||||
256
Web/src/views/statisticsV2/modules/BudgetSection.vue
Normal file
256
Web/src/views/statisticsV2/modules/BudgetSection.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="budget-section">
|
||||
<!-- 预算标题 -->
|
||||
<div class="section-header">
|
||||
<span class="label">预算使用</span>
|
||||
<div
|
||||
class="header-right"
|
||||
@click="goToBudget"
|
||||
>
|
||||
<span class="link-text">管理预算</span>
|
||||
<van-icon
|
||||
name="arrow-right"
|
||||
class="link-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预算卡片 -->
|
||||
<div class="budget-card">
|
||||
<van-loading v-if="loading" />
|
||||
<van-empty
|
||||
v-else-if="budgets.length === 0"
|
||||
description="暂无预算数据"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="budget-list"
|
||||
>
|
||||
<template
|
||||
v-for="(budget, index) in budgets"
|
||||
:key="budget.id"
|
||||
>
|
||||
<div class="budget-item">
|
||||
<div class="budget-header">
|
||||
<span class="budget-name">{{ budget.name }}</span>
|
||||
<div class="budget-stats">
|
||||
<span class="used-amount">¥{{ formatMoney(budget.current) }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="limit-amount">¥{{ formatMoney(budget.limit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: getProgressWidth(budget) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < budgets.length - 1"
|
||||
class="divider"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getBudgetList } from '@/api/budget'
|
||||
|
||||
const props = defineProps({
|
||||
year: Number,
|
||||
month: Number
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const budgets = ref([])
|
||||
|
||||
// 方法
|
||||
const formatMoney = (value) => {
|
||||
if (!value && value !== 0) {return '0'}
|
||||
return Number(value).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
const getProgressWidth = (budget) => {
|
||||
if (budget.limit === 0) {return 0}
|
||||
const percent = (budget.current / budget.limit) * 100
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
}
|
||||
|
||||
const goToBudget = () => {
|
||||
router.push('/budget')
|
||||
}
|
||||
|
||||
// 获取预算数据
|
||||
const fetchBudgets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const referenceDate = new Date(props.year, props.month - 1, 1).toISOString()
|
||||
const response = await getBudgetList(referenceDate)
|
||||
|
||||
if (response.success) {
|
||||
// 只显示支出预算且非不记额预算
|
||||
budgets.value = response.data
|
||||
.filter(b => b.category === 0 && !b.noLimit)
|
||||
.slice(0, 4) // 最多显示4个
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化
|
||||
watch([() => props.year, () => props.month], fetchBudgets, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: #888888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #0D6E6E;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
color: #0D6E6E;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.budget-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-height: 120px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #1A1A1A;
|
||||
border-color: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.budget-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.budget-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.budget-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budget-name {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1A1A1A;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.budget-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.used-amount {
|
||||
font-weight: 600;
|
||||
color: #1A1A1A;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.limit-amount {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #F0F0F0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #0D6E6E;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #F0F0F0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #2A2A2A;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
300
Web/src/views/statisticsV2/modules/CategorySection.vue
Normal file
300
Web/src/views/statisticsV2/modules/CategorySection.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="category-section">
|
||||
<!-- 分类标题 -->
|
||||
<div class="section-header">
|
||||
<div class="header-left">
|
||||
<span class="label">分类支出</span>
|
||||
</div>
|
||||
<div
|
||||
class="header-right"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<span class="link-text">{{ isExpanded ? '收起' : '查看全部' }}</span>
|
||||
<van-icon
|
||||
:name="isExpanded ? 'arrow-up' : 'arrow-right'"
|
||||
class="link-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类卡片 -->
|
||||
<div class="category-card">
|
||||
<van-loading v-if="loading" />
|
||||
<van-empty
|
||||
v-else-if="categories.length === 0"
|
||||
description="暂无分类数据"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="category-list"
|
||||
>
|
||||
<template
|
||||
v-for="(category, index) in displayCategories"
|
||||
:key="category.classify"
|
||||
>
|
||||
<div class="category-item">
|
||||
<div class="item-left">
|
||||
<div
|
||||
class="icon-wrapper"
|
||||
:style="{ background: category.color + '15' }"
|
||||
>
|
||||
<van-icon
|
||||
:name="getCategoryIcon(category.classify)"
|
||||
:color="category.color"
|
||||
size="20"
|
||||
/>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="category-name">
|
||||
{{ category.classify || '未分类' }}
|
||||
</div>
|
||||
<div class="category-count">
|
||||
{{ category.count }}笔
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<div class="category-amount">
|
||||
¥{{ formatMoney(category.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < displayCategories.length - 1"
|
||||
class="divider"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getCategoryStatistics } from '@/api/statistics'
|
||||
|
||||
const props = defineProps({
|
||||
year: Number,
|
||||
month: Number
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const categories = ref([])
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// 显示的分类列表
|
||||
const displayCategories = computed(() => {
|
||||
if (isExpanded.value) {
|
||||
return categories.value
|
||||
}
|
||||
// 只显示前3个
|
||||
return categories.value.slice(0, 3)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const formatMoney = (value) => {
|
||||
if (!value && value !== 0) {return '0'}
|
||||
return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
const getCategoryIcon = (classify) => {
|
||||
// 简单的图标映射
|
||||
const iconMap = {
|
||||
'餐饮': 'goods-collect-o',
|
||||
'购物': 'cart-o',
|
||||
'交通': 'guide-o',
|
||||
'娱乐': 'smile-o',
|
||||
'医疗': 'medic-o',
|
||||
'教育': 'certificate-o',
|
||||
'住房': 'home-o',
|
||||
'通讯': 'phone-o'
|
||||
}
|
||||
return iconMap[classify] || 'records-o'
|
||||
}
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
// 颜色配置
|
||||
const colors = [
|
||||
'#0D6E6E',
|
||||
'#E07B54',
|
||||
'#888888',
|
||||
'#4c9cf1',
|
||||
'#51cf66',
|
||||
'#ff6b6b',
|
||||
'#f59f00',
|
||||
'#7950f2'
|
||||
]
|
||||
|
||||
// 获取分类数据
|
||||
const fetchCategories = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getCategoryStatistics({
|
||||
year: props.year,
|
||||
month: props.month,
|
||||
type: 0 // 支出
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
categories.value = response.data.map((item, index) => ({
|
||||
...item,
|
||||
color: colors[index % colors.length]
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化
|
||||
watch([() => props.year, () => props.month], fetchCategories, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: #888888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #0D6E6E;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
color: #0D6E6E;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-height: 120px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #1A1A1A;
|
||||
border-color: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1A1A1A;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-amount {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1A1A1A;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #F0F0F0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #2A2A2A;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
401
Web/src/views/statisticsV2/modules/MetricsCards.vue
Normal file
401
Web/src/views/statisticsV2/modules/MetricsCards.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="metrics-cards">
|
||||
<!-- 核心指标标题 -->
|
||||
<div class="section-header">
|
||||
核心指标
|
||||
</div>
|
||||
|
||||
<!-- 第一行:总支出 + 交易笔数 -->
|
||||
<div class="metrics-row">
|
||||
<!-- 总支出卡片 -->
|
||||
<div class="metric-card">
|
||||
<div class="card-header">
|
||||
<span class="label">总支出</span>
|
||||
<span
|
||||
v-if="expenseChange !== 0"
|
||||
class="badge expense"
|
||||
>
|
||||
{{ expenseChange > 0 ? '+' : '' }}{{ expenseChange }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="value">
|
||||
¥{{ formatMoney(stats.totalExpense) }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ getChangeDescription('expense') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 交易笔数卡片 -->
|
||||
<div class="metric-card">
|
||||
<div class="card-header">
|
||||
<span class="label">交易笔数</span>
|
||||
<span
|
||||
v-if="countChange > 0"
|
||||
class="badge success"
|
||||
>
|
||||
+{{ countChange }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="value">
|
||||
{{ stats.totalCount }}
|
||||
</div>
|
||||
<!-- 7天趋势小图 -->
|
||||
<div class="chart-bars">
|
||||
<div
|
||||
v-for="(bar, index) in weeklyBars"
|
||||
:key="index"
|
||||
class="bar"
|
||||
:style="{ height: bar.height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:总收入 + 结余 -->
|
||||
<div class="metrics-row">
|
||||
<!-- 总收入卡片 -->
|
||||
<div class="metric-card">
|
||||
<div class="card-header">
|
||||
<span class="label">总收入</span>
|
||||
<span
|
||||
v-if="incomeChange !== 0"
|
||||
class="badge success"
|
||||
>
|
||||
{{ incomeChange > 0 ? '+' : '' }}{{ incomeChange }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="value income">
|
||||
¥{{ formatMoney(stats.totalIncome) }}
|
||||
</div>
|
||||
<div class="description">
|
||||
工资、红包等
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结余卡片 -->
|
||||
<div class="metric-card">
|
||||
<div class="card-header">
|
||||
<span class="label">结余</span>
|
||||
</div>
|
||||
<div
|
||||
class="value"
|
||||
:class="balanceClass"
|
||||
>
|
||||
{{ stats.balance >= 0 ? '+' : '' }}¥{{ formatMoney(Math.abs(stats.balance)) }}
|
||||
</div>
|
||||
<div class="balance-indicator">
|
||||
<div
|
||||
class="indicator-dot"
|
||||
:class="balanceClass"
|
||||
/>
|
||||
<span class="description">{{ balanceDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getMonthlyStatistics, getDailyStatistics } from '@/api/statistics'
|
||||
|
||||
const props = defineProps({
|
||||
year: Number,
|
||||
period: String
|
||||
})
|
||||
|
||||
// 数据状态
|
||||
const stats = ref({
|
||||
totalExpense: 0,
|
||||
totalIncome: 0,
|
||||
balance: 0,
|
||||
totalCount: 0
|
||||
})
|
||||
|
||||
const prevStats = ref({
|
||||
totalExpense: 0,
|
||||
totalIncome: 0,
|
||||
totalCount: 0
|
||||
})
|
||||
|
||||
const weeklyBars = ref([])
|
||||
|
||||
// 计算环比变化
|
||||
const expenseChange = computed(() => {
|
||||
if (prevStats.value.totalExpense === 0) {return 0}
|
||||
const change = ((stats.value.totalExpense - prevStats.value.totalExpense) / prevStats.value.totalExpense) * 100
|
||||
return Math.round(change)
|
||||
})
|
||||
|
||||
const incomeChange = computed(() => {
|
||||
if (prevStats.value.totalIncome === 0) {return 0}
|
||||
const change = ((stats.value.totalIncome - prevStats.value.totalIncome) / prevStats.value.totalIncome) * 100
|
||||
return Math.round(change)
|
||||
})
|
||||
|
||||
const countChange = computed(() => {
|
||||
return stats.value.totalCount - prevStats.value.totalCount
|
||||
})
|
||||
|
||||
const balanceClass = computed(() => {
|
||||
return stats.value.balance >= 0 ? 'income' : 'expense'
|
||||
})
|
||||
|
||||
const balanceDescription = computed(() => {
|
||||
return stats.value.balance >= 0 ? '收入大于支出' : '支出大于收入'
|
||||
})
|
||||
|
||||
// 方法
|
||||
const formatMoney = (value) => {
|
||||
if (!value && value !== 0) {return '0'}
|
||||
return Math.abs(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
const getChangeDescription = (type) => {
|
||||
const change = type === 'expense' ? expenseChange.value : incomeChange.value
|
||||
if (change === 0) {return '与上期持平'}
|
||||
if (change > 0) {return '较上期增加'}
|
||||
return '较上期减少'
|
||||
}
|
||||
|
||||
// 获取当前期数据
|
||||
const fetchCurrentStats = async () => {
|
||||
try {
|
||||
const month = props.period === 'year' ? 0 : new Date().getMonth() + 1
|
||||
const response = await getMonthlyStatistics({
|
||||
year: props.year,
|
||||
month
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
stats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取上期数据(用于环比计算)
|
||||
const fetchPrevStats = async () => {
|
||||
try {
|
||||
let prevYear = props.year
|
||||
let prevMonth = new Date().getMonth()
|
||||
|
||||
if (props.period === 'year') {
|
||||
prevYear = props.year - 1
|
||||
prevMonth = 0
|
||||
} else if (props.period === 'week') {
|
||||
// 周期:上周数据(简化处理,使用上月数据)
|
||||
prevMonth = new Date().getMonth()
|
||||
if (prevMonth === 0) {
|
||||
prevYear--
|
||||
prevMonth = 12
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getMonthlyStatistics({
|
||||
year: prevYear,
|
||||
month: prevMonth
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
prevStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取上期数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取7天趋势数据
|
||||
const fetchWeeklyTrend = async () => {
|
||||
try {
|
||||
const response = await getDailyStatistics({
|
||||
year: props.year,
|
||||
month: new Date().getMonth() + 1
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 取最近7天数据
|
||||
const recent7Days = response.data.slice(-7)
|
||||
const maxExpense = Math.max(...recent7Days.map(d => d.expense))
|
||||
|
||||
weeklyBars.value = recent7Days.map(day => ({
|
||||
height: maxExpense > 0 ? (day.expense / maxExpense) * 24 : 4
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取趋势数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化
|
||||
watch([() => props.year, () => props.period], async () => {
|
||||
await Promise.all([
|
||||
fetchCurrentStats(),
|
||||
fetchPrevStats(),
|
||||
fetchWeeklyTrend()
|
||||
])
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metrics-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: #888888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 146px;
|
||||
justify-content: space-between;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #1A1A1A;
|
||||
border-color: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.expense {
|
||||
background: #FFE5E5;
|
||||
color: #E07B54;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #E07B5415;
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: #E5F5E5;
|
||||
color: #0D6E6E;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #0D6E6E15;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 0.85;
|
||||
color: #1A1A1A;
|
||||
|
||||
&.income {
|
||||
color: #0D6E6E;
|
||||
}
|
||||
|
||||
&.expense {
|
||||
color: #E07B54;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
|
||||
&.income {
|
||||
color: #0D6E6E;
|
||||
}
|
||||
|
||||
&.expense {
|
||||
color: #E07B54;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: #E5E5E5;
|
||||
border-radius: 2px;
|
||||
min-height: 4px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:last-child {
|
||||
background: #0D6E6E;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #2A2A2A;
|
||||
|
||||
&:last-child {
|
||||
background: #0D6E6E;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.balance-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.income {
|
||||
background: #0D6E6E;
|
||||
}
|
||||
|
||||
&.expense {
|
||||
background: #E07B54;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
85
Web/src/views/statisticsV2/modules/PeriodSelector.vue
Normal file
85
Web/src/views/statisticsV2/modules/PeriodSelector.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="period-selector">
|
||||
<div class="segment-control">
|
||||
<div
|
||||
v-for="period in periods"
|
||||
:key="period.value"
|
||||
class="segment-item"
|
||||
:class="{ active: currentPeriod === period.value }"
|
||||
@click="selectPeriod(period.value)"
|
||||
>
|
||||
{{ period.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
currentPeriod: {
|
||||
type: String,
|
||||
default: 'month'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:period'])
|
||||
|
||||
const periods = [
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '年', value: 'year' }
|
||||
]
|
||||
|
||||
const selectPeriod = (value) => {
|
||||
emit('update:period', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.period-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment-control {
|
||||
display: flex;
|
||||
background: #F0F0F0;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
height: 44px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #1A1A1A;
|
||||
}
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
font-family: 'DM Sans', -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #888888;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: #1A1A1A;
|
||||
font-weight: 600;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #27272a;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
224
Web/src/views/statisticsV2/modules/TrendSection.vue
Normal file
224
Web/src/views/statisticsV2/modules/TrendSection.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="trend-section">
|
||||
<!-- 趋势标题 -->
|
||||
<div class="section-header">
|
||||
<span class="label">支出趋势</span>
|
||||
<div class="percent-badge">
|
||||
<span class="percent-value">{{ completionPercent }}</span>
|
||||
<span class="percent-sign">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="hr-line" />
|
||||
|
||||
<!-- 趋势卡片 -->
|
||||
<div class="trend-card">
|
||||
<van-loading v-if="loading" />
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="week-chart"
|
||||
>
|
||||
<div
|
||||
v-for="(day, index) in weekDays"
|
||||
:key="index"
|
||||
class="day-col"
|
||||
>
|
||||
<div
|
||||
class="day-bar"
|
||||
:style="{ height: day.barHeight + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="bar-fill"
|
||||
:style="{ height: '100%', background: day.color }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="day-label"
|
||||
:class="{ highlight: day.isToday }"
|
||||
>
|
||||
{{ day.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getDailyStatistics } from '@/api/statistics'
|
||||
|
||||
const props = defineProps({
|
||||
year: Number,
|
||||
period: String
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const dailyData = ref([])
|
||||
|
||||
// 计算本周完成百分比
|
||||
const completionPercent = computed(() => {
|
||||
if (dailyData.value.length === 0) {return 0}
|
||||
const totalDays = dailyData.value.length
|
||||
const completedDays = dailyData.value.filter(d => d.expense > 0).length
|
||||
return Math.round((completedDays / totalDays) * 100)
|
||||
})
|
||||
|
||||
// 周数据
|
||||
const weekDays = computed(() => {
|
||||
if (dailyData.value.length === 0) {return []}
|
||||
|
||||
// 取最近7天
|
||||
const recent7Days = dailyData.value.slice(-7)
|
||||
const maxExpense = Math.max(...recent7Days.map(d => d.expense), 1)
|
||||
const today = new Date().getDay()
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
return recent7Days.map((day, index) => {
|
||||
const dayOfWeek = new Date(day.date).getDay()
|
||||
const barHeight = (day.expense / maxExpense) * 60
|
||||
const isToday = index === recent7Days.length - 1
|
||||
|
||||
return {
|
||||
label: dayLabels[dayOfWeek],
|
||||
barHeight: Math.max(barHeight, 8),
|
||||
isToday,
|
||||
color: isToday ? '#E07B54' : '#F0F0F0'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 获取每日数据
|
||||
const fetchDailyData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getDailyStatistics({
|
||||
year: props.year,
|
||||
month: new Date().getMonth() + 1
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
dailyData.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取每日数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化
|
||||
watch([() => props.year, () => props.period], fetchDailyData, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: #888888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.percent-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.percent-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1A1A1A;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.percent-sign {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.hr-line {
|
||||
height: 1px;
|
||||
background: #E5E5E5;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-height: 120px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #1A1A1A;
|
||||
border-color: #2A2A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.week-chart {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.day-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.day-bar {
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888888;
|
||||
|
||||
&.highlight {
|
||||
color: #E07B54;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user