This commit is contained in:
SunCheng
2026-02-15 10:10:28 +08:00
parent e51a3edd50
commit a88556c784
92 changed files with 6751 additions and 776 deletions

View File

@@ -146,16 +146,24 @@ watch(
)
const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar-v2'
return (
route.path === '/' ||
route.path === '/balance' ||
route.path === '/message' ||
route.path === '/calendar-v2'
)
})
// 需要显示底部导航栏的路由
const showNav = computed(() => {
return [
'/', '/statistics-v2',
'/',
'/statistics-v2',
'/calendar-v2',
'/balance', '/message',
'/budget-v2', '/setting'
'/balance',
'/message',
'/budget-v2',
'/setting'
].includes(route.path)
})

View File

@@ -4,9 +4,11 @@
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Axios-based HTTP client modules for backend API integration with request/response interceptors.
## STRUCTURE
```
Web/src/api/
├── request.js # Base HTTP client setup
@@ -26,17 +28,19 @@ Web/src/api/
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Base HTTP setup | request.js | Axios interceptors, error handling |
| Authentication | auth.js | Login, token management |
| Budget data | budget.js | Budget CRUD, statistics |
| Transactions | transactionRecord.js | Transaction operations |
| Categories | transactionCategory.js | Category management |
| Statistics | statistics.js | Analytics, reports |
| Notifications | notification.js | Push subscription handling |
| Task | Location | Notes |
| --------------- | ---------------------- | ---------------------------------- |
| Base HTTP setup | request.js | Axios interceptors, error handling |
| Authentication | auth.js | Login, token management |
| Budget data | budget.js | Budget CRUD, statistics |
| Transactions | transactionRecord.js | Transaction operations |
| Categories | transactionCategory.js | Category management |
| Statistics | statistics.js | Analytics, reports |
| Notifications | notification.js | Push subscription handling |
## CONVENTIONS
- All functions return Promises with async/await
- Error handling via try/catch with user messages
- HTTP methods: get, post, put, delete mapping to REST
@@ -45,6 +49,7 @@ Web/src/api/
- Consistent error message format
## ANTI-PATTERNS (THIS LAYER)
- Never fetch directly without going through these modules
- Don't hardcode API endpoints (use environment variables)
- Avoid synchronous operations
@@ -52,8 +57,9 @@ Web/src/api/
- No business logic in API clients
## UNIQUE STYLES
- Chinese error messages for user feedback
- Automatic token refresh handling
- Request/response logging for debugging
- Paged query patterns for list endpoints
- File upload handling for imports
- File upload handling for imports

View File

@@ -15,8 +15,8 @@ const request = axios.create({
// 生成请求ID
const generateRequestId = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
/**
* 获取分类列表(支持按类型筛选)
@@ -103,3 +103,15 @@ export const updateSelectedIcon = (categoryId, selectedIndex) => {
data: { categoryId, selectedIndex }
})
}
/**
* 删除分类图标
* @param {number} id - 分类ID
* @returns {Promise<{success: boolean}>}
*/
export const deleteCategoryIcon = (id) => {
return request({
url: `/TransactionCategory/DeleteIcon/${id}`,
method: 'delete'
})
}

View File

@@ -87,17 +87,8 @@ body {
background-color 0.5s;
line-height: 1.6;
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@@ -5,38 +5,37 @@
:root {
/* ============ 颜色变量 - 浅色主题 ============ */
/* 背景色 */
--bg-primary: #FFFFFF;
--bg-secondary: #F6F7F8;
--bg-tertiary: #F3F4F6;
--bg-button: #F5F5F5;
--bg-primary: #ffffff;
--bg-secondary: #f6f7f8;
--bg-tertiary: #f3f4f6;
--bg-button: #f5f5f5;
/* 文字颜色 */
--text-primary: #1A1A1A;
--text-secondary: #6B7280;
--text-tertiary: #9CA3AF;
--text-primary: #1a1a1a;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* 强调色 */
--accent-primary: #FF6B6B;
--accent-danger: #EF4444;
--accent-warning: #D97706;
--accent-warning-bg: #FFFBEB;
--accent-success: #22C55E;
--accent-success-bg: #F0FDF4;
--accent-info: #6366F1;
--accent-info-bg: #E0E7FF;
--accent-primary: #ff6b6b;
--accent-danger: #ef4444;
--accent-warning: #d97706;
--accent-warning-bg: #fffbeb;
--accent-success: #22c55e;
--accent-success-bg: #f0fdf4;
--accent-info: #6366f1;
--accent-info-bg: #e0e7ff;
/* 图标色 */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
--icon-star: #ff6b6b;
--icon-coffee: #fcd34d;
/* 边框颜色 */
--border-color: #E5E7EB;
--border-color: #e5e7eb;
/* ============ 布局变量 ============ */
/* 间距 */
--spacing-xs: 2px;
--spacing-sm: 4px;
@@ -45,13 +44,13 @@
--spacing-xl: 16px;
--spacing-2xl: 20px;
--spacing-3xl: 24px;
/* 圆角 */
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-full: 22px;
/* 字体大小 */
--font-xs: 9px;
--font-sm: 11px;
@@ -61,48 +60,48 @@
--font-xl: 18px;
--font-2xl: 24px;
--font-3xl: 32px;
/* 字体粗细 */
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* 字体 */
--font-primary: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* 阴影 (可选) */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.05);
/* 边框颜色 */
--border-color: #E5E7EB;
--border-color: #e5e7eb;
/* 分段控制器 (Segmented Control) - From .pans/v2.pen NDWwE */
--segmented-bg: #F4F4F5;
--segmented-active-bg: #FFFFFF;
--segmented-bg: #f4f4f5;
--segmented-active-bg: #ffffff;
}
/* ============ 深色主题 ============ */
[data-theme="dark"] {
[data-theme='dark'] {
/* 背景色 */
--bg-primary: #09090B;
--bg-primary: #09090b;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
--bg-button: #27272a;
/* 文字颜色 */
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
/* 边框颜色 */
--border-color: #3f3f46;
/* 强调色 (深色主题调整) */
--accent-primary: #FF6B6B;
--accent-primary: #ff6b6b;
--accent-danger: #f87171;
--accent-warning: #fbbf24;
--accent-warning-bg: #451a03;
@@ -110,10 +109,10 @@
--accent-success-bg: #064e3b;
--accent-info: #818cf8;
--accent-info-bg: #312e81;
/* 图标色 (深色主题) */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
--icon-star: #ff6b6b;
--icon-coffee: #fcd34d;
/* 分段控制器 (Segmented Control) - From .pans/v2.pen NDWwE */
--segmented-bg: #27272a;
@@ -152,9 +151,7 @@
background-color: var(--bg-tertiary);
}
/* 布局容器 */
/* 布局容器 */
.container-fluid {
width: 100%;
max-width: 402px;
@@ -183,22 +180,52 @@
}
/* 间距 */
.gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); }
.gap-2xl { gap: var(--spacing-2xl); }
.gap-3xl { gap: var(--spacing-3xl); }
.gap-xs {
gap: var(--spacing-xs);
}
.gap-sm {
gap: var(--spacing-sm);
}
.gap-md {
gap: var(--spacing-md);
}
.gap-lg {
gap: var(--spacing-lg);
}
.gap-xl {
gap: var(--spacing-xl);
}
.gap-2xl {
gap: var(--spacing-2xl);
}
.gap-3xl {
gap: var(--spacing-3xl);
}
/* 内边距 */
.p-sm { padding: var(--spacing-md); }
.p-md { padding: var(--spacing-xl); }
.p-lg { padding: var(--spacing-2xl); }
.p-xl { padding: var(--spacing-3xl); }
.p-sm {
padding: var(--spacing-md);
}
.p-md {
padding: var(--spacing-xl);
}
.p-lg {
padding: var(--spacing-2xl);
}
.p-xl {
padding: var(--spacing-3xl);
}
/* 圆角 */
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-full { border-radius: var(--radius-full); }
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-full {
border-radius: var(--radius-full);
}

View File

@@ -1,20 +1,30 @@
<template>
<van-dialog
<template>
<PopupContainer
v-model:show="show"
title="新增交易分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirm"
@cancel="resetAddForm"
>
<van-field
v-model="classifyName"
placeholder="请输入新的交易分类"
/>
</van-dialog>
<van-form ref="addFormRef">
<van-field
v-model="classifyName"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from 'vant'
import PopupContainer from './PopupContainer.vue'
const emit = defineEmits(['confirm'])
@@ -39,6 +49,11 @@ const handleConfirm = () => {
classifyName.value = ''
}
// 重置表单
const resetAddForm = () => {
classifyName.value = ''
}
// 暴露方法给父组件
defineExpose({
open

View File

@@ -0,0 +1,843 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<!-- 普通预算卡片 -->
<div
v-if="!budget.noLimit"
class="common-card budget-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer">
<div class="collapsed-item">
<span class="compact-label">实际/目标</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{
budget.current !== undefined && budget.limit !== undefined
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
: '--'
}}
</slot>
</span>
</div>
<div class="collapsed-item">
<span class="compact-label">达成率</span>
<span
class="compact-value"
:class="percentClass"
>{{ percentage }}%</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info" />
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span
class="percent"
:class="percentClass"
>{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="var(--van-gray-6)"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
<!-- 不记额预算卡片 -->
<div
v-else
class="common-card budget-card no-limit-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer no-limit-footer">
<div class="collapsed-item">
<span class="compact-label">实际</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
</slot>
</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="no-limit-amount-info">
<div class="amount-item">
<span>
<span class="label">实际</span>
<span
class="value"
style="margin-left: 12px"
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
</span>
</div>
</div>
<div class="no-limit-notice">
<span>
<van-icon
name="info-o"
style="margin-right: 4px"
/>
不记额预算 - 直接计入存款明细
</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import TransactionList from '@/components/TransactionList.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
budget: {
type: Object,
required: true
},
progressColor: {
type: String,
default: 'var(--van-primary-color)'
},
percentClass: {
type: [String, Object],
default: ''
},
periodLabel: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
const isExpanded = ref(props.budget.category === 2)
const showDescription = ref(false)
const showBillListModal = ref(false)
const billList = ref([])
const billLoading = ref(false)
const toggleExpand = () => {
// 存款类型category === 2强制保持展开状态不可折叠
if (props.budget.category === 2) {
return
}
isExpanded.value = !isExpanded.value
}
const handleQueryBills = async () => {
showBillListModal.value = true
billLoading.value = true
try {
const classify = props.budget.selectedCategories
? props.budget.selectedCategories.join(',')
: ''
if (classify === '') {
// 如果没有选中任何分类,则不查询
billList.value = []
billLoading.value = false
return
}
const response = await getTransactionList({
page: 1,
pageSize: 100,
startDate: props.budget.periodStart,
endDate: props.budget.periodEnd,
classify: classify,
type: props.budget.category,
sortByAmount: true
})
if (response.success) {
billList.value = response.data || []
} else {
billList.value = []
}
} catch (error) {
console.error('查询账单列表失败:', error)
billList.value = []
} finally {
billLoading.value = false
}
}
const percentage = computed(() => {
if (!props.budget.limit) {
return 0
}
return Math.round((props.budget.current / props.budget.limit) * 100)
})
const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd) {
return 0
}
const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime()
if (now <= start) {
return 0
}
if (now >= end) {
return 100
}
return Math.round(((now - start) / (end - start)) * 100)
})
const onEnter = (el) => {
el.style.height = '0'
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = `${el.scrollHeight}px`
}
const onAfterEnter = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
const onLeave = (el) => {
el.style.height = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = '0'
}
const onAfterLeave = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.budget-card.cursor-default {
cursor: default;
}
.no-limit-card {
border-left: 3px solid var(--van-success-color);
}
.collapsed-footer.no-limit-footer {
justify-content: flex-start;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 折叠状态样式 */
.budget-collapsed {
display: flex;
flex-direction: column;
gap: 6px;
}
.collapsed-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapsed-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.card-title-compact {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.status-tag-compact) {
padding: 2px 6px !important;
font-size: 11px !important;
height: auto !important;
flex-shrink: 0;
}
.collapsed-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.collapsed-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.collapsed-item:first-child {
flex: 2;
}
.collapsed-item:last-child {
flex: 1;
}
.compact-label {
font-size: 12px;
color: var(--van-text-color-2);
line-height: 1.2;
}
.compact-value {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-value.warning {
color: var(--van-warning-color);
}
.compact-value.income {
color: var(--van-success-color);
}
.expand-icon {
color: var(--van-primary-color);
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.collapse-icon {
color: var(--van-primary-color);
font-size: 16px;
cursor: pointer;
}
.budget-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.card-subtitle {
font-size: 12px;
color: var(--van-text-color-2);
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.header-actions {
display: flex;
gap: 8px;
}
.category-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0 4px;
}
.category-tag {
opacity: 0.7;
font-size: 10px;
}
.amount-info {
display: flex;
justify-content: space-between;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: var(--van-text-color-2);
margin-bottom: 2px;
}
:deep(.info-item) .value {
font-size: 15px;
font-weight: 600;
}
:deep(.value.expense) {
color: var(--van-danger-color);
}
:deep(.value.income) {
color: var(--van-success-color);
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: var(--van-gray-6);
}
.progress-section :deep(.van-progress) {
flex: 1;
}
.period-type {
white-space: nowrap;
width: 65px;
}
.percent {
white-space: nowrap;
width: 35px;
text-align: right;
}
.percent.warning {
color: var(--van-warning-color);
font-weight: bold;
}
.percent.income {
color: var(--van-success-color);
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.no-limit-notice {
text-align: center;
font-size: 12px;
color: var(--van-text-color-2);
background-color: var(--van-light-gray);
border-radius: 4px;
margin-top: 8px;
}
.no-limit-amount-info {
display: flex;
justify-content: center;
margin: 0px 0;
}
.amount-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.amount-item .label {
font-size: 12px;
color: var(--van-text-color-2);
}
.amount-item .value {
font-size: 20px;
font-weight: 600;
color: var(--van-success-color);
}
.budget-collapse-wrapper {
overflow: hidden;
}
.budget-description {
border-top: 1px solid var(--van-border-color);
margin-top: 8px;
}
.description-content {
font-size: 11px;
color: var(--van-gray-6);
line-height: 1.4;
}
.mandatory-mark {
margin-left: 4px;
font-size: 14px;
display: inline-block;
}
</style>

View File

@@ -38,9 +38,7 @@
:disabled="form.noLimit"
>
{{ form.category === BudgetCategory.Expense ? '硬性消费' : '硬性收入' }}
<span class="mandatory-tip">
当前周期 / 按天数自动累加(无记录时)
</span>
<span class="mandatory-tip"> 当前周期 / 按天数自动累加(无记录时) </span>
</van-checkbox>
</div>
</template>

View File

@@ -1,127 +1,111 @@
<template>
<van-popup
<PopupContainer
v-model:show="visible"
position="bottom"
:style="{ height: '80%' }"
round
closeable
:title="title"
:subtitle="total > 0 ? `共 ${total} 笔交易` : ''"
:closeable="true"
>
<div class="popup-wrapper">
<!-- 头部 -->
<div class="popup-header">
<h2 class="popup-title">
{{ title }}
</h2>
<div
v-if="total > 0"
class="popup-subtitle"
>
{{ total }} 笔交易
<!-- 交易列表 -->
<div class="transactions">
<!-- 加载状态 -->
<van-loading
v-if="loading && transactions.length === 0"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<!-- 空状态 -->
<div
v-else-if="transactions.length === 0"
class="txn-empty"
>
<div class="empty-icon">
<van-icon
name="balance-list-o"
size="48"
/>
</div>
<div class="empty-text">
暂无交易记录
</div>
</div>
<!-- 交易列表 -->
<div class="transactions">
<!-- 加载状态 -->
<van-loading
v-if="loading && transactions.length === 0"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<!-- 空状态 -->
<div
v-else
class="txn-list"
>
<div
v-else-if="transactions.length === 0"
class="txn-empty"
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div class="empty-icon">
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
name="balance-list-o"
size="48"
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="empty-text">
暂无交易记录
<div class="txn-content">
<div class="txn-name">
{{ txn.reason }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ formatDateTime(txn.occurredAt) }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ formatAmount(txn.amount, txn.type) }}
</div>
</div>
<!-- 交易列表 -->
<!-- 加载更多 -->
<div
v-if="!finished"
class="load-more"
>
<van-loading
v-if="loading"
size="20px"
>
加载中...
</van-loading>
<van-button
v-else
type="primary"
size="small"
@click="loadMore"
>
加载更多
</van-button>
</div>
<!-- 已加载全部 -->
<div
v-else
class="txn-list"
class="finished-text"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.reason }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ formatDateTime(txn.occurredAt) }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ formatAmount(txn.amount, txn.type) }}
</div>
</div>
<!-- 加载更多 -->
<div
v-if="!finished"
class="load-more"
>
<van-loading
v-if="loading"
size="20px"
>
加载中...
</van-loading>
<van-button
v-else
type="primary"
size="small"
@click="loadMore"
>
加载更多
</van-button>
</div>
<!-- 已加载全部 -->
<div
v-else
class="finished-text"
>
已加载全部
</div>
已加载全部
</div>
</div>
</div>
</van-popup>
</PopupContainer>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
@@ -136,6 +120,7 @@
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
@@ -207,13 +192,13 @@ const formatAmount = (amount, type) => {
// 根据分类获取图标
const getIconByClassify = (classify) => {
const iconMap = {
'餐饮': 'food',
'购物': 'shopping',
'交通': 'logistics',
'娱乐': 'play-circle',
'医疗': 'medic',
'工资': 'gold-coin',
'红包': 'gift'
餐饮: 'food',
购物: 'shopping',
交通: 'logistics',
娱乐: 'play-circle',
医疗: 'medic',
工资: 'gold-coin',
红包: 'gift'
}
return iconMap[classify] || 'bill'
}
@@ -256,7 +241,7 @@ const loadData = async (isRefresh = false) => {
const newList = response.data || []
// 转换数据格式,添加显示所需的字段
const formattedList = newList.map(txn => ({
const formattedList = newList.map((txn) => ({
...txn,
icon: getIconByClassify(txn.classify),
iconColor: getColorByType(txn.type),
@@ -308,7 +293,7 @@ const handleSave = () => {
const handleDelete = (id) => {
showDetail.value = false
// 从列表中移除
transactions.value = transactions.value.filter(t => t.id !== id)
transactions.value = transactions.value.filter((t) => t.id !== id)
total.value--
// 通知父组件刷新
emit('refresh')
@@ -331,39 +316,6 @@ watch(visible, (newValue) => {
<style scoped>
@import '@/assets/theme.css';
.popup-wrapper {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
}
.popup-header {
flex-shrink: 0;
padding: var(--spacing-2xl);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.popup-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
text-align: center;
letter-spacing: -0.02em;
}
.popup-subtitle {
margin-top: var(--spacing-sm);
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
text-align: center;
}
.transactions {
flex: 1;
overflow-y: auto;
@@ -452,7 +404,7 @@ watch(visible, (newValue) => {
.txn-classify-tag.tag-expense {
background-color: rgba(59, 130, 246, 0.15);
color: #3B82F6;
color: #3b82f6;
}
.txn-amount {

View File

@@ -42,7 +42,7 @@ const props = defineProps({
default () {
return [
{ name: 'calendar', label: '日历', icon: 'notes', path: '/calendar-v2' },
{ name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/' },
{ name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/statistics-v2' },
{ name: 'balance', label: '账单', icon: 'balance-list', path: '/balance' },
{ name: 'budget', label: '预算', icon: 'bill-o', path: '/budget-v2' },
{ name: 'setting', label: '设置', icon: 'setting', path: '/setting' }
@@ -84,8 +84,10 @@ const getActiveTabFromRoute = (currentPath) => {
// 规范化路径: 去掉 -v2 后缀以支持版本切换
const normalizedPath = currentPath.replace(/-v2$/, '')
const matchedItem = navItems.value.find(item => {
if (!item.path) {return false}
const matchedItem = navItems.value.find((item) => {
if (!item.path) {
return false
}
// 完全匹配
if (item.path === currentPath || item.path === normalizedPath) {
@@ -112,15 +114,23 @@ const updateActiveTab = (newTab) => {
}
// 监听外部 modelValue 的变化
watch(() => props.modelValue, (newValue) => {
updateActiveTab(newValue)
}, { immediate: true })
watch(
() => props.modelValue,
(newValue) => {
updateActiveTab(newValue)
},
{ immediate: true }
)
// 监听路由变化,自动同步底部导航高亮状态
watch(() => route.path, (newPath) => {
const matchedTab = getActiveTabFromRoute(newPath)
updateActiveTab(matchedTab)
}, { immediate: true })
watch(
() => route.path,
(newPath) => {
const matchedTab = getActiveTabFromRoute(newPath)
updateActiveTab(matchedTab)
},
{ immediate: true }
)
const handleTabClick = (item, index) => {
activeTab.value = item.name
@@ -129,7 +139,7 @@ const handleTabClick = (item, index) => {
// 如果有路径定义,则进行路由跳转
if (item.path) {
router.push(item.path).catch(err => {
router.push(item.path).catch((err) => {
// 忽略相同路由导航错误
if (err.name !== 'NavigationDuplicated') {
console.warn('Navigation error:', err)
@@ -195,7 +205,9 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(255, 255, 255, 0.5) inset;
box-shadow:
0 -4px 24px rgba(0, 0, 0, 0.08),
0 0 0 0.5px rgba(255, 255, 255, 0.5) inset;
pointer-events: auto;
}
@@ -218,17 +230,21 @@ onMounted(() => {
/* 亮色模式文字颜色(默认) */
.nav-label {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
font-size: 10px;
font-weight: 500;
color: #9CA3AF;
color: #9ca3af;
transition: all 0.2s ease;
line-height: 1.2;
}
.nav-label-active {
font-weight: 600;
color: #1A1A1A;
color: #1a1a1a;
}
/* 响应式适配 */
@@ -254,15 +270,17 @@ onMounted(() => {
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-color: rgba(42, 42, 46, 0.6);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25), 0 0 0 0.5px rgba(255, 255, 255, 0.1) inset;
box-shadow:
0 -4px 24px rgba(0, 0, 0, 0.25),
0 0 0 0.5px rgba(255, 255, 255, 0.1) inset;
}
.nav-label {
color: #6B6B6F;
color: #6b6b6f;
}
.nav-label-active {
color: #FAFAF9;
color: #fafaf9;
}
}
@@ -302,4 +320,4 @@ onMounted(() => {
}
}
}
</style>
</style>

View File

@@ -108,37 +108,103 @@ defineEmits(['action-click'])
// 根据类型选择SVG图标路径
const iconPath = computed(() => {
const icons = {
search: () => h('g', [
h('circle', { cx: '26', cy: '26', r: '18', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M40 40L54 54', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' })
]),
data: () => h('g', [
h('path', { d: 'M8 48L22 32L36 40L56 16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', fill: 'none' }),
h('circle', { cx: '8', cy: '48', r: '3', fill: 'currentColor' }),
h('circle', { cx: '22', cy: '32', r: '3', fill: 'currentColor' }),
h('circle', { cx: '36', cy: '40', r: '3', fill: 'currentColor' }),
h('circle', { cx: '56', cy: '16', r: '3', fill: 'currentColor' })
]),
inbox: () => h('g', [
h('path', { d: 'M8 16L32 4L56 16V52C56 54.2 54.2 56 52 56H12C9.8 56 8 54.2 8 52V16Z', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M8 32H20L24 40H40L44 32H56', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' })
]),
calendar: () => h('g', [
h('rect', { x: '8', y: '12', width: '48', height: '44', rx: '4', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M8 24H56', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', { d: 'M20 8V16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' }),
h('path', { d: 'M44 8V16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' })
]),
finance: () => h('g', [
h('circle', { cx: '32', cy: '32', r: '24', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M32 16V48', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', { d: 'M24 22H36C38.2 22 40 23.8 40 26C40 28.2 38.2 30 36 30H28C25.8 30 24 31.8 24 34C24 36.2 25.8 38 28 38H40', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' })
]),
chart: () => h('g', [
h('rect', { x: '12', y: '36', width: '8', height: '20', rx: '2', fill: 'currentColor' }),
h('rect', { x: '28', y: '24', width: '8', height: '32', rx: '2', fill: 'currentColor' }),
h('rect', { x: '44', y: '12', width: '8', height: '44', rx: '2', fill: 'currentColor' })
])
search: () =>
h('g', [
h('circle', {
cx: '26',
cy: '26',
r: '18',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
}),
h('path', {
d: 'M40 40L54 54',
stroke: 'currentColor',
'stroke-width': '3',
'stroke-linecap': 'round'
})
]),
data: () =>
h('g', [
h('path', {
d: 'M8 48L22 32L36 40L56 16',
stroke: 'currentColor',
'stroke-width': '3',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
fill: 'none'
}),
h('circle', { cx: '8', cy: '48', r: '3', fill: 'currentColor' }),
h('circle', { cx: '22', cy: '32', r: '3', fill: 'currentColor' }),
h('circle', { cx: '36', cy: '40', r: '3', fill: 'currentColor' }),
h('circle', { cx: '56', cy: '16', r: '3', fill: 'currentColor' })
]),
inbox: () =>
h('g', [
h('path', {
d: 'M8 16L32 4L56 16V52C56 54.2 54.2 56 52 56H12C9.8 56 8 54.2 8 52V16Z',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
}),
h('path', {
d: 'M8 32H20L24 40H40L44 32H56',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
})
]),
calendar: () =>
h('g', [
h('rect', {
x: '8',
y: '12',
width: '48',
height: '44',
rx: '4',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
}),
h('path', { d: 'M8 24H56', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', {
d: 'M20 8V16',
stroke: 'currentColor',
'stroke-width': '3',
'stroke-linecap': 'round'
}),
h('path', {
d: 'M44 8V16',
stroke: 'currentColor',
'stroke-width': '3',
'stroke-linecap': 'round'
})
]),
finance: () =>
h('g', [
h('circle', {
cx: '32',
cy: '32',
r: '24',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
}),
h('path', { d: 'M32 16V48', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', {
d: 'M24 22H36C38.2 22 40 23.8 40 26C40 28.2 38.2 30 36 30H28C25.8 30 24 31.8 24 34C24 36.2 25.8 38 28 38H40',
stroke: 'currentColor',
'stroke-width': '3',
fill: 'none'
})
]),
chart: () =>
h('g', [
h('rect', { x: '12', y: '36', width: '8', height: '20', rx: '2', fill: 'currentColor' }),
h('rect', { x: '28', y: '24', width: '8', height: '32', rx: '2', fill: 'currentColor' }),
h('rect', { x: '44', y: '12', width: '8', height: '44', rx: '2', fill: 'currentColor' })
])
}
return icons[props.type] || icons.search
})
@@ -275,7 +341,8 @@ const iconPath = computed(() => {
// 动画
@keyframes pulse {
0%, 100% {
0%,
100% {
transform: scale(1);
opacity: 0.1;
}
@@ -286,7 +353,8 @@ const iconPath = computed(() => {
}
@keyframes float {
0%, 100% {
0%,
100% {
transform: translateY(0px);
}
50% {

View File

@@ -1,3 +1,37 @@
<!--
统一弹窗组件
## 基础用法
<PopupContainer v-model:show="show" title="标题">
内容
</PopupContainer>
## 确认对话框用法
<PopupContainer
v-model:show="showConfirm"
title="确认操作"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirm"
@cancel="handleCancel"
>
确定要执行此操作吗
</PopupContainer>
## 带副标题和页脚
<PopupContainer
v-model:show="show"
title="分类详情"
subtitle="共 10 笔交易"
>
内容区域
<template #footer>
<van-button type="primary">提交</van-button>
</template>
</PopupContainer>
-->
<!-- eslint-disable vue/no-v-html -->
<template>
<van-popup
@@ -52,10 +86,29 @@
<!-- 底部页脚固定不可滚动 -->
<div
v-if="slots.footer"
v-if="slots.footer || showConfirmButton || showCancelButton"
class="popup-footer-fixed"
>
<slot name="footer" />
<!-- 用户自定义页脚插槽 -->
<slot name="footer">
<!-- 默认确认/取消按钮 -->
<div class="footer-buttons">
<van-button
v-if="showCancelButton"
plain
@click="handleCancel"
>
{{ cancelText }}
</van-button>
<van-button
v-if="showConfirmButton"
type="primary"
@click="handleConfirm"
>
{{ confirmText }}
</van-button>
</div>
</slot>
</div>
</div>
</van-popup>
@@ -84,10 +137,26 @@ const props = defineProps({
closeable: {
type: Boolean,
default: true
},
showConfirmButton: {
type: Boolean,
default: false
},
showCancelButton: {
type: Boolean,
default: false
},
confirmText: {
type: String,
default: '确认'
},
cancelText: {
type: String,
default: '取消'
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const slots = useSlots()
@@ -99,6 +168,16 @@ const visible = computed({
// 判断是否有操作按钮
const hasActions = computed(() => !!slots['header-actions'])
// 确认按钮点击
const handleConfirm = () => {
emit('confirm')
}
// 取消按钮点击
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
@@ -184,4 +263,15 @@ const hasActions = computed(() => !!slots['header-actions'])
background-color: var(--van-background-2);
padding: 12px 16px;
}
.footer-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.footer-buttons .van-button {
flex: 1;
max-width: 120px;
}
</style>

View File

@@ -93,4 +93,4 @@ defineEmits(['change'])
}
}
}
</style>
</style>

View File

@@ -2,7 +2,12 @@
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: 'auto', maxHeight: '85%', borderTopLeftRadius: '16px', borderTopRightRadius: '16px' }"
:style="{
height: 'auto',
maxHeight: '85%',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}"
teleport="body"
@close="handleClose"
>
@@ -185,10 +190,7 @@ import { ref, reactive, watch, computed } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import ClassifySelector from '@/components/ClassifySelector.vue'
import {
updateTransaction,
deleteTransaction
} from '@/api/transactionRecord'
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
const props = defineProps({
show: {
@@ -293,7 +295,9 @@ const finishEditAmount = () => {
// 格式化日期时间显示
const formatDateTime = (dateTime) => {
if (!dateTime) {return ''}
if (!dateTime) {
return ''
}
return dayjs(dateTime).format('YYYY-MM-DD HH:mm')
}
@@ -372,38 +376,39 @@ const handleDelete = async () => {
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonColor: '#EF4444'
}).then(async () => {
try {
deleting.value = true
const response = await deleteTransaction(editForm.id)
if (response.success) {
showToast('删除成功')
emit('delete', editForm.id)
visible.value = false
} else {
showToast(response.message || '删除失败')
}
} catch (error) {
console.error('删除出错:', error)
showToast('删除失败')
} finally {
deleting.value = false
}
}).catch(() => {
// 用户取消删除
})
.then(async () => {
try {
deleting.value = true
const response = await deleteTransaction(editForm.id)
if (response.success) {
showToast('删除成功')
emit('delete', editForm.id)
visible.value = false
} else {
showToast(response.message || '删除失败')
}
} catch (error) {
console.error('删除出错:', error)
showToast('删除失败')
} finally {
deleting.value = false
}
})
.catch(() => {
// 用户取消删除
})
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.transaction-detail-sheet {
background: #FFFFFF;
background: #ffffff;
padding: 24px;
display: flex;
flex-direction: column;
@@ -418,12 +423,12 @@ const handleClose = () => {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090B;
color: #09090b;
}
.header-close {
font-size: 24px;
color: #71717A;
color: #71717a;
cursor: pointer;
}
}
@@ -439,14 +444,14 @@ const handleClose = () => {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: normal;
color: #71717A;
color: #71717a;
}
.amount-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
color: #09090B;
color: #09090b;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
@@ -464,28 +469,28 @@ const handleClose = () => {
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090B;
color: #09090b;
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090B;
color: #09090b;
border: none;
outline: none;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #E4E4E7;
border-bottom: 2px solid #e4e4e7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366F1;
border-bottom-color: #6366f1;
}
&::placeholder {
color: #A1A1AA;
color: #a1a1aa;
}
// 移除 number 类型的上下箭头
@@ -513,7 +518,7 @@ const handleClose = () => {
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #E4E4E7;
border-bottom: 1px solid #e4e4e7;
&.no-border {
border-bottom: none;
@@ -523,14 +528,14 @@ const handleClose = () => {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #71717A;
color: #71717a;
}
.form-value {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #09090B;
color: #09090b;
text-align: right;
flex: 1;
margin-left: 16px;
@@ -546,7 +551,7 @@ const handleClose = () => {
}
.placeholder {
color: #A1A1AA;
color: #a1a1aa;
}
.reason-input {
@@ -556,11 +561,11 @@ const handleClose = () => {
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090B;
color: #09090b;
background: transparent;
&::placeholder {
color: #A1A1AA;
color: #a1a1aa;
}
}
@@ -583,7 +588,7 @@ const handleClose = () => {
.classify-section {
padding: 16px;
background: #F4F4F5;
background: #f4f4f5;
border-radius: 8px;
margin-top: -8px;
}
@@ -597,9 +602,9 @@ const handleClose = () => {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #EF4444;
border: 1px solid #ef4444;
background: transparent;
color: #EF4444;
color: #ef4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
@@ -609,8 +614,8 @@ const handleClose = () => {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366F1;
color: #FAFAFA;
background: #6366f1;
color: #fafafa;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
@@ -621,38 +626,38 @@ const handleClose = () => {
// 暗色模式
@media (prefers-color-scheme: dark) {
.transaction-detail-sheet {
background: #18181B;
background: #18181b;
.sheet-header {
.header-title {
color: #FAFAFA;
color: #fafafa;
}
.header-close {
color: #A1A1AA;
color: #a1a1aa;
}
}
.amount-section {
.amount-label {
color: #A1A1AA;
color: #a1a1aa;
}
.amount-value {
color: #FAFAFA;
color: #fafafa;
}
.amount-input-wrapper {
.currency-symbol {
color: #FAFAFA;
color: #fafafa;
}
.amount-input {
color: #FAFAFA;
border-bottom-color: #27272A;
color: #fafafa;
border-bottom-color: #27272a;
&:focus {
border-bottom-color: #6366F1;
border-bottom-color: #6366f1;
}
}
}
@@ -660,24 +665,24 @@ const handleClose = () => {
.form-section {
.form-row {
border-bottom-color: #27272A;
border-bottom-color: #27272a;
.form-label {
color: #A1A1AA;
color: #a1a1aa;
}
.form-value {
color: #FAFAFA;
color: #fafafa;
.reason-input {
color: #FAFAFA;
color: #fafafa;
}
}
}
}
.classify-section {
background: #27272A;
background: #27272a;
}
}
}

View File

@@ -180,9 +180,7 @@ import { showToast } from 'vant'
import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import {
updateTransaction
} from '@/api/transactionRecord'
import { updateTransaction } from '@/api/transactionRecord'
const props = defineProps({
show: {
@@ -363,7 +361,6 @@ const formatDate = (dateString) => {
minute: '2-digit'
})
}
</script>
<style scoped>

View File

@@ -10,7 +10,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import vant from 'vant'
import Vant from 'vant'
import { ConfigProvider } from 'vant'
import 'vant/lib/index.css'
@@ -21,7 +21,7 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vant)
app.use(Vant)
app.use(ConfigProvider)
app.mount('#app')

View File

@@ -2,36 +2,36 @@
:root {
/* 亮色主题 - 背景色系统 */
--bg-primary: #FFFFFF;
--bg-secondary: #F6F7F8;
--bg-tertiary: #F5F5F5;
--bg-primary: #ffffff;
--bg-secondary: #f6f7f8;
--bg-tertiary: #f5f5f5;
/* 亮色主题 - 文本色系统 */
--text-primary: #1A1A1A;
--text-secondary: #6B7280;
--text-tertiary: #9CA3AF;
--text-primary: #1a1a1a;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* 语义色 */
--color-primary: #3B82F6;
--color-danger: #FF6B6B;
--color-success: #07C160;
--color-warning: #FAAD14;
--color-primary: #3b82f6;
--color-danger: #ff6b6b;
--color-success: #07c160;
--color-warning: #faad14;
}
[data-theme="dark"] {
[data-theme='dark'] {
/* 暗色主题 - 背景色系统 */
--bg-primary: #09090B;
--bg-secondary: #18181B;
--bg-tertiary: #27272A;
--bg-primary: #09090b;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
/* 暗色主题 - 文本色系统 */
--text-primary: #F4F4F5;
--text-secondary: #A1A1AA;
--text-tertiary: #71717A;
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
/* 语义色在暗色模式下保持不变或微调 */
--color-primary: #3B82F6;
--color-danger: #FF6B6B;
--color-success: #07C160;
--color-warning: #FAAD14;
--color-primary: #3b82f6;
--color-danger: #ff6b6b;
--color-success: #07c160;
--color-warning: #faad14;
}

View File

@@ -24,10 +24,7 @@ export const formatDate = (date, format = 'YYYY-MM-DD') => {
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
return format.replace('YYYY', year).replace('MM', month).replace('DD', day)
}
/**
@@ -41,4 +38,4 @@ export const formatPercent = (value, decimals = 1) => {
return '0%'
}
return `${Number(value).toFixed(decimals)}%`
}
}

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable vue/no-v-html -->
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
@@ -94,11 +94,15 @@
</div>
<!-- 提示词设置弹窗 -->
<van-dialog
<PopupContainer
v-model:show="showPromptDialog"
title="编辑分析提示词"
:show-cancel-button="true"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="confirmPrompt"
@cancel="showPromptDialog = false"
>
<van-field
v-model="promptValue"
@@ -109,7 +113,7 @@
placeholder="输入自定义的分析提示词..."
show-word-limit
/>
</van-dialog>
</PopupContainer>
</div>
</template>
@@ -118,6 +122,7 @@ import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
import PopupContainer from '@/components/PopupContainer.vue'
const router = useRouter()
const userInput = ref('')

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container-flex">
<van-nav-bar
:title="navTitle"
@@ -111,9 +111,13 @@
</div>
<!-- 新增分类对话框 -->
<van-dialog
<PopupContainer
v-model:show="showAddDialog"
title="新增分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirmAdd"
@cancel="resetAddForm"
>
@@ -126,14 +130,18 @@
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</van-dialog>
</PopupContainer>
<!-- 编辑分类对话框 -->
<van-dialog
<PopupContainer
v-model:show="showEditDialog"
title="编辑分类"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="handleConfirmEdit"
@cancel="showEditDialog = false"
>
<van-form ref="editFormRef">
<van-field
@@ -144,22 +152,45 @@
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</van-dialog>
</PopupContainer>
<!-- 删除确认对话框 -->
<van-dialog
<PopupContainer
v-model:show="showDeleteConfirm"
title="删除分类"
message="删除后无法恢复,确定要删除吗?"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDelete"
/>
@cancel="showDeleteConfirm = false"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
删除后无法恢复确定要删除吗
</p>
</PopupContainer>
<!-- 删除图标确认对话框 -->
<PopupContainer
v-model:show="showDeleteIconConfirm"
title="删除图标"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDeleteIcon"
@cancel="showDeleteIconConfirm = false"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
确定要删除图标吗
</p>
</PopupContainer>
<!-- 图标选择对话框 -->
<van-dialog
<PopupContainer
v-model:show="showIconDialog"
title="选择图标"
show-cancel-button
@confirm="handleConfirmIconSelect"
:closeable="false"
>
<div class="icon-selector">
<div
@@ -185,7 +216,8 @@
>
<van-empty description="暂无图标" />
</div>
</div>
<template #footer>
<div class="icon-actions">
<van-button
type="primary"
@@ -196,9 +228,28 @@
>
{{ isGeneratingIcon ? 'AI生成中...' : '生成新图标' }}
</van-button>
<van-button
v-if="currentCategory && currentCategory.icon"
type="danger"
size="small"
plain
:disabled="isDeletingIcon"
style="margin-left: 20px;"
@click="handleDeleteIcon"
>
{{ isDeletingIcon ? '删除中...' : '删除图标' }}
</van-button>
<van-button
size="small"
plain
style="margin-left: 10px;"
@click="showIconDialog = false"
>
关闭
</van-button>
</div>
</div>
</van-dialog>
</template>
</PopupContainer>
</div>
</div>
</template>
@@ -213,8 +264,10 @@ import {
deleteCategory,
updateCategory,
generateIcon,
updateSelectedIcon
updateSelectedIcon,
deleteCategoryIcon
} from '@/api/transactionCategory'
import PopupContainer from '@/components/PopupContainer.vue'
const router = useRouter()
@@ -261,6 +314,10 @@ const currentCategory = ref(null) // 当前正在编辑图标的分类
const selectedIconIndex = ref(0)
const isGeneratingIcon = ref(false)
// 删除图标确认对话框
const showDeleteIconConfirm = ref(false)
const isDeletingIcon = ref(false)
// 计算导航栏标题
const navTitle = computed(() => {
if (currentLevel.value === 0) {
@@ -437,7 +494,9 @@ const handleGenerateIcon = async () => {
* 确认选择图标
*/
const handleConfirmIconSelect = async () => {
if (!currentCategory.value) {return}
if (!currentCategory.value) {
return
}
try {
showLoadingToast({
@@ -466,6 +525,51 @@ const handleConfirmIconSelect = async () => {
}
}
/**
* 删除图标
*/
const handleDeleteIcon = () => {
if (!currentCategory.value || !currentCategory.value.icon) {
return
}
showDeleteIconConfirm.value = true
}
/**
* 确认删除图标
*/
const handleConfirmDeleteIcon = async () => {
if (!currentCategory.value) {
return
}
try {
isDeletingIcon.value = true
showLoadingToast({
message: '删除中...',
forbidClick: true,
duration: 0
})
const { success, message } = await deleteCategoryIcon(currentCategory.value.id)
if (success) {
showSuccessToast('图标删除成功')
showDeleteIconConfirm.value = false
showIconDialog.value = false
await loadCategories()
} else {
showToast(message || '删除失败')
}
} catch (error) {
console.error('删除图标失败:', error)
showToast('删除图标失败: ' + (error.message || '未知错误'))
} finally {
isDeletingIcon.value = false
closeToast()
}
}
/**
* 编辑分类
*/
@@ -564,7 +668,9 @@ const resetAddForm = () => {
* 解析图标数组(第一个图标为当前选中的)
*/
const parseIcon = (iconJson) => {
if (!iconJson) {return ''}
if (!iconJson) {
return ''
}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) && icons.length > 0 ? icons[0] : ''
@@ -577,7 +683,9 @@ const parseIcon = (iconJson) => {
* 解析图标数组为完整数组
*/
const parseIconArray = (iconJson) => {
if (!iconJson) {return []}
if (!iconJson) {
return []
}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) ? icons : []
@@ -679,12 +787,14 @@ onMounted(() => {
}
.icon-actions {
padding-top: 16px;
border-top: 1px solid var(--van-border-color);
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0;
}
/* PopupContainer 的 footer 已有边框,所以这里不需要重复 */
/* 深色模式 */
/* @media (prefers-color-scheme: dark) {
.level-container {

View File

@@ -120,7 +120,12 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { getLogList, getAvailableDates, getAvailableClassNames, getLogsByRequestId } from '@/api/log'
import {
getLogList,
getAvailableDates,
getAvailableClassNames,
getLogsByRequestId
} from '@/api/log'
const router = useRouter()

View File

@@ -507,7 +507,8 @@ const editPeriodic = (item) => {
form.type = parseInt(item.type)
form.classify = item.classify
form.periodicType = parseInt(item.periodicType)
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
form.periodicTypeText =
periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
// 解析周期配置
if (item.periodicConfig) {

View File

@@ -16,7 +16,9 @@
<template #right>
<!-- 未覆盖分类警告图标支出和收入 tab -->
<van-icon
v-if="activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0 && !isArchive"
v-if="
activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0 && !isArchive
"
name="warning-o"
size="20"
color="var(--van-danger-color)"
@@ -285,7 +287,13 @@
<!-- 空状态 -->
<van-empty
v-if="activeTab !== BudgetCategory.Savings && !loading && !hasError && ((activeTab === BudgetCategory.Expense && expenseBudgets?.length === 0) || (activeTab === BudgetCategory.Income && incomeBudgets?.length === 0))"
v-if="
activeTab !== BudgetCategory.Savings &&
!loading &&
!hasError &&
((activeTab === BudgetCategory.Expense && expenseBudgets?.length === 0) ||
(activeTab === BudgetCategory.Income && incomeBudgets?.length === 0))
"
:description="`暂无${activeTab === BudgetCategory.Expense ? '支出' : '收入'}预算`"
/>
</div>
@@ -347,7 +355,10 @@
<div style="padding: 16px">
<div
class="rich-html-content"
v-html="archiveSummary || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'"
v-html="
archiveSummary ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
"
/>
</div>
</PopupContainer>
@@ -402,7 +413,7 @@ defineOptions({
})
const messageStore = useMessageStore()
const theme = computed(() => messageStore.isDarkMode ? 'dark' : 'light')
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
// 日期状态
const currentDate = ref(new Date())
@@ -607,11 +618,7 @@ const loadBudgetData = async () => {
try {
// 并发加载多个数据源
await Promise.allSettled([
loadMonthlyData(),
loadCategoryStats(),
loadUncoveredCategories()
])
await Promise.allSettled([loadMonthlyData(), loadCategoryStats(), loadUncoveredCategories()])
} catch (_error) {
console.error('加载预算数据失败:', _error)
hasError.value = true
@@ -910,7 +917,8 @@ onBeforeUnmount(() => {
}
.budget-content {
padding: 12px;
padding: var(--spacing-md);
padding-top: 0;
}
.error-state {
@@ -970,7 +978,9 @@ onBeforeUnmount(() => {
.item-amount {
font-size: 18px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
font-family:
DIN Alternate,
system-ui;
}
.info-item {
@@ -988,7 +998,9 @@ onBeforeUnmount(() => {
.info-item .value {
font-size: 16px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
font-family:
DIN Alternate,
system-ui;
}
.info-item .value.expense {

View File

@@ -71,125 +71,112 @@
</div>
<!-- 计划存款明细弹窗 -->
<van-popup
v-model:show="showDetailPopup"
position="bottom"
round
:style="{ height: '80%' }"
<PopupContainer
v-model="showDetailPopup"
title="计划存款明细"
height="80%"
>
<div class="detail-popup-content">
<div class="popup-header">
<h3 class="popup-title">
计划存款明细
</h3>
<van-icon
name="cross"
size="20"
class="close-icon"
@click="showDetailPopup = false"
/>
</div>
<div class="popup-body">
<div
v-if="currentBudget"
class="detail-content"
>
<div class="detail-section income-section">
<div class="section-title">
<van-icon name="balance-o" />
收入预算
<div class="popup-body">
<div
v-if="currentBudget"
class="detail-content"
>
<div class="detail-section income-section">
<div class="section-title">
<van-icon name="balance-o" />
收入预算
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际收入</span>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际收入</span>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
</div>
</div>
</div>
<div class="detail-section expense-section">
<div class="section-title">
<van-icon name="bill-o" />
支出预算
<div class="detail-section expense-section">
<div class="section-title">
<van-icon name="bill-o" />
支出预算
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际支出</span>
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际支出</span>
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
</div>
</div>
</div>
<div class="detail-section formula-section">
<div class="section-title">
<van-icon name="calculator-o" />
计划存款公式
<div class="detail-section formula-section">
<div class="section-title">
<van-icon name="calculator-o" />
计划存款公式
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="formula-operator">
-
</div>
<div class="formula-item">
<span class="formula-label">支出预算</span>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="formula-operator">
=
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
<div class="formula-operator">
-
</div>
<div class="formula-item">
<span class="formula-label">支出预算</span>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="formula-operator">
=
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
</div>
</div>
<div class="detail-section result-section">
<div class="section-title">
<van-icon name="chart-trending-o" />
存款结果
<div class="detail-section result-section">
<div class="section-title">
<van-icon name="chart-trending-o" />
存款结果
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">计划存款</span>
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">计划存款</span>
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际存款</span>
<span
class="detail-value"
:class="{ income: currentBudget.current >= currentBudget.limit }"
>¥{{ formatMoney(currentBudget.current) }}</span>
</div>
<div class="detail-row highlight">
<span class="detail-label">还差</span>
<span class="detail-value expense">¥{{
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
}}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际存款</span>
<span
class="detail-value"
:class="{ income: currentBudget.current >= currentBudget.limit }"
>¥{{ formatMoney(currentBudget.current) }}</span>
</div>
<div class="detail-row highlight">
<span class="detail-label">还差</span>
<span class="detail-value expense">¥{{
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
}}</span>
</div>
</div>
</div>
</div>
</div>
</van-popup>
</PopupContainer>
</template>
<script setup>
import { ref, computed } from 'vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
// Props
const props = defineProps({
@@ -349,41 +336,6 @@ const getProgressColor = (budget) => {
color: var(--van-success-color);
}
.detail-popup-content {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--van-background-2);
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--van-border-color);
background-color: var(--van-background-2);
}
.popup-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--van-text-color);
}
.close-icon {
color: var(--van-text-color-2);
cursor: pointer;
padding: 8px;
}
.popup-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.detail-content {
display: flex;
flex-direction: column;

View File

@@ -30,9 +30,7 @@
/>
<!-- 统计模块 -->
<StatsModule
:selected-date="selectedDate"
/>
<StatsModule :selected-date="selectedDate" />
<!-- 交易列表模块 -->
<TransactionListModule
@@ -125,9 +123,10 @@ const onDayClick = async (day) => {
const clickedMonth = clickedDate.getMonth()
const currentMonth = currentDate.value.getMonth()
slideDirection.value = clickedMonth > currentMonth || (clickedMonth === 0 && currentMonth === 11)
? 'slide-left'
: 'slide-right'
slideDirection.value =
clickedMonth > currentMonth || (clickedMonth === 0 && currentMonth === 11)
? 'slide-left'
: 'slide-right'
// 更新 key 触发过渡
calendarKey.value += 1
@@ -189,8 +188,10 @@ const onDatePickerConfirm = ({ selectedValues }) => {
// 检查是否超过当前月
const today = new Date()
if (newDate.getFullYear() > today.getFullYear() ||
(newDate.getFullYear() === today.getFullYear() && newDate.getMonth() > today.getMonth())) {
if (
newDate.getFullYear() > today.getFullYear() ||
(newDate.getFullYear() === today.getFullYear() && newDate.getMonth() > today.getMonth())
) {
showToast('不能选择未来的月份')
showDatePicker.value = false
return
@@ -200,8 +201,8 @@ const onDatePickerConfirm = ({ selectedValues }) => {
currentDate.value = newDate
// 判断是否选择了当前月(复用上面的 today 变量)
const isCurrentMonth = newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()
const isCurrentMonth =
newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()
// 如果选择的是当前月,选中今天;否则选中该月第一天
selectedDate.value = isCurrentMonth ? today : newDate
@@ -252,8 +253,8 @@ const changeMonth = async (offset) => {
currentDate.value = newDate
// 判断是否切换到当前月(复用上面的 today 变量)
const isCurrentMonth = newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()
const isCurrentMonth =
newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()
// 根据切换方向和是否为当前月选择合适的日期
let newSelectedDate

View File

@@ -133,7 +133,7 @@ const fetchDailyStats = async (year, month) => {
if (response.success && response.data) {
// 构建日期 Map
const statsMap = {}
response.data.forEach(item => {
response.data.forEach((item) => {
// 后端返回的是 day (1-31),需要构建完整的日期字符串
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(item.day).padStart(2, '0')}`
statsMap[dateKey] = {
@@ -160,7 +160,9 @@ const fetchAllRelevantMonthsData = async (year, month) => {
const firstDay = new Date(year, month, 1)
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
if (startDayOfWeek === -1) {
startDayOfWeek = 6
}
// 判断是否需要加载上月数据
const needPrevMonth = startDayOfWeek > 0
@@ -173,7 +175,7 @@ const fetchAllRelevantMonthsData = async (year, month) => {
const totalCells = totalWeeks * 7
// 判断是否需要加载下月数据
const needNextMonth = totalCells > (startDayOfWeek + lastDay.getDate())
const needNextMonth = totalCells > startDayOfWeek + lastDay.getDate()
// 并行加载所有需要的月份数据
// JavaScript Date.month 是 0-11但后端 API 期望 1-12
@@ -221,7 +223,7 @@ const fetchHolidays = async (year, month) => {
const response = await getMonthHolidays(year, month)
if (response.success && response.data) {
const map = {}
response.data.forEach(item => {
response.data.forEach((item) => {
map[item.date] = item
})
holidaysMap.value = { ...holidaysMap.value, ...map }
@@ -233,11 +235,15 @@ const fetchHolidays = async (year, month) => {
}
// 监听 currentDate 变化,重新加载数据
watch(() => props.currentDate, async (newDate) => {
if (newDate) {
await fetchAllRelevantMonthsData(newDate.getFullYear(), newDate.getMonth())
}
}, { immediate: true })
watch(
() => props.currentDate,
async (newDate) => {
if (newDate) {
await fetchAllRelevantMonthsData(newDate.getFullYear(), newDate.getMonth())
}
},
{ immediate: true }
)
// 初始加载预算数据
fetchBudgetData()
@@ -254,7 +260,9 @@ const calendarWeeks = computed(() => {
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
if (startDayOfWeek === -1) {
startDayOfWeek = 6
}
const weeks = []
let currentWeek = []
@@ -389,7 +397,9 @@ const onDayClick = (day) => {
// 节假日长按事件处理
const onTouchStartHoliday = (e, day) => {
if (!day.isHoliday) {return}
if (!day.isHoliday) {
return
}
// 长按500ms显示提示
holdTimer = setTimeout(() => {
@@ -558,22 +568,22 @@ const onTouchEnd = () => {
/* ========== 节假日样式 ========== */
/* 节假日放假样式(绿色系) */
.day-number.day-holiday {
background-color: #E8F5E9;
color: #2E7D32;
background-color: #e8f5e9;
color: #2e7d32;
font-weight: var(--font-bold);
}
/* 调休工作日样式(橙色/黄色系) */
.day-number.day-workday {
background-color: #FFF3E0;
color: #E65100;
background-color: #fff3e0;
color: #e65100;
font-weight: var(--font-bold);
}
/* 选中状态优先级最高 */
.day-number.day-selected {
background-color: var(--accent-primary) !important;
color: #FFFFFF !important;
color: #ffffff !important;
font-weight: var(--font-bold);
}

View File

@@ -63,11 +63,11 @@ const fetchDayStats = async (date) => {
if (response.success && response.data) {
// 计算当日支出和收入
selectedDayExpense.value = response.data
.filter(t => t.type === 0) // 只统计支出
.filter((t) => t.type === 0) // 只统计支出
.reduce((sum, t) => sum + t.amount, 0)
selectedDayIncome.value = response.data
.filter(t => t.type === 1) // 只统计收入
.filter((t) => t.type === 1) // 只统计收入
.reduce((sum, t) => sum + t.amount, 0)
}
} catch (error) {
@@ -78,11 +78,15 @@ const fetchDayStats = async (date) => {
}
// 监听 selectedDate 变化,重新加载数据
watch(() => props.selectedDate, async (newDate) => {
if (newDate) {
await fetchDayStats(newDate)
}
}, { immediate: true })
watch(
() => props.selectedDate,
async (newDate) => {
if (newDate) {
await fetchDayStats(newDate)
}
},
{ immediate: true }
)
// 判断是否为今天
const isToday = computed(() => {
@@ -112,7 +116,7 @@ const selectedDateFormatted = computed(() => {
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-3xl);
padding-top: 8px
padding-top: 8px;
}
.stats-header {

View File

@@ -153,7 +153,7 @@ const router = useRouter()
const messageStore = useMessageStore()
// 主题
const theme = computed(() => messageStore.isDarkMode ? 'dark' : 'light')
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
// 状态管理
const loading = ref(false)
@@ -196,8 +196,16 @@ const noneCategories = ref([])
// 颜色配置
const categoryColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
]
// 计算属性
@@ -266,7 +274,6 @@ const loadStatistics = async () => {
// 加载分类统计
await loadCategoryStatistics(year, month)
} catch (error) {
console.error('加载统计数据失败:', error)
hasError.value = true
@@ -303,8 +310,8 @@ const loadMonthlyData = async (year, month) => {
if (dailyResult?.success && dailyResult.data) {
// 转换数据格式:添加完整的 date 字段
trendStats.value = dailyResult.data
.filter(item => item != null)
.map(item => ({
.filter((item) => item != null)
.map((item) => ({
date: `${year}-${month.toString().padStart(2, '0')}-${item.day.toString().padStart(2, '0')}`,
expense: item.expense || 0,
income: item.income || 0,
@@ -323,15 +330,18 @@ const loadYearlyData = async (year) => {
const trendResult = await getTrendStatistics({ startYear: year, startMonth: 1, monthCount: 12 })
if (trendResult?.success && trendResult.data) {
// 计算年度汇总
const yearTotal = trendResult.data.reduce((acc, item) => {
const expense = item.expense || 0
const income = item.income || 0
return {
totalExpense: acc.totalExpense + expense,
totalIncome: acc.totalIncome + income,
balance: acc.balance + income - expense
}
}, { totalExpense: 0, totalIncome: 0, balance: 0 })
const yearTotal = trendResult.data.reduce(
(acc, item) => {
const expense = item.expense || 0
const income = item.income || 0
return {
totalExpense: acc.totalExpense + expense,
totalIncome: acc.totalIncome + income,
balance: acc.balance + income - expense
}
},
{ totalExpense: 0, totalIncome: 0, balance: 0 }
)
monthlyStats.value = {
...yearTotal,
@@ -339,7 +349,7 @@ const loadYearlyData = async (year) => {
incomeCount: 0
}
trendStats.value = trendResult.data.map(item => ({
trendStats.value = trendResult.data.map((item) => ({
date: `${item.year}-${item.month.toString().padStart(2, '0')}-01`,
amount: (item.income || 0) - (item.expense || 0),
count: 1
@@ -371,7 +381,8 @@ const loadWeeklyData = async () => {
monthlyStats.value = {
totalExpense: weekSummaryResult.data.totalExpense || 0,
totalIncome: weekSummaryResult.data.totalIncome || 0,
balance: (weekSummaryResult.data.totalIncome || 0) - (weekSummaryResult.data.totalExpense || 0),
balance:
(weekSummaryResult.data.totalIncome || 0) - (weekSummaryResult.data.totalExpense || 0),
expenseCount: weekSummaryResult.data.expenseCount || 0,
incomeCount: weekSummaryResult.data.incomeCount || 0
}
@@ -450,7 +461,11 @@ const loadCategoryStatistics = async (year, month) => {
const currentColors = getChartColors()
// 处理支出分类结果
if (expenseResult.status === 'fulfilled' && expenseResult.value?.success && expenseResult.value.data) {
if (
expenseResult.status === 'fulfilled' &&
expenseResult.value?.success &&
expenseResult.value.data
) {
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
classify: item.classify,
amount: item.amount || 0,
@@ -461,7 +476,11 @@ const loadCategoryStatistics = async (year, month) => {
}
// 处理收入分类结果
if (incomeResult.status === 'fulfilled' && incomeResult.value?.success && incomeResult.value.data) {
if (
incomeResult.status === 'fulfilled' &&
incomeResult.value?.success &&
incomeResult.value.data
) {
incomeCategories.value = incomeResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
@@ -510,7 +529,11 @@ const loadCategoryStatistics = async (year, month) => {
const currentColors = getChartColors()
// 处理支出分类结果
if (expenseResult.status === 'fulfilled' && expenseResult.value?.success && expenseResult.value.data) {
if (
expenseResult.status === 'fulfilled' &&
expenseResult.value?.success &&
expenseResult.value.data
) {
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
classify: item.classify,
amount: item.amount || 0,
@@ -521,7 +544,11 @@ const loadCategoryStatistics = async (year, month) => {
}
// 处理收入分类结果
if (incomeResult.status === 'fulfilled' && incomeResult.value?.success && incomeResult.value.data) {
if (
incomeResult.status === 'fulfilled' &&
incomeResult.value?.success &&
incomeResult.value.data
) {
incomeCategories.value = incomeResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
@@ -618,8 +645,7 @@ const isLastPeriod = () => {
}
case 'month': {
// 比较年月
return current.getFullYear() === now.getFullYear() &&
current.getMonth() === now.getMonth()
return current.getFullYear() === now.getFullYear() && current.getMonth() === now.getMonth()
}
case 'year': {
// 比较年份
@@ -717,20 +743,14 @@ watch(currentPeriod, () => {
if (currentPeriod.value === 'year') {
selectedDate.value = [currentDate.value.getFullYear()]
} else {
selectedDate.value = [
currentDate.value.getFullYear(),
currentDate.value.getMonth() + 1
]
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
}
})
// 初始化
onMounted(() => {
// 设置默认选中日期
selectedDate.value = [
currentDate.value.getFullYear(),
currentDate.value.getMonth() + 1
]
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
loadStatistics()
})
</script>
@@ -792,4 +812,4 @@ onMounted(() => {
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
}
}
</style>
</style>

View File

@@ -101,7 +101,7 @@ const updateChart = () => {
if (props.period === 'week') {
// 周统计:直接使用传入的数据,按日期排序
chartData = [...props.data].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
@@ -121,14 +121,14 @@ const updateChart = () => {
// 创建完整的数据映射
const dataMap = new Map()
props.data.forEach(item => {
props.data.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
// 生成完整的数据序列
chartData = allDays.map(date => {
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
@@ -141,9 +141,9 @@ const updateChart = () => {
} else if (props.period === 'year') {
// 年统计:直接使用数据,显示月份标签
chartData = [...props.data]
.filter(item => item && item.date)
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
@@ -153,27 +153,29 @@ const updateChart = () => {
if (chartData.length === 0) {
const option = {
backgroundColor: 'transparent',
graphic: [{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
graphic: [
{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
}
}
}]
]
}
chartInstance.setOption(option)
return
}
// 准备图表数据
const expenseData = chartData.map(item => {
const expenseData = chartData.map((item) => {
const amount = item.amount || 0
return amount < 0 ? Math.abs(amount) : 0
})
const incomeData = chartData.map(item => {
const incomeData = chartData.map((item) => {
const amount = item.amount || 0
return amount > 0 ? amount : 0
})
@@ -305,18 +307,25 @@ const updateChart = () => {
}
// 监听数据变化
watch(() => props.data, () => {
if (chartInstance) {
updateChart()
}
}, { deep: true })
watch(
() => props.data,
() => {
if (chartInstance) {
updateChart()
}
},
{ deep: true }
)
// 监听主题变化
watch(() => messageStore.isDarkMode, () => {
if (chartInstance) {
updateChart()
watch(
() => messageStore.isDarkMode,
() => {
if (chartInstance) {
updateChart()
}
}
})
)
onMounted(() => {
initChart()
@@ -358,4 +367,4 @@ onBeforeUnmount(() => {
width: 100%;
height: 180px;
}
</style>
</style>

View File

@@ -24,4 +24,4 @@ const emit = defineEmits(['category-click'])
const handleCategoryClick = (classify, type) => {
emit('category-click', classify, type)
}
</script>
</script>

View File

@@ -2,7 +2,7 @@
<!-- 支出分类统计 -->
<div
class="common-card"
style="padding-bottom: 10px;"
style="padding-bottom: 10px"
>
<div class="card-header">
<h3 class="card-title">
@@ -255,11 +255,15 @@ const renderPieChart = () => {
}
// 监听数据变化重新渲染图表
watch(() => [props.categories, props.totalExpense, props.colors], () => {
nextTick(() => {
renderPieChart()
})
}, { deep: true, immediate: true })
watch(
() => [props.categories, props.totalExpense, props.colors],
() => {
nextTick(() => {
renderPieChart()
})
},
{ deep: true, immediate: true }
)
// 组件销毁时清理图表实例
onBeforeUnmount(() => {
@@ -404,4 +408,4 @@ onBeforeUnmount(() => {
padding: 2px 8px;
border-radius: 10px;
}
</style>
</style>

View File

@@ -41,8 +41,8 @@ const props = defineProps({
})
const balanceClass = computed(() => ({
'positive': props.balance >= 0,
'negative': props.balance < 0
positive: props.balance >= 0,
negative: props.balance < 0
}))
</script>
@@ -90,4 +90,4 @@ const balanceClass = computed(() => ({
}
}
}
</style>
</style>

View File

@@ -24,4 +24,4 @@ const emit = defineEmits(['category-click'])
const handleCategoryClick = (classify, type) => {
emit('category-click', classify, type)
}
</script>
</script>

View File

@@ -269,4 +269,4 @@ const noneCategories = computed(() => {
.none-text {
color: var(--van-gray-6);
}
</style>
</style>

View File

@@ -80,8 +80,8 @@ let chartInstance = null
// 计算结余样式类
const balanceClass = computed(() => ({
'positive': props.balance >= 0,
'negative': props.balance < 0
positive: props.balance >= 0,
negative: props.balance < 0
}))
// 计算图表标题
@@ -152,7 +152,7 @@ const updateChart = () => {
if (props.period === 'week') {
// 周统计:直接使用传入的数据,按日期排序
chartData = [...props.trendData].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
@@ -172,14 +172,14 @@ const updateChart = () => {
// 创建完整的数据映射
const dataMap = new Map()
props.trendData.forEach(item => {
props.trendData.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
// 生成完整的数据序列
chartData = allDays.map(date => {
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
@@ -193,9 +193,9 @@ const updateChart = () => {
} else if (props.period === 'year') {
// 年统计:直接使用数据,显示月份标签
chartData = [...props.trendData]
.filter(item => item && item.date)
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
@@ -205,16 +205,18 @@ const updateChart = () => {
if (chartData.length === 0) {
const option = {
backgroundColor: 'transparent',
graphic: [{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
graphic: [
{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
}
}
}]
]
}
chartInstance.setOption(option)
return
@@ -227,7 +229,7 @@ const updateChart = () => {
const expenseData = []
const incomeData = []
chartData.forEach(item => {
chartData.forEach((item) => {
// 支持两种数据格式1) expense/income字段 2) amount字段兼容旧数据
let expense = 0
let income = 0
@@ -401,18 +403,25 @@ const updateChart = () => {
}
// 监听数据变化
watch(() => props.trendData, () => {
if (chartInstance) {
updateChart()
}
}, { deep: true })
watch(
() => props.trendData,
() => {
if (chartInstance) {
updateChart()
}
},
{ deep: true }
)
// 监听主题变化
watch(() => messageStore.isDarkMode, () => {
if (chartInstance) {
updateChart()
watch(
() => messageStore.isDarkMode,
() => {
if (chartInstance) {
updateChart()
}
}
})
)
onMounted(() => {
initChart()