Compare commits
1 Commits
main
...
statistics
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2871cb775 |
@@ -71,6 +71,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/StatisticsView.vue'),
|
component: () => import('../views/StatisticsView.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/statistics-v2',
|
||||||
|
name: 'statistics-v2',
|
||||||
|
component: () => import('../views/statisticsV2/Index.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/bill-analysis',
|
path: '/bill-analysis',
|
||||||
name: 'bill-analysis',
|
name: 'bill-analysis',
|
||||||
|
|||||||
@@ -65,6 +65,14 @@
|
|||||||
<div class="bottom-spacer" />
|
<div class="bottom-spacer" />
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 交易详情弹窗 -->
|
||||||
|
<TransactionDetailSheet
|
||||||
|
v-model:show="showTransactionDetail"
|
||||||
|
:transaction="currentTransaction"
|
||||||
|
@save="handleTransactionSave"
|
||||||
|
@delete="handleTransactionDelete"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,6 +83,8 @@ import { showToast } from 'vant'
|
|||||||
import CalendarModule from './modules/Calendar.vue'
|
import CalendarModule from './modules/Calendar.vue'
|
||||||
import StatsModule from './modules/Stats.vue'
|
import StatsModule from './modules/Stats.vue'
|
||||||
import TransactionListModule from './modules/TransactionList.vue'
|
import TransactionListModule from './modules/TransactionList.vue'
|
||||||
|
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
|
||||||
|
import { getTransactionDetail } from '@/api/transactionRecord'
|
||||||
|
|
||||||
// 定义组件名称(keep-alive 需要通过 name 识别)
|
// 定义组件名称(keep-alive 需要通过 name 识别)
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -135,12 +145,35 @@ const onDayClick = async (day) => {
|
|||||||
selectedDate.value = clickedDate
|
selectedDate.value = clickedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击交易卡片 - 跳转到详情页
|
// 交易详情弹窗相关
|
||||||
const onTransactionClick = (txn) => {
|
const showTransactionDetail = ref(false)
|
||||||
router.push({
|
const currentTransaction = ref(null)
|
||||||
path: '/transaction-detail',
|
|
||||||
query: { id: txn.id }
|
// 点击交易卡片 - 打开详情弹窗
|
||||||
})
|
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