大量的代码格式化
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 1m10s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
孙诚
2026-01-16 11:15:44 +08:00
parent 9069e3dbcf
commit 319f8f7d7b
54 changed files with 2973 additions and 2200 deletions

View File

@@ -58,7 +58,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <param name="year">年份</param> /// <param name="year">年份</param>
/// <param name="month">月份</param> /// <param name="month">月份</param>
/// <returns>每天的消费笔数和金额详情</returns> /// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsAsync(int year, int month); Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
/// <summary> /// <summary>
/// 获取指定日期范围内的每日统计 /// 获取指定日期范围内的每日统计
@@ -66,7 +66,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <param name="startDate">开始日期</param> /// <param name="startDate">开始日期</param>
/// <param name="endDate">结束日期</param> /// <param name="endDate">结束日期</param>
/// <returns>每天的消费笔数和金额详情</returns> /// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate); Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
/// <summary> /// <summary>
/// 获取指定日期范围内的交易记录 /// 获取指定日期范围内的交易记录
@@ -345,15 +345,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync(t => t.Classify); .ToListAsync(t => t.Classify);
} }
public async Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsAsync(int year, int month) public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
{ {
var startDate = new DateTime(year, month, 1); var startDate = new DateTime(year, month, 1);
var endDate = startDate.AddMonths(1); var endDate = startDate.AddMonths(1);
return await GetDailyStatisticsByRangeAsync(startDate, endDate); return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
} }
public async Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate) public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
{ {
var records = await FreeSql.Select<TransactionRecord>() var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
@@ -368,7 +368,14 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
// 分别统计收入和支出 // 分别统计收入和支出
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
return (count: g.Count(), expense: expense, income: income);
var saving = 0m;
if(!string.IsNullOrEmpty(savingClassify))
{
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
}
return (count: g.Count(), expense, income, saving);
} }
); );

View File

@@ -2,5 +2,6 @@
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"printWidth": 100 "printWidth": 100,
"trailingComma": "none"
} }

View File

@@ -1,52 +1,82 @@
import js from '@eslint/js' import js from '@eslint/js'
import globals from 'globals' import globals from 'globals'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [ export default [
{ {
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**'], ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**']
}, },
// Load Vue recommended rules first (sets up parser etc.)
...pluginVue.configs['flat/recommended'],
// General Configuration for all JS/Vue files
{ {
files: ['**/*.{js,mjs,jsx}'], files: ['**/*.{js,mjs,jsx,vue}'],
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
}, },
ecmaVersion: 'latest',
sourceType: 'module'
}, },
rules: { rules: {
// Import standard JS recommended rules
...js.configs.recommended.rules, ...js.configs.recommended.rules,
'indent': ['error', 2],
// --- Logic & Best Practices ---
'no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}],
'no-undef': 'error',
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-debugger': 'warn',
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'curly': ['error', 'all'],
'prefer-const': 'warn',
'no-var': 'error',
// --- Formatting & Style (User requested warnings) ---
'indent': ['error', 2, { SwitchCase: 1 }],
'quotes': ['error', 'single', { avoidEscape: true }], 'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'never'], 'semi': ['error', 'never'],
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'comma-dangle': ['error', 'never'], 'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error', 'no-trailing-spaces': 'error',
'no-multiple-empty-lines': ['error', { max: 1 }], 'no-multiple-empty-lines': ['error', { max: 1 }],
'space-before-function-paren': ['error', 'always'], 'space-before-function-paren': ['error', 'always'],
}, 'object-curly-spacing': ['error', 'always'],
'array-bracket-spacing': ['error', 'never']
}
}, },
...pluginVue.configs['flat/recommended'],
// Vue Specific Overrides
{ {
files: ['**/*.vue'], files: ['**/*.vue'],
rules: { rules: {
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn', 'vue/no-v-html': 'warn',
// Turn off standard indent for Vue files to avoid conflicts with vue/html-indent
// or script indentation issues. Vue plugin handles this better.
'indent': 'off', 'indent': 'off',
}, // Ensure Vue's own indentation rules are active (they are in 'recommended' but let's be explicit if needed)
'vue/html-indent': ['error', 2],
'vue/script-indent': ['error', 2, {
baseIndent: 0,
switchCase: 1,
ignores: []
}]
}
}, },
skipFormatting,
// Service Worker specific globals
{ {
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'], files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
languageOptions: { languageOptions: {
globals: { globals: {
...globals.serviceworker, ...globals.serviceworker
...globals.browser, }
}, }
}, }
},
] ]

View File

@@ -1,4 +1,4 @@
{ {
"name": "email-bill", "name": "email-bill",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
@@ -11,7 +11,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix --cache", "lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",

View File

@@ -1,56 +1,56 @@
const VERSION = '1.0.0'; // Build Time: 2026-01-07 15:59:36 const VERSION = '1.0.0' // Build Time: 2026-01-07 15:59:36
const CACHE_NAME = `emailbill-${VERSION}`; const CACHE_NAME = `emailbill-${VERSION}`
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/index.html', '/index.html',
'/favicon.ico', '/favicon.ico',
'/manifest.json' '/manifest.json'
]; ]
// 安装 Service Worker // 安装 Service Worker
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
console.log('[Service Worker] 安装中...'); console.log('[Service Worker] 安装中...')
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then((cache) => { .then((cache) => {
console.log('[Service Worker] 缓存文件'); console.log('[Service Worker] 缓存文件')
return cache.addAll(urlsToCache); return cache.addAll(urlsToCache)
}) })
); )
}); })
// 监听跳过等待消息 // 监听跳过等待消息
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') { if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting(); self.skipWaiting()
} }
}); })
// 激活 Service Worker // 激活 Service Worker
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.log('[Service Worker] 激活中...'); console.log('[Service Worker] 激活中...')
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
return Promise.all( return Promise.all(
cacheNames.map((cacheName) => { cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) { if (cacheName !== CACHE_NAME) {
console.log('[Service Worker] 删除旧缓存:', cacheName); console.log('[Service Worker] 删除旧缓存:', cacheName)
return caches.delete(cacheName); return caches.delete(cacheName)
} }
}) })
); )
}).then(() => self.clients.claim()) }).then(() => self.clients.claim())
); )
}); })
// 拦截请求 // 拦截请求
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const { request } = event; const { request } = event
const url = new URL(request.url); const url = new URL(request.url)
// 跳过跨域请求 // 跳过跨域请求
if (url.origin !== location.origin) { if (url.origin !== location.origin) {
return; return
} }
// API请求使用网络优先策略 // API请求使用网络优先策略
@@ -60,19 +60,19 @@ self.addEventListener('fetch', (event) => {
.then((response) => { .then((response) => {
// 只针对成功的GET请求进行缓存 // 只针对成功的GET请求进行缓存
if (request.method === 'GET' && response.status === 200) { if (request.method === 'GET' && response.status === 200) {
const responseClone = response.clone(); const responseClone = response.clone()
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone); cache.put(request, responseClone)
}); })
} }
return response; return response
}) })
.catch(() => { .catch(() => {
// 网络失败时尝试从缓存获取 // 网络失败时尝试从缓存获取
return caches.match(request); return caches.match(request)
}) })
); )
return; return
} }
// 页面请求使用网络优先策略,确保能获取到最新的 index.html // 页面请求使用网络优先策略,确保能获取到最新的 index.html
@@ -80,17 +80,17 @@ self.addEventListener('fetch', (event) => {
event.respondWith( event.respondWith(
fetch(request) fetch(request)
.then((response) => { .then((response) => {
const responseClone = response.clone(); const responseClone = response.clone()
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone); cache.put(request, responseClone)
}); })
return response; return response
}) })
.catch(() => { .catch(() => {
return caches.match('/index.html') || caches.match(request); return caches.match('/index.html') || caches.match(request)
}) })
); )
return; return
} }
// 其他静态资源使用缓存优先策略 // 其他静态资源使用缓存优先策略
@@ -98,50 +98,50 @@ self.addEventListener('fetch', (event) => {
caches.match(request) caches.match(request)
.then((response) => { .then((response) => {
if (response) { if (response) {
return response; return response
} }
return fetch(request).then((response) => { return fetch(request).then((response) => {
// 检查是否是有效响应 // 检查是否是有效响应
if (!response || response.status !== 200 || response.type !== 'basic') { if (!response || response.status !== 200 || response.type !== 'basic') {
return response; return response
} }
const responseClone = response.clone(); const responseClone = response.clone()
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone); cache.put(request, responseClone)
}); })
return response; return response
}); })
}) })
.catch(() => { .catch(() => {
// 返回离线页面或默认内容 // 返回离线页面或默认内容
if (request.destination === 'document') { if (request.destination === 'document') {
return caches.match('/index.html'); return caches.match('/index.html')
} }
}) })
); )
}); })
// 后台同步 // 后台同步
self.addEventListener('sync', (event) => { self.addEventListener('sync', (event) => {
console.log('[Service Worker] 后台同步:', event.tag); console.log('[Service Worker] 后台同步:', event.tag)
if (event.tag === 'sync-data') { if (event.tag === 'sync-data') {
event.waitUntil(syncData()); event.waitUntil(syncData())
} }
}); })
// 推送通知 // 推送通知
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
console.log('[Service Worker] 收到推送消息'); console.log('[Service Worker] 收到推送消息')
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' }; let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' }
if (event.data) { if (event.data) {
try { try {
const json = event.data.json(); const json = event.data.json()
data = { ...data, ...json }; data = { ...data, ...json }
} catch { } catch {
data.body = event.data.text(); data.body = event.data.text()
} }
} }
@@ -153,41 +153,41 @@ self.addEventListener('push', (event) => {
tag: 'emailbill-notification', tag: 'emailbill-notification',
requireInteraction: false, requireInteraction: false,
data: { url: data.url } data: { url: data.url }
}; }
event.waitUntil( event.waitUntil(
self.registration.showNotification(data.title, options) self.registration.showNotification(data.title, options)
); )
}); })
// 通知点击 // 通知点击
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] 通知被点击'); console.log('[Service Worker] 通知被点击')
event.notification.close(); event.notification.close()
const urlToOpen = event.notification.data?.url || '/'; const urlToOpen = event.notification.data?.url || '/'
event.waitUntil( event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// 如果已经打开了该 URL则聚焦 // 如果已经打开了该 URL则聚焦
for (let i = 0; i < windowClients.length; i++) { for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i]; const client = windowClients[i]
if (client.url === urlToOpen && 'focus' in client) { if (client.url === urlToOpen && 'focus' in client) {
return client.focus(); return client.focus()
} }
} }
// 否则打开新窗口 // 否则打开新窗口
if (clients.openWindow) { if (clients.openWindow) {
return clients.openWindow(urlToOpen); return clients.openWindow(urlToOpen)
} }
}) })
); )
}); })
// 数据同步函数 // 数据同步函数
async function syncData() { async function syncData () {
try { try {
// 这里添加需要同步的逻辑 // 这里添加需要同步的逻辑
console.log('[Service Worker] 执行数据同步'); console.log('[Service Worker] 执行数据同步')
} catch (error) { } catch (error) {
console.error('[Service Worker] 同步失败:', error); console.error('[Service Worker] 同步失败:', error)
} }
} }

View File

@@ -3,30 +3,36 @@
<div class="app-root"> <div class="app-root">
<RouterView /> <RouterView />
<van-tabbar v-show="showTabbar" v-model="active"> <van-tabbar v-show="showTabbar" v-model="active">
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> <van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item>
日历 <van-tabbar-item
</van-tabbar-item> name="statistics"
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')"> icon="chart-trending-o"
to="/"
@click="handleTabClick('/statistics')"
>
统计 统计
</van-tabbar-item> </van-tabbar-item>
<van-tabbar-item <van-tabbar-item
name="balance" name="balance"
icon="balance-list" icon="balance-list"
:to="messageStore.unreadCount > 0 ? '/balance?tab=message' : '/balance'" :to="messageStore.unreadCount > 0 ? '/balance?tab=message' : '/balance'"
:badge="messageStore.unreadCount || null" :badge="messageStore.unreadCount || null"
@click="handleTabClick('/balance')" @click="handleTabClick('/balance')"
> >
账单 账单
</van-tabbar-item> </van-tabbar-item>
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')"> <van-tabbar-item
name="budget"
icon="bill-o"
to="/budget"
@click="handleTabClick('/budget')"
>
预算 预算
</van-tabbar-item> </van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting"> <van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item>
设置
</van-tabbar-item>
</van-tabbar> </van-tabbar>
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess"/> <GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" />
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker"> <div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
<van-icon name="upgrade" class="update-icon" /> <van-icon name="upgrade" class="update-icon" />
<span>新版本可用点击刷新</span> <span>新版本可用点击刷新</span>
@@ -85,12 +91,14 @@ onUnmounted(() => {
const route = useRoute() const route = useRoute()
// 根据路由判断是否显示Tabbar // 根据路由判断是否显示Tabbar
const showTabbar = computed(() => { const showTabbar = computed(() => {
return route.path === '/' || return (
route.path === '/' ||
route.path === '/calendar' || route.path === '/calendar' ||
route.path === '/message' || route.path === '/message' ||
route.path === '/setting' || route.path === '/setting' ||
route.path === '/balance' || route.path === '/balance' ||
route.path === '/budget' route.path === '/budget'
)
}) })
const active = ref('') const active = ref('')
@@ -116,11 +124,14 @@ setInterval(() => {
}, 60 * 1000) // 每60秒更新一次未读消息数 }, 60 * 1000) // 每60秒更新一次未读消息数
// 监听路由变化调整 // 监听路由变化调整
watch(() => route.path, (newPath) => { watch(
setActive(newPath) () => route.path,
(newPath) => {
setActive(newPath)
messageStore.updateUnreadCount() messageStore.updateUnreadCount()
}) }
)
const setActive = (path) => { const setActive = (path) => {
active.value = (() => { active.value = (() => {
@@ -142,9 +153,7 @@ const setActive = (path) => {
} }
const isShowAddBill = computed(() => { const isShowAddBill = computed(() => {
return route.path === '/' return route.path === '/' || route.path === '/balance' || route.path === '/message'
|| route.path === '/balance'
|| route.path === '/message'
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -165,7 +174,6 @@ const handleAddTransactionSuccess = () => {
const event = new Event('transactions-changed') const event = new Event('transactions-changed')
window.dispatchEvent(event) window.dispatchEvent(event)
} }
</script> </script>
<style scoped> <style scoped>

View File

@@ -26,45 +26,47 @@ export const uploadBillFile = (file, type) => {
Authorization: `Bearer ${useAuthStore().token || ''}` Authorization: `Bearer ${useAuthStore().token || ''}`
}, },
timeout: 60000 // 文件上传增加超时时间 timeout: 60000 // 文件上传增加超时时间
}).then(response => {
const { data } = response
if (data.success === false) {
showToast(data.message || '上传失败')
return Promise.reject(new Error(data.message || '上传失败'))
}
return data
}).catch(error => {
console.error('上传错误:', error)
if (error.response) {
const { status, data } = error.response
let message = '上传失败'
switch (status) {
case 400:
message = data?.message || '请求参数错误'
break
case 401:
message = '未授权,请先登录'
break
case 403:
message = '没有权限'
break
case 413:
message = '文件过大'
break
case 500:
message = '服务器错误'
break
}
showToast(message)
return Promise.reject(new Error(message))
}
showToast('网络错误,请检查网络连接')
return Promise.reject(error)
}) })
.then((response) => {
const { data } = response
if (data.success === false) {
showToast(data.message || '上传失败')
return Promise.reject(new Error(data.message || '上传失败'))
}
return data
})
.catch((error) => {
console.error('上传错误:', error)
if (error.response) {
const { status, data } = error.response
let message = '上传失败'
switch (status) {
case 400:
message = data?.message || '请求参数错误'
break
case 401:
message = '未授权,请先登录'
break
case 403:
message = '没有权限'
break
case 413:
message = '文件过大'
break
case 500:
message = '服务器错误'
break
}
showToast(message)
return Promise.reject(new Error(message))
}
showToast('网络错误,请检查网络连接')
return Promise.reject(error)
})
} }

View File

@@ -37,7 +37,7 @@ export const getEmailDetail = (id) => {
*/ */
export const deleteEmail = (id) => { export const deleteEmail = (id) => {
return request({ return request({
url: `/EmailMessage/DeleteById`, url: '/EmailMessage/DeleteById',
method: 'post', method: 'post',
params: { id } params: { id }
}) })
@@ -50,7 +50,7 @@ export const deleteEmail = (id) => {
*/ */
export const refreshTransactionRecords = (id) => { export const refreshTransactionRecords = (id) => {
return request({ return request({
url: `/EmailMessage/RefreshTransactionRecords`, url: '/EmailMessage/RefreshTransactionRecords',
method: 'post', method: 'post',
params: { id } params: { id }
}) })
@@ -62,7 +62,7 @@ export const refreshTransactionRecords = (id) => {
*/ */
export const syncEmails = () => { export const syncEmails = () => {
return request({ return request({
url: `/EmailMessage/SyncEmails`, url: '/EmailMessage/SyncEmails',
method: 'post' method: 'post'
}) })
} }

View File

@@ -1,13 +1,13 @@
import request from '@/api/request' import request from '@/api/request'
export function getJobs() { export function getJobs () {
return request({ return request({
url: '/Job/GetJobs', url: '/Job/GetJobs',
method: 'get' method: 'get'
}) })
} }
export function executeJob(jobName) { export function executeJob (jobName) {
return request({ return request({
url: '/Job/Execute', url: '/Job/Execute',
method: 'post', method: 'post',
@@ -15,7 +15,7 @@ export function executeJob(jobName) {
}) })
} }
export function pauseJob(jobName) { export function pauseJob (jobName) {
return request({ return request({
url: '/Job/Pause', url: '/Job/Pause',
method: 'post', method: 'post',
@@ -23,7 +23,7 @@ export function pauseJob(jobName) {
}) })
} }
export function resumeJob(jobName) { export function resumeJob (jobName) {
return request({ return request({
url: '/Job/Resume', url: '/Job/Resume',
method: 'post', method: 'post',

View File

@@ -14,7 +14,7 @@ const request = axios.create({
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
config => { (config) => {
// 添加 token 认证信息 // 添加 token 认证信息
const authStore = useAuthStore() const authStore = useAuthStore()
if (authStore.token) { if (authStore.token) {
@@ -22,7 +22,7 @@ request.interceptors.request.use(
} }
return config return config
}, },
error => { (error) => {
console.error('请求错误:', error) console.error('请求错误:', error)
return Promise.reject(error) return Promise.reject(error)
} }
@@ -30,25 +30,25 @@ request.interceptors.request.use(
// 响应拦截器 // 响应拦截器
request.interceptors.response.use( request.interceptors.response.use(
response => { (response) => {
const { data } = response const { data } = response
// 统一处理业务错误 // 统一处理业务错误
if (data.success === false) { if (data.success === false) {
showToast(data.message || '请求失败') showToast(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败')) return Promise.reject(new Error(data.message || '请求失败'))
} }
return data return data
}, },
error => { (error) => {
console.error('响应错误:', error) console.error('响应错误:', error)
// 统一处理 HTTP 错误 // 统一处理 HTTP 错误
if (error.response) { if (error.response) {
const { status, data } = error.response const { status, data } = error.response
let message = '请求失败' let message = '请求失败'
switch (status) { switch (status) {
case 400: case 400:
message = data?.message || '请求参数错误' message = data?.message || '请求参数错误'
@@ -58,7 +58,10 @@ request.interceptors.response.use(
// 清除登录状态并跳转到登录页 // 清除登录状态并跳转到登录页
const authStore = useAuthStore() const authStore = useAuthStore()
authStore.logout() authStore.logout()
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } }) router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
break break
} }
case 403: case 403:
@@ -73,14 +76,14 @@ request.interceptors.response.use(
default: default:
message = data?.message || `请求失败 (${status})` message = data?.message || `请求失败 (${status})`
} }
showToast(message) showToast(message)
} else if (error.request) { } else if (error.request) {
showToast('网络连接失败,请检查网络') showToast('网络连接失败,请检查网络')
} else { } else {
showToast(error.message || '请求失败') showToast(error.message || '请求失败')
} }
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -79,7 +79,7 @@ export const updatePeriodic = (data) => {
*/ */
export const deletePeriodic = (id) => { export const deletePeriodic = (id) => {
return request({ return request({
url: `/TransactionPeriodic/DeleteById`, url: '/TransactionPeriodic/DeleteById',
method: 'post', method: 'post',
params: { id } params: { id }
}) })

View File

@@ -99,7 +99,7 @@ export const updateTransaction = (data) => {
*/ */
export const deleteTransaction = (id) => { export const deleteTransaction = (id) => {
return request({ return request({
url: `/TransactionRecord/DeleteById`, url: '/TransactionRecord/DeleteById',
method: 'post', method: 'post',
params: { id } params: { id }
}) })
@@ -118,7 +118,6 @@ export const getTransactionsByDate = (date) => {
}) })
} }
// 注意分类相关的API已迁移到 transactionCategory.js // 注意分类相关的API已迁移到 transactionCategory.js
// 请使用 getCategoryList 等新接口 // 请使用 getCategoryList 等新接口
@@ -155,12 +154,12 @@ export const smartClassify = (transactionIds = []) => {
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5071/api' const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5071/api'
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const url = `${baseURL}/TransactionRecord/SmartClassify` const url = `${baseURL}/TransactionRecord/SmartClassify`
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ transactionIds }) body: JSON.stringify({ transactionIds })
}) })
@@ -261,4 +260,4 @@ export const parseOneLine = (text) => {
method: 'post', method: 'post',
data: { text } data: { text }
}) })
} }

View File

@@ -1,7 +1,8 @@
@import './base.css'; @import './base.css';
/* 禁用页面弹性缩放和橡皮筋效果 */ /* 禁用页面弹性缩放和橡皮筋效果 */
html, body { html,
body {
overscroll-behavior: none; overscroll-behavior: none;
overscroll-behavior-y: none; overscroll-behavior-y: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -57,8 +58,10 @@ a,
} }
} }
html, body, #app { html,
body,
#app {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
} }

View File

@@ -1,11 +1,14 @@
<template> <template>
<van-dialog <van-dialog
v-model:show="show" v-model:show="show"
title="新增交易分类" title="新增交易分类"
show-cancel-button show-cancel-button
@confirm="handleConfirm" @confirm="handleConfirm"
> >
<van-field v-model="classifyName" placeholder="请输入新的交易分类" /> <van-field
v-model="classifyName"
placeholder="请输入新的交易分类"
/>
</van-dialog> </van-dialog>
</template> </template>
@@ -30,7 +33,7 @@ const handleConfirm = () => {
showToast('请输入分类名称') showToast('请输入分类名称')
return return
} }
emit('confirm', classifyName.value.trim()) emit('confirm', classifyName.value.trim())
show.value = false show.value = false
classifyName.value = '' classifyName.value = ''

View File

@@ -6,8 +6,12 @@
<van-field label="时间"> <van-field label="时间">
<template #input> <template #input>
<div style="display: flex; gap: 16px"> <div style="display: flex; gap: 16px">
<div @click="showDatePicker = true">{{ form.date }}</div> <div @click="showDatePicker = true">
<div @click="showTimePicker = true">{{ form.time }}</div> {{ form.date }}
</div>
<div @click="showTimePicker = true">
{{ form.time }}
</div>
</div> </div>
</template> </template>
</van-field> </van-field>
@@ -37,9 +41,9 @@
<van-field name="type" label="类型"> <van-field name="type" label="类型">
<template #input> <template #input>
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange"> <van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
<van-radio :name="0">支出</van-radio> <van-radio :name="0"> 支出 </van-radio>
<van-radio :name="1">收入</van-radio> <van-radio :name="1"> 收入 </van-radio>
<van-radio :name="2">不计</van-radio> <van-radio :name="2"> 不计 </van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
@@ -47,23 +51,20 @@
<!-- 分类 --> <!-- 分类 -->
<van-field name="category" label="分类"> <van-field name="category" label="分类">
<template #input> <template #input>
<span v-if="!categoryName" style="color: var(--van-text-color-3);">请选择分类</span> <span v-if="!categoryName" style="color: var(--van-text-color-3)">请选择分类</span>
<span v-else>{{ categoryName }}</span> <span v-else>{{ categoryName }}</span>
</template> </template>
</van-field> </van-field>
<!-- 分类选择组件 --> <!-- 分类选择组件 -->
<ClassifySelector <ClassifySelector v-model="categoryName" :type="form.type" />
v-model="categoryName"
:type="form.type"
/>
</van-cell-group> </van-cell-group>
<div class="actions"> <div class="actions">
<van-button round block type="primary" native-type="submit" :loading="loading"> <van-button round block type="primary" native-type="submit" :loading="loading">
{{ submitText }} {{ submitText }}
</van-button> </van-button>
<slot name="actions"></slot> <slot name="actions" />
</div> </div>
</van-form> </van-form>
@@ -137,7 +138,7 @@ const initForm = async () => {
if (props.initialData) { if (props.initialData) {
isSyncing.value = true isSyncing.value = true
const { occurredAt, amount, reason, type, classify } = props.initialData const { occurredAt, amount, reason, type, classify } = props.initialData
if (occurredAt) { if (occurredAt) {
const dt = dayjs(occurredAt) const dt = dayjs(occurredAt)
form.value.date = dt.format('YYYY-MM-DD') form.value.date = dt.format('YYYY-MM-DD')
@@ -145,11 +146,17 @@ const initForm = async () => {
currentDate.value = form.value.date.split('-') currentDate.value = form.value.date.split('-')
currentTime.value = form.value.time.split(':') currentTime.value = form.value.time.split(':')
} }
if (amount !== undefined) form.value.amount = amount if (amount !== undefined) {
if (reason !== undefined) form.value.note = reason form.value.amount = amount
if (type !== undefined) form.value.type = type }
if (reason !== undefined) {
form.value.note = reason
}
if (type !== undefined) {
form.value.type = type
}
// 如果有传入分类名称,尝试设置 // 如果有传入分类名称,尝试设置
if (classify) { if (classify) {
categoryName.value = classify categoryName.value = classify
@@ -166,9 +173,13 @@ onMounted(() => {
}) })
// 监听 initialData 变化 (例如重新解析后) // 监听 initialData 变化 (例如重新解析后)
watch(() => props.initialData, () => { watch(
initForm() () => props.initialData,
}, { deep: true }) () => {
initForm()
},
{ deep: true }
)
const handleTypeChange = (newType) => { const handleTypeChange = (newType) => {
if (!isSyncing.value) { if (!isSyncing.value) {
@@ -197,7 +208,7 @@ const handleSubmit = () => {
} }
const fullDateTime = `${form.value.date}T${form.value.time}:00` const fullDateTime = `${form.value.date}T${form.value.time}:00`
const payload = { const payload = {
occurredAt: fullDateTime, occurredAt: fullDateTime,
classify: categoryName.value, classify: categoryName.value,
@@ -205,7 +216,7 @@ const handleSubmit = () => {
reason: form.value.note || '', reason: form.value.note || '',
type: form.value.type type: form.value.type
} }
emit('submit', payload) emit('submit', payload)
} }

View File

@@ -1,10 +1,6 @@
<template> <template>
<div class="manual-bill-add"> <div class="manual-bill-add">
<BillForm <BillForm ref="billFormRef" :loading="saving" @submit="handleSave" />
ref="billFormRef"
:loading="saving"
@submit="handleSave"
/>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px;"> <div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px">
<van-field <van-field
v-model="text" v-model="text"
type="textarea" type="textarea"
@@ -10,11 +10,11 @@
:disabled="parsing || saving" :disabled="parsing || saving"
/> />
<div class="actions"> <div class="actions">
<van-button <van-button
type="primary" type="primary"
round round
block block
:loading="parsing" :loading="parsing"
:disabled="!text.trim()" :disabled="!text.trim()"
@click="handleParse" @click="handleParse"
> >
@@ -31,13 +31,7 @@
@submit="handleSave" @submit="handleSave"
> >
<template #actions> <template #actions>
<van-button <van-button plain round block class="mt-2" @click="parseResult = null">
plain
round
block
class="mt-2"
@click="parseResult = null"
>
重新输入 重新输入
</van-button> </van-button>
</template> </template>
@@ -60,17 +54,19 @@ const saving = ref(false)
const parseResult = ref(null) const parseResult = ref(null)
const handleParse = async () => { const handleParse = async () => {
if (!text.value.trim()) return if (!text.value.trim()) {
return
}
parsing.value = true parsing.value = true
parseResult.value = null parseResult.value = null
try { try {
const res = await parseOneLine(text.value) const res = await parseOneLine(text.value)
if(!res.success){ if (!res.success) {
throw new Error(res.message || '解析失败') throw new Error(res.message || '解析失败')
} }
parseResult.value = res.data parseResult.value = res.data
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -84,11 +80,11 @@ const handleSave = async (payload) => {
saving.value = true saving.value = true
try { try {
const res = await createTransaction(payload) const res = await createTransaction(payload)
if (!res.success) { if (!res.success) {
throw new Error(res.message || '保存失败') throw new Error(res.message || '保存失败')
} }
showToast('保存成功') showToast('保存成功')
text.value = '' text.value = ''
parseResult.value = null parseResult.value = null

View File

@@ -8,34 +8,38 @@
<div class="collapsed-header"> <div class="collapsed-header">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag <van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'" :type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain plain
class="status-tag" class="status-tag"
> >
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title">{{ budget.name }}</h3> <h3 class="card-title">
{{ budget.name }}
</h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle"> <span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }}) ({{ budget.selectedCategories.join('') }})
</span> </span>
</div> </div>
<van-icon name="arrow-down" class="expand-icon" /> <van-icon name="arrow-down" class="expand-icon" />
</div> </div>
<div class="collapsed-footer"> <div class="collapsed-footer">
<div class="collapsed-item"> <div class="collapsed-item">
<span class="compact-label">实际/目标</span> <span class="compact-label">实际/目标</span>
<span class="compact-value"> <span class="compact-value">
<slot name="collapsed-amount"> <slot name="collapsed-amount">
{{ budget.current !== undefined && budget.limit !== undefined {{
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}` budget.current !== undefined && budget.limit !== undefined
: '--' }} ? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
: '--'
}}
</slot> </slot>
</span> </span>
</div> </div>
<div class="collapsed-item"> <div class="collapsed-item">
<span class="compact-label">达成率</span> <span class="compact-label">达成率</span>
<span class="compact-value" :class="percentClass">{{ percentage }}%</span> <span class="compact-value" :class="percentClass">{{ percentage }}%</span>
@@ -45,52 +49,49 @@
<!-- 展开状态 --> <!-- 展开状态 -->
<div v-else class="budget-inner-card"> <div v-else class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;"> <div class="card-header" style="margin-bottom: 0">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag <van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'" :type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain plain
class="status-tag" class="status-tag"
> >
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3> <h3 class="card-title" style="max-width: 120px">
{{ budget.name }}
</h3>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<slot name="actions"> <slot name="actions">
<van-button <van-button
v-if="budget.description" v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'" :icon="showDescription ? 'info' : 'info-o'"
size="small" size="small"
:type="showDescription ? 'primary' : 'default'" :type="showDescription ? 'primary' : 'default'"
plain plain
@click.stop="showDescription = !showDescription" @click.stop="showDescription = !showDescription"
/> />
<van-button <van-button
icon="orders-o" icon="orders-o"
size="small" size="small"
plain plain
title="查询关联账单" title="查询关联账单"
@click.stop="handleQueryBills" @click.stop="handleQueryBills"
/> />
<template v-if="budget.category !== 2"> <template v-if="budget.category !== 2">
<van-button <van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" />
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template> </template>
</slot> </slot>
</div> </div>
</div> </div>
<div class="budget-body"> <div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags"> <div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag <van-tag
v-for="cat in budget.selectedCategories" v-for="cat in budget.selectedCategories"
:key="cat" :key="cat"
size="mini" size="mini"
class="category-tag" class="category-tag"
@@ -101,14 +102,16 @@
</van-tag> </van-tag>
</div> </div>
<div class="amount-info"> <div class="amount-info">
<slot name="amount-info"></slot> <slot name="amount-info" />
</div> </div>
<div class="progress-section"> <div class="progress-section">
<slot name="progress-info"> <slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span> <span class="period-type"
<van-progress >{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span
:percentage="Math.min(percentage, 100)" >
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8" stroke-width="8"
:color="progressColor" :color="progressColor"
:show-pivot="false" :show-pivot="false"
@@ -118,8 +121,8 @@
</div> </div>
<div class="progress-section time-progress"> <div class="progress-section time-progress">
<span class="period-type">时间进度</span> <span class="period-type">时间进度</span>
<van-progress <van-progress
:percentage="timePercentage" :percentage="timePercentage"
stroke-width="4" stroke-width="4"
color="var(--van-gray-6)" color="var(--van-gray-6)"
:show-pivot="false" :show-pivot="false"
@@ -129,24 +132,20 @@
<van-collapse-transition> <van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description"> <div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div> <div class="description-content rich-html-content" v-html="budget.description" />
</div> </div>
</van-collapse-transition> </van-collapse-transition>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<slot name="footer"></slot> <slot name="footer" />
</div> </div>
</div> </div>
</div> </div>
<!-- 关联账单列表弹窗 --> <!-- 关联账单列表弹窗 -->
<PopupContainer <PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
v-model="showBillListModal" <TransactionList
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList" :transactions="billList"
:loading="billLoading" :loading="billLoading"
:finished="true" :finished="true"
@@ -166,30 +165,26 @@
<div class="collapsed-header"> <div class="collapsed-header">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag <van-tag type="success" plain class="status-tag">
type="success" {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title">{{ budget.name }}</h3> <h3 class="card-title">
{{ budget.name }}
</h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle"> <span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }}) ({{ budget.selectedCategories.join('') }})
</span> </span>
</div> </div>
<van-icon name="arrow-down" class="expand-icon" /> <van-icon name="arrow-down" class="expand-icon" />
</div> </div>
<div class="collapsed-footer no-limit-footer"> <div class="collapsed-footer no-limit-footer">
<div class="collapsed-item"> <div class="collapsed-item">
<span class="compact-label">实际</span> <span class="compact-label">实际</span>
<span class="compact-value"> <span class="compact-value">
<slot name="collapsed-amount"> <slot name="collapsed-amount">
{{ budget.current !== undefined {{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
? `¥${budget.current?.toFixed(0) || 0}`
: '--' }}
</slot> </slot>
</span> </span>
</div> </div>
@@ -198,52 +193,45 @@
<!-- 展开状态 --> <!-- 展开状态 -->
<div v-else class="budget-inner-card"> <div v-else class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;"> <div class="card-header" style="margin-bottom: 0">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag <van-tag type="success" plain class="status-tag">
type="success" {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3> <h3 class="card-title" style="max-width: 120px">
{{ budget.name }}
</h3>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<slot name="actions"> <slot name="actions">
<van-button <van-button
v-if="budget.description" v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'" :icon="showDescription ? 'info' : 'info-o'"
size="small" size="small"
:type="showDescription ? 'primary' : 'default'" :type="showDescription ? 'primary' : 'default'"
plain plain
@click.stop="showDescription = !showDescription" @click.stop="showDescription = !showDescription"
/> />
<van-button <van-button
icon="orders-o" icon="orders-o"
size="small" size="small"
plain plain
title="查询关联账单" title="查询关联账单"
@click.stop="handleQueryBills" @click.stop="handleQueryBills"
/> />
<template v-if="budget.category !== 2"> <template v-if="budget.category !== 2">
<van-button <van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" />
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template> </template>
</slot> </slot>
</div> </div>
</div> </div>
<div class="budget-body"> <div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags"> <div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag <van-tag
v-for="cat in budget.selectedCategories" v-for="cat in budget.selectedCategories"
:key="cat" :key="cat"
size="mini" size="mini"
class="category-tag" class="category-tag"
@@ -253,26 +241,28 @@
{{ cat }} {{ cat }}
</van-tag> </van-tag>
</div> </div>
<div class="no-limit-amount-info"> <div class="no-limit-amount-info">
<div class="amount-item"> <div class="amount-item">
<span> <span>
<span class="label">实际</span> <span class="label">实际</span>
<span class="value" style="margin-left: 12px;">¥{{ budget.current?.toFixed(0) || 0 }}</span> <span class="value" style="margin-left: 12px"
>¥{{ budget.current?.toFixed(0) || 0 }}</span
>
</span> </span>
</div> </div>
</div> </div>
<div class="no-limit-notice"> <div class="no-limit-notice">
<span> <span>
<van-icon name="info-o" style="margin-right: 4px;" /> <van-icon name="info-o" style="margin-right: 4px" />
不记额预算 - 直接计入存款明细 不记额预算 - 直接计入存款明细
</span> </span>
</div> </div>
<van-collapse-transition> <van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description"> <div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div> <div class="description-content rich-html-content" v-html="budget.description" />
</div> </div>
</van-collapse-transition> </van-collapse-transition>
</div> </div>
@@ -280,12 +270,8 @@
</div> </div>
<!-- 关联账单列表弹窗 --> <!-- 关联账单列表弹窗 -->
<PopupContainer <PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
v-model="showBillListModal" <TransactionList
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList" :transactions="billList"
:loading="billLoading" :loading="billLoading"
:finished="true" :finished="true"
@@ -339,10 +325,10 @@ const toggleExpand = () => {
const handleQueryBills = async () => { const handleQueryBills = async () => {
showBillListModal.value = true showBillListModal.value = true
billLoading.value = true billLoading.value = true
try { try {
const classify = props.budget.selectedCategories const classify = props.budget.selectedCategories
? props.budget.selectedCategories.join(',') ? props.budget.selectedCategories.join(',')
: '' : ''
if (classify === '') { if (classify === '') {
@@ -362,12 +348,11 @@ const handleQueryBills = async () => {
sortByAmount: true sortByAmount: true
}) })
if(response.success) { if (response.success) {
billList.value = response.data || [] billList.value = response.data || []
} else { } else {
billList.value = [] billList.value = []
} }
} catch (error) { } catch (error) {
console.error('查询账单列表失败:', error) console.error('查询账单列表失败:', error)
billList.value = [] billList.value = []
@@ -377,19 +362,27 @@ const handleQueryBills = async () => {
} }
const percentage = computed(() => { const percentage = computed(() => {
if (!props.budget.limit) return 0 if (!props.budget.limit) {
return 0
}
return Math.round((props.budget.current / props.budget.limit) * 100) return Math.round((props.budget.current / props.budget.limit) * 100)
}) })
const timePercentage = computed(() => { const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd) return 0 if (!props.budget.periodStart || !props.budget.periodEnd) {
return 0
}
const start = new Date(props.budget.periodStart).getTime() const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime() const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime() const now = new Date().getTime()
if (now <= start) return 0 if (now <= start) {
if (now >= end) return 100 return 0
}
if (now >= end) {
return 100
}
return Math.round(((now - start) / (end - start)) * 100) return Math.round(((now - start) / (end - start)) * 100)
}) })
</script> </script>

View File

@@ -1,7 +1,11 @@
<template> <template>
<PopupContainer <PopupContainer
v-model="visible" v-model="visible"
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`" :title="
isEdit
? `编辑${getCategoryName(form.category)}预算`
: `新增${getCategoryName(form.category)}预算`
"
height="75%" height="75%"
> >
<div class="add-budget-form"> <div class="add-budget-form">
@@ -17,18 +21,20 @@
<!-- 新增不记额预算复选框 --> <!-- 新增不记额预算复选框 -->
<van-field label="不记额预算"> <van-field label="不记额预算">
<template #input> <template #input>
<van-checkbox v-model="form.noLimit" @update:model-value="onNoLimitChange">不记额预算仅限年度</van-checkbox> <van-checkbox v-model="form.noLimit" @update:model-value="onNoLimitChange">
不记额预算仅限年度
</van-checkbox>
</template> </template>
</van-field> </van-field>
<van-field name="type" label="统计周期"> <van-field name="type" label="统计周期">
<template #input> <template #input>
<van-radio-group <van-radio-group
v-model="form.type" v-model="form.type"
direction="horizontal" direction="horizontal"
:disabled="isEdit || form.noLimit" :disabled="isEdit || form.noLimit"
> >
<van-radio :name="BudgetPeriodType.Month"></van-radio> <van-radio :name="BudgetPeriodType.Month"> </van-radio>
<van-radio :name="BudgetPeriodType.Year"></van-radio> <van-radio :name="BudgetPeriodType.Year"> </van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
@@ -48,7 +54,12 @@
</van-field> </van-field>
<van-field label="相关分类"> <van-field label="相关分类">
<template #input> <template #input>
<div v-if="form.selectedCategories.length === 0" style="color: var(--van-text-color-3);">可多选分类</div> <div
v-if="form.selectedCategories.length === 0"
style="color: var(--van-text-color-3)"
>
可多选分类
</div>
<div v-else class="selected-categories"> <div v-else class="selected-categories">
<span class="ellipsis-text"> <span class="ellipsis-text">
{{ form.selectedCategories.join('、') }} {{ form.selectedCategories.join('、') }}
@@ -67,7 +78,7 @@
</van-form> </van-form>
</div> </div>
<template #footer> <template #footer>
<van-button block round type="primary" @click="onSubmit">保存预算</van-button> <van-button block round type="primary" @click="onSubmit"> 保存预算 </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
</template> </template>
@@ -92,15 +103,11 @@ const form = reactive({
category: BudgetCategory.Expense, category: BudgetCategory.Expense,
limit: '', limit: '',
selectedCategories: [], selectedCategories: [],
noLimit: false // 新增字段 noLimit: false // 新增字段
}) })
const open = ({ const open = ({ data, isEditFlag, category }) => {
data, if (category === undefined) {
isEditFlag,
category
}) => {
if(category === undefined) {
showToast('缺少必要参数category') showToast('缺少必要参数category')
return return
} }
@@ -114,7 +121,7 @@ const open = ({
category: category, category: category,
limit: data.limit, limit: data.limit,
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [], selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [],
noLimit: data.noLimit || false // 新增 noLimit: data.noLimit || false // 新增
}) })
} else { } else {
Object.assign(form, { Object.assign(form, {
@@ -124,7 +131,7 @@ const open = ({
category: category, category: category,
limit: '', limit: '',
selectedCategories: [], selectedCategories: [],
noLimit: false // 新增 noLimit: false // 新增
}) })
} }
visible.value = true visible.value = true
@@ -135,18 +142,22 @@ defineExpose({
}) })
const budgetType = computed(() => { const budgetType = computed(() => {
return form.category === BudgetCategory.Expense ? 0 : (form.category === BudgetCategory.Income ? 1 : 2) return form.category === BudgetCategory.Expense
? 0
: form.category === BudgetCategory.Income
? 1
: 2
}) })
const onSubmit = async () => { const onSubmit = async () => {
try { try {
const data = { const data = {
...form, ...form,
limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0 limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0
selectedCategories: form.selectedCategories, selectedCategories: form.selectedCategories,
noLimit: form.noLimit // 新增 noLimit: form.noLimit // 新增
} }
const res = form.id ? await updateBudget(data) : await createBudget(data) const res = form.id ? await updateBudget(data) : await createBudget(data)
if (res.success) { if (res.success) {
showToast('保存成功') showToast('保存成功')
@@ -160,7 +171,7 @@ const onSubmit = async () => {
} }
const getCategoryName = (category) => { const getCategoryName = (category) => {
switch(category) { switch (category) {
case BudgetCategory.Expense: case BudgetCategory.Expense:
return '支出' return '支出'
case BudgetCategory.Income: case BudgetCategory.Income:

View File

@@ -1,7 +1,11 @@
<template> <template>
<div class="summary-container"> <div class="summary-container">
<transition :name="transitionName" mode="out-in"> <transition :name="transitionName" mode="out-in">
<div v-if="stats && (stats.month || stats.year)" :key="dateKey" class="summary-card common-card"> <div
v-if="stats && (stats.month || stats.year)"
:key="dateKey"
class="summary-card common-card"
>
<!-- 左切换按钮 --> <!-- 左切换按钮 -->
<div class="nav-arrow left" @click.stop="changeMonth(-1)"> <div class="nav-arrow left" @click.stop="changeMonth(-1)">
<van-icon name="arrow-left" /> <van-icon name="arrow-left" />
@@ -20,13 +24,13 @@
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span> <span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div> </div>
</div> </div>
<div v-if="config.showDivider" class="divider"></div> <div v-if="config.showDivider" class="divider" />
</template> </template>
</div> </div>
<!-- 右切换按钮 --> <!-- 右切换按钮 -->
<div <div
class="nav-arrow right" class="nav-arrow right"
:class="{ disabled: isCurrentMonth }" :class="{ disabled: isCurrentMonth }"
@click.stop="!isCurrentMonth && changeMonth(1)" @click.stop="!isCurrentMonth && changeMonth(1)"
> >
@@ -71,18 +75,17 @@ const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMo
const isCurrentMonth = computed(() => { const isCurrentMonth = computed(() => {
const now = new Date() const now = new Date()
return props.date.getFullYear() === now.getFullYear() && return props.date.getFullYear() === now.getFullYear() && props.date.getMonth() === now.getMonth()
props.date.getMonth() === now.getMonth()
}) })
const periodConfigs = computed(() => ({ const periodConfigs = computed(() => ({
month: { month: {
label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}`, label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}`,
showDivider: true showDivider: true
}, },
year: { year: {
label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}`, label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}`,
showDivider: false showDivider: false
} }
})) }))
@@ -94,7 +97,10 @@ const changeMonth = (delta) => {
} }
const formatMoney = (val) => { const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
} }
</script> </script>

View File

@@ -1,9 +1,5 @@
<template> <template>
<PopupContainer <PopupContainer v-model="visible" title="设置存款分类" height="60%">
v-model="visible"
title="设置存款分类"
height="60%"
>
<div class="savings-config-content"> <div class="savings-config-content">
<div class="config-header"> <div class="config-header">
<p class="subtitle">这些分类的统计值将计入存款</p> <p class="subtitle">这些分类的统计值将计入存款</p>
@@ -20,9 +16,9 @@
/> />
</div> </div>
</div> </div>
<template #footer> <template #footer>
<van-button block round type="primary" @click="onSubmit">保存配置</van-button> <van-button block round type="primary" @click="onSubmit"> 保存配置 </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
</template> </template>
@@ -52,7 +48,7 @@ const fetchConfig = async () => {
try { try {
const res = await getConfig('SavingsCategories') const res = await getConfig('SavingsCategories')
if (res.success && res.data) { if (res.success && res.data) {
selectedCategories.value = res.data.split(',').filter(x => x) selectedCategories.value = res.data.split(',').filter((x) => x)
} else { } else {
selectedCategories.value = [] selectedCategories.value = []
} }

View File

@@ -98,17 +98,21 @@ const innerOptions = ref([])
const addClassifyDialogRef = ref() const addClassifyDialogRef = ref()
const displayOptions = computed(() => { const displayOptions = computed(() => {
if (props.options) return props.options if (props.options) {
return props.options
}
return innerOptions.value return innerOptions.value
}) })
const fetchOptions = async () => { const fetchOptions = async () => {
if (props.options) return if (props.options) {
return
}
try { try {
const response = await getCategoryList(props.type) const response = await getCategoryList(props.type)
if (response.success) { if (response.success) {
innerOptions.value = (response.data || []).map(item => ({ innerOptions.value = (response.data || []).map((item) => ({
text: item.name, text: item.name,
value: item.name, value: item.name,
id: item.id id: item.id
@@ -132,12 +136,12 @@ const handleAddConfirm = async (categoryName) => {
name: categoryName, name: categoryName,
type: props.type type: props.type
}) })
if (response.success) { if (response.success) {
showToast('分类创建成功') showToast('分类创建成功')
// 刷新列表 // 刷新列表
await fetchOptions() await fetchOptions()
// 如果是单选模式,且当前没有选值或就是为了新增,则自动选中 // 如果是单选模式,且当前没有选值或就是为了新增,则自动选中
if (!props.multiple) { if (!props.multiple) {
emit('update:modelValue', categoryName) emit('update:modelValue', categoryName)
@@ -152,9 +156,12 @@ const handleAddConfirm = async (categoryName) => {
} }
} }
watch(() => props.type, () => { watch(
fetchOptions() () => props.type,
}) () => {
fetchOptions()
}
)
onMounted(() => { onMounted(() => {
fetchOptions() fetchOptions()
@@ -175,8 +182,10 @@ const isSelected = (item) => {
// 是否全部选中 // 是否全部选中
const isAllSelected = computed(() => { const isAllSelected = computed(() => {
if (!props.multiple || displayOptions.value.length === 0) return false if (!props.multiple || displayOptions.value.length === 0) {
return displayOptions.value.every(item => props.modelValue.includes(item.text)) return false
}
return displayOptions.value.every((item) => props.modelValue.includes(item.text))
}) })
// 是否有任何选中 // 是否有任何选中
@@ -208,13 +217,15 @@ const toggleItem = (item) => {
// 切换全选 // 切换全选
const toggleAll = () => { const toggleAll = () => {
if (!props.multiple) return if (!props.multiple) {
return
}
if (isAllSelected.value) { if (isAllSelected.value) {
emit('update:modelValue', []) emit('update:modelValue', [])
emit('change', []) emit('change', [])
} else { } else {
const allValues = displayOptions.value.map(item => item.text) const allValues = displayOptions.value.map((item) => item.text)
emit('update:modelValue', allValues) emit('update:modelValue', allValues)
emit('change', allValues) emit('change', allValues)
} }

View File

@@ -7,15 +7,15 @@
<div class="weekday-label"></div> <div class="weekday-label"></div>
<div class="weekday-label"></div> <div class="weekday-label"></div>
</div> </div>
<!-- Scrollable Heatmap Area --> <!-- Scrollable Heatmap Area -->
<div ref="scrollContainer" class="heatmap-scroll-container"> <div ref="scrollContainer" class="heatmap-scroll-container">
<div class="heatmap-content"> <div class="heatmap-content">
<!-- Month Labels --> <!-- Month Labels -->
<div class="month-row"> <div class="month-row">
<div <div
v-for="(month, index) in monthLabels" v-for="(month, index) in monthLabels"
:key="index" :key="index"
class="month-label" class="month-label"
:style="{ left: month.left + 'px' }" :style="{ left: month.left + 'px' }"
> >
@@ -40,18 +40,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="heatmap-footer"> <div class="heatmap-footer">
<div v-if="totalCount > 0" class="summary-text"> <div v-if="totalCount > 0" class="summary-text">过去一年共 {{ totalCount }} 笔交易</div>
过去一年共 {{ totalCount }} 笔交易
</div>
<div class="legend"> <div class="legend">
<span></span> <span></span>
<div class="legend-item level-0"></div> <div class="legend-item level-0" />
<div class="legend-item level-1"></div> <div class="legend-item level-1" />
<div class="legend-item level-2"></div> <div class="legend-item level-2" />
<div class="legend-item level-3"></div> <div class="legend-item level-3" />
<div class="legend-item level-4"></div> <div class="legend-item level-4" />
<span></span> <span></span>
</div> </div>
</div> </div>
@@ -59,84 +57,84 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue'; import { ref, onMounted, nextTick } from 'vue'
import { getDailyStatisticsRange } from '@/api/statistics'; import { getDailyStatisticsRange } from '@/api/statistics'
const stats = ref({}); const stats = ref({})
const weeks = ref([]); const weeks = ref([])
const monthLabels = ref([]); const monthLabels = ref([])
const totalCount = ref(0); const totalCount = ref(0)
const scrollContainer = ref(null); const scrollContainer = ref(null)
const thresholds = ref([2, 4, 7]); // Default thresholds const thresholds = ref([2, 4, 7]) // Default thresholds
const CELL_SIZE = 15; const CELL_SIZE = 15
const CELL_GAP = 3; const CELL_GAP = 3
const WEEK_WIDTH = CELL_SIZE + CELL_GAP; const WEEK_WIDTH = CELL_SIZE + CELL_GAP
const formatDate = (d) => { const formatDate = (d) => {
const year = d.getFullYear(); const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`
}; }
const fetchData = async () => { const fetchData = async () => {
const endDate = new Date(); const endDate = new Date()
const startDate = new Date(); const startDate = new Date()
startDate.setFullYear(endDate.getFullYear() - 1); startDate.setFullYear(endDate.getFullYear() - 1)
try { try {
const res = await getDailyStatisticsRange({ const res = await getDailyStatisticsRange({
startDate: formatDate(startDate), startDate: formatDate(startDate),
endDate: formatDate(endDate) endDate: formatDate(endDate)
}); })
if (res.success) { if (res.success) {
const map = {}; const map = {}
let count = 0; let count = 0
res.data.forEach(item => { res.data.forEach((item) => {
map[item.date] = item; map[item.date] = item
count += item.count; count += item.count
}); })
stats.value = map; stats.value = map
totalCount.value = count; totalCount.value = count
// Calculate thresholds based on last 15 days average // Calculate thresholds based on last 15 days average
const today = new Date(); const today = new Date()
let last15DaysSum = 0; let last15DaysSum = 0
for(let i = 0; i < 15; i++) { for (let i = 0; i < 15; i++) {
const d = new Date(today); const d = new Date(today)
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i)
const dateStr = formatDate(d); const dateStr = formatDate(d)
last15DaysSum += (map[dateStr]?.count || 0); last15DaysSum += map[dateStr]?.count || 0
} }
const avg = last15DaysSum / 15; const avg = last15DaysSum / 15
console.log("avg", avg) console.log('avg', avg)
// Step size calculation: ensure at least 1, roughly avg/2 to create spread // Step size calculation: ensure at least 1, roughly avg/2 to create spread
// Level 1: 1 ~ step // Level 1: 1 ~ step
// Level 2: step+1 ~ step*2 // Level 2: step+1 ~ step*2
// Level 3: step*2+1 ~ step*3 // Level 3: step*2+1 ~ step*3
// Level 4: > step*3 // Level 4: > step*3
const step = Math.max(Math.ceil(avg / 2), 1); const step = Math.max(Math.ceil(avg / 2), 1)
thresholds.value = [step, step * 2, step * 3]; thresholds.value = [step, step * 2, step * 3]
generateHeatmapData(startDate, endDate); generateHeatmapData(startDate, endDate)
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch heatmap data", e); console.error('Failed to fetch heatmap data', e)
} }
}; }
const generateHeatmapData = (startDate, endDate) => { const generateHeatmapData = (startDate, endDate) => {
const data = []; const data = []
const current = new Date(startDate); const current = new Date(startDate)
const allDays = []; const allDays = []
// Adjust start date to be Monday to align weeks // Adjust start date to be Monday to align weeks
// 0 = Sunday, 1 = Monday // 0 = Sunday, 1 = Monday
const startDay = current.getDay(); const startDay = current.getDay()
// If startDay is 0 (Sunday), we need to go back 6 days to Monday // If startDay is 0 (Sunday), we need to go back 6 days to Monday
// If startDay is 1 (Monday), we are good // If startDay is 1 (Monday), we are good
// If startDay is 2 (Tuesday), we need to go back 1 day // If startDay is 2 (Tuesday), we need to go back 1 day
@@ -144,114 +142,124 @@ const generateHeatmapData = (startDate, endDate) => {
// Monday (1) -> 0 days back // Monday (1) -> 0 days back
// Sunday (0) -> 6 days back // Sunday (0) -> 6 days back
// Tuesday (2) -> 1 day back // Tuesday (2) -> 1 day back
const daysToSubtract = (startDay + 6) % 7; const daysToSubtract = (startDay + 6) % 7
// We don't necessarily need to subtract from startDate for data fetching, // We don't necessarily need to subtract from startDate for data fetching,
// but for grid alignment we want the first column to start on Monday. // but for grid alignment we want the first column to start on Monday.
const alignStart = new Date(startDate); const alignStart = new Date(startDate)
// alignStart.setDate(alignStart.getDate() - daysToSubtract); // alignStart.setDate(alignStart.getDate() - daysToSubtract);
const tempDate = new Date(alignStart); const tempDate = new Date(alignStart)
while (tempDate <= endDate) { while (tempDate <= endDate) {
const dateStr = formatDate(tempDate); const dateStr = formatDate(tempDate)
allDays.push({ allDays.push({
date: dateStr, date: dateStr,
count: stats.value[dateStr]?.count || 0, count: stats.value[dateStr]?.count || 0,
obj: new Date(tempDate) obj: new Date(tempDate)
}); })
tempDate.setDate(tempDate.getDate() + 1); tempDate.setDate(tempDate.getDate() + 1)
} }
// Now group into weeks // Now group into weeks
const resultWeeks = []; const resultWeeks = []
let currentWeek = []; let currentWeek = []
// Pad first week if start date is not Monday // Pad first week if start date is not Monday
// allDays[0] is startDate // allDays[0] is startDate
const firstDayObj = new Date(allDays[0].date); const firstDayObj = new Date(allDays[0].date)
const firstDay = firstDayObj.getDay(); // 0-6 (Sun-Sat) const firstDay = firstDayObj.getDay() // 0-6 (Sun-Sat)
// We want Monday (1) to be index 0 // We want Monday (1) to be index 0
// Mon(1)->0, Tue(2)->1, ..., Sun(0)->6 // Mon(1)->0, Tue(2)->1, ..., Sun(0)->6
const padCount = (firstDay + 6) % 7; const padCount = (firstDay + 6) % 7
for (let i = 0; i < padCount; i++) { for (let i = 0; i < padCount; i++) {
currentWeek.push(null); currentWeek.push(null)
} }
allDays.forEach(day => { allDays.forEach((day) => {
currentWeek.push(day); currentWeek.push(day)
if (currentWeek.length === 7) { if (currentWeek.length === 7) {
resultWeeks.push(currentWeek); resultWeeks.push(currentWeek)
currentWeek = []; currentWeek = []
} }
}); })
// Push last partial week // Push last partial week
if (currentWeek.length > 0) { if (currentWeek.length > 0) {
while (currentWeek.length < 7) { while (currentWeek.length < 7) {
currentWeek.push(null); currentWeek.push(null)
} }
resultWeeks.push(currentWeek); resultWeeks.push(currentWeek)
} }
weeks.value = resultWeeks; weeks.value = resultWeeks
// Generate Month Labels // Generate Month Labels
const labels = []; const labels = []
let lastMonth = -1; let lastMonth = -1
resultWeeks.forEach((week, index) => { resultWeeks.forEach((week, index) => {
// Check the first valid day in the week // Check the first valid day in the week
const day = week.find(d => d !== null); const day = week.find((d) => d !== null)
if (day) { if (day) {
const d = new Date(day.date); const d = new Date(day.date)
const month = d.getMonth(); const month = d.getMonth()
if (month !== lastMonth) { if (month !== lastMonth) {
labels.push({ labels.push({
text: d.toLocaleString('zh-CN', { month: 'short' }), text: d.toLocaleString('zh-CN', { month: 'short' }),
left: index * WEEK_WIDTH left: index * WEEK_WIDTH
}); })
lastMonth = month; lastMonth = month
} }
} }
}); })
monthLabels.value = labels; monthLabels.value = labels
// Scroll to end // Scroll to end
nextTick(() => { nextTick(() => {
if (scrollContainer.value) { if (scrollContainer.value) {
scrollContainer.value.scrollLeft = scrollContainer.value.scrollWidth; scrollContainer.value.scrollLeft = scrollContainer.value.scrollWidth
} }
}); })
}; }
const getLevelClass = (day) => { const getLevelClass = (day) => {
if (!day) return 'invisible'; if (!day) {
const count = day.count; return 'invisible'
if (count === 0) return 'level-0'; }
if (count <= thresholds.value[0]) return 'level-1'; const count = day.count
if (count <= thresholds.value[1]) return 'level-2'; if (count === 0) {
if (count <= thresholds.value[2]) return 'level-3'; return 'level-0'
return 'level-4'; }
}; if (count <= thresholds.value[0]) {
return 'level-1'
}
if (count <= thresholds.value[1]) {
return 'level-2'
}
if (count <= thresholds.value[2]) {
return 'level-3'
}
return 'level-4'
}
const onCellClick = (day) => { const onCellClick = (day) => {
if (day) { if (day) {
// Emit event or show toast // Emit event or show toast
// console.log(day); // console.log(day);
} }
}; }
defineExpose({ defineExpose({
refresh: fetchData refresh: fetchData
}); })
onMounted(() => { onMounted(() => {
fetchData(); fetchData()
}); })
</script> </script>
<style scoped> <style scoped>
@@ -260,7 +268,7 @@ onMounted(() => {
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
color: var(--van-text-color); color: var(--van-text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
margin: 0 10px; margin: 0 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--van-border-color); border: 1px solid var(--van-border-color);
@@ -328,7 +336,7 @@ onMounted(() => {
Row 0: 0px top Row 0: 0px top
Row 1: 18px top (15+3) - Label "二" aligns here? No, "二" is usually row 1 (index 1, 2nd row) Row 1: 18px top (15+3) - Label "二" aligns here? No, "二" is usually row 1 (index 1, 2nd row)
If we want to align with 2nd, 4th, 6th rows (indices 1, 3, 5): If we want to align with 2nd, 4th, 6th rows (indices 1, 3, 5):
Row 0: y=0 Row 0: y=0
Row 1: y=18 Row 1: y=18
Row 2: y=36 Row 2: y=36
@@ -336,28 +344,30 @@ onMounted(() => {
Row 4: y=72 Row 4: y=72
Row 5: y=90 Row 5: y=90
Row 6: y=108 Row 6: y=108
Label 1 ("二") at Row 1 (y=18) Label 1 ("二") at Row 1 (y=18)
Label 2 ("四") at Row 3 (y=54) Label 2 ("四") at Row 3 (y=54)
Label 3 ("六") at Row 5 (y=90) Label 3 ("六") at Row 5 (y=90)
Padding-top of container is 19px. Padding-top of container is 19px.
First label margin-top: 18px First label margin-top: 18px
Second label margin-top: (54 - (18+15)) = 21px Second label margin-top: (54 - (18+15)) = 21px
Third label margin-top: (90 - (54+15)) = 21px Third label margin-top: (90 - (54+15)) = 21px
Let's try standard spacing. Let's try standard spacing.
Gap between tops is 36px (2 rows). Gap between tops is 36px (2 rows).
Height of label is 15px. Height of label is 15px.
Margin needed is 36 - 15 = 21px. Margin needed is 36 - 15 = 21px.
First label top needs to be at 18px relative to grid start. First label top needs to be at 18px relative to grid start.
Container padding-top aligns with grid start (row 0 top). Container padding-top aligns with grid start (row 0 top).
So first label margin-top should be 18px. So first label margin-top should be 18px.
*/ */
margin-top: 21px; margin-top: 21px;
} }
.weekday-label:first-child { margin-top: 18px; } .weekday-label:first-child {
margin-top: 18px;
}
.heatmap-grid { .heatmap-grid {
display: flex; display: flex;
@@ -382,45 +392,63 @@ onMounted(() => {
background-color: transparent; background-color: transparent;
} }
.level-0 { background-color: var(--van-gray-2); } .level-0 {
background-color: var(--van-gray-2);
}
/* Default (Light Mode) - Light to Deep Green */ /* Default (Light Mode) - Light to Deep Green */
.level-1 { background-color: #9be9a8; } .level-1 {
.level-2 { background-color: #40c463; } background-color: #9be9a8;
.level-3 { background-color: #30a14e; } }
.level-4 { background-color: #216e39; } .level-2 {
background-color: #40c463;
}
.level-3 {
background-color: #30a14e;
}
.level-4 {
background-color: #216e39;
}
/* Dark Mode - Dark to Light/Bright Green (GitHub Dark Mode Style) */ /* Dark Mode - Dark to Light/Bright Green (GitHub Dark Mode Style) */
/* The user requested "From Light to Deep" (浅至深) which usually means standard heatmap logic (darker = more). /* The user requested "From Light to Deep" (浅至深) which usually means standard heatmap logic (darker = more).
HOWEVER, in dark interfaces, usually "Brighter = More". HOWEVER, in dark interfaces, usually "Brighter = More".
If the user explicitly says "colors are wrong, should be from light to deep", and they are referring to the visual gradient: If the user explicitly says "colors are wrong, should be from light to deep", and they are referring to the visual gradient:
If they mean visual brightness: If they mean visual brightness:
Light (Dim) -> Deep (Bright) Light (Dim) -> Deep (Bright)
Let's stick to the GitHub Dark Mode palette which is scientifically designed for dark backgrounds: Let's stick to the GitHub Dark Mode palette which is scientifically designed for dark backgrounds:
L1 (Less): Dark Green (#0e4429) L1 (Less): Dark Green (#0e4429)
L4 (More): Neon Green (#39d353) L4 (More): Neon Green (#39d353)
This is visually "Dim to Bright". This is visually "Dim to Bright".
If the user meant "Light color to Dark color" literally (like white -> black green), that would look bad on dark mode. If the user meant "Light color to Dark color" literally (like white -> black green), that would look bad on dark mode.
"浅至深" in color context usually implies saturation/intensity. "浅至深" in color context usually implies saturation/intensity.
Let's restore the GitHub Dark Mode colors for dark mode, as my previous change might have inverted them incorrectly or caused confusion. Let's restore the GitHub Dark Mode colors for dark mode, as my previous change might have inverted them incorrectly or caused confusion.
GitHub Dark Mode: GitHub Dark Mode:
L0: #161b22 L0: #161b22
L1: #0e4429 L1: #0e4429
L2: #006d32 L2: #006d32
L3: #26a641 L3: #26a641
L4: #39d353 L4: #39d353
This goes from Dark Green -> Bright Green. This goes from Dark Green -> Bright Green.
*/ */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.level-1 { background-color: #9be9a8; } .level-1 {
.level-2 { background-color: #40c463; } background-color: #9be9a8;
.level-3 { background-color: #30a14e; } }
.level-4 { background-color: #216e39; } .level-2 {
background-color: #40c463;
}
.level-3 {
background-color: #30a14e;
}
.level-4 {
background-color: #216e39;
}
} }
.heatmap-footer { .heatmap-footer {
@@ -443,4 +471,4 @@ onMounted(() => {
height: 15px; height: 15px;
border-radius: 2px; border-radius: 2px;
} }
</style> </style>

View File

@@ -6,11 +6,7 @@
</div> </div>
<!-- Add Bill Modal --> <!-- Add Bill Modal -->
<PopupContainer <PopupContainer v-model="showAddBill" title="记一笔" height="75%">
v-model="showAddBill"
title="记一笔"
height="75%"
>
<van-tabs v-model:active="activeTab" shrink> <van-tabs v-model:active="activeTab" shrink>
<van-tab title="一句话录账" name="one"> <van-tab title="一句话录账" name="one">
<OneLineBillAdd :key="componentKey" @success="handleSuccess" /> <OneLineBillAdd :key="componentKey" @success="handleSuccess" />

View File

@@ -13,10 +13,12 @@
<div class="popup-header-fixed"> <div class="popup-header-fixed">
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) --> <!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }"> <div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
<h3 class="popup-title">{{ title }}</h3> <h3 class="popup-title">
{{ title }}
</h3>
<!-- 无子标题时操作按钮与标题同行 --> <!-- 无子标题时操作按钮与标题同行 -->
<div v-if="!subtitle && hasActions" class="header-actions-inline"> <div v-if="!subtitle && hasActions" class="header-actions-inline">
<slot name="header-actions"></slot> <slot name="header-actions" />
</div> </div>
</div> </div>
@@ -24,18 +26,18 @@
<div v-if="subtitle" class="header-stats"> <div v-if="subtitle" class="header-stats">
<span class="stats-text" v-html="subtitle" /> <span class="stats-text" v-html="subtitle" />
<!-- 额外操作插槽 --> <!-- 额外操作插槽 -->
<slot v-if="hasActions" name="header-actions"></slot> <slot v-if="hasActions" name="header-actions" />
</div> </div>
</div> </div>
<!-- 内容区域可滚动 --> <!-- 内容区域可滚动 -->
<div class="popup-scroll-content"> <div class="popup-scroll-content">
<slot></slot> <slot />
</div> </div>
<!-- 底部页脚固定不可滚动 --> <!-- 底部页脚固定不可滚动 -->
<div v-if="slots.footer" class="popup-footer-fixed"> <div v-if="slots.footer" class="popup-footer-fixed">
<slot name="footer"></slot> <slot name="footer" />
</div> </div>
</div> </div>
</van-popup> </van-popup>
@@ -47,24 +49,24 @@ import { computed, useSlots } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
required: true, required: true
}, },
title: { title: {
type: String, type: String,
default: '', default: ''
}, },
subtitle: { subtitle: {
type: String, type: String,
default: '', default: ''
}, },
height: { height: {
type: String, type: String,
default: '80%', default: '80%'
}, },
closeable: { closeable: {
type: Boolean, type: Boolean,
default: true, default: true
}, }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -74,7 +76,7 @@ const slots = useSlots()
// 双向绑定 // 双向绑定
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => emit('update:modelValue', value), set: (value) => emit('update:modelValue', value)
}) })
// 判断是否有操作按钮 // 判断是否有操作按钮

View File

@@ -1,10 +1,16 @@
<template> <template>
<div class="reason-group-list-v2"> <div class="reason-group-list-v2">
<van-empty v-if="groups.length === 0 && !loading" description="暂无数据" /> <van-empty
v-if="groups.length === 0 && !loading"
<van-cell-group v-else inset> description="暂无数据"
<van-cell />
v-for="group in groups"
<van-cell-group
v-else
inset
>
<van-cell
v-for="group in groups"
:key="group.reason" :key="group.reason"
clickable clickable
@click="handleGroupClick(group)" @click="handleGroupClick(group)"
@@ -12,7 +18,7 @@
> >
<template #title> <template #title>
<div class="group-header"> <div class="group-header">
<van-checkbox <van-checkbox
v-if="selectable" v-if="selectable"
:model-value="isSelected(group.reason)" :model-value="isSelected(group.reason)"
@click.stop="handleToggleSelection(group.reason)" @click.stop="handleToggleSelection(group.reason)"
@@ -24,23 +30,26 @@
</template> </template>
<template #label> <template #label>
<div class="group-info"> <div class="group-info">
<van-tag <van-tag
:type="getTypeColor(group.sampleType)" :type="getTypeColor(group.sampleType)"
size="medium" size="medium"
style="margin-right: 8px;" style="margin-right: 8px"
> >
{{ getTypeName(group.sampleType) }} {{ getTypeName(group.sampleType) }}
</van-tag> </van-tag>
<van-tag <van-tag
v-if="group.sampleClassify" v-if="group.sampleClassify"
type="primary" type="primary"
size="medium" size="medium"
style="margin-right: 8px;" style="margin-right: 8px"
> >
{{ group.sampleClassify }} {{ group.sampleClassify }}
</van-tag> </van-tag>
<span class="count-text">{{ group.count }} </span> <span class="count-text">{{ group.count }} </span>
<span v-if="group.totalAmount" class="amount-text"> <span
v-if="group.totalAmount"
class="amount-text"
>
¥{{ Math.abs(group.totalAmount).toFixed(2) }} ¥{{ Math.abs(group.totalAmount).toFixed(2) }}
</span> </span>
</div> </div>
@@ -59,8 +68,8 @@
height="75%" height="75%"
> >
<template #header-actions> <template #header-actions>
<van-button <van-button
type="primary" type="primary"
size="small" size="small"
class="batch-classify-btn" class="batch-classify-btn"
@click.stop="handleBatchClassify(selectedGroup)" @click.stop="handleBatchClassify(selectedGroup)"
@@ -68,7 +77,7 @@
批量分类 批量分类
</van-button> </van-button>
</template> </template>
<TransactionList <TransactionList
:transactions="groupTransactions" :transactions="groupTransactions"
:loading="transactionLoading" :loading="transactionLoading"
@@ -92,7 +101,10 @@
title="批量设置分类" title="批量设置分类"
height="60%" height="60%"
> >
<van-form ref="batchFormRef" class="setting-form"> <van-form
ref="batchFormRef"
class="setting-form"
>
<van-cell-group inset> <van-cell-group inset>
<!-- 显示选中的摘要 --> <!-- 显示选中的摘要 -->
<van-field <van-field
@@ -101,7 +113,7 @@
readonly readonly
input-align="left" input-align="left"
/> />
<!-- 显示记录数量 --> <!-- 显示记录数量 -->
<van-field <van-field
:model-value="`${batchGroup?.count || 0} `" :model-value="`${batchGroup?.count || 0} `"
@@ -111,24 +123,42 @@
/> />
<!-- 交易类型 --> <!-- 交易类型 -->
<van-field name="type" label="交易类型"> <van-field
name="type"
label="交易类型"
>
<template #input> <template #input>
<van-radio-group v-model="batchForm.type" direction="horizontal"> <van-radio-group
<van-radio :name="0">支出</van-radio> v-model="batchForm.type"
<van-radio :name="1">收入</van-radio> direction="horizontal"
<van-radio :name="2">不计</van-radio> >
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<!-- 分类选择 --> <!-- 分类选择 -->
<van-field name="classify" label="分类"> <van-field
name="classify"
label="分类"
>
<template #input> <template #input>
<span v-if="!batchForm.classify" style="opacity: 0.4;">请选择分类</span> <span
v-if="!batchForm.classify"
style="opacity: 0.4"
>请选择分类</span>
<span v-else>{{ batchForm.classify }}</span> <span v-else>{{ batchForm.classify }}</span>
</template> </template>
</van-field> </van-field>
<!-- 分类选择组件 --> <!-- 分类选择组件 -->
<ClassifySelector <ClassifySelector
v-model="batchForm.classify" v-model="batchForm.classify"
@@ -138,9 +168,9 @@
</van-form> </van-form>
<template #footer> <template #footer>
<van-button <van-button
round round
block block
type="primary" type="primary"
@click="handleConfirmBatchUpdate" @click="handleConfirmBatchUpdate"
> >
确定 确定
@@ -152,13 +182,7 @@
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { import { showToast, showSuccessToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
showToast,
showSuccessToast,
showLoadingToast,
closeToast,
showConfirmDialog
} from 'vant'
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord' import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
import ClassifySelector from './ClassifySelector.vue' import ClassifySelector from './ClassifySelector.vue'
import TransactionList from './TransactionList.vue' import TransactionList from './TransactionList.vue'
@@ -212,9 +236,12 @@ const batchForm = ref({
}) })
// 监听交易类型变化,重新加载分类 // 监听交易类型变化,重新加载分类
watch(() => batchForm.value.type, (newVal) => { watch(
batchForm.value.classify = '' () => batchForm.value.type,
}) (newVal) => {
batchForm.value.classify = ''
}
)
// 获取类型名称 // 获取类型名称
const getTypeName = (type) => { const getTypeName = (type) => {
@@ -256,8 +283,10 @@ const handleGroupClick = async (group) => {
// 加载分组的交易记录 // 加载分组的交易记录
const loadGroupTransactions = async () => { const loadGroupTransactions = async () => {
if (transactionFinished.value || !selectedGroup.value) return if (transactionFinished.value || !selectedGroup.value) {
return
}
transactionLoading.value = true transactionLoading.value = true
try { try {
const res = await getTransactionList({ const res = await getTransactionList({
@@ -265,17 +294,17 @@ const loadGroupTransactions = async () => {
pageIndex: transactionPageIndex.value, pageIndex: transactionPageIndex.value,
pageSize: transactionPageSize.value pageSize: transactionPageSize.value
}) })
if (res.success) { if (res.success) {
const newData = res.data || [] const newData = res.data || []
groupTransactions.value = [...groupTransactions.value, ...newData] groupTransactions.value = [...groupTransactions.value, ...newData]
groupTransactionsTotal.value = res.total || 0 groupTransactionsTotal.value = res.total || 0
// 判断是否还有更多数据 // 判断是否还有更多数据
if (newData.length < transactionPageSize.value) { if (newData.length < transactionPageSize.value) {
transactionFinished.value = true transactionFinished.value = true
} }
transactionPageIndex.value++ transactionPageIndex.value++
} else { } else {
showToast(res.message || '获取交易记录失败') showToast(res.message || '获取交易记录失败')
@@ -323,7 +352,7 @@ const handleConfirmBatchUpdate = async () => {
try { try {
// 表单验证 // 表单验证
await batchFormRef.value?.validate() await batchFormRef.value?.validate()
// 二次确认 // 二次确认
await showConfirmDialog({ await showConfirmDialog({
title: '确认批量设置', title: '确认批量设置',
@@ -352,21 +381,19 @@ const handleConfirmBatchUpdate = async () => {
await refresh() await refresh()
// 通知父组件数据已更改 // 通知父组件数据已更改
emit('data-changed') emit('data-changed')
try { try {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent( new CustomEvent('transactions-changed', {
'transactions-changed', detail: {
{ reason: batchGroup.value.reason
detail: { }
reason: batchGroup.value.reason })
} )
}) } catch (e) {
)
} catch(e) {
console.error('触发全局 transactions-changed 事件失败:', e) console.error('触发全局 transactions-changed 事件失败:', e)
} }
// 关闭弹窗 // 关闭弹窗
showTransactionList.value = false showTransactionList.value = false
} else { } else {
showToast(res.message || '批量更新失败') showToast(res.message || '批量更新失败')
} }
@@ -398,18 +425,18 @@ const handleTransactionClick = (transaction) => {
// 处理分组中的删除事件 // 处理分组中的删除事件
const handleGroupTransactionDelete = async (transactionId) => { const handleGroupTransactionDelete = async (transactionId) => {
groupTransactions.value = groupTransactions.value.filter(t => t.id !== transactionId) groupTransactions.value = groupTransactions.value.filter((t) => t.id !== transactionId)
groupTransactionsTotal.value = Math.max(0, (groupTransactionsTotal.value || 0) - 1) groupTransactionsTotal.value = Math.max(0, (groupTransactionsTotal.value || 0) - 1)
if(groupTransactions.value.length === 0 && !transactionFinished.value) { if (groupTransactions.value.length === 0 && !transactionFinished.value) {
// 如果当前页数据为空且未加载完,则尝试加载下一页 // 如果当前页数据为空且未加载完,则尝试加载下一页
await loadGroupTransactions() await loadGroupTransactions()
} }
if(groupTransactions.value.length === 0){ if (groupTransactions.value.length === 0) {
// 如果删除后当前分组没有交易了,关闭弹窗 // 如果删除后当前分组没有交易了,关闭弹窗
showTransactionList.value = false showTransactionList.value = false
groups.value = groups.value.filter(g => g.reason !== selectedGroup.value.reason) groups.value = groups.value.filter((g) => g.reason !== selectedGroup.value.reason)
selectedGroup.value = null selectedGroup.value = null
total.value-- total.value--
} }
@@ -433,10 +460,12 @@ const onGlobalTransactionDeleted = () => {
} }
} }
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted) window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted) window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
}) })
// 当有交易新增/修改/批量更新时的刷新监听 // 当有交易新增/修改/批量更新时的刷新监听
@@ -451,10 +480,12 @@ const onGlobalTransactionsChanged = () => {
} }
} }
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged) window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged) window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
}) })
// 处理账单保存后的回调 // 处理账单保存后的回调
@@ -471,8 +502,10 @@ const handleTransactionSaved = async () => {
* 加载数据(支持分页) * 加载数据(支持分页)
*/ */
const loadData = async () => { const loadData = async () => {
if (finished.value) return if (finished.value) {
return
}
loading.value = true loading.value = true
try { try {
const res = await getReasonGroups(pageIndex.value, props.pageSize) const res = await getReasonGroups(pageIndex.value, props.pageSize)
@@ -480,14 +513,14 @@ const loadData = async () => {
const newData = res.data || [] const newData = res.data || []
groups.value = [...groups.value, ...newData] groups.value = [...groups.value, ...newData]
total.value = res.total || 0 total.value = res.total || 0
// 判断是否还有更多数据 // 判断是否还有更多数据
if (groups.value.length >= total.value) { if (groups.value.length >= total.value) {
finished.value = true finished.value = true
} }
pageIndex.value++ pageIndex.value++
emit('data-loaded', { emit('data-loaded', {
groups: groups.value, groups: groups.value,
total: total.value, total: total.value,
@@ -522,7 +555,7 @@ const refresh = async () => {
*/ */
const getList = (onlySelected = false) => { const getList = (onlySelected = false) => {
if (onlySelected && props.selectable) { if (onlySelected && props.selectable) {
return groups.value.filter(g => selectedReasons.value.has(g.reason)) return groups.value.filter((g) => selectedReasons.value.has(g.reason))
} }
return [...groups.value] return [...groups.value]
} }
@@ -564,7 +597,7 @@ const clearSelection = () => {
* 全选 * 全选
*/ */
const selectAll = () => { const selectAll = () => {
selectedReasons.value = new Set(groups.value.map(g => g.reason)) selectedReasons.value = new Set(groups.value.map((g) => g.reason))
} }
// 暴露方法给父组件 // 暴露方法给父组件

View File

@@ -11,7 +11,7 @@
> >
<template v-if="!loading && !saving"> <template v-if="!loading && !saving">
<van-icon :name="buttonIcon" /> <van-icon :name="buttonIcon" />
<span style="margin-left: 4px;">{{ buttonText }}</span> <span style="margin-left: 4px">{{ buttonText }}</span>
</template> </template>
</van-button> </van-button>
</template> </template>
@@ -52,28 +52,42 @@ const hasClassifiedResults = computed(() => {
// 按钮类型 // 按钮类型
const buttonType = computed(() => { const buttonType = computed(() => {
if (saving.value) return 'warning' if (saving.value) {
if (loading.value) return 'primary' return 'warning'
if (hasClassifiedResults.value) return 'success' }
if (loading.value) {
return 'primary'
}
if (hasClassifiedResults.value) {
return 'success'
}
return 'primary' return 'primary'
}) })
// 按钮图标 // 按钮图标
const buttonIcon = computed(() => { const buttonIcon = computed(() => {
if (hasClassifiedResults.value) return 'success' if (hasClassifiedResults.value) {
return 'success'
}
return 'fire' return 'fire'
}) })
// 按钮文字(非加载状态) // 按钮文字(非加载状态)
const buttonText = computed(() => { const buttonText = computed(() => {
if (hasClassifiedResults.value) return '保存分类' if (hasClassifiedResults.value) {
return '保存分类'
}
return '智能分类' return '智能分类'
}) })
// 加载中文字 // 加载中文字
const loadingText = computed(() => { const loadingText = computed(() => {
if (saving.value) return '保存中...' if (saving.value) {
if (loading.value) return '分类中...' return '保存中...'
}
if (loading.value) {
return '分类中...'
}
return '' return ''
}) })
@@ -92,8 +106,10 @@ const handleClick = () => {
* 保存分类结果 * 保存分类结果
*/ */
const handleSaveClassify = async () => { const handleSaveClassify = async () => {
if (saving.value || loading.value) return if (saving.value || loading.value) {
return
}
try { try {
saving.value = true saving.value = true
showToast({ showToast({
@@ -104,27 +120,27 @@ const handleSaveClassify = async () => {
}) })
// 准备批量更新数据 // 准备批量更新数据
const items = classifiedResults.value.map(item => ({ const items = classifiedResults.value.map((item) => ({
id: item.id, id: item.id,
classify: item.classify, classify: item.classify,
type: item.type type: item.type
})) }))
const response = await batchUpdateClassify(items) const response = await batchUpdateClassify(items)
closeToast() closeToast()
if (response.success) { if (response.success) {
showToast({ showToast({
type: 'success', type: 'success',
message: `保存成功,已更新 ${items.length} 条记录`, message: `保存成功,已更新 ${items.length} 条记录`,
duration: 2000 duration: 2000
}) })
// 清空已分类结果 // 清空已分类结果
classifiedResults.value = [] classifiedResults.value = []
isAllCompleted.value = false isAllCompleted.value = false
// 通知父组件刷新数据 // 通知父组件刷新数据
emit('save') emit('save')
} else { } else {
@@ -161,7 +177,7 @@ const handleSmartClassify = async () => {
return return
} }
if(lockClassifiedResults.value) { if (lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,请稍后再试') showToast('当前有分类任务正在进行,请稍后再试')
loading.value = false loading.value = false
return return
@@ -170,10 +186,10 @@ const handleSmartClassify = async () => {
// 清空之前的分类结果 // 清空之前的分类结果
isAllCompleted.value = false isAllCompleted.value = false
classifiedResults.value = [] classifiedResults.value = []
const batchSize = 3 const batchSize = 3
let processedCount = 0 let processedCount = 0
try { try {
lockClassifiedResults.value = true lockClassifiedResults.value = true
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise // 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise
@@ -200,10 +216,10 @@ const handleSmartClassify = async () => {
// 分批处理 // 分批处理
for (let i = 0; i < allTransactions.length; i += batchSize) { for (let i = 0; i < allTransactions.length; i += batchSize) {
const batch = allTransactions.slice(i, i + batchSize) const batch = allTransactions.slice(i, i + batchSize)
const transactionIds = batch.map(t => t.id) const transactionIds = batch.map((t) => t.id)
const currentBatch = Math.floor(i / batchSize) + 1 const currentBatch = Math.floor(i / batchSize) + 1
const totalBatches = Math.ceil(allTransactions.length / batchSize) const totalBatches = Math.ceil(allTransactions.length / batchSize)
// 更新批次进度 // 更新批次进度
closeToast() closeToast()
toastInstance = showToast({ toastInstance = showToast({
@@ -214,7 +230,7 @@ const handleSmartClassify = async () => {
}) })
const response = await smartClassify(transactionIds) const response = await smartClassify(transactionIds)
if (!response.ok) { if (!response.ok) {
throw new Error('智能分类请求失败') throw new Error('智能分类请求失败')
} }
@@ -228,23 +244,27 @@ const handleSmartClassify = async () => {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) {
break
}
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
// 处理完整的事件SSE格式event: type\ndata: data\n\n // 处理完整的事件SSE格式event: type\ndata: data\n\n
const events = buffer.split('\n\n') const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个不完整的部分 buffer = events.pop() || '' // 保留最后一个不完整的部分
for (const eventBlock of events) { for (const eventBlock of events) {
if (!eventBlock.trim()) continue if (!eventBlock.trim()) {
continue
}
try { try {
const lines = eventBlock.split('\n') const lines = eventBlock.split('\n')
let eventType = '' let eventType = ''
let eventData = '' let eventData = ''
for (const line of lines) { for (const line of lines) {
if (line.startsWith('event: ')) { if (line.startsWith('event: ')) {
eventType = line.slice(7).trim() eventType = line.slice(7).trim()
@@ -252,7 +272,7 @@ const handleSmartClassify = async () => {
eventData = line.slice(6).trim() eventData = line.slice(6).trim()
} }
} }
if (eventType === 'start') { if (eventType === 'start') {
// 开始分类 // 开始分类
closeToast() closeToast()
@@ -267,23 +287,23 @@ const handleSmartClassify = async () => {
// 收到分类结果 // 收到分类结果
const data = JSON.parse(eventData) const data = JSON.parse(eventData)
processedCount++ processedCount++
// 记录分类结果 // 记录分类结果
classifiedResults.value.push({ classifiedResults.value.push({
id: data.id, id: data.id,
classify: data.Classify, classify: data.Classify,
type: data.Type type: data.Type
}) })
// 实时更新交易记录的分类信息 // 实时更新交易记录的分类信息
const index = props.transactions.findIndex(t => t.id === data.id) const index = props.transactions.findIndex((t) => t.id === data.id)
if (index !== -1) { if (index !== -1) {
const transaction = props.transactions[index] const transaction = props.transactions[index]
transaction.upsetedClassify = data.Classify transaction.upsetedClassify = data.Classify
transaction.upsetedType = data.Type transaction.upsetedType = data.Type
emit('notifyDonedTransactionId', data.id) emit('notifyDonedTransactionId', data.id)
} }
// 限制Toast更新频率避免频繁的DOM操作 // 限制Toast更新频率避免频繁的DOM操作
const now = Date.now() const now = Date.now()
if (now - lastUpdateTime > updateInterval) { if (now - lastUpdateTime > updateInterval) {
@@ -310,7 +330,7 @@ const handleSmartClassify = async () => {
} }
} }
} }
// 所有批次完成 // 所有批次完成
closeToast() closeToast()
toastInstance = null toastInstance = null
@@ -344,18 +364,18 @@ const handleSmartClassify = async () => {
const removeClassifiedTransaction = (transactionId) => { const removeClassifiedTransaction = (transactionId) => {
// 从已分类结果中移除指定ID的项 // 从已分类结果中移除指定ID的项
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId) classifiedResults.value = classifiedResults.value.filter((item) => item.id !== transactionId)
} }
/** /**
* 重置组件状态 * 重置组件状态
*/ */
const reset = () => { const reset = () => {
if(lockClassifiedResults.value) { if (lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,无法重置') showToast('当前有分类任务正在进行,无法重置')
return return
} }
isAllCompleted.value = false isAllCompleted.value = false
classifiedResults.value = [] classifiedResults.value = []
loading.value = false loading.value = false
@@ -365,8 +385,7 @@ const reset = () => {
defineExpose({ defineExpose({
reset, reset,
removeClassifiedTransaction removeClassifiedTransaction
}); })
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,123 +1,124 @@
<template> <template>
<PopupContainer <PopupContainer v-model="visible" title="交易详情" height="75%" :closeable="false">
v-model="visible"
title="交易详情"
height="75%"
:closeable="false"
>
<template #header-actions> <template #header-actions>
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button> <van-button size="small" type="primary" plain @click="handleOffsetClick"> 抵账 </van-button>
</template> </template>
<van-form style="margin-top: 12px;"> <van-form style="margin-top: 12px">
<van-cell-group inset> <van-cell-group inset>
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" /> <van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
</van-cell-group> </van-cell-group>
<van-cell-group inset title="交易明细"> <van-cell-group inset title="交易明细">
<van-field <van-field
v-model="occurredAtLabel" v-model="occurredAtLabel"
name="occurredAt" name="occurredAt"
label="交易时间" label="交易时间"
readonly readonly
is-link is-link
placeholder="请选择交易时间" placeholder="请选择交易时间"
:rules="[{ required: true, message: '请选择交易时间' }]" :rules="[{ required: true, message: '请选择交易时间' }]"
@click="showDatePicker = true" @click="showDatePicker = true"
/> />
<van-field <van-field
v-model="editForm.reason" v-model="editForm.reason"
name="reason" name="reason"
label="交易摘要" label="交易摘要"
placeholder="请输入交易摘要" placeholder="请输入交易摘要"
type="textarea" type="textarea"
rows="2" rows="2"
autosize autosize
maxlength="200" maxlength="200"
show-word-limit show-word-limit
/> />
<van-field <van-field
v-model="editForm.amount" v-model="editForm.amount"
name="amount" name="amount"
label="交易金额" label="交易金额"
placeholder="请输入交易金额" placeholder="请输入交易金额"
type="number" type="number"
:rules="[{ required: true, message: '请输入交易金额' }]" :rules="[{ required: true, message: '请输入交易金额' }]"
/> />
<van-field <van-field
v-model="editForm.balance" v-model="editForm.balance"
name="balance" name="balance"
label="交易后余额" label="交易后余额"
placeholder="请输入交易后余额" placeholder="请输入交易后余额"
type="number" type="number"
:rules="[{ required: true, message: '请输入交易后余额' }]" :rules="[{ required: true, message: '请输入交易后余额' }]"
/> />
<van-field name="type" label="交易类型"> <van-field name="type" label="交易类型">
<template #input> <template #input>
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange"> <van-radio-group
<van-radio :name="0">支出</van-radio> v-model="editForm.type"
<van-radio :name="1">收入</van-radio> direction="horizontal"
<van-radio :name="2">不计</van-radio> @change="handleTypeChange"
</van-radio-group> >
</template> <van-radio :name="0"> 支出 </van-radio>
</van-field> <van-radio :name="1"> 收入 </van-radio>
<van-radio :name="2"> 不计 </van-radio>
</van-radio-group>
</template>
</van-field>
<van-field name="classify" label="交易分类"> <van-field name="classify" label="交易分类">
<template #input> <template #input>
<div style="flex: 1;"> <div style="flex: 1">
<div <div
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify" v-if="
class="suggestion-tip" transaction &&
@click="applySuggestion" transaction.unconfirmedClassify &&
> transaction.unconfirmedClassify !== editForm.classify
<van-icon name="bulb-o" class="suggestion-icon" /> "
<span class="suggestion-text"> class="suggestion-tip"
建议: {{ transaction.unconfirmedClassify }} @click="applySuggestion"
<span v-if="transaction.unconfirmedType !== null && transaction.unconfirmedType !== undefined && transaction.unconfirmedType !== editForm.type"> >
({{ getTypeName(transaction.unconfirmedType) }}) <van-icon name="bulb-o" class="suggestion-icon" />
</span> <span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }}
<span
v-if="
transaction.unconfirmedType !== null &&
transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type
"
>
({{ getTypeName(transaction.unconfirmedType) }})
</span> </span>
<div class="suggestion-apply">应用</div> </span>
</div> <div class="suggestion-apply">应用</div>
<span v-else-if="!editForm.classify" style="color: var(--van-gray-5);">请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
</div> </div>
</template> <span v-else-if="!editForm.classify" style="color: var(--van-gray-5)"
</van-field> >请选择交易分类</span
>
<ClassifySelector <span v-else>{{ editForm.classify }}</span>
v-model="editForm.classify" </div>
:type="editForm.type" </template>
@change="handleClassifyChange" </van-field>
/>
</van-cell-group> <ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
@change="handleClassifyChange"
/>
</van-cell-group>
</van-form> </van-form>
<template #footer> <template #footer>
<van-button <van-button round block type="primary" :loading="submitting" @click="onSubmit">
round
block
type="primary"
:loading="submitting"
@click="onSubmit"
>
保存修改 保存修改
</van-button> </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
<!-- 抵账候选列表弹窗 --> <!-- 抵账候选列表弹窗 -->
<PopupContainer <PopupContainer v-model="showOffsetPopup" title="选择抵账交易" height="75%">
v-model="showOffsetPopup"
title="选择抵账交易"
height="75%"
>
<van-list> <van-list>
<van-cell <van-cell
v-for="item in offsetCandidates" v-for="item in offsetCandidates"
:key="item.id" :key="item.id"
:title="item.reason" :title="item.reason"
:label="formatDate(item.occurredAt)" :label="formatDate(item.occurredAt)"
:value="item.amount" :value="item.amount"
is-link is-link
@@ -154,7 +155,11 @@ import { showToast, showConfirmDialog } from 'vant'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue' import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord' import {
updateTransaction,
getCandidatesForOffset,
offsetTransactions
} from '@/api/transactionRecord'
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -196,35 +201,41 @@ const occurredAtLabel = computed(() => {
}) })
// 监听props变化 // 监听props变化
watch(() => props.show, (newVal) => { watch(
visible.value = newVal () => props.show,
}) (newVal) => {
visible.value = newVal
watch(() => props.transaction, (newVal) => {
if (newVal) {
isSyncing.value = true
// 填充编辑表单
editForm.id = newVal.id
editForm.reason = newVal.reason || ''
editForm.amount = String(newVal.amount)
editForm.balance = String(newVal.balance)
editForm.type = newVal.type
editForm.classify = newVal.classify || ''
// 初始化日期时间
if (newVal.occurredAt) {
editForm.occurredAt = newVal.occurredAt
const dt = dayjs(newVal.occurredAt)
currentDate.value = dt.format('YYYY-MM-DD').split('-')
currentTime.value = dt.format('HH:mm').split(':')
}
// 在下一个 tick 结束同步状态,确保 van-radio-group 的 @change 已触发完毕
nextTick(() => {
isSyncing.value = false
})
} }
}) )
watch(
() => props.transaction,
(newVal) => {
if (newVal) {
isSyncing.value = true
// 填充编辑表单
editForm.id = newVal.id
editForm.reason = newVal.reason || ''
editForm.amount = String(newVal.amount)
editForm.balance = String(newVal.balance)
editForm.type = newVal.type
editForm.classify = newVal.classify || ''
// 初始化日期时间
if (newVal.occurredAt) {
editForm.occurredAt = newVal.occurredAt
const dt = dayjs(newVal.occurredAt)
currentDate.value = dt.format('YYYY-MM-DD').split('-')
currentTime.value = dt.format('HH:mm').split(':')
}
// 在下一个 tick 结束同步状态,确保 van-radio-group 的 @change 已触发完毕
nextTick(() => {
isSyncing.value = false
})
}
}
)
watch(visible, (newVal) => { watch(visible, (newVal) => {
emit('update:show', newVal) emit('update:show', newVal)
@@ -258,7 +269,10 @@ const onConfirmTime = ({ selectedValues }) => {
const applySuggestion = () => { const applySuggestion = () => {
if (props.transaction.unconfirmedClassify) { if (props.transaction.unconfirmedClassify) {
editForm.classify = props.transaction.unconfirmedClassify editForm.classify = props.transaction.unconfirmedClassify
if (props.transaction.unconfirmedType !== null && props.transaction.unconfirmedType !== undefined) { if (
props.transaction.unconfirmedType !== null &&
props.transaction.unconfirmedType !== undefined
) {
editForm.type = props.transaction.unconfirmedType editForm.type = props.transaction.unconfirmedType
} }
} }
@@ -277,7 +291,7 @@ const getTypeName = (type) => {
const onSubmit = async () => { const onSubmit = async () => {
try { try {
submitting.value = true submitting.value = true
const data = { const data = {
id: editForm.id, id: editForm.id,
reason: editForm.reason, reason: editForm.reason,
@@ -287,7 +301,7 @@ const onSubmit = async () => {
classify: editForm.classify, classify: editForm.classify,
occurredAt: editForm.occurredAt occurredAt: editForm.occurredAt
} }
const response = await updateTransaction(data) const response = await updateTransaction(data)
if (response.success) { if (response.success) {
showToast('保存成功') showToast('保存成功')
@@ -314,11 +328,13 @@ const handleClassifyChange = () => {
// 清空分类 // 清空分类
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return '' if (!dateString) {
return ''
}
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
@@ -347,7 +363,7 @@ const handleOffsetClick = async () => {
const handleCandidateSelect = (candidate) => { const handleCandidateSelect = (candidate) => {
showConfirmDialog({ showConfirmDialog({
title: '确认抵账', title: '确认抵账',
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除`, message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗\n抵消后两笔交易将被删除`
}) })
.then(async () => { .then(async () => {
try { try {
@@ -367,7 +383,7 @@ const handleCandidateSelect = (candidate) => {
}) })
.catch(() => { .catch(() => {
// on cancel // on cancel
}); })
} }
</script> </script>

View File

@@ -1,11 +1,6 @@
<template> <template>
<div class="transaction-list-container transaction-list"> <div class="transaction-list-container transaction-list">
<van-list <van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px"> <van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
<van-swipe-cell <van-swipe-cell
v-for="transaction in transactions" v-for="transaction in transactions"
@@ -13,16 +8,13 @@
class="transaction-item" class="transaction-item"
> >
<div class="transaction-row"> <div class="transaction-row">
<van-checkbox <van-checkbox
v-if="showCheckbox" v-if="showCheckbox"
:model-value="isSelected(transaction.id)" :model-value="isSelected(transaction.id)"
class="checkbox-col" class="checkbox-col"
@update:model-value="toggleSelection(transaction)" @update:model-value="toggleSelection(transaction)"
/> />
<div <div class="transaction-card" @click="handleClick(transaction)">
class="transaction-card"
@click="handleClick(transaction)"
>
<div class="card-left"> <div class="card-left">
<div class="transaction-title"> <div class="transaction-title">
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span> <span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
@@ -30,34 +22,32 @@
<div class="transaction-info"> <div class="transaction-info">
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div> <div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
<div> <div>
<span v-if="transaction.classify"> <span v-if="transaction.classify"> 分类: {{ transaction.classify }} </span>
分类: {{ transaction.classify }} <span
</span> v-if="
<span v-if="transaction.upsetedClassify && transaction.upsetedClassify !== transaction.classify" style="color: var(--van-warning-color)"> transaction.upsetedClassify &&
transaction.upsetedClassify !== transaction.classify
"
style="color: var(--van-warning-color)"
>
→ {{ transaction.upsetedClassify }} → {{ transaction.upsetedClassify }}
</span> </span>
</div>
<div v-if="transaction.importFrom">
来源: {{ transaction.importFrom }}
</div> </div>
<div v-if="transaction.importFrom">来源: {{ transaction.importFrom }}</div>
</div> </div>
</div> </div>
<div class="card-middle"> <div class="card-middle">
<van-tag <van-tag :type="getTypeTagType(transaction.type)" size="medium">
:type="getTypeTagType(transaction.type)"
size="medium"
>
{{ getTypeName(transaction.type) }} {{ getTypeName(transaction.type) }}
</van-tag> </van-tag>
<template <template
v-if="Number.isFinite(transaction.upsetedType) && transaction.upsetedType !== transaction.type" v-if="
Number.isFinite(transaction.upsetedType) &&
transaction.upsetedType !== transaction.type
"
> >
<van-tag <van-tag :type="getTypeTagType(transaction.upsetedType)" size="medium">
:type="getTypeTagType(transaction.upsetedType)"
size="medium"
>
{{ getTypeName(transaction.upsetedType) }} {{ getTypeName(transaction.upsetedType) }}
</van-tag> </van-tag>
</template> </template>
@@ -70,7 +60,10 @@
<div v-if="transaction.balance && transaction.balance > 0" class="balance"> <div v-if="transaction.balance && transaction.balance > 0" class="balance">
余额: {{ formatMoney(transaction.balance) }} 余额: {{ formatMoney(transaction.balance) }}
</div> </div>
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance"> <div
v-if="transaction.refundAmount && transaction.refundAmount > 0"
class="balance"
>
退款: {{ formatMoney(transaction.refundAmount) }} 退款: {{ formatMoney(transaction.refundAmount) }}
</div> </div>
</div> </div>
@@ -79,9 +72,9 @@
</div> </div>
</div> </div>
<template v-if="showDelete" #right> <template v-if="showDelete" #right>
<van-button <van-button
square square
type="danger" type="danger"
text="删除" text="删除"
class="delete-button" class="delete-button"
@click="handleDeleteClick(transaction)" @click="handleDeleteClick(transaction)"
@@ -90,9 +83,9 @@
</van-swipe-cell> </van-swipe-cell>
</van-cell-group> </van-cell-group>
<van-empty <van-empty
v-if="!loading && !(transactions && transactions.length)" v-if="!loading && !(transactions && transactions.length)"
description="暂无交易记录" description="暂无交易记录"
/> />
</van-list> </van-list>
</div> </div>
@@ -212,16 +205,24 @@ const getTypeTagType = (type) => {
// 获取金额样式类 // 获取金额样式类
const getAmountClass = (type) => { const getAmountClass = (type) => {
if (type === 0) return 'expense' if (type === 0) {
if (type === 1) return 'income' return 'expense'
}
if (type === 1) {
return 'income'
}
return 'neutral' return 'neutral'
} }
// 格式化金额(带符号) // 格式化金额(带符号)
const formatAmount = (amount, type) => { const formatAmount = (amount, type) => {
const formatted = formatMoney(amount) const formatted = formatMoney(amount)
if (type === 0) return `- ${formatted}` if (type === 0) {
if (type === 1) return `+ ${formatted}` return `- ${formatted}`
}
if (type === 1) {
return `+ ${formatted}`
}
return formatted return formatted
} }
@@ -232,11 +233,13 @@ const formatMoney = (amount) => {
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return '' if (!dateString) {
return ''
}
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'

View File

@@ -8,7 +8,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import vant from 'vant' import vant from 'vant'
import { ConfigProvider } from 'vant'; import { ConfigProvider } from 'vant'
import 'vant/lib/index.css' import 'vant/lib/index.css'
// 注册 Service Worker // 注册 Service Worker
@@ -19,7 +19,7 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(vant) app.use(vant)
app.use(ConfigProvider); app.use(ConfigProvider)
app.mount('#app') app.mount('#app')

View File

@@ -1,65 +1,68 @@
import { ref } from 'vue'; import { ref } from 'vue'
export const needRefresh = ref(false); export const needRefresh = ref(false)
let swRegistration = null; let swRegistration = null
export async function updateServiceWorker() { export async function updateServiceWorker() {
if (swRegistration && swRegistration.waiting) { if (swRegistration && swRegistration.waiting) {
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
} }
} }
export function register() { export function register() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
const swUrl = `/service-worker.js`; const swUrl = '/service-worker.js'
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then((registration) => { .then((registration) => {
swRegistration = registration; swRegistration = registration
console.log('[SW] Service Worker 注册成功:', registration.scope); console.log('[SW] Service Worker 注册成功:', registration.scope)
// 如果已经有等待中的更新 // 如果已经有等待中的更新
if (registration.waiting) { if (registration.waiting) {
console.log('[SW] 发现未处理的新版本'); console.log('[SW] 发现未处理的新版本')
needRefresh.value = true; needRefresh.value = true
} }
// 检查更新 // 检查更新
registration.addEventListener('updatefound', () => { registration.addEventListener('updatefound', () => {
const newWorker = registration.installing; const newWorker = registration.installing
console.log('[SW] 发现新版本'); console.log('[SW] 发现新版本')
newWorker.addEventListener('statechange', () => { newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') { if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
// 新的 Service Worker 已安装,提示用户刷新 // 新的 Service Worker 已安装,提示用户刷新
console.log('[SW] 新版本可用,请刷新页面'); console.log('[SW] 新版本可用,请刷新页面')
needRefresh.value = true; needRefresh.value = true
} else { } else {
// 首次安装 // 首次安装
console.log('[SW] 内容已缓存,可离线使用'); console.log('[SW] 内容已缓存,可离线使用')
} }
} }
}); })
}); })
// 定期检查更新 // 定期检查更新
setInterval(() => { setInterval(
registration.update(); () => {
}, 60 * 60 * 1000); // 每小时检查一次 registration.update()
},
60 * 60 * 1000
) // 每小时检查一次
}) })
.catch((error) => { .catch((error) => {
console.error('[SW] Service Worker 注册失败:', error); console.error('[SW] Service Worker 注册失败:', error)
}); })
// 监听 Service Worker 控制器变化 // 监听 Service Worker 控制器变化
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] 控制器已更改,页面将刷新'); console.log('[SW] 控制器已更改,页面将刷新')
window.location.reload(); window.location.reload()
}); })
}); })
} }
} }
@@ -67,35 +70,37 @@ export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => { .then((registration) => {
registration.unregister(); registration.unregister()
}) })
.catch((error) => { .catch((error) => {
console.error(error.message); console.error(error.message)
}); })
} }
} }
// 请求通知权限 // 请求通知权限
export function requestNotificationPermission() { export function requestNotificationPermission() {
if ('Notification' in window && 'serviceWorker' in navigator) { if ('Notification' in window && 'serviceWorker' in navigator) {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === 'granted') { if (permission === 'granted') {
console.log('[SW] 通知权限已授予'); console.log('[SW] 通知权限已授予')
} }
}); })
} }
} }
// 后台同步 // 后台同步
export function registerBackgroundSync(tag = 'sync-data') { export function registerBackgroundSync(tag = 'sync-data') {
if ('serviceWorker' in navigator && 'SyncManager' in window) { if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready
return registration.sync.register(tag); .then((registration) => {
}).then(() => { return registration.sync.register(tag)
console.log('[SW] 后台同步已注册:', tag); })
}).catch((err) => { .then(() => {
console.error('[SW] 后台同步注册失败:', err); console.log('[SW] 后台同步注册:', tag)
}); })
.catch((err) => {
console.error('[SW] 后台同步注册失败:', err)
})
} }
} }

View File

@@ -8,106 +8,106 @@ const router = createRouter({
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
meta: { requiresAuth: false }, meta: { requiresAuth: false }
}, },
{ {
path: '/balance', path: '/balance',
name: 'balance', name: 'balance',
component: () => import('../views/BalanceView.vue'), component: () => import('../views/BalanceView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/email', path: '/email',
name: 'email', name: 'email',
component: () => import('../views/EmailRecord.vue'), component: () => import('../views/EmailRecord.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/setting', path: '/setting',
name: 'setting', name: 'setting',
component: () => import('../views/SettingView.vue'), component: () => import('../views/SettingView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/calendar', path: '/calendar',
name: 'calendar', name: 'calendar',
component: () => import('../views/CalendarView.vue'), component: () => import('../views/CalendarView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/smart-classification', path: '/smart-classification',
name: 'smart-classification', name: 'smart-classification',
component: () => import('../views/ClassificationSmart.vue'), component: () => import('../views/ClassificationSmart.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/classification-edit', path: '/classification-edit',
name: 'classification-edit', name: 'classification-edit',
component: () => import('../views/ClassificationEdit.vue'), component: () => import('../views/ClassificationEdit.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/classification-batch', path: '/classification-batch',
name: 'classification-batch', name: 'classification-batch',
component: () => import('../views/ClassificationBatch.vue'), component: () => import('../views/ClassificationBatch.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/classification-nlp', path: '/classification-nlp',
name: 'classification-nlp', name: 'classification-nlp',
component: () => import('../views/ClassificationNLP.vue'), component: () => import('../views/ClassificationNLP.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/', path: '/',
name: 'statistics', name: 'statistics',
component: () => import('../views/StatisticsView.vue'), component: () => import('../views/StatisticsView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/bill-analysis', path: '/bill-analysis',
name: 'bill-analysis', name: 'bill-analysis',
component: () => import('../views/BillAnalysisView.vue'), component: () => import('../views/BillAnalysisView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/message', path: '/message',
name: 'message', name: 'message',
redirect: { path: '/balance', query: { tab: 'message' } }, redirect: { path: '/balance', query: { tab: 'message' } },
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/periodic-record', path: '/periodic-record',
name: 'periodic-record', name: 'periodic-record',
component: () => import('../views/PeriodicRecord.vue'), component: () => import('../views/PeriodicRecord.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/log', path: '/log',
name: 'log', name: 'log',
component: () => import('../views/LogView.vue'), component: () => import('../views/LogView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/budget', path: '/budget',
name: 'budget', name: 'budget',
component: () => import('../views/BudgetView.vue'), component: () => import('../views/BudgetView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
path: '/scheduled-tasks', path: '/scheduled-tasks',
name: 'scheduled-tasks', name: 'scheduled-tasks',
component: () => import('../views/ScheduledTasksView.vue'), component: () => import('../views/ScheduledTasksView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
}, },
{ {
// 待确认的分类项 // 待确认的分类项
path: '/unconfirmed-classification', path: '/unconfirmed-classification',
name: 'unconfirmed-classification', name: 'unconfirmed-classification',
component: () => import('../views/UnconfirmedClassification.vue'), component: () => import('../views/UnconfirmedClassification.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true }
} }
], ]
}) })
// 路由守卫 // 路由守卫
@@ -127,4 +127,3 @@ router.beforeEach((to, from, next) => {
}) })
export default router export default router

View File

@@ -7,7 +7,9 @@ export const useAuthStore = defineStore('auth', () => {
const expiresAt = ref(localStorage.getItem('expiresAt') || '') const expiresAt = ref(localStorage.getItem('expiresAt') || '')
const isAuthenticated = computed(() => { const isAuthenticated = computed(() => {
if (!token.value || !expiresAt.value) return false if (!token.value || !expiresAt.value) {
return false
}
// 检查token是否过期 // 检查token是否过期
return new Date(expiresAt.value) > new Date() return new Date(expiresAt.value) > new Date()
}) })
@@ -16,13 +18,13 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await request.post('/Auth/Login', { password }) const response = await request.post('/Auth/Login', { password })
const { token: newToken, expiresAt: newExpiresAt } = response.data const { token: newToken, expiresAt: newExpiresAt } = response.data
token.value = newToken token.value = newToken
expiresAt.value = newExpiresAt expiresAt.value = newExpiresAt
localStorage.setItem('token', newToken) localStorage.setItem('token', newToken)
localStorage.setItem('expiresAt', newExpiresAt) localStorage.setItem('expiresAt', newExpiresAt)
return true return true
} catch (error) { } catch (error) {
if (error.response?.status === 401) { if (error.response?.status === 401) {
@@ -44,6 +46,6 @@ export const useAuthStore = defineStore('auth', () => {
expiresAt, expiresAt,
isAuthenticated, isAuthenticated,
login, login,
logout, logout
} }
}) })

View File

@@ -16,14 +16,14 @@
line-height: 1.2; line-height: 1.2;
} }
.rich-html-content h1 { .rich-html-content h1 {
font-size: 1.7em; font-size: 1.7em;
text-align: center; text-align: center;
border-bottom: 1px solid var(--van-border-color); border-bottom: 1px solid var(--van-border-color);
padding-bottom: 6px; padding-bottom: 6px;
} }
.rich-html-content h2 { .rich-html-content h2 {
font-size: 1.5em; font-size: 1.5em;
margin-top: 14px; margin-top: 14px;
} }
.rich-html-content h3 { .rich-html-content h3 {

View File

@@ -1,58 +1,60 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<van-nav-bar title="账单" placeholder> <van-nav-bar title="账单" placeholder>
<template #right> <template #right>
<van-button <van-button
v-if="tabActive === 'email'" v-if="tabActive === 'email'"
size="small" size="small"
type="primary" type="primary"
:loading="syncing" :loading="syncing"
@click="emailRecordRef.handleSync()" @click="emailRecordRef.handleSync()"
> >
立即同步 立即同步
</van-button> </van-button>
<van-icon <van-icon
v-if="tabActive === 'message'" v-if="tabActive === 'message'"
name="passed" name="passed"
size="20" size="20"
@click="messageViewRef?.handleMarkAllRead()" @click="messageViewRef?.handleMarkAllRead()"
/> />
</template> </template>
</van-nav-bar> </van-nav-bar>
<van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0;"> <van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0">
<van-tab title="账单" name="balance" /> <van-tab title="账单" name="balance" />
<van-tab title="邮件" name="email" /> <van-tab title="邮件" name="email" />
<van-tab title="消息" name="message" /> <van-tab title="消息" name="message" />
</van-tabs> </van-tabs>
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/> <TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef" />
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" /> <EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" /> <MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import TransactionsRecord from './TransactionsRecord.vue'; import TransactionsRecord from './TransactionsRecord.vue'
import EmailRecord from './EmailRecord.vue'; import EmailRecord from './EmailRecord.vue'
import MessageView from './MessageView.vue'; import MessageView from './MessageView.vue'
const route = useRoute(); const route = useRoute()
const tabActive = ref(route.query.tab || 'balance'); const tabActive = ref(route.query.tab || 'balance')
// 监听路由参数变化,用于从 tabbar 点击时切换 tab // 监听路由参数变化,用于从 tabbar 点击时切换 tab
watch(() => route.query.tab, (newTab) => { watch(
if (newTab) { () => route.query.tab,
tabActive.value = newTab; (newTab) => {
if (newTab) {
tabActive.value = newTab
}
} }
}); )
const transactionsRecordRef = ref(null); const transactionsRecordRef = ref(null)
const emailRecordRef = ref(null); const emailRecordRef = ref(null)
const messageViewRef = ref(null); const messageViewRef = ref(null)
</script> </script>
<style scoped> <style scoped>
@@ -66,4 +68,4 @@ const messageViewRef = ref(null);
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -1,18 +1,18 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<van-nav-bar <van-nav-bar
title="智能分析" title="智能分析"
left-arrow left-arrow
placeholder placeholder
@click-left="onClickLeft" @click-left="onClickLeft"
> >
<template #right> <template #right>
<van-icon <van-icon
name="setting-o" name="setting-o"
size="20" size="20"
style="cursor: pointer; padding-right: 12px;" style="cursor: pointer; padding-right: 12px"
@click="onClickPrompt" @click="onClickPrompt"
/> />
</template> </template>
@@ -31,11 +31,13 @@
show-word-limit show-word-limit
:disabled="analyzing" :disabled="analyzing"
/> />
<div class="quick-questions"> <div class="quick-questions">
<div class="quick-title">快捷问题</div> <div class="quick-title">
<van-tag 快捷问题
v-for="(q, index) in quickQuestions" </div>
<van-tag
v-for="(q, index) in quickQuestions"
:key="index" :key="index"
type="primary" type="primary"
plain plain
@@ -47,9 +49,9 @@
</van-tag> </van-tag>
</div> </div>
<van-button <van-button
type="primary" type="primary"
block block
round round
:loading="analyzing" :loading="analyzing"
loading-text="分析中..." loading-text="分析中..."
@@ -61,23 +63,32 @@
</div> </div>
<!-- 结果区域 --> <!-- 结果区域 -->
<div v-if="showResult" class="result-section"> <div
v-if="showResult"
class="result-section"
>
<div class="result-header"> <div class="result-header">
<h3>分析结果</h3> <h3>分析结果</h3>
<van-icon <van-icon
v-if="!analyzing" v-if="!analyzing"
name="delete-o" name="delete-o"
size="18" size="18"
@click="clearResult" @click="clearResult"
/> />
</div> </div>
<div ref="resultContainer" class="result-content rich-html-content"> <div
<div v-html="resultHtml"></div> ref="resultContainer"
<van-loading v-if="analyzing" class="result-loading"> class="result-content rich-html-content"
>
<div v-html="resultHtml" />
<van-loading
v-if="analyzing"
class="result-loading"
>
AI正在分析中... AI正在分析中...
</van-loading> </van-loading>
<div ref="scrollAnchor"></div> <div ref="scrollAnchor" />
</div> </div>
</div> </div>
</div> </div>
@@ -210,7 +221,7 @@ const startAnalysis = async () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
userInput: userInput.value userInput: userInput.value
@@ -226,8 +237,10 @@ const startAnalysis = async () => {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) {
break
}
const chunk = decoder.decode(value, { stream: true }) const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n') const lines = chunk.split('\n')
@@ -235,7 +248,7 @@ const startAnalysis = async () => {
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
const data = line.substring(6).trim() const data = line.substring(6).trim()
if (data === '[DONE]') { if (data === '[DONE]') {
continue continue
} }
@@ -254,7 +267,6 @@ const startAnalysis = async () => {
} }
} }
} }
} catch (error) { } catch (error) {
console.error('分析失败:', error) console.error('分析失败:', error)
showToast('分析失败,请重试') showToast('分析失败,请重试')

View File

@@ -1,18 +1,21 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-nav-bar title="预算管理" placeholder> <van-nav-bar
title="预算管理"
placeholder
>
<template #right> <template #right>
<van-icon <van-icon
v-if="activeTab !== BudgetCategory.Savings v-if="
&& uncoveredCategories.length > 0 activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0 && !isArchive
&& !isArchive" "
name="warning-o" name="warning-o"
size="20" size="20"
color="var(--van-danger-color)" color="var(--van-danger-color)"
style="margin-right: 12px" style="margin-right: 12px"
title="查看未覆盖预算的分类" title="查看未覆盖预算的分类"
@click="showUncoveredDetails = true" @click="showUncoveredDetails = true"
/> />
<van-icon <van-icon
v-if="isArchive" v-if="isArchive"
@@ -22,193 +25,289 @@
style="margin-right: 12px" style="margin-right: 12px"
@click="showArchiveSummary()" @click="showArchiveSummary()"
/> />
<van-icon <van-icon
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
name="plus" name="plus"
size="20" size="20"
title="添加预算" title="添加预算"
@click="budgetEditRef.open({ category: activeTab })" @click="budgetEditRef.open({ category: activeTab })"
/> />
<van-icon <van-icon
v-else v-else
name="setting-o" name="setting-o"
size="20" size="20"
title="储蓄分类配置" title="储蓄分类配置"
@click="savingsConfigRef.open()" @click="savingsConfigRef.open()"
/> />
</template> </template>
</van-nav-bar> </van-nav-bar>
<van-tabs v-model:active="activeTab" type="card" class="budget-tabs" style="margin: 12px 4px;"> <van-tabs
<van-tab title="支出" :name="BudgetCategory.Expense"> v-model:active="activeTab"
<BudgetSummary type="card"
class="budget-tabs"
style="margin: 12px 4px"
>
<van-tab
title="支出"
:name="BudgetCategory.Expense"
>
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate" v-model:date="selectedDate"
:stats="overallStats" :stats="overallStats"
:title="activeTabTitle" :title="activeTabTitle"
:get-value-class="getValueClass" :get-value-class="getValueClass"
/> />
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh"> <van-pull-refresh
v-model="isRefreshing"
class="scroll-content"
@refresh="onRefresh"
>
<div class="budget-list"> <div class="budget-list">
<template v-if="expenseBudgets?.length > 0"> <template v-if="expenseBudgets?.length > 0">
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id"> <van-swipe-cell
<BudgetCard v-for="budget in expenseBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget" :budget="budget"
:progress-color="getProgressColor(budget)" :progress-color="getProgressColor(budget)"
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }" :percent-class="{
warning: budget.current / budget.limit > 0.8
}"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
@click="budgetEditRef.open({ @click="
data: budget, budgetEditRef.open({
isEditFlag: true, data: budget,
category: budget.category isEditFlag: true,
})" category: budget.category
})
"
> >
<template #amount-info> <template #amount-info>
<div class="info-item"> <div class="info-item">
<div class="label">已支出</div> <div class="label">
<div class="value expense">¥{{ formatMoney(budget.current) }}</div> 已支出
</div>
<div class="value expense">
¥{{ formatMoney(budget.current) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">预算</div> <div class="label">
<div class="value">¥{{ formatMoney(budget.limit) }}</div> 预算
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">余额</div> <div class="label">
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"> 余额
</div>
<div
class="value"
:class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"
>
¥{{ formatMoney(budget.limit - budget.current) }} ¥{{ formatMoney(budget.limit - budget.current) }}
</div> </div>
</div> </div>
</template> </template>
</BudgetCard> </BudgetCard>
<template #right> <template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" /> <van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template> </template>
</van-swipe-cell> </van-swipe-cell>
</template> </template>
<van-empty v-else description="暂无支出预算" /> <van-empty
v-else
description="暂无支出预算"
/>
</div> </div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
</van-tab> </van-tab>
<van-tab title="收入" :name="BudgetCategory.Income"> <van-tab
<BudgetSummary title="收入"
:name="BudgetCategory.Income"
>
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate" v-model:date="selectedDate"
:stats="overallStats" :stats="overallStats"
:title="activeTabTitle" :title="activeTabTitle"
:get-value-class="getValueClass" :get-value-class="getValueClass"
/> />
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh"> <van-pull-refresh
v-model="isRefreshing"
class="scroll-content"
@refresh="onRefresh"
>
<div class="budget-list"> <div class="budget-list">
<template v-if="incomeBudgets?.length > 0"> <template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id"> <van-swipe-cell
<BudgetCard v-for="budget in incomeBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget" :budget="budget"
:progress-color="getProgressColor(budget)" :progress-color="getProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }" :percent-class="{
income: budget.current / budget.limit >= 1
}"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
@click="budgetEditRef.open({ @click="
data: budget, budgetEditRef.open({
isEditFlag: true, data: budget,
category: budget.category isEditFlag: true,
})" category: budget.category
})
"
> >
<template #amount-info> <template #amount-info>
<div class="info-item"> <div class="info-item">
<div class="label">已收入</div> <div class="label">
<div class="value income">¥{{ formatMoney(budget.current) }}</div> 已收入
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">目标</div> <div class="label">
<div class="value">¥{{ formatMoney(budget.limit) }}</div> 目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">差额</div> <div class="label">
<div class="value" :class="budget.current >= budget.limit ? 'income' : 'expense'"> 差额
</div>
<div
class="value"
:class="budget.current >= budget.limit ? 'income' : 'expense'"
>
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }} ¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div> </div>
</div> </div>
</template> </template>
</BudgetCard> </BudgetCard>
<template #right> <template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" /> <van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template> </template>
</van-swipe-cell> </van-swipe-cell>
</template> </template>
<van-empty v-else description="暂无收入预算" /> <van-empty
v-else
description="暂无收入预算"
/>
</div> </div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
</van-tab> </van-tab>
<van-tab title="存款" :name="BudgetCategory.Savings"> <van-tab
<van-pull-refresh v-model="isRefreshing" class="scroll-content" style="padding-top:4px" @refresh="onRefresh"> title="存款"
:name="BudgetCategory.Savings"
>
<van-pull-refresh
v-model="isRefreshing"
class="scroll-content"
style="padding-top: 4px"
@refresh="onRefresh"
>
<div class="budget-list"> <div class="budget-list">
<template v-if="savingsBudgets?.length > 0"> <template v-if="savingsBudgets?.length > 0">
<BudgetCard <BudgetCard
v-for="budget in savingsBudgets" v-for="budget in savingsBudgets"
:key="budget.id" :key="budget.id"
:budget="budget" :budget="budget"
:progress-color="getProgressColor(budget)" :progress-color="getProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }" :percent-class="{ income: budget.current / budget.limit >= 1 }"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;" style="margin: 0 12px 12px"
> >
<template #amount-info> <template #amount-info>
<div class="info-item"> <div class="info-item">
<div class="label">已存</div> <div class="label">
<div class="value income">¥{{ formatMoney(budget.current) }}</div> 已存
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">目标</div> <div class="label">
<div class="value">¥{{ formatMoney(budget.limit) }}</div> 目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label">还差</div> <div class="label">
还差
</div>
<div class="value expense"> <div class="value expense">
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }} ¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
</div> </div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<div class="card-footer-actions"> <div class="card-footer-actions">
<van-button <van-button
size="small" size="small"
icon="arrow-left" icon="arrow-left"
plain plain
type="primary" type="primary"
@click.stop="handleSavingsNav(budget, -1)" @click.stop="handleSavingsNav(budget, -1)"
> />
</van-button>
<span class="current-date-label"> <span class="current-date-label">
{{ getSavingsDateLabel(budget) }} {{ getSavingsDateLabel(budget) }}
</span> </span>
<van-button <van-button
size="small" size="small"
icon="arrow" icon="arrow"
plain plain
type="primary" type="primary"
icon-position="right" icon-position="right"
:disabled="disabledSavingsNextNav(budget)" :disabled="disabledSavingsNextNav(budget)"
@click.stop="handleSavingsNav(budget, 1)" @click.stop="handleSavingsNav(budget, 1)"
> />
</van-button>
</div> </div>
</template> </template>
</BudgetCard> </BudgetCard>
</template> </template>
<van-empty v-else description="暂无存款计划" /> <van-empty
v-else
description="暂无存款计划"
/>
</div> </div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
</van-tab> </van-tab>
</van-tabs> </van-tabs>
<BudgetEditPopup <BudgetEditPopup
ref="budgetEditRef" ref="budgetEditRef"
@success="fetchBudgetList" @success="fetchBudgetList"
/> />
<SavingsConfigPopup <SavingsConfigPopup
ref="savingsConfigRef" ref="savingsConfigRef"
@@ -222,21 +321,37 @@
height="60%" height="60%"
> >
<div class="uncovered-list"> <div class="uncovered-list">
<div v-for="item in uncoveredCategories" :key="item.category" class="uncovered-item"> <div
v-for="item in uncoveredCategories"
:key="item.category"
class="uncovered-item"
>
<div class="item-left"> <div class="item-left">
<div class="category-name">{{ item.category }}</div> <div class="category-name">
<div class="transaction-count">{{ item.transactionCount }} 笔记录</div> {{ item.category }}
</div>
<div class="transaction-count">
{{ item.transactionCount }} 笔记录
</div>
</div> </div>
<div class="item-right"> <div class="item-right">
<div class="item-amount" :class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"> <div
class="item-amount"
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
>
¥{{ formatMoney(item.totalAmount) }} ¥{{ formatMoney(item.totalAmount) }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<van-button block round type="primary" @click="showUncoveredDetails = false"> <van-button
block
round
type="primary"
@click="showUncoveredDetails = false"
>
我知道了 我知道了
</van-button> </van-button>
</template> </template>
@@ -248,10 +363,13 @@
:subtitle="`${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月`" :subtitle="`${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月`"
height="70%" height="70%"
> >
<div style="padding: 16px;"> <div style="padding: 16px">
<div <div
class="rich-html-content" 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> </div>
</PopupContainer> </PopupContainer>
@@ -261,7 +379,14 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant' import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, getCategoryStats, getUncoveredCategories, getArchiveSummary, updateArchiveSummary, getSavingsBudget } from '@/api/budget' import {
getBudgetList,
deleteBudget,
getCategoryStats,
getUncoveredCategories,
getArchiveSummary,
getSavingsBudget
} from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums' import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue' import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue' import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
@@ -279,7 +404,6 @@ const uncoveredCategories = ref([])
const showSummaryPopup = ref(false) const showSummaryPopup = ref(false)
const archiveSummary = ref('') const archiveSummary = ref('')
const isSavingSummary = ref(false)
const expenseBudgets = ref([]) const expenseBudgets = ref([])
const incomeBudgets = ref([]) const incomeBudgets = ref([])
@@ -290,14 +414,19 @@ const overallStats = ref({
}) })
const activeTabTitle = computed(() => { const activeTabTitle = computed(() => {
if (activeTab.value === BudgetCategory.Expense) return '使用' if (activeTab.value === BudgetCategory.Expense) {
return '使用'
}
return '达成' return '达成'
}) })
const isArchive = computed(() => { const isArchive = computed(() => {
const now = new Date() const now = new Date()
return selectedDate.value.getFullYear() < now.getFullYear() || return (
(selectedDate.value.getFullYear() === now.getFullYear() && selectedDate.value.getMonth() < now.getMonth()) selectedDate.value.getFullYear() < now.getFullYear() ||
(selectedDate.value.getFullYear() === now.getFullYear() &&
selectedDate.value.getMonth() < now.getMonth())
)
}) })
watch(activeTab, async () => { watch(activeTab, async () => {
@@ -305,23 +434,29 @@ watch(activeTab, async () => {
}) })
watch(selectedDate, async () => { watch(selectedDate, async () => {
await Promise.all([ await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
}) })
const getValueClass = (rate) => { const getValueClass = (rate) => {
const numRate = parseFloat(rate) const numRate = parseFloat(rate)
if (numRate === 0) return '' if (numRate === 0) {
return ''
}
if (activeTab.value === BudgetCategory.Expense) { if (activeTab.value === BudgetCategory.Expense) {
if (numRate >= 100) return 'expense' if (numRate >= 100) {
if (numRate >= 80) return 'warning' return 'expense'
}
if (numRate >= 80) {
return 'warning'
}
return 'income' return 'income'
} else { } else {
if (numRate >= 100) return 'income' if (numRate >= 100) {
if (numRate >= 80) return 'warning' return 'income'
}
if (numRate >= 80) {
return 'warning'
}
return 'expense' return 'expense'
} }
} }
@@ -331,9 +466,9 @@ const fetchBudgetList = async () => {
const res = await getBudgetList(selectedDate.value.toISOString()) const res = await getBudgetList(selectedDate.value.toISOString())
if (res.success) { if (res.success) {
const data = res.data || [] const data = res.data || []
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense) expenseBudgets.value = data.filter((b) => b.category === BudgetCategory.Expense)
incomeBudgets.value = data.filter(b => b.category === BudgetCategory.Income) incomeBudgets.value = data.filter((b) => b.category === BudgetCategory.Income)
savingsBudgets.value = data.filter(b => b.category === BudgetCategory.Savings) savingsBudgets.value = data.filter((b) => b.category === BudgetCategory.Savings)
} }
} catch (err) { } catch (err) {
console.error('加载预算列表失败', err) console.error('加载预算列表失败', err)
@@ -393,18 +528,17 @@ const fetchUncoveredCategories = async () => {
onMounted(async () => { onMounted(async () => {
try { try {
await Promise.all([ await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
} catch (err) { } catch (err) {
console.error('获取初始化数据失败', err) console.error('获取初始化数据失败', err)
} }
}) })
const formatMoney = (val) => { const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
} }
const getPeriodLabel = (type) => { const getPeriodLabel = (type) => {
@@ -427,10 +561,12 @@ const getPeriodLabel = (type) => {
} }
const getProgressColor = (budget) => { const getProgressColor = (budget) => {
if (!budget.limit || budget.limit === 0) return 'var(--van-primary-color)' if (!budget.limit || budget.limit === 0) {
return 'var(--van-primary-color)'
}
const ratio = Math.min(Math.max(budget.current / budget.limit, 0), 1) const ratio = Math.min(Math.max(budget.current / budget.limit, 0), 1)
// 颜色插值辅助函数 // 颜色插值辅助函数
const interpolate = (start, end, t) => { const interpolate = (start, end, t) => {
return Math.round(start + (end - start) * t) return Math.round(start + (end - start) * t)
@@ -441,7 +577,7 @@ const getProgressColor = (budget) => {
// 找到当前值所在的区间 // 找到当前值所在的区间
let startStop = stops[0] let startStop = stops[0]
let endStop = stops[stops.length - 1] let endStop = stops[stops.length - 1]
for (let i = 0; i < stops.length - 1; i++) { for (let i = 0; i < stops.length - 1; i++) {
if (value >= stops[i].p && value <= stops[i + 1].p) { if (value >= stops[i].p && value <= stops[i + 1].p) {
startStop = stops[i] startStop = stops[i]
@@ -449,28 +585,28 @@ const getProgressColor = (budget) => {
break break
} }
} }
// 计算区间内的相对比例 // 计算区间内的相对比例
const range = endStop.p - startStop.p const range = endStop.p - startStop.p
const t = (value - startStop.p) / range const t = (value - startStop.p) / range
const r = interpolate(startStop.c.r, endStop.c.r, t) const r = interpolate(startStop.c.r, endStop.c.r, t)
const g = interpolate(startStop.c.g, endStop.c.g, t) const g = interpolate(startStop.c.g, endStop.c.g, t)
const b = interpolate(startStop.c.b, endStop.c.b, t) const b = interpolate(startStop.c.b, endStop.c.b, t)
return `rgb(${r}, ${g}, ${b})` return `rgb(${r}, ${g}, ${b})`
} }
let stops let stops
if (budget.category === BudgetCategory.Expense) { if (budget.category === BudgetCategory.Expense) {
// 支出: 这是一个"安全 -> 警示 -> 危险"的过程 // 支出: 这是一个"安全 -> 警示 -> 危险"的过程
// 使用 蓝绿色 -> 黄色 -> 橙红色的渐变,更加自然且具有高级感 // 使用 蓝绿色 -> 黄色 -> 橙红色的渐变,更加自然且具有高级感
stops = [ stops = [
{ p: 0, c: { r: 64, g: 169, b: 255 } }, // 0% 清新的蓝色 (Safe/Fresh) { p: 0, c: { r: 64, g: 169, b: 255 } }, // 0% 清新的蓝色 (Safe/Fresh)
{ p: 0.4, c: { r: 54, g: 207, b: 201 } }, // 40% 青色过渡 { p: 0.4, c: { r: 54, g: 207, b: 201 } }, // 40% 青色过渡
{ p: 0.7, c: { r: 250, g: 173, b: 20 } }, // 70% 温暖的黄色 (Warning) { p: 0.7, c: { r: 250, g: 173, b: 20 } }, // 70% 温暖的黄色 (Warning)
{ p: 1, c: { r: 255, g: 77, b: 79 } } // 100% 柔和的红色 (Danger) { p: 1, c: { r: 255, g: 77, b: 79 } } // 100% 柔和的红色 (Danger)
] ]
} else { } else {
// 收入/存款: 这是一个"开始 -> 积累 -> 达成"的过程 // 收入/存款: 这是一个"开始 -> 积累 -> 达成"的过程
@@ -481,17 +617,17 @@ const getProgressColor = (budget) => {
// { p: 0.7, c: { r: 115, g: 209, b: 61 } }, // 70% 草绿 (Good) // { p: 0.7, c: { r: 115, g: 209, b: 61 } }, // 70% 草绿 (Good)
// { p: 1, c: { r: 35, g: 120, b: 4 } } // 100% 深绿 (Excellent) // { p: 1, c: { r: 35, g: 120, b: 4 } } // 100% 深绿 (Excellent)
// ] // ]
// 如果用户喜欢"红->蓝"的逻辑,可以尝试这种"红->白->蓝"的冷暖过渡: // 如果用户喜欢"红->蓝"的逻辑,可以尝试这种"红->白->蓝"的冷暖过渡:
stops = [ stops = [
{ p: 0, c: { r: 245, g: 34, b: 45 } }, // 深红 { p: 0, c: { r: 245, g: 34, b: 45 } }, // 深红
{ p: 0.45, c: { r: 255, g: 204, b: 204 } }, // 浅红 { p: 0.45, c: { r: 255, g: 204, b: 204 } }, // 浅红
{ p: 0.5, c: { r: 240, g: 242, b: 245 } }, // 中性灰白 { p: 0.5, c: { r: 240, g: 242, b: 245 } }, // 中性灰白
{ p: 0.55, c: { r: 186, g: 231, b: 255 } }, // 浅蓝 { p: 0.55, c: { r: 186, g: 231, b: 255 } }, // 浅蓝
{ p: 1, c: { r: 24, g: 144, b: 255 } } // 深蓝 { p: 1, c: { r: 24, g: 144, b: 255 } } // 深蓝
] ]
} }
return getGradientColor(ratio, stops) return getGradientColor(ratio, stops)
} }
@@ -514,7 +650,7 @@ const handleDelete = async (budget) => {
title: '删除预算', title: '删除预算',
message: `确定要删除预算 "${budget.name}" ` message: `确定要删除预算 "${budget.name}" `
}) })
const res = await deleteBudget(budget.id) const res = await deleteBudget(budget.id)
if (res.success) { if (res.success) {
showToast('删除成功') showToast('删除成功')
@@ -531,7 +667,9 @@ const handleDelete = async (budget) => {
} }
const getSavingsDateLabel = (budget) => { const getSavingsDateLabel = (budget) => {
if (!budget.periodStart) return '' if (!budget.periodStart) {
return ''
}
const date = new Date(budget.periodStart) const date = new Date(budget.periodStart)
if (budget.type === BudgetPeriodType.Year) { if (budget.type === BudgetPeriodType.Year) {
return `${date.getFullYear()}` return `${date.getFullYear()}`
@@ -541,12 +679,14 @@ const getSavingsDateLabel = (budget) => {
} }
const handleSavingsNav = async (budget, offset) => { const handleSavingsNav = async (budget, offset) => {
if (!budget.periodStart) return if (!budget.periodStart) {
return
}
const date = new Date(budget.periodStart) const date = new Date(budget.periodStart)
let year = date.getFullYear() let year = date.getFullYear()
let month = date.getMonth() + 1 let month = date.getMonth() + 1
if (budget.type === BudgetPeriodType.Year) { if (budget.type === BudgetPeriodType.Year) {
year += offset year += offset
} else { } else {
@@ -559,12 +699,12 @@ const handleSavingsNav = async (budget, offset) => {
year-- year--
} }
} }
try { try {
const res = await getSavingsBudget(year, month, budget.type) const res = await getSavingsBudget(year, month, budget.type)
if (res.success && res.data) { if (res.success && res.data) {
// 找到并更新对应的 budget 对象 // 找到并更新对应的 budget 对象
const index = savingsBudgets.value.findIndex(b => b.id === budget.id) const index = savingsBudgets.value.findIndex((b) => b.id === budget.id)
if (index !== -1) { if (index !== -1) {
savingsBudgets.value[index] = res.data savingsBudgets.value[index] = res.data
} }
@@ -578,7 +718,9 @@ const handleSavingsNav = async (budget, offset) => {
} }
const disabledSavingsNextNav = (budget) => { const disabledSavingsNextNav = (budget) => {
if (!budget.periodStart) return true if (!budget.periodStart) {
return true
}
const date = new Date(budget.periodStart) const date = new Date(budget.periodStart)
const now = new Date() const now = new Date()
if (budget.type === BudgetPeriodType.Year) { if (budget.type === BudgetPeriodType.Year) {
@@ -693,7 +835,9 @@ const disabledSavingsNextNav = (budget) => {
.item-amount { .item-amount {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
font-family: DIN Alternate, system-ui; font-family:
DIN Alternate,
system-ui;
} }
/* 设置页面容器背景色 */ /* 设置页面容器背景色 */

View File

@@ -10,11 +10,11 @@
@month-show="onMonthShow" @month-show="onMonthShow"
@select="onDateSelect" @select="onDateSelect"
/> />
<ContributionHeatmap ref="heatmapRef" /> <ContributionHeatmap ref="heatmapRef" />
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(60px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" />
<!-- 日期交易列表弹出层 --> <!-- 日期交易列表弹出层 -->
<PopupContainer <PopupContainer
@@ -24,7 +24,7 @@
height="75%" height="75%"
> >
<template #header-actions> <template #header-actions>
<SmartClassifyButton <SmartClassifyButton
ref="smartClassifyButtonRef" ref="smartClassifyButtonRef"
:transactions="dateTransactions" :transactions="dateTransactions"
@save="onSmartClassifySave" @save="onSmartClassifySave"
@@ -50,221 +50,227 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick, onBeforeUnmount } from "vue"; import { ref, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { showToast } from "vant"; import { showToast } from 'vant'
import request from "@/api/request"; import request from '@/api/request'
import { getTransactionDetail, getTransactionsByDate } from "@/api/transactionRecord"; import { getTransactionDetail, getTransactionsByDate } from '@/api/transactionRecord'
import TransactionList from "@/components/TransactionList.vue"; import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from "@/components/TransactionDetail.vue"; import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from "@/components/SmartClassifyButton.vue"; import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from "@/components/PopupContainer.vue"; import PopupContainer from '@/components/PopupContainer.vue'
import ContributionHeatmap from "@/components/ContributionHeatmap.vue"; import ContributionHeatmap from '@/components/ContributionHeatmap.vue'
const dailyStatistics = ref({}); const dailyStatistics = ref({})
const listVisible = ref(false); const listVisible = ref(false)
const detailVisible = ref(false); const detailVisible = ref(false)
const dateTransactions = ref([]); const dateTransactions = ref([])
const currentTransaction = ref(null); const currentTransaction = ref(null)
const listLoading = ref(false); const listLoading = ref(false)
const selectedDate = ref(null); const selectedDate = ref(null)
const selectedDateText = ref(""); const selectedDateText = ref('')
const heatmapRef = ref(null); const heatmapRef = ref(null)
// 设置日历可选范围例如过去2年到未来1年 // 设置日历可选范围例如过去2年到未来1年
const minDate = new Date(new Date().getFullYear() - 2, 0, 1); // 2年前的1月1日 const minDate = new Date(new Date().getFullYear() - 2, 0, 1) // 2年前的1月1日
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31); // 明年12月31日 const maxDate = new Date(new Date().getFullYear() + 1, 11, 31) // 明年12月31日
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick()
setTimeout(() => { setTimeout(() => {
// 计算页面高度滚动3/4高度以显示更多日期 // 计算页面高度滚动3/4高度以显示更多日期
const height = document.querySelector(".calendar-container").clientHeight * 0.43; const height = document.querySelector('.calendar-container').clientHeight * 0.43
document.querySelector(".van-calendar__body").scrollBy({ document.querySelector('.van-calendar__body').scrollBy({
top: -height, top: -height,
behavior: "smooth", behavior: 'smooth'
}); })
}, 300); }, 300)
}); })
// 获取日历统计数据 // 获取日历统计数据
const fetchDailyStatistics = async (year, month) => { const fetchDailyStatistics = async (year, month) => {
try { try {
const response = await request.get("/TransactionRecord/GetDailyStatistics", { const response = await request.get('/TransactionRecord/GetDailyStatistics', {
params: { year, month }, params: { year, month }
}); })
if (response.success && response.data) { if (response.success && response.data) {
// 将数组转换为对象key为日期 // 将数组转换为对象key为日期
const statsMap = {}; const statsMap = {}
response.data.forEach((item) => { response.data.forEach((item) => {
statsMap[item.date] = { statsMap[item.date] = {
count: item.count, count: item.count,
amount: item.amount, amount: item.amount
}; }
}); })
dailyStatistics.value = { dailyStatistics.value = {
...dailyStatistics.value, ...dailyStatistics.value,
...statsMap, ...statsMap
}; }
} }
} catch (error) { } catch (error) {
console.error("获取日历统计数据失败:", error); console.error('获取日历统计数据失败:', error)
} }
}; }
const smartClassifyButtonRef = ref(null); const smartClassifyButtonRef = ref(null)
// 获取指定日期的交易列表 // 获取指定日期的交易列表
const fetchDateTransactions = async (date) => { const fetchDateTransactions = async (date) => {
try { try {
listLoading.value = true; listLoading.value = true
const dateStr = date const dateStr = date
.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }) .toLocaleString('zh-CN', {
.replace(/\//g, "-"); year: 'numeric',
month: '2-digit',
day: '2-digit'
})
.replace(/\//g, '-')
const response = await getTransactionsByDate(dateStr); const response = await getTransactionsByDate(dateStr)
if (response.success && response.data) { if (response.success && response.data) {
// 根据金额从大到小排序 // 根据金额从大到小排序
dateTransactions.value = response dateTransactions.value = response.data.sort((a, b) => b.amount - a.amount)
.data
.sort((a, b) => b.amount - a.amount);
// 重置智能分类按钮 // 重置智能分类按钮
smartClassifyButtonRef.value?.reset() smartClassifyButtonRef.value?.reset()
} else { } else {
dateTransactions.value = []; dateTransactions.value = []
showToast(response.message || "获取交易列表失败"); showToast(response.message || '获取交易列表失败')
} }
} catch (error) { } catch (error) {
console.error("获取日期交易列表失败:", error); console.error('获取日期交易列表失败:', error)
dateTransactions.value = []; dateTransactions.value = []
showToast("获取交易列表失败"); showToast('获取交易列表失败')
} finally { } finally {
listLoading.value = false; listLoading.value = false
} }
}; }
const getBalance = (transactions) => { const getBalance = (transactions) => {
let balance = 0; let balance = 0
transactions.forEach(tx => { transactions.forEach((tx) => {
if(tx.type === 1) { if (tx.type === 1) {
balance += tx.amount; balance += tx.amount
} else if(tx.type === 0) { } else if (tx.type === 0) {
balance -= tx.amount; balance -= tx.amount
} }
}); })
if(balance >= 0) { if (balance >= 0) {
return `结余收入 ${balance.toFixed(1)}`; return `结余收入 ${balance.toFixed(1)}`
} else { } else {
return `结余支出 ${(-balance).toFixed(1)}`; return `结余支出 ${(-balance).toFixed(1)}`
} }
}; }
// 当月份显示时触发 // 当月份显示时触发
const onMonthShow = ({ date }) => { const onMonthShow = ({ date }) => {
const year = date.getFullYear(); const year = date.getFullYear()
const month = date.getMonth() + 1; const month = date.getMonth() + 1
fetchDailyStatistics(year, month); fetchDailyStatistics(year, month)
}; }
// 日期选择事件 // 日期选择事件
const onDateSelect = (date) => { const onDateSelect = (date) => {
selectedDate.value = date; selectedDate.value = date
selectedDateText.value = formatSelectedDate(date); selectedDateText.value = formatSelectedDate(date)
fetchDateTransactions(date); fetchDateTransactions(date)
listVisible.value = true; listVisible.value = true
}; }
// 格式化选中的日期 // 格式化选中的日期
const formatSelectedDate = (date) => { const formatSelectedDate = (date) => {
return date.toLocaleDateString("zh-CN", { return date.toLocaleDateString('zh-CN', {
year: "numeric", year: 'numeric',
month: "long", month: 'long',
day: "numeric", day: 'numeric',
weekday: "long", weekday: 'long'
}); })
}; }
// 查看详情 // 查看详情
const viewDetail = async (transaction) => { const viewDetail = async (transaction) => {
try { try {
const response = await getTransactionDetail(transaction.id); const response = await getTransactionDetail(transaction.id)
if (response.success) { if (response.success) {
currentTransaction.value = response.data; currentTransaction.value = response.data
detailVisible.value = true; detailVisible.value = true
} else { } else {
showToast(response.message || "获取详情失败"); showToast(response.message || '获取详情失败')
} }
} catch (error) { } catch (error) {
console.error("获取详情出错:", error); console.error('获取详情出错:', error)
showToast("获取详情失败"); showToast('获取详情失败')
} }
}; }
// 详情保存后的回调 // 详情保存后的回调
const onDetailSave = async (saveData) => { const onDetailSave = async (saveData) => {
var item = dateTransactions.value.find(tx => tx.id === saveData.id); const item = dateTransactions.value.find((tx) => tx.id === saveData.id)
if(!item) return if (!item) {
return
}
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖 // 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
if(item.classify !== saveData.classify) { if (item.classify !== saveData.classify) {
// 通知智能分类按钮组件移除指定项 // 通知智能分类按钮组件移除指定项
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id) smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
item.upsetedClassify = '' item.upsetedClassify = ''
} }
// 更新当前日期交易列表中的数据 // 更新当前日期交易列表中的数据
Object.assign(item, saveData); Object.assign(item, saveData)
// 重新加载当前月份的统计数据 // 重新加载当前月份的统计数据
const now = selectedDate.value || new Date(); const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1); fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}; }
// 处理删除事件:从当前日期交易列表中移除,并刷新当日和当月统计 // 处理删除事件:从当前日期交易列表中移除,并刷新当日和当月统计
const handleDateTransactionDelete = async (transactionId) => { const handleDateTransactionDelete = async (transactionId) => {
dateTransactions.value = dateTransactions.value.filter(t => t.id !== transactionId) dateTransactions.value = dateTransactions.value.filter((t) => t.id !== transactionId)
// 刷新当前日期以及当月的统计数据 // 刷新当前日期以及当月的统计数据
const now = selectedDate.value || new Date(); const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1); fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}; }
// 智能分类保存回调 // 智能分类保存回调
const onSmartClassifySave = async () => { const onSmartClassifySave = async () => {
// 保存完成后重新加载数据 // 保存完成后重新加载数据
if (selectedDate.value) { if (selectedDate.value) {
await fetchDateTransactions(selectedDate.value); await fetchDateTransactions(selectedDate.value)
} }
// 重新加载统计数据 // 重新加载统计数据
const now = selectedDate.value || new Date(); const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1); fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}; }
const formatterCalendar = (day) => { const formatterCalendar = (day) => {
const dayCopy = { ...day }; const dayCopy = { ...day }
if (dayCopy.date.toDateString() === new Date().toDateString()) { if (dayCopy.date.toDateString() === new Date().toDateString()) {
dayCopy.text = "今天"; dayCopy.text = '今天'
} }
// 格式化日期为 yyyy-MM-dd // 格式化日期为 yyyy-MM-dd
const dateKey = dayCopy.date const dateKey = dayCopy.date
.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }) .toLocaleString('zh-CN', {
.replace(/\//g, "-"); year: 'numeric',
const stats = dailyStatistics.value[dateKey]; month: '2-digit',
day: '2-digit'
})
.replace(/\//g, '-')
const stats = dailyStatistics.value[dateKey]
if (stats) { if (stats) {
dayCopy.topInfo = `${stats.count}`; // 展示消费笔数 dayCopy.topInfo = `${stats.count}` // 展示消费笔数
dayCopy.bottomInfo = `${stats.amount.toFixed(1)}`; // 展示消费金额 dayCopy.bottomInfo = `${(stats.amount || 0).toFixed(1)}` // 展示消费金额
} }
return dayCopy; return dayCopy
}; }
// 初始加载当前月份数据 // 初始加载当前月份数据
const now = new Date(); const now = new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1); fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
// 全局删除事件监听,确保日历页面数据一致 // 全局删除事件监听,确保日历页面数据一致
const onGlobalTransactionDeleted = () => { const onGlobalTransactionDeleted = () => {
@@ -276,10 +282,12 @@ const onGlobalTransactionDeleted = () => {
heatmapRef.value?.refresh() heatmapRef.value?.refresh()
} }
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted) window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted) window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
}) })
// 当有交易被新增/修改/批量更新时刷新 // 当有交易被新增/修改/批量更新时刷新
@@ -292,15 +300,17 @@ const onGlobalTransactionsChanged = () => {
heatmapRef.value?.refresh() heatmapRef.value?.refresh()
} }
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged) window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged) window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
}) })
</script> </script>
<style scoped> <style scoped>
.van-calendar{ .van-calendar {
background: transparent !important; background: transparent !important;
} }
@@ -340,5 +350,4 @@ onBeforeUnmount(() => {
:deep(.heatmap-card) { :deep(.heatmap-card) {
flex-shrink: 0; /* Prevent heatmap from shrinking */ flex-shrink: 0; /* Prevent heatmap from shrinking */
} }
</style> </style>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-nav-bar <van-nav-bar
title="批量分类" title="批量分类"
left-text="返回" left-text="返回"
left-arrow left-arrow
placeholder placeholder
@click-left="handleBack" @click-left="handleBack"
/> />
@@ -16,7 +16,7 @@
<!-- 分组列表 --> <!-- 分组列表 -->
<van-empty v-if="!hasData && finished" description="暂无数据" /> <van-empty v-if="!hasData && finished" description="暂无数据" />
<van-list <van-list
v-model:loading="listLoading" v-model:loading="listLoading"
v-model:error="error" v-model:error="error"
@@ -85,7 +85,7 @@ const onLoad = async () => {
listLoading.value = false listLoading.value = false
return return
} }
try { try {
await groupListRef.value.loadData() await groupListRef.value.loadData()
@@ -94,7 +94,7 @@ const onLoad = async () => {
await loadUnclassifiedCount() await loadUnclassifiedCount()
_loadedUnclassifiedInitially.value = true _loadedUnclassifiedInitially.value = true
} }
error.value = false error.value = false
} catch (err) { } catch (err) {
console.error('加载分组数据失败:', err) console.error('加载分组数据失败:', err)

View File

@@ -1,121 +1,102 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-nav-bar <van-nav-bar
:title="navTitle" :title="navTitle"
left-text="返回" left-text="返回"
left-arrow left-arrow
placeholder placeholder
@click-left="handleBack" @click-left="handleBack"
/> />
<div class="scroll-content"> <div class="scroll-content">
<!-- 第一层选择交易类型 --> <!-- 第一层选择交易类型 -->
<div v-if="currentLevel === 0" class="level-container"> <div v-if="currentLevel === 0" class="level-container">
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="type in typeOptions" v-for="type in typeOptions"
:key="type.value" :key="type.value"
:title="type.label" :title="type.label"
is-link is-link
@click="handleSelectType(type.value)" @click="handleSelectType(type.value)"
/> />
</van-cell-group> </van-cell-group>
</div>
<!-- 第二层分类列表 -->
<div v-else class="level-container">
<!-- 面包屑导航 -->
<div class="breadcrumb">
<van-tag
type="primary"
closeable
style="margin-left: 16px;"
@close="handleBackToRoot"
>
{{ currentTypeName }}
</van-tag>
</div> </div>
<!-- 分类列表 --> <!-- 第二层分类列表 -->
<van-empty v-if="categories.length === 0" description="暂无分类" /> <div v-else class="level-container">
<!-- 面包屑导航 -->
<van-cell-group v-else inset> <div class="breadcrumb">
<van-swipe-cell v-for="category in categories" :key="category.id"> <van-tag type="primary" closeable style="margin-left: 16px" @close="handleBackToRoot">
<van-cell {{ currentTypeName }}
:title="category.name" </van-tag>
is-link </div>
@click="handleEdit(category)"
/>
<template #right>
<van-button
square
type="danger"
text="删除"
@click="handleDelete(category)"
/>
</template>
</van-swipe-cell>
</van-cell-group>
</div>
<!-- 底部安全距离 --> <!-- 分类列表 -->
<div style="height: calc(55px + env(safe-area-inset-bottom, 0px))"></div> <van-empty v-if="categories.length === 0" description="暂无分类" />
<div class="bottom-button"> <van-cell-group v-else inset>
<!-- 新增分类按钮 --> <van-swipe-cell v-for="category in categories" :key="category.id">
<van-button <van-cell :title="category.name" is-link @click="handleEdit(category)" />
type="primary" <template #right>
size="large" <van-button square type="danger" text="删除" @click="handleDelete(category)" />
icon="plus" </template>
@click="handleAddCategory" </van-swipe-cell>
</van-cell-group>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(55px + env(safe-area-inset-bottom, 0px))" />
<div class="bottom-button">
<!-- 新增分类按钮 -->
<van-button type="primary" size="large" icon="plus" @click="handleAddCategory">
新增分类
</van-button>
</div>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddDialog"
title="新增分类"
@confirm="handleConfirmAdd"
@cancel="resetAddForm"
> >
新增分类 <van-form ref="addFormRef">
</van-button> <van-field
</div> v-model="addForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</van-dialog>
<!-- 新增分类对话框 --> <!-- 编辑分类对话框 -->
<van-dialog <van-dialog
v-model:show="showAddDialog" v-model:show="showEditDialog"
title="新增分类" title="编辑分类"
@confirm="handleConfirmAdd" show-cancel-button
@cancel="resetAddForm" @confirm="handleConfirmEdit"
> >
<van-form ref="addFormRef"> <van-form ref="editFormRef">
<van-field <van-field
v-model="addForm.name" v-model="editForm.name"
name="name" name="name"
label="分类名称" label="分类名称"
placeholder="请输入分类名称" placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]" :rules="[{ required: true, message: '请输入分类名称' }]"
/> />
</van-form> </van-form>
</van-dialog> </van-dialog>
<!-- 编辑分类对话框 --> <!-- 删除确认对话框 -->
<van-dialog <van-dialog
v-model:show="showEditDialog" v-model:show="showDeleteConfirm"
title="编辑分类" title="删除分类"
show-cancel-button message="删除后无法恢复,确定要删除吗?"
@confirm="handleConfirmEdit" @confirm="handleConfirmDelete"
> />
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</van-dialog>
<!-- 删除确认对话框 -->
<van-dialog
v-model:show="showDeleteConfirm"
title="删除分类"
message="删除后无法恢复,确定要删除吗?"
@confirm="handleConfirmDelete"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -123,12 +104,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
showSuccessToast,
showToast,
showLoadingToast,
closeToast
} from 'vant'
import { import {
getCategoryList, getCategoryList,
createCategory, createCategory,
@@ -149,7 +125,7 @@ const typeOptions = [
const currentLevel = ref(0) // 0=类型选择, 1=分类管理 const currentLevel = ref(0) // 0=类型选择, 1=分类管理
const currentType = ref(null) // 当前选中的交易类型 const currentType = ref(null) // 当前选中的交易类型
const currentTypeName = computed(() => { const currentTypeName = computed(() => {
const type = typeOptions.find(t => t.value === currentType.value) const type = typeOptions.find((t) => t.value === currentType.value)
return type ? type.label : '' return type ? type.label : ''
}) })
@@ -301,7 +277,7 @@ const handleEdit = (category) => {
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
try { try {
await editFormRef.value?.validate() await editFormRef.value?.validate()
showLoadingToast({ showLoadingToast({
message: '保存中...', message: '保存中...',
forbidClick: true, forbidClick: true,
@@ -340,7 +316,9 @@ const handleDelete = async (category) => {
* 确认删除 * 确认删除
*/ */
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (!deleteTarget.value) return if (!deleteTarget.value) {
return
}
try { try {
showLoadingToast({ showLoadingToast({
@@ -382,7 +360,6 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped>
.level-container { .level-container {
min-height: calc(100vh - 50px); min-height: calc(100vh - 50px);

View File

@@ -1,11 +1,6 @@
<template> <template>
<div class="page-container-flex classification-nlp"> <div class="page-container-flex classification-nlp">
<van-nav-bar <van-nav-bar title="自然语言分类" left-text="返回" left-arrow @click-left="onClickLeft" />
title="自然语言分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content"> <div class="scroll-content">
<!-- 输入区域 --> <!-- 输入区域 -->
@@ -21,15 +16,9 @@
show-word-limit show-word-limit
/> />
</van-cell-group> </van-cell-group>
<div class="action-buttons"> <div class="action-buttons">
<van-button <van-button type="primary" block round :loading="analyzing" @click="handleAnalyze">
type="primary"
block
round
:loading="analyzing"
@click="handleAnalyze"
>
分析查询 分析查询
</van-button> </van-button>
</div> </div>
@@ -41,9 +30,9 @@
<van-cell title="查询关键词" :value="analysisResult.searchKeyword" /> <van-cell title="查询关键词" :value="analysisResult.searchKeyword" />
<van-cell title="AI建议类型" :value="getTypeName(analysisResult.targetType)" /> <van-cell title="AI建议类型" :value="getTypeName(analysisResult.targetType)" />
<van-cell title="AI建议分类" :value="analysisResult.targetClassify" /> <van-cell title="AI建议分类" :value="analysisResult.targetClassify" />
<van-cell <van-cell
title="找到记录" title="找到记录"
:value="`${analysisResult.records.length} 条`" :value="`${analysisResult.records.length} 条`"
is-link is-link
@click="showRecordsList = true" @click="showRecordsList = true"
/> />
@@ -59,32 +48,14 @@
/> />
<!-- 记录列表弹窗 --> <!-- 记录列表弹窗 -->
<PopupContainer <PopupContainer v-model="showRecordsList" title="交易记录列表" height="75%">
v-model="showRecordsList" <div style="background: var(--van-background)">
title="交易记录列表"
height="75%"
>
<div style="background: var(--van-background);">
<!-- 批量操作按钮 --> <!-- 批量操作按钮 -->
<div class="batch-actions"> <div class="batch-actions">
<van-button <van-button plain type="primary" size="small" @click="selectAll"> 全选 </van-button>
plain <van-button plain type="default" size="small" @click="selectNone"> 全不选 </van-button>
type="primary" <van-button
size="small" type="success"
@click="selectAll"
>
全选
</van-button>
<van-button
plain
type="default"
size="small"
@click="selectNone"
>
全不选
</van-button>
<van-button
type="success"
size="small" size="small"
:loading="submitting" :loading="submitting"
:disabled="selectedIds.size === 0" :disabled="selectedIds.size === 0"
@@ -138,9 +109,11 @@ const onClickLeft = () => {
// 将带目标分类的记录转换为普通交易记录格式供列表显示 // 将带目标分类的记录转换为普通交易记录格式供列表显示
const displayRecords = computed(() => { const displayRecords = computed(() => {
if (!analysisResult.value) return [] if (!analysisResult.value) {
return []
return analysisResult.value.records.map(r => ({ }
return analysisResult.value.records.map((r) => ({
id: r.id, id: r.id,
reason: r.reason, reason: r.reason,
amount: r.amount, amount: r.amount,
@@ -178,14 +151,14 @@ const handleAnalyze = async () => {
try { try {
analyzing.value = true analyzing.value = true
const response = await nlpAnalysis(userInput.value) const response = await nlpAnalysis(userInput.value)
if (response.success) { if (response.success) {
analysisResult.value = response.data analysisResult.value = response.data
// 默认全选 // 默认全选
const allIds = new Set(response.data.records.map(r => r.id)) const allIds = new Set(response.data.records.map((r) => r.id))
selectedIds.value = allIds selectedIds.value = allIds
showToast(`找到 ${response.data.records.length} 条记录`) showToast(`找到 ${response.data.records.length} 条记录`)
} else { } else {
showToast(response.message || '分析失败') showToast(response.message || '分析失败')
@@ -200,8 +173,10 @@ const handleAnalyze = async () => {
// 全选 // 全选
const selectAll = () => { const selectAll = () => {
if (!analysisResult.value) return if (!analysisResult.value) {
const allIds = new Set(analysisResult.value.records.map(r => r.id)) return
}
const allIds = new Set(analysisResult.value.records.map((r) => r.id))
selectedIds.value = allIds selectedIds.value = allIds
} }
@@ -218,7 +193,7 @@ const updateSelectedIds = (newSelectedIds) => {
// 点击记录查看详情 // 点击记录查看详情
const handleRecordClick = (transaction) => { const handleRecordClick = (transaction) => {
// 从原始记录中获取完整信息 // 从原始记录中获取完整信息
const record = analysisResult.value?.records.find(r => r.id === transaction.id) const record = analysisResult.value?.records.find((r) => r.id === transaction.id)
if (record) { if (record) {
currentTransaction.value = { currentTransaction.value = {
id: record.id, id: record.id,
@@ -263,18 +238,18 @@ const handleSubmit = async () => {
try { try {
submitting.value = true submitting.value = true
// 构建批量更新数据使用AI修改后的结果 // 构建批量更新数据使用AI修改后的结果
const items = analysisResult.value.records const items = analysisResult.value.records
.filter(r => selectedIds.value.has(r.id)) .filter((r) => selectedIds.value.has(r.id))
.map(r => ({ .map((r) => ({
id: r.id, id: r.id,
classify: r.upsetedClassify, classify: r.upsetedClassify,
type: r.upsetedType type: r.upsetedType
})) }))
const response = await batchUpdateClassify(items) const response = await batchUpdateClassify(items)
if (response.success) { if (response.success) {
showToast('分类设置成功') showToast('分类设置成功')
// 清空结果,让用户进行新的查询 // 清空结果,让用户进行新的查询

View File

@@ -1,13 +1,8 @@
<template> <template>
<div class="page-container-flex smart-classification"> <div class="page-container-flex smart-classification">
<van-nav-bar <van-nav-bar title="智能分类" left-text="返回" left-arrow @click-left="onClickLeft" />
title="智能分类"
left-text="返回" <div class="scroll-content" style="padding-top: 5px">
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content" style="padding-top: 5px;">
<!-- 统计信息 --> <!-- 统计信息 -->
<div class="stats-info"> <div class="stats-info">
<span class="stats-label">未分类账单 </span> <span class="stats-label">未分类账单 </span>
@@ -23,13 +18,13 @@
/> />
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</div> </div>
<!-- 底部操作按钮 --> <!-- 底部操作按钮 -->
<div class="bottom-button"> <div class="bottom-button">
<van-button <van-button
type="primary" type="primary"
:loading="classifying" :loading="classifying"
:disabled="selectedCount === 0" :disabled="selectedCount === 0"
round round
@@ -38,9 +33,9 @@
> >
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }} {{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
</van-button> </van-button>
<van-button <van-button
type="success" type="success"
:disabled="!hasChanges || classifying" :disabled="!hasChanges || classifying"
round round
class="action-btn" class="action-btn"
@@ -56,11 +51,7 @@
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant' import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import { import { getUnclassifiedCount, smartClassify, batchUpdateClassify } from '@/api/transactionRecord'
getUnclassifiedCount,
smartClassify,
batchUpdateClassify
} from '@/api/transactionRecord'
import ReasonGroupList from '@/components/ReasonGroupList.vue' import ReasonGroupList from '@/components/ReasonGroupList.vue'
const router = useRouter() const router = useRouter()
@@ -74,7 +65,9 @@ const suppressDataChanged = ref(false)
// 计算已选中的数量 // 计算已选中的数量
const selectedCount = computed(() => { const selectedCount = computed(() => {
if (!groupListRef.value) return 0 if (!groupListRef.value) {
return 0
}
return groupListRef.value.getSelectedReasons().size return groupListRef.value.getSelectedReasons().size
}) })
@@ -114,10 +107,12 @@ const onClickLeft = () => {
if (hasChanges.value) { if (hasChanges.value) {
showConfirmDialog({ showConfirmDialog({
title: '提示', title: '提示',
message: '有未保存的分类结果,确定要离开吗?', message: '有未保存的分类结果,确定要离开吗?'
}).then(() => { })
router.back() .then(() => {
}).catch(() => {}) router.back()
})
.catch(() => {})
} else { } else {
router.back() router.back()
} }
@@ -125,17 +120,19 @@ const onClickLeft = () => {
// 开始智能分类 // 开始智能分类
const startClassify = async () => { const startClassify = async () => {
if (!groupListRef.value) return if (!groupListRef.value) {
return
}
// 获取所有选中分组 // 获取所有选中分组
const selectedGroups = groupListRef.value.getList(true) const selectedGroups = groupListRef.value.getList(true)
// 获取所有选中分组的账单ID // 获取所有选中分组的账单ID
const idsToClassify = [] const idsToClassify = []
for (const group of selectedGroups) { for (const group of selectedGroups) {
idsToClassify.push(...group.transactionIds) idsToClassify.push(...group.transactionIds)
} }
if (idsToClassify.length === 0) { if (idsToClassify.length === 0) {
showToast('请先选择要分类的账单组') showToast('请先选择要分类的账单组')
return return
@@ -149,13 +146,13 @@ const startClassify = async () => {
classifying.value = true classifying.value = true
classifyBuffer.value = '' classifyBuffer.value = ''
// 用于存储分类结果的临时对象 // 用于存储分类结果的临时对象
const classifyResults = new Map() const classifyResults = new Map()
try { try {
const response = await smartClassify(idsToClassify) const response = await smartClassify(idsToClassify)
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }
@@ -166,23 +163,27 @@ const startClassify = async () => {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) {
break
}
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n') const lines = buffer.split('\n\n')
buffer = lines.pop() || '' buffer = lines.pop() || ''
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue if (!line.trim()) {
continue
}
const eventMatch = line.match(/^event: (.+)$/m) const eventMatch = line.match(/^event: (.+)$/m)
const dataMatch = line.match(/^data: (.+)$/m) const dataMatch = line.match(/^data: (.+)$/m)
if (eventMatch && dataMatch) { if (eventMatch && dataMatch) {
const eventType = eventMatch[1] const eventType = eventMatch[1]
const data = dataMatch[1] const data = dataMatch[1]
handleSSEEvent(eventType, data, classifyResults) handleSSEEvent(eventType, data, classifyResults)
} }
} }
@@ -215,8 +216,9 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
let braceCount = 0 let braceCount = 0
let closeBrace = -1 let closeBrace = -1
for (let i = openBrace; i < classifyBuffer.value.length; i++) { for (let i = openBrace; i < classifyBuffer.value.length; i++) {
if (classifyBuffer.value[i] === '{') braceCount++ if (classifyBuffer.value[i] === '{') {
else if (classifyBuffer.value[i] === '}') { braceCount++
} else if (classifyBuffer.value[i] === '}') {
braceCount-- braceCount--
if (braceCount === 0) { if (braceCount === 0) {
closeBrace = i closeBrace = i
@@ -227,7 +229,7 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
if (closeBrace !== -1) { if (closeBrace !== -1) {
const jsonStr = classifyBuffer.value.substring(openBrace, closeBrace + 1) const jsonStr = classifyBuffer.value.substring(openBrace, closeBrace + 1)
try { try {
const result = JSON.parse(jsonStr) const result = JSON.parse(jsonStr)
if (result.id && groupListRef.value) { if (result.id && groupListRef.value) {
@@ -235,7 +237,7 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
classify: result.Classify || '', classify: result.Classify || '',
type: result.Type !== undefined ? result.Type : null type: result.Type !== undefined ? result.Type : null
}) })
// 更新组件内的分组显示状态 // 更新组件内的分组显示状态
const groups = groupListRef.value.getList() const groups = groupListRef.value.getList()
for (const group of groups) { for (const group of groups) {
@@ -260,7 +262,7 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
} catch (e) { } catch (e) {
console.error('JSON解析失败:', e) console.error('JSON解析失败:', e)
} }
classifyBuffer.value = classifyBuffer.value.substring(closeBrace + 1) classifyBuffer.value = classifyBuffer.value.substring(closeBrace + 1)
startIndex = 0 startIndex = 0
} else { } else {
@@ -283,12 +285,14 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
// 保存分类 // 保存分类
const saveClassifications = async () => { const saveClassifications = async () => {
if (!groupListRef.value) return if (!groupListRef.value) {
return
}
// 收集所有已分类的账单 // 收集所有已分类的账单
const groups = groupListRef.value.getList() const groups = groupListRef.value.getList()
const itemsToUpdate = [] const itemsToUpdate = []
for (const group of groups) { for (const group of groups) {
if (group.sampleClassify) { if (group.sampleClassify) {
// 为该分组的所有账单添加分类 // 为该分组的所有账单添加分类
@@ -362,4 +366,4 @@ onMounted(async () => {
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -2,9 +2,16 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading v-if="loading && !(emailList && emailList.length)" vertical style="padding: 50px 0"> <van-loading
v-if="loading && !(emailList && emailList.length)"
vertical
style="padding: 50px 0"
>
加载中... 加载中...
</van-loading> </van-loading>
<!-- 邮件列表 --> <!-- 邮件列表 -->
@@ -14,7 +21,11 @@
finished-text="没有更多了" finished-text="没有更多了"
@load="onLoad" @load="onLoad"
> >
<van-cell-group v-if="emailList && emailList.length" inset style="margin-top: 10px"> <van-cell-group
v-if="emailList && emailList.length"
inset
style="margin-top: 10px"
>
<van-swipe-cell <van-swipe-cell
v-for="email in emailList" v-for="email in emailList"
:key="email.id" :key="email.id"
@@ -27,18 +38,22 @@
> >
<template #value> <template #value>
<div class="email-info"> <div class="email-info">
<div class="email-date">{{ formatDate(email.receivedDate) }}</div> <div class="email-date">
<div v-if="email.transactionCount > 0" class="bill-count"> {{ formatDate(email.receivedDate) }}
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span> </div>
<div
v-if="email.transactionCount > 0"
class="bill-count"
>
<span style="font-size: 12px">已解析{{ email.transactionCount }}条账单</span>
</div> </div>
</div> </div>
</template> </template>
</van-cell> </van-cell>
<template #right> <template #right>
<van-button <van-button
square square
type="danger" type="danger"
text="删除" text="删除"
class="delete-button" class="delete-button"
@click="handleDelete(email)" @click="handleDelete(email)"
@@ -47,26 +62,26 @@
</van-swipe-cell> </van-swipe-cell>
</van-cell-group> </van-cell-group>
<van-empty <van-empty
v-if="!loading && !(emailList && emailList.length)" v-if="!loading && !(emailList && emailList.length)"
description="暂无邮件记录" description="暂无邮件记录"
/> />
</van-list> </van-list>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
<!-- 详情弹出层 --> <!-- 详情弹出层 -->
<PopupContainer <PopupContainer
v-model="detailVisible" v-model="detailVisible"
:title="currentEmail ? (currentEmail.Subject || currentEmail.subject || '(无主题)') : ''" :title="currentEmail ? currentEmail.Subject || currentEmail.subject || '(无主题)' : ''"
height="75%" height="75%"
> >
<template #header-actions> <template #header-actions>
<van-button <van-button
size="small" size="small"
type="primary" type="primary"
:loading="refreshingAnalysis" :loading="refreshingAnalysis"
@click="handleRefreshAnalysis" @click="handleRefreshAnalysis"
> >
@@ -75,34 +90,51 @@
</template> </template>
<div v-if="currentEmail"> <div v-if="currentEmail">
<van-cell-group inset style="margin-top: 12px;"> <van-cell-group
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" /> inset
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" /> style="margin-top: 12px"
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" /> >
<van-cell <van-cell
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0" title="发件人"
title="已解析账单数" :value="currentEmail.From || currentEmail.from || '未知'"
/>
<van-cell
title="接收时间"
:value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)"
/>
<van-cell
title="记录时间"
:value="formatDate(currentEmail.CreateTime || currentEmail.createTime)"
/>
<van-cell
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
title="已解析账单数"
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`" :value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
is-link is-link
@click="viewTransactions" @click="viewTransactions"
/> />
</van-cell-group> </van-cell-group>
<div class="email-content"> <div class="email-content">
<h4 style="margin-left: 10px;">邮件内容</h4> <h4 style="margin-left: 10px">
<div 邮件内容
v-if="currentEmail.htmlBody" </h4>
class="content-body html-content" <div
v-if="currentEmail.htmlBody"
class="content-body html-content"
v-html="currentEmail.htmlBody" v-html="currentEmail.htmlBody"
></div> />
<div <div
v-else-if="currentEmail.body" v-else-if="currentEmail.body"
class="content-body" class="content-body"
> >
{{ currentEmail.body }} {{ currentEmail.body }}
</div> </div>
<div v-else class="content-body empty-content"> <div
v-else
class="content-body empty-content"
>
暂无邮件内容 暂无邮件内容
<div style="font-size: 12px; margin-top: 8px; color: var(--van-gray-6);"> <div style="font-size: 12px; margin-top: 8px; color: var(--van-gray-6)">
Debug: {{ Object.keys(currentEmail).join(', ') }} Debug: {{ Object.keys(currentEmail).join(', ') }}
</div> </div>
</div> </div>
@@ -139,7 +171,14 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { showToast, showConfirmDialog } from 'vant' import { showToast, showConfirmDialog } from 'vant'
import { getEmailList, getEmailDetail, deleteEmail, refreshTransactionRecords, syncEmails, getEmailTransactions } from '@/api/emailRecord' import {
getEmailList,
getEmailDetail,
deleteEmail,
refreshTransactionRecords,
syncEmails,
getEmailTransactions
} from '@/api/emailRecord'
import { getTransactionDetail } from '@/api/transactionRecord' import { getTransactionDetail } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue' import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue' import TransactionDetail from '@/components/TransactionDetail.vue'
@@ -163,8 +202,10 @@ const currentTransaction = ref(null)
// 加载数据 // 加载数据
const loadData = async (isRefresh = false) => { const loadData = async (isRefresh = false) => {
if (loading.value) return // 防止重复加载 if (loading.value) {
return
} // 防止重复加载
if (isRefresh) { if (isRefresh) {
pageIndex.value = 1 pageIndex.value = 1
emailList.value = [] emailList.value = []
@@ -177,19 +218,19 @@ const loadData = async (isRefresh = false) => {
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize pageSize: pageSize
} }
const response = await getEmailList(params) const response = await getEmailList(params)
if (response.success) { if (response.success) {
const newList = response.data || [] const newList = response.data || []
total.value = response.total || 0 total.value = response.total || 0
if (isRefresh) { if (isRefresh) {
emailList.value = newList emailList.value = newList
} else { } else {
emailList.value = [...(emailList.value || []), ...newList] emailList.value = [...(emailList.value || []), ...newList]
} }
// 判断是否还有更多数据返回数据少于pageSize条或为空说明没有更多了 // 判断是否还有更多数据返回数据少于pageSize条或为空说明没有更多了
if (newList.length === 0 || newList.length < pageSize) { if (newList.length === 0 || newList.length < pageSize) {
finished.value = true finished.value = true
@@ -246,7 +287,7 @@ const handleDelete = async (email) => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: '确定要删除这封邮件吗?', message: '确定要删除这封邮件吗?'
}) })
const response = await deleteEmail(email.id) const response = await deleteEmail(email.id)
@@ -266,17 +307,19 @@ const handleDelete = async (email) => {
// 重新分析 // 重新分析
const handleRefreshAnalysis = async () => { const handleRefreshAnalysis = async () => {
if (!currentEmail.value) return if (!currentEmail.value) {
return
}
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: '确定要重新分析该邮件并刷新交易记录吗?', message: '确定要重新分析该邮件并刷新交易记录吗?'
}) })
refreshingAnalysis.value = true refreshingAnalysis.value = true
const response = await refreshTransactionRecords(currentEmail.value.id) const response = await refreshTransactionRecords(currentEmail.value.id)
if (response.success) { if (response.success) {
showToast('重新分析成功') showToast('重新分析成功')
detailVisible.value = false detailVisible.value = false
@@ -298,7 +341,7 @@ const handleSync = async () => {
try { try {
syncing.value = true syncing.value = true
const response = await syncEmails() const response = await syncEmails()
if (response.success) { if (response.success) {
showToast(response.message || '同步成功') showToast(response.message || '同步成功')
// 同步成功后刷新列表 // 同步成功后刷新列表
@@ -316,12 +359,14 @@ const handleSync = async () => {
// 查看关联的账单列表 // 查看关联的账单列表
const viewTransactions = async () => { const viewTransactions = async () => {
if (!currentEmail.value) return if (!currentEmail.value) {
return
}
try { try {
const emailId = currentEmail.value.id const emailId = currentEmail.value.id
const response = await getEmailTransactions(emailId) const response = await getEmailTransactions(emailId)
if (response.success) { if (response.success) {
transactionList.value = response.data || [] transactionList.value = response.data || []
transactionListVisible.value = true transactionListVisible.value = true
@@ -340,18 +385,22 @@ const onGlobalTransactionDeleted = (e) => {
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表 // 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
if (transactionListVisible.value && currentEmail.value) { if (transactionListVisible.value && currentEmail.value) {
const emailId = currentEmail.value.id || currentEmail.value.Id const emailId = currentEmail.value.id || currentEmail.value.Id
getEmailTransactions(emailId).then(response => { getEmailTransactions(emailId)
if (response.success) { .then((response) => {
transactionList.value = response.data || [] if (response.success) {
} transactionList.value = response.data || []
}).catch(() => {}) }
})
.catch(() => {})
} }
} }
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted) window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted) window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
}) })
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表 // 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
@@ -359,21 +408,25 @@ const onGlobalTransactionsChanged = (e) => {
console.log('收到全局交易变更事件:', e) console.log('收到全局交易变更事件:', e)
if (transactionListVisible.value && currentEmail.value) { if (transactionListVisible.value && currentEmail.value) {
const emailId = currentEmail.value.id || currentEmail.value.Id const emailId = currentEmail.value.id || currentEmail.value.Id
getEmailTransactions(emailId).then(response => { getEmailTransactions(emailId)
if (response.success) { .then((response) => {
transactionList.value = response.data || [] if (response.success) {
} transactionList.value = response.data || []
}).catch(() => {}) }
})
.catch(() => {})
} else { } else {
// 也刷新邮件列表以保持统计一致 // 也刷新邮件列表以保持统计一致
loadData(true) loadData(true)
} }
} }
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged) window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged) window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
}) })
// 处理点击账单 // 处理点击账单
@@ -394,7 +447,7 @@ const handleTransactionClick = async (transaction) => {
const handleTransactionDelete = (transactionId) => { const handleTransactionDelete = (transactionId) => {
// 从当前的交易列表中移除该交易 // 从当前的交易列表中移除该交易
transactionList.value = transactionList.value.filter(t => t.id !== transactionId) transactionList.value = transactionList.value.filter((t) => t.id !== transactionId)
// 刷新邮件列表 // 刷新邮件列表
loadData(true) loadData(true)
@@ -402,16 +455,15 @@ const handleTransactionDelete = (transactionId) => {
// 刷新当前邮件详情 // 刷新当前邮件详情
if (currentEmail.value) { if (currentEmail.value) {
const emailId = currentEmail.value.id const emailId = currentEmail.value.id
getEmailDetail(emailId).then(response => { getEmailDetail(emailId).then((response) => {
if (response.success) { if (response.success) {
currentEmail.value = response.data currentEmail.value = response.data
} }
}) })
} }
try { try {
window.dispatchEvent( window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transactionId }))
new CustomEvent('transaction-deleted', { detail: transactionId })) } catch (e) {
} catch(e) {
console.error(e) console.error(e)
} }
} }
@@ -426,27 +478,28 @@ const handleTransactionSave = async () => {
transactionList.value = response.data || [] transactionList.value = response.data || []
} }
} }
try { try {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent( new CustomEvent('transactions-changed', {
'transactions-changed', detail: {
{ emailId: currentEmail.value?.id
detail: { }
emailId: currentEmail.value?.id })
} )
})) } catch (e) {
} catch(e) {
console.error(e) console.error(e)
} }
} }
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return '' if (!dateString) {
return ''
}
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@@ -454,8 +507,6 @@ const formatDate = (dateString) => {
}) })
} }
onMounted(() => { onMounted(() => {
loadData(true) loadData(true)
}) })
@@ -467,7 +518,6 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
:deep(.van-pull-refresh) { :deep(.van-pull-refresh) {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -528,4 +578,4 @@ defineExpose({
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="page-container-flex log-view"> <div class="page-container-flex log-view">
<van-nav-bar <van-nav-bar
title="查看日志" title="查看日志"
left-text="返回" left-text="返回"
left-arrow left-arrow
placeholder placeholder
@click-left="handleBack" @click-left="handleBack"
/> />
@@ -17,19 +17,34 @@
@search="handleSearch" @search="handleSearch"
@clear="handleClear" @clear="handleClear"
/> />
<div class="filter-row"> <div class="filter-row">
<van-dropdown-menu> <van-dropdown-menu>
<van-dropdown-item v-model="selectedLevel" :options="levelOptions" @change="handleSearch" /> <van-dropdown-item
<van-dropdown-item v-model="selectedDate" :options="dateOptions" @change="handleSearch" /> v-model="selectedLevel"
:options="levelOptions"
@change="handleSearch"
/>
<van-dropdown-item
v-model="selectedDate"
:options="dateOptions"
@change="handleSearch"
/>
</van-dropdown-menu> </van-dropdown-menu>
</div> </div>
</div> </div>
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading v-if="loading && !logList.length" vertical style="padding: 50px 0"> <van-loading
v-if="loading && !logList.length"
vertical
style="padding: 50px 0"
>
加载中... 加载中...
</van-loading> </van-loading>
@@ -41,8 +56,8 @@
class="log-list" class="log-list"
@load="onLoad" @load="onLoad"
> >
<div <div
v-for="(log, index) in logList" v-for="(log, index) in logList"
:key="index" :key="index"
class="log-item" class="log-item"
:class="getLevelClass(log.level)" :class="getLevelClass(log.level)"
@@ -51,19 +66,21 @@
<span class="log-level">{{ log.level }}</span> <span class="log-level">{{ log.level }}</span>
<span class="log-time">{{ formatTime(log.timestamp) }}</span> <span class="log-time">{{ formatTime(log.timestamp) }}</span>
</div> </div>
<div class="log-message">{{ log.message }}</div> <div class="log-message">
{{ log.message }}
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
<van-empty <van-empty
v-if="!loading && !logList.length" v-if="!loading && !logList.length"
description="暂无日志" description="暂无日志"
image="search" image="search"
/> />
</van-list> </van-list>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: 20px"></div> <div style="height: 20px" />
</van-pull-refresh> </van-pull-refresh>
</div> </div>
</div> </div>
@@ -106,9 +123,7 @@ const levelOptions = ref([
]) ])
// 日期选项 // 日期选项
const dateOptions = ref([ const dateOptions = ref([{ text: '全部日期', value: '' }])
{ text: '全部日期', value: '' }
])
/** /**
* 返回上一页 * 返回上一页
@@ -122,12 +137,12 @@ const handleBack = () => {
*/ */
const getLevelClass = (level) => { const getLevelClass = (level) => {
const levelMap = { const levelMap = {
'ERR': 'level-error', ERR: 'level-error',
'FTL': 'level-fatal', FTL: 'level-fatal',
'WRN': 'level-warning', WRN: 'level-warning',
'INF': 'level-info', INF: 'level-info',
'DBG': 'level-debug', DBG: 'level-debug',
'VRB': 'level-verbose' VRB: 'level-verbose'
} }
return levelMap[level] || 'level-default' return levelMap[level] || 'level-default'
} }
@@ -145,7 +160,9 @@ const formatTime = (timestamp) => {
* 加载日志数据 * 加载日志数据
*/ */
const loadLogs = async (reset = false) => { const loadLogs = async (reset = false) => {
if (fetching.value) return if (fetching.value) {
return
}
fetching.value = true fetching.value = true
@@ -175,7 +192,7 @@ const loadLogs = async (reset = false) => {
if (response.success) { if (response.success) {
const newLogs = response.data || [] const newLogs = response.data || []
if (reset) { if (reset) {
logList.value = newLogs logList.value = newLogs
} else { } else {
@@ -223,8 +240,10 @@ const onRefresh = async () => {
* 加载更多 * 加载更多
*/ */
const onLoad = async () => { const onLoad = async () => {
if (finished.value || fetching.value) return if (finished.value || fetching.value) {
return
}
// 如果是第一次加载 // 如果是第一次加载
if (pageIndex.value === 1 && logList.value.length === 0) { if (pageIndex.value === 1 && logList.value.length === 0) {
await loadLogs(false) await loadLogs(false)
@@ -257,14 +276,11 @@ const loadAvailableDates = async () => {
try { try {
const response = await getAvailableDates() const response = await getAvailableDates()
if (response.success && response.data) { if (response.success && response.data) {
const dates = response.data.map(date => ({ const dates = response.data.map((date) => ({
text: formatDate(date), text: formatDate(date),
value: date value: date
})) }))
dateOptions.value = [ dateOptions.value = [{ text: '全部日期', value: '' }, ...dates]
{ text: '全部日期', value: '' },
...dates
]
} }
} catch (error) { } catch (error) {
console.error('加载日期列表失败:', error) console.error('加载日期列表失败:', error)

View File

@@ -15,7 +15,7 @@
<van-icon name="lock" /> <van-icon name="lock" />
</template> </template>
</van-field> </van-field>
<van-button <van-button
type="primary" type="primary"
block block

View File

@@ -1,18 +1,34 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<van-list <van-list
v-model:loading="loading" v-model:loading="loading"
:finished="finished" :finished="finished"
finished-text="没有更多了" finished-text="没有更多了"
@load="onLoad" @load="onLoad"
> >
<van-cell-group v-if="list.length" inset style="margin-top: 10px"> <van-cell-group
<van-swipe-cell v-for="item in list" :key="item.id"> v-if="list.length"
<div class="message-card" @click="viewDetail(item)"> inset
style="margin-top: 10px"
>
<van-swipe-cell
v-for="item in list"
:key="item.id"
>
<div
class="message-card"
@click="viewDetail(item)"
>
<div class="card-left"> <div class="card-left">
<div class="message-title" :class="{ 'unread': !item.isRead }"> <div
class="message-title"
:class="{ unread: !item.isRead }"
>
{{ item.title }} {{ item.title }}
</div> </div>
<div class="message-content"> <div class="message-content">
@@ -23,16 +39,34 @@
</div> </div>
</div> </div>
<div class="card-right"> <div class="card-right">
<van-tag v-if="!item.isRead" type="danger">未读</van-tag> <van-tag
<van-icon name="arrow" size="16" class="arrow-icon" /> v-if="!item.isRead"
type="danger"
>
未读
</van-tag>
<van-icon
name="arrow"
size="16"
class="arrow-icon"
/>
</div> </div>
</div> </div>
<template #right> <template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(item)" /> <van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(item)"
/>
</template> </template>
</van-swipe-cell> </van-swipe-cell>
</van-cell-group> </van-cell-group>
<van-empty v-else-if="!loading" description="暂无消息" /> <van-empty
v-else-if="!loading"
description="暂无消息"
/>
</van-list> </van-list>
</van-pull-refresh> </van-pull-refresh>
@@ -44,16 +78,26 @@
height="75%" height="75%"
> >
<div <div
v-if="currentMessage.messageType === 2" v-if="currentMessage.messageType === 2"
class="detail-content rich-html-content" class="detail-content rich-html-content"
v-html="currentMessage.content" v-html="currentMessage.content"
/>
<div
v-else
class="detail-content"
> >
</div>
<div v-else class="detail-content">
{{ currentMessage.content }} {{ currentMessage.content }}
</div> </div>
<template v-if="currentMessage.url && currentMessage.messageType === 1" #footer> <template
<van-button type="primary" block round @click="handleUrlJump(currentMessage.url)"> v-if="currentMessage.url && currentMessage.messageType === 1"
#footer
>
<van-button
type="primary"
block
round
@click="handleUrlJump(currentMessage.url)"
>
查看详情 查看详情
</van-button> </van-button>
</template> </template>
@@ -62,164 +106,166 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'; import { showToast, showDialog } from 'vant'
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message'; import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message'
import { useMessageStore } from '@/stores/message'; import { useMessageStore } from '@/stores/message'
import PopupContainer from '@/components/PopupContainer.vue'; import PopupContainer from '@/components/PopupContainer.vue'
const messageStore = useMessageStore(); const messageStore = useMessageStore()
const router = useRouter(); const router = useRouter()
const list = ref([]); const list = ref([])
const loading = ref(false); const loading = ref(false)
const finished = ref(false); const finished = ref(false)
const refreshing = ref(false); const refreshing = ref(false)
const pageIndex = ref(1); const pageIndex = ref(1)
const pageSize = ref(20); const pageSize = ref(20)
const detailVisible = ref(false); const detailVisible = ref(false)
const currentMessage = ref({}); const currentMessage = ref({})
const onLoad = async () => { const onLoad = async () => {
if (refreshing.value) { if (refreshing.value) {
list.value = []; list.value = []
pageIndex.value = 1; pageIndex.value = 1
refreshing.value = false; refreshing.value = false
} }
try { try {
const res = await getMessageList({ const res = await getMessageList({
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize.value pageSize: pageSize.value
}); })
if (res.success) { if (res.success) {
// 格式化时间 // 格式化时间
const data = res.data.map(item => ({ const data = res.data.map((item) => ({
...item, ...item,
createTime: new Date(item.createTime).toLocaleString() createTime: new Date(item.createTime).toLocaleString()
})); }))
if (pageIndex.value === 1) { if (pageIndex.value === 1) {
list.value = data; list.value = data
} else { } else {
list.value = [...list.value, ...data]; list.value = [...list.value, ...data]
} }
// 判断是否加载完成 // 判断是否加载完成
if (list.value.length >= res.total || data.length < pageSize.value) { if (list.value.length >= res.total || data.length < pageSize.value) {
finished.value = true; finished.value = true
} else { } else {
pageIndex.value++; pageIndex.value++
} }
} else { } else {
showToast(res.message || '加载失败'); showToast(res.message || '加载失败')
finished.value = true; finished.value = true
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error)
showToast('加载失败'); showToast('加载失败')
finished.value = true; finished.value = true
} finally { } finally {
loading.value = false; loading.value = false
} }
}; }
const onRefresh = () => { const onRefresh = () => {
finished.value = false; finished.value = false
loading.value = true; loading.value = true
onLoad(); onLoad()
}; }
const viewDetail = async (item) => { const viewDetail = async (item) => {
if (!item.isRead) { if (!item.isRead) {
try { try {
await markAsRead(item.id); await markAsRead(item.id)
item.isRead = true; item.isRead = true
messageStore.updateUnreadCount(); messageStore.updateUnreadCount()
} catch (error) { } catch (error) {
console.error('标记已读失败', error); console.error('标记已读失败', error)
} }
} }
currentMessage.value = item; currentMessage.value = item
detailVisible.value = true; detailVisible.value = true
}; }
const handleUrlJump = (targetUrl) => { const handleUrlJump = (targetUrl) => {
if (!targetUrl) return; if (!targetUrl) {
return
if (targetUrl.startsWith('http')) {
window.open(targetUrl, '_blank');
} else if (targetUrl.startsWith('/')) {
router.push(targetUrl);
detailVisible.value = false;
} else {
showToast('无效的URL');
} }
};
if (targetUrl.startsWith('http')) {
window.open(targetUrl, '_blank')
} else if (targetUrl.startsWith('/')) {
router.push(targetUrl)
detailVisible.value = false
} else {
showToast('无效的URL')
}
}
const handleDelete = (item) => { const handleDelete = (item) => {
showDialog({ showDialog({
title: '提示', title: '提示',
message: '确定要删除这条消息吗?', message: '确定要删除这条消息吗?',
showCancelButton: true, showCancelButton: true
}).then(async (action) => { }).then(async (action) => {
if (action === 'confirm') { if (action === 'confirm') {
try { try {
const res = await deleteMessage(item.id); const res = await deleteMessage(item.id)
if (res.success) { if (res.success) {
showToast('删除成功'); showToast('删除成功')
const wasUnread = !item.isRead; const wasUnread = !item.isRead
list.value = list.value.filter(i => i.id !== item.id); list.value = list.value.filter((i) => i.id !== item.id)
if (wasUnread) { if (wasUnread) {
messageStore.updateUnreadCount(); messageStore.updateUnreadCount()
} }
} else { } else {
showToast(res.message || '删除失败'); showToast(res.message || '删除失败')
} }
} catch (error) { } catch (error) {
console.error('删除消息失败', error); console.error('删除消息失败', error)
showToast('删除失败'); showToast('删除失败')
} }
} }
}); })
}; }
const handleMarkAllRead = () => { const handleMarkAllRead = () => {
showDialog({ showDialog({
title: '提示', title: '提示',
message: '确定要将所有消息标记为已读吗?', message: '确定要将所有消息标记为已读吗?',
showCancelButton: true, showCancelButton: true
}).then(async (action) => { }).then(async (action) => {
if (action === 'confirm') { if (action === 'confirm') {
try { try {
const res = await markAllAsRead(); const res = await markAllAsRead()
if (res.success) { if (res.success) {
showToast('操作成功'); showToast('操作成功')
// 刷新列表 // 刷新列表
onRefresh(); onRefresh()
// 更新未读计数 // 更新未读计数
messageStore.updateUnreadCount(); messageStore.updateUnreadCount()
} else { } else {
showToast(res.message || '操作失败'); showToast(res.message || '操作失败')
} }
} catch (error) { } catch (error) {
console.error('标记所有已读失败', error); console.error('标记所有已读失败', error)
showToast('操作失败'); showToast('操作失败')
} }
} }
}); })
}; }
onMounted(() => { onMounted(() => {
// onLoad 会由 van-list 自动触发 // onLoad 会由 van-list 自动触发
}); })
defineExpose({ defineExpose({
handleMarkAllRead handleMarkAllRead
}); })
</script> </script>
<style scoped> <style scoped>
@@ -257,7 +303,7 @@ defineExpose({
white-space: nowrap; white-space: nowrap;
} }
.message-content{ .message-content {
font-size: 14px; font-size: 14px;
color: var(--van-text-color-2); color: var(--van-text-color-2);
margin-bottom: 6px; margin-bottom: 6px;
@@ -305,4 +351,4 @@ defineExpose({
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="page-container-flex periodic-record"> <div class="page-container-flex periodic-record">
<van-nav-bar <van-nav-bar
:title="navTitle" :title="navTitle"
left-text="返回" left-text="返回"
left-arrow left-arrow
placeholder placeholder
@click-left="handleBack" @click-left="handleBack"
/> />
@@ -36,11 +36,14 @@
</template> </template>
</van-cell> </van-cell>
<van-cell title="分类" :value="item.classify || '未分类'" /> <van-cell title="分类" :value="item.classify || '未分类'" />
<van-cell title="下次执行时间" :value="formatDateTime(item.nextExecuteTime) || '未设置'" /> <van-cell
title="下次执行时间"
:value="formatDateTime(item.nextExecuteTime) || '未设置'"
/>
<van-cell title="状态"> <van-cell title="状态">
<template #value> <template #value>
<van-switch <van-switch
:model-value="item.isEnabled" :model-value="item.isEnabled"
size="20px" size="20px"
@update:model-value="(val) => toggleEnabled(item.id, val)" @update:model-value="(val) => toggleEnabled(item.id, val)"
@click.stop @click.stop
@@ -49,9 +52,9 @@
</van-cell> </van-cell>
</div> </div>
<template #right> <template #right>
<van-button <van-button
square square
type="danger" type="danger"
text="删除" text="删除"
class="delete-button" class="delete-button"
@click="deletePeriodic(item)" @click="deletePeriodic(item)"
@@ -61,26 +64,20 @@
</van-cell-group> </van-cell-group>
<!-- 空状态 --> <!-- 空状态 -->
<van-empty <van-empty
v-if="!loading && !periodicList.length" v-if="!loading && !periodicList.length"
description="暂无周期性账单" description="暂无周期性账单"
image="search" image="search"
/> />
</van-list> </van-list>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
<!-- 底部新增按钮 --> <!-- 底部新增按钮 -->
<div class="bottom-button"> <div class="bottom-button">
<van-button <van-button type="primary" size="large" round icon="plus" @click="openAddDialog">
type="primary"
size="large"
round
icon="plus"
@click="openAddDialog"
>
新增周期账单 新增周期账单
</van-button> </van-button>
</div> </div>
@@ -93,111 +90,104 @@
> >
<van-form> <van-form>
<van-cell-group inset title="周期设置"> <van-cell-group inset title="周期设置">
<van-field <van-field
v-model="form.periodicTypeText" v-model="form.periodicTypeText"
is-link is-link
readonly readonly
name="periodicType" name="periodicType"
label="周期" label="周期"
placeholder="请选择周期类型" placeholder="请选择周期类型"
:rules="[{ required: true, message: '请选择周期类型' }]" :rules="[{ required: true, message: '请选择周期类型' }]"
@click="showPeriodicTypePicker = true" @click="showPeriodicTypePicker = true"
/> />
<!-- 每周配置 --> <!-- 每周配置 -->
<van-field <van-field
v-if="form.periodicType === 1" v-if="form.periodicType === 1"
v-model="form.weekdaysText" v-model="form.weekdaysText"
is-link is-link
readonly readonly
name="weekdays" name="weekdays"
label="星期" label="星期"
placeholder="请选择星期几" placeholder="请选择星期几"
:rules="[{ required: true, message: '请选择星期几' }]" :rules="[{ required: true, message: '请选择星期几' }]"
@click="showWeekdaysPicker = true" @click="showWeekdaysPicker = true"
/> />
<!-- 每月配置 --> <!-- 每月配置 -->
<van-field <van-field
v-if="form.periodicType === 2" v-if="form.periodicType === 2"
v-model="form.monthDaysText" v-model="form.monthDaysText"
is-link is-link
readonly readonly
name="monthDays" name="monthDays"
label="日期" label="日期"
placeholder="请选择每月的日期" placeholder="请选择每月的日期"
:rules="[{ required: true, message: '请选择日期' }]" :rules="[{ required: true, message: '请选择日期' }]"
@click="showMonthDaysPicker = true" @click="showMonthDaysPicker = true"
/> />
<!-- 每季度配置 --> <!-- 每季度配置 -->
<van-field <van-field
v-if="form.periodicType === 3" v-if="form.periodicType === 3"
v-model="form.quarterDay" v-model="form.quarterDay"
name="quarterDay" name="quarterDay"
label="季度第几天" label="季度第几天"
placeholder="请输入季度开始后第几天" placeholder="请输入季度开始后第几天"
type="number" type="number"
:rules="[{ required: true, message: '请输入季度开始后第几天' }]" :rules="[{ required: true, message: '请输入季度开始后第几天' }]"
/> />
<!-- 每年配置 --> <!-- 每年配置 -->
<van-field <van-field
v-if="form.periodicType === 4" v-if="form.periodicType === 4"
v-model="form.yearDay" v-model="form.yearDay"
name="yearDay" name="yearDay"
label="年第几天" label="年第几天"
placeholder="请输入年开始后第几天" placeholder="请输入年开始后第几天"
type="number" type="number"
:rules="[{ required: true, message: '请输入年开始后第几天' }]" :rules="[{ required: true, message: '请输入年开始后第几天' }]"
/> />
</van-cell-group> </van-cell-group>
<van-cell-group inset title="基本信息"> <van-cell-group inset title="基本信息">
<van-field <van-field
v-model="form.reason" v-model="form.reason"
name="reason" name="reason"
label="摘要" label="摘要"
placeholder="请输入交易摘要" placeholder="请输入交易摘要"
type="textarea" type="textarea"
rows="2" rows="2"
autosize autosize
maxlength="200" maxlength="200"
show-word-limit show-word-limit
/> />
<van-field <van-field
v-model="form.amount" v-model="form.amount"
name="amount" name="amount"
label="金额" label="金额"
placeholder="请输入金额" placeholder="请输入金额"
type="number" type="number"
:rules="[{ required: true, message: '请输入金额' }]" :rules="[{ required: true, message: '请输入金额' }]"
/> />
<van-field <van-field v-model="form.type" name="type" label="类型">
v-model="form.type" <template #input>
name="type" <van-radio-group v-model="form.type" direction="horizontal">
label="类型" <van-radio :name="0"> 支出 </van-radio>
> <van-radio :name="1"> 收入 </van-radio>
<template #input> <van-radio :name="2"> 不计 </van-radio>
<van-radio-group v-model="form.type" direction="horizontal"> </van-radio-group>
<van-radio :name="0">支出</van-radio> </template>
<van-radio :name="1">收入</van-radio> </van-field>
<van-radio :name="2">不计</van-radio> <van-field name="classify" label="分类">
</van-radio-group> <template #input>
</template> <span v-if="!form.classify" style="color: var(--van-gray-5)">请选择交易分类</span>
</van-field> <span v-else>{{ form.classify }}</span>
<van-field name="classify" label="分类"> </template>
<template #input> </van-field>
<span v-if="!form.classify" style="color: var(--van-gray-5);">请选择交易分类</span>
<span v-else>{{ form.classify }}</span> <!-- 分类选择组件 -->
</template> <ClassifySelector v-model="form.classify" :type="form.type" />
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group> </van-cell-group>
</van-form> </van-form>
<template #footer> <template #footer>
@@ -240,8 +230,8 @@
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant' import { showToast, showConfirmDialog } from 'vant'
import { import {
getPeriodicList, getPeriodicList,
deletePeriodic as deletePeriodicApi, deletePeriodic as deletePeriodicApi,
togglePeriodicEnabled togglePeriodicEnabled
} from '@/api/transactionPeriodic' } from '@/api/transactionPeriodic'
@@ -330,19 +320,19 @@ const loadData = async (isRefresh = false) => {
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize pageSize: pageSize
} }
const response = await getPeriodicList(params) const response = await getPeriodicList(params)
if (response.success) { if (response.success) {
const newList = response.data || [] const newList = response.data || []
total.value = response.total || 0 total.value = response.total || 0
if (isRefresh) { if (isRefresh) {
periodicList.value = newList periodicList.value = newList
} else { } else {
periodicList.value = [...periodicList.value, ...newList] periodicList.value = [...periodicList.value, ...newList]
} }
if (newList.length === 0 || newList.length < pageSize) { if (newList.length === 0 || newList.length < pageSize) {
finished.value = true finished.value = true
} else { } else {
@@ -387,27 +377,29 @@ const getPeriodicTypeText = (item) => {
3: '每季度', 3: '每季度',
4: '每年' 4: '每年'
} }
let text = typeMap[item.periodicType] || '未知' let text = typeMap[item.periodicType] || '未知'
if (item.periodicConfig) { if (item.periodicConfig) {
switch (item.periodicType) { switch (item.periodicType) {
case 1: // 每周 case 1: {
{ // 每周
const weekdays = item.periodicConfig.split(',').map( const weekdays = item.periodicConfig
d => { .split(',')
const dayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] .map((d) => {
return dayMap[parseInt(d)] || '' const dayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
}).join('、') return dayMap[parseInt(d)] || ''
text += ` (${weekdays})` })
break .join('、')
} text += ` (${weekdays})`
case 2: // 每月 break
{ }
const days = item.periodicConfig.split(',').join('、') case 2: {
text += ` (${days}日)` // 每月
break const days = item.periodicConfig.split(',').join('、')
} text += ` (${days}日)`
break
}
case 3: // 每季度 case 3: // 每季度
text += ` (第${item.periodicConfig}天)` text += ` (第${item.periodicConfig}天)`
break break
@@ -416,7 +408,7 @@ const getPeriodicTypeText = (item) => {
break break
} }
} }
return text return text
} }
@@ -436,20 +428,22 @@ const editPeriodic = (item) => {
form.type = item.type form.type = item.type
form.classify = item.classify form.classify = item.classify
form.periodicType = item.periodicType form.periodicType = item.periodicType
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || '' form.periodicTypeText = periodicTypeColumns.find((t) => t.value === item.periodicType)?.text || ''
// 解析周期配置 // 解析周期配置
if (item.periodicConfig) { if (item.periodicConfig) {
switch (item.periodicType) { switch (item.periodicType) {
case 1: // 每周 case 1: // 每周
form.weekdays = item.periodicConfig.split(',').map(d => parseInt(d)) form.weekdays = item.periodicConfig.split(',').map((d) => parseInt(d))
form.weekdaysText = form.weekdays.map(d => { form.weekdaysText = form.weekdays
return weekdaysColumns.find(w => w.value === d)?.text || '' .map((d) => {
}).join('、') return weekdaysColumns.find((w) => w.value === d)?.text || ''
})
.join('、')
break break
case 2: // 每月 case 2: // 每月
form.monthDays = item.periodicConfig.split(',').map(d => parseInt(d)) form.monthDays = item.periodicConfig.split(',').map((d) => parseInt(d))
form.monthDaysText = form.monthDays.map(d => `${d}`).join('、') form.monthDaysText = form.monthDays.map((d) => `${d}`).join('、')
break break
case 3: // 每季度 case 3: // 每季度
form.quarterDay = item.periodicConfig form.quarterDay = item.periodicConfig
@@ -459,7 +453,7 @@ const editPeriodic = (item) => {
break break
} }
} }
dialogVisible.value = true dialogVisible.value = true
} }
@@ -468,7 +462,7 @@ const deletePeriodic = async (item) => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: '确定要删除这条周期性账单吗?', message: '确定要删除这条周期性账单吗?'
}) })
const response = await deletePeriodicApi(item.id) const response = await deletePeriodicApi(item.id)
@@ -493,7 +487,7 @@ const toggleEnabled = async (id, enabled) => {
if (response.success) { if (response.success) {
showToast(enabled ? '已启用' : '已禁用') showToast(enabled ? '已启用' : '已禁用')
// 更新本地数据 // 更新本地数据
const item = periodicList.value.find(p => p.id === id) const item = periodicList.value.find((p) => p.id === id)
if (item) { if (item) {
item.isEnabled = enabled item.isEnabled = enabled
} }
@@ -510,7 +504,9 @@ const toggleEnabled = async (id, enabled) => {
} }
const formatDateTime = (date) => { const formatDateTime = (date) => {
if (!date) return '' if (!date) {
return ''
}
return dayjs(date).format('YYYY-MM-DD HH:mm:ss') return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
} }
@@ -557,7 +553,6 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
form.monthDaysText = selectedOptions[0].text form.monthDaysText = selectedOptions[0].text
showMonthDaysPicker.value = false showMonthDaysPicker.value = false
} }
</script> </script>
<style scoped> <style scoped>
@@ -599,5 +594,4 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -1,26 +1,47 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-nav-bar title="定时任务" left-arrow placeholder @click-left="onClickLeft" /> <van-nav-bar
title="定时任务"
left-arrow
placeholder
@click-left="onClickLeft"
/>
<div class="scroll-content"> <div class="scroll-content">
<van-pull-refresh v-model="loading" @refresh="fetchTasks"> <van-pull-refresh
<div v-for="task in tasks" :key="task.name" class="task-card"> v-model="loading"
@refresh="fetchTasks"
>
<div
v-for="task in tasks"
:key="task.name"
class="task-card"
>
<van-cell-group inset> <van-cell-group inset>
<van-cell :title="task.jobDescription" :label="task.triggerDescription || task.name"> <van-cell
:title="task.jobDescription"
:label="task.triggerDescription || task.name"
>
<template #value> <template #value>
<van-tag :type="task.status === 'Paused' ? 'warning' : 'success'"> <van-tag :type="task.status === 'Paused' ? 'warning' : 'success'">
{{ task.status === 'Paused' ? '已暂停' : '已启动' }} {{ task.status === 'Paused' ? '已暂停' : '已启动' }}
</van-tag> </van-tag>
</template> </template>
</van-cell> </van-cell>
<van-cell title="任务标识" :value="task.name" /> <van-cell
<van-cell title="下次执行" :value="task.nextRunTime || '无'" /> title="任务标识"
:value="task.name"
/>
<van-cell
title="下次执行"
:value="task.nextRunTime || '无'"
/>
<div class="card-footer"> <div class="card-footer">
<van-row gutter="10"> <van-row gutter="10">
<van-col span="12"> <van-col span="12">
<van-button <van-button
type="primary" type="primary"
size="small" size="small"
block block
icon="play" icon="play"
@click="handleExecute(task)" @click="handleExecute(task)"
> >
@@ -28,21 +49,21 @@
</van-button> </van-button>
</van-col> </van-col>
<van-col span="12"> <van-col span="12">
<van-button <van-button
v-if="task.status !== 'Paused'" v-if="task.status !== 'Paused'"
type="warning" type="warning"
size="small" size="small"
block block
icon="pause" icon="pause"
@click="handlePause(task)" @click="handlePause(task)"
> >
暂停任务 暂停任务
</van-button> </van-button>
<van-button <van-button
v-else v-else
type="success" type="success"
size="small" size="small"
block block
icon="play-circle-o" icon="play-circle-o"
@click="handleResume(task)" @click="handleResume(task)"
> >
@@ -55,10 +76,13 @@
</div> </div>
</van-pull-refresh> </van-pull-refresh>
<van-empty v-if="tasks.length === 0 && !loading" description="无定时任务" /> <van-empty
v-if="tasks.length === 0 && !loading"
description="无定时任务"
/>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(20px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(20px + env(safe-area-inset-bottom, 0px))" />
</div> </div>
</div> </div>
</template> </template>
@@ -102,7 +126,7 @@ const handleExecute = async (task) => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '确认执行', title: '确认执行',
message: `确定要立即执行"${task.jobDescription}"吗?`, message: `确定要立即执行"${task.jobDescription}"吗?`
}) })
showLoadingToast({ showLoadingToast({
@@ -132,7 +156,7 @@ const handlePause = async (task) => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '确认暂停', title: '确认暂停',
message: `确定要暂停"${task.jobDescription}"吗?`, message: `确定要暂停"${task.jobDescription}"吗?`
}) })
const { success, message } = await pauseJob(task.name) const { success, message } = await pauseJob(task.name)

View File

@@ -1,59 +1,137 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<van-nav-bar title="设置" placeholder/> <van-nav-bar
title="设置"
placeholder
/>
<div class="scroll-content"> <div class="scroll-content">
<div class="detail-header" style="padding-bottom: 5px;"> <div
class="detail-header"
style="padding-bottom: 5px"
>
<p>账单</p> <p>账单</p>
</div> </div>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" /> <van-cell
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" /> title="从支付宝导入"
<van-cell title="周期记录" is-link @click="handlePeriodicRecord" /> is-link
@click="handleImportClick('Alipay')"
/>
<van-cell
title="从微信导入"
is-link
@click="handleImportClick('WeChat')"
/>
<van-cell
title="周期记录"
is-link
@click="handlePeriodicRecord"
/>
</van-cell-group> </van-cell-group>
<!-- 隐藏的文件选择器 --> <!-- 隐藏的文件选择器 -->
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" /> <input
ref="fileInputRef"
type="file"
accept=".csv,.xlsx,.xls"
style="display: none"
@change="handleFileChange"
>
<div class="detail-header" style="padding-bottom: 5px;"> <div
class="detail-header"
style="padding-bottom: 5px"
>
<p>分类</p> <p>分类</p>
</div> </div>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="待确认分类" is-link @click="handleUnconfirmedClassification" /> <van-cell
<van-cell title="编辑分类" is-link @click="handleEditClassification" /> title="待确认分类"
<van-cell title="批量分类" is-link @click="handleBatchClassification" /> is-link
<van-cell title="智能分类" is-link @click="handleSmartClassification" /> @click="handleUnconfirmedClassification"
/>
<van-cell
title="编辑分类"
is-link
@click="handleEditClassification"
/>
<van-cell
title="批量分类"
is-link
@click="handleBatchClassification"
/>
<van-cell
title="智能分类"
is-link
@click="handleSmartClassification"
/>
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> --> <!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
</van-cell-group> </van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;"> <div
class="detail-header"
style="padding-bottom: 5px"
>
<p>通知</p> <p>通知</p>
</div> </div>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="开启消息通知"> <van-cell title="开启消息通知">
<template #right-icon> <template #right-icon>
<van-switch v-model="notificationEnabled" size="24" :loading="notificationLoading" @change="handleNotificationToggle" /> <van-switch
v-model="notificationEnabled"
size="24"
:loading="notificationLoading"
@change="handleNotificationToggle"
/>
</template> </template>
</van-cell> </van-cell>
<van-cell v-if="notificationEnabled" title="测试通知" is-link @click="handleTestNotification" /> <van-cell
v-if="notificationEnabled"
title="测试通知"
is-link
@click="handleTestNotification"
/>
</van-cell-group> </van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;"> <div
class="detail-header"
style="padding-bottom: 5px"
>
<p>开发者</p> <p>开发者</p>
</div> </div>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="查看日志" is-link @click="handleLogView" /> <van-cell
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" /> title="查看日志"
<van-cell title="定时任务" is-link @click="handleScheduledTasks" /> is-link
@click="handleLogView"
/>
<van-cell
title="清除缓存"
is-link
@click="handleReloadFromNetwork"
/>
<van-cell
title="定时任务"
is-link
@click="handleScheduledTasks"
/>
</van-cell-group> </van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;"> <div
class="detail-header"
style="padding-bottom: 5px"
>
<p>账户</p> <p>账户</p>
</div> </div>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="退出登录" is-link @click="handleLogout" /> <van-cell
title="退出登录"
is-link
@click="handleLogout"
/>
</van-cell-group> </van-cell-group>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(80px + env(safe-area-inset-bottom, 0px))" />
</div> </div>
</div> </div>
</template> </template>
@@ -82,15 +160,15 @@ onMounted(async () => {
} }
}) })
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4); const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64); const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length); const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) { for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i); outputArray[i] = rawData.charCodeAt(i)
} }
return outputArray; return outputArray
} }
const handleNotificationToggle = async (checked) => { const handleNotificationToggle = async (checked) => {
@@ -113,12 +191,12 @@ const handleNotificationToggle = async (checked) => {
return return
} }
let { success, data, message } = await getVapidPublicKey() const { success, data, message } = await getVapidPublicKey()
if (!success) { if (!success) {
throw new Error(message || '获取 VAPID 公钥失败') throw new Error(message || '获取 VAPID 公钥失败')
} }
const convertedVapidKey = urlBase64ToUint8Array(data) const convertedVapidKey = urlBase64ToUint8Array(data)
const subscription = await registration.pushManager.subscribe({ const subscription = await registration.pushManager.subscribe({
@@ -132,7 +210,7 @@ const handleNotificationToggle = async (checked) => {
p256DH: subJson.keys.p256dh, p256DH: subJson.keys.p256dh,
auth: subJson.keys.auth auth: subJson.keys.auth
}) })
showSuccessToast('开启成功') showSuccessToast('开启成功')
} else { } else {
// 关闭通知 // 关闭通知
@@ -184,7 +262,11 @@ const handleFileChange = async (event) => {
} }
// 验证文件类型 // 验证文件类型
const validTypes = ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] const validTypes = [
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
showToast('请选择 CSV 或 Excel 文件') showToast('请选择 CSV 或 Excel 文件')
return return
@@ -218,8 +300,7 @@ const handleFileChange = async (event) => {
} catch (error) { } catch (error) {
console.error('上传失败:', error) console.error('上传失败:', error)
showToast('上传失败: ' + (error.message || '未知错误')) showToast('上传失败: ' + (error.message || '未知错误'))
} } finally {
finally {
closeToast() closeToast()
// 清空文件输入,允许重复选择同一文件 // 清空文件输入,允许重复选择同一文件
event.target.value = '' event.target.value = ''
@@ -249,9 +330,9 @@ const handleLogout = async () => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: '确定要退出登录吗?', message: '确定要退出登录吗?'
}) })
authStore.logout() authStore.logout()
showSuccessToast('已退出登录') showSuccessToast('已退出登录')
router.push({ name: 'login' }) router.push({ name: 'login' })
@@ -276,7 +357,7 @@ const handleReloadFromNetwork = async () => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: '确定要刷新网络吗?此操作不可撤销。', message: '确定要刷新网络吗?此操作不可撤销。'
}) })
// PWA程序强制页面更新到最新版本 // PWA程序强制页面更新到最新版本
@@ -300,7 +381,6 @@ const handleReloadFromNetwork = async () => {
const handleScheduledTasks = () => { const handleScheduledTasks = () => {
router.push({ name: 'scheduled-tasks' }) router.push({ name: 'scheduled-tasks' })
} }
</script> </script>
<style scoped> <style scoped>
@@ -331,4 +411,4 @@ const handleScheduledTasks = () => {
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,11 @@
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading v-if="loading && !(transactionList && transactionList.length)" vertical style="padding: 50px 0"> <van-loading
v-if="loading && !(transactionList && transactionList.length)"
vertical
style="padding: 50px 0"
>
加载中... 加载中...
</van-loading> </van-loading>
@@ -26,14 +30,16 @@
:show-delete="true" :show-delete="true"
@load="onLoad" @load="onLoad"
@click="viewDetail" @click="viewDetail"
@delete="(id) => { @delete="
// 从当前的交易列表中移除该交易 (id) => {
transactionList.value = transactionList.value.filter(t => t.id !== id) // 从当前的交易列表中移除该交易
}" transactionList.value = transactionList.value.filter((t) => t.id !== id)
}
"
/> />
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div> <div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh> </van-pull-refresh>
<!-- 详情/编辑弹出层 --> <!-- 详情/编辑弹出层 -->
@@ -48,10 +54,7 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { showToast } from 'vant' import { showToast } from 'vant'
import { import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
getTransactionList,
getTransactionDetail
} from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue' import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue' import TransactionDetail from '@/components/TransactionDetail.vue'
@@ -69,8 +72,6 @@ const currentTransaction = ref(null)
const searchKeyword = ref('') const searchKeyword = ref('')
let searchTimer = null let searchTimer = null
// 加载数据 // 加载数据
const loadData = async (isRefresh = false) => { const loadData = async (isRefresh = false) => {
if (isRefresh) { if (isRefresh) {
@@ -85,24 +86,24 @@ const loadData = async (isRefresh = false) => {
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize pageSize: pageSize
} }
// 添加搜索关键词 // 添加搜索关键词
if (searchKeyword.value) { if (searchKeyword.value) {
params.searchKeyword = searchKeyword.value params.searchKeyword = searchKeyword.value
} }
const response = await getTransactionList(params) const response = await getTransactionList(params)
if (response.success) { if (response.success) {
const newList = response.data || [] const newList = response.data || []
total.value = response.total || 0 total.value = response.total || 0
if (isRefresh) { if (isRefresh) {
transactionList.value = newList transactionList.value = newList
} else { } else {
transactionList.value = [...(transactionList.value || []), ...newList] transactionList.value = [...(transactionList.value || []), ...newList]
} }
if (newList.length === 0 || newList.length < pageSize) { if (newList.length === 0 || newList.length < pageSize) {
finished.value = true finished.value = true
} else { } else {
@@ -194,10 +195,12 @@ const onGlobalTransactionDeleted = () => {
loadData(true) loadData(true)
} }
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted) window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted) window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
}) })
// 外部新增/修改/批量更新时的刷新监听 // 外部新增/修改/批量更新时的刷新监听
@@ -208,18 +211,16 @@ const onGlobalTransactionsChanged = () => {
loadData(true) loadData(true)
} }
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged) window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged) window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
}) })
</script> </script>
<style scoped> <style scoped>
:deep(.van-pull-refresh) { :deep(.van-pull-refresh) {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -231,7 +232,6 @@ onBeforeUnmount(() => {
padding: 4px 12px; padding: 4px 12px;
z-index: 100; z-index: 100;
margin-top: 10px; margin-top: 10px;
} }
.top-search-bar :deep(.van-search) { .top-search-bar :deep(.van-search) {
@@ -239,10 +239,8 @@ onBeforeUnmount(() => {
background: transparent; background: transparent;
} }
/* 设置页面容器背景色 */ /* 设置页面容器背景色 */
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
</style> </style>

View File

@@ -7,10 +7,10 @@
@click-left="onClickLeft" @click-left="onClickLeft"
> >
<template #right> <template #right>
<van-button <van-button
v-if="selectedIds.size > 0" v-if="selectedIds.size > 0"
type="primary" type="primary"
size="small" size="small"
:loading="confirming" :loading="confirming"
@click="handleConfirmSelected" @click="handleConfirmSelected"
> >
@@ -20,8 +20,13 @@
</van-nav-bar> </van-nav-bar>
<div class="scroll-content"> <div class="scroll-content">
<div v-if="loading && transactions.length === 0" class="loading-container"> <div
<van-loading vertical>加载中...</van-loading> v-if="loading && transactions.length === 0"
class="loading-container"
>
<van-loading vertical>
加载中...
</van-loading>
</div> </div>
<TransactionList <TransactionList
@@ -92,7 +97,7 @@ const handleConfirmSelected = async () => {
// 转换数据格式以适配 TransactionList 组件 // 转换数据格式以适配 TransactionList 组件
const displayTransactions = computed(() => { const displayTransactions = computed(() => {
return transactions.value.map(t => ({ return transactions.value.map((t) => ({
...t, ...t,
upsetedClassify: t.unconfirmedClassify, upsetedClassify: t.unconfirmedClassify,
upsetedType: t.unconfirmedType upsetedType: t.unconfirmedType
@@ -104,13 +109,12 @@ const loadData = async () => {
try { try {
const response = await getUnconfirmedTransactionList() const response = await getUnconfirmedTransactionList()
if (response && response.success) { if (response && response.success) {
transactions.value = (response.data || []) transactions.value = (response.data || []).map((t) => ({
.map(t => ({ ...t,
...t, upsetedClassify: t.unconfirmedClassify,
upsetedClassify: t.unconfirmedClassify, upsetedType: t.unconfirmedType
upsetedType: t.unconfirmedType }))
})) selectedIds.value = new Set(response.data.map((t) => t.id))
selectedIds.value = new Set(response.data.map(t => t.id))
} }
} catch (error) { } catch (error) {
console.error('获取待确认列表失败:', error) console.error('获取待确认列表失败:', error)
@@ -125,7 +129,7 @@ const handleTransactionClick = (transaction) => {
} }
const handleTransactionDeleted = (id) => { const handleTransactionDeleted = (id) => {
transactions.value = transactions.value.filter(t => t.id !== id) transactions.value = transactions.value.filter((t) => t.id !== id)
} }
const updateSelectedIds = (ids) => { const updateSelectedIds = (ids) => {

View File

@@ -14,7 +14,7 @@ export default defineConfig({
{ {
name: 'update-sw-version', name: 'update-sw-version',
apply: 'build', apply: 'build',
closeBundle() { closeBundle () {
const swPath = resolve(fileURLToPath(new URL('.', import.meta.url)), 'dist/service-worker.js') const swPath = resolve(fileURLToPath(new URL('.', import.meta.url)), 'dist/service-worker.js')
if (existsSync(swPath)) { if (existsSync(swPath)) {
let content = readFileSync(swPath, 'utf8') let content = readFileSync(swPath, 'utf8')
@@ -29,7 +29,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, }
}, },
build: { build: {
// 确保 Service Worker 和 manifest 被正确复制 // 确保 Service Worker 和 manifest 被正确复制

View File

@@ -1,4 +1,4 @@
namespace WebApi.Controllers; namespace WebApi.Controllers;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -9,7 +9,8 @@ using Repository;
public class TransactionRecordController( public class TransactionRecordController(
ITransactionRecordRepository transactionRepository, ITransactionRecordRepository transactionRepository,
ISmartHandleService smartHandleService, ISmartHandleService smartHandleService,
ILogger<TransactionRecordController> logger ILogger<TransactionRecordController> logger,
IConfigService configService
) : ControllerBase ) : ControllerBase
{ {
/// <summary> /// <summary>
@@ -272,13 +273,16 @@ public class TransactionRecordController(
{ {
try try
{ {
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month); // 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month, savingClassify);
var result = statistics.Select(s => new DailyStatisticsDto( var result = statistics.Select(s => new DailyStatisticsDto(
s.Key, s.Key,
s.Value.count, s.Value.count,
s.Value.expense, s.Value.expense,
s.Value.income, s.Value.income,
s.Value.income - s.Value.expense // Balance = Income - Expense s.Value.saving
)).ToList(); )).ToList();
return result.Ok(); return result.Ok();
@@ -305,7 +309,13 @@ public class TransactionRecordController(
var effectiveEndDate = endDate.Date.AddDays(1); var effectiveEndDate = endDate.Date.AddDays(1);
var effectiveStartDate = startDate.Date; var effectiveStartDate = startDate.Date;
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(effectiveStartDate, effectiveEndDate); // 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(
effectiveStartDate,
effectiveEndDate,
savingClassify);
var result = statistics.Select(s => new DailyStatisticsDto( var result = statistics.Select(s => new DailyStatisticsDto(
s.Key, s.Key,
s.Value.count, s.Value.count,
@@ -725,17 +735,6 @@ public class TransactionRecordController(
await Response.WriteAsync(message); await Response.WriteAsync(message);
await Response.Body.FlushAsync(); await Response.Body.FlushAsync();
} }
private static string GetTypeName(TransactionType type)
{
return type switch
{
TransactionType.Expense => "支出",
TransactionType.Income => "收入",
TransactionType.None => "不计入收支",
_ => "未知"
};
}
} }
/// <summary> /// <summary>