+
+
{{ roleLabel(item.role) }}
- {{ formatTime(item.transactionTime) }}
- {{ item.categoryName || t('transaction.uncategorized') }}
- {{ item.remark || '' }}
- {{ formatAmount(item) }}
+ {{ formatTime(item.transactionTime) }}
+ {{ item.categoryName || t('transaction.uncategorized') }}
+ {{ item.remark || '' }}
+ {{ formatAmount(item) }}
diff --git a/frontend/src/composables/useBreakpoints.ts b/frontend/src/composables/useBreakpoints.ts
index bc8e365..03dfe95 100644
--- a/frontend/src/composables/useBreakpoints.ts
+++ b/frontend/src/composables/useBreakpoints.ts
@@ -34,8 +34,7 @@ function updateByWidth(width: number) {
/** 更新是否触屏设备(粗指针或无 hover 能力) */
function updateTouch() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
- isTouch.value = window.matchMedia('(pointer: coarse)').matches
- || window.matchMedia('(hover: none)').matches
+ isTouch.value = window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches
}
/** 初始化断点监听(幂等),在浏览器环境首次调用时绑定 resize */
@@ -44,10 +43,14 @@ export function initBreakpoints() {
initialized = true
updateByWidth(window.innerWidth)
updateTouch()
- window.addEventListener('resize', () => {
- updateByWidth(window.innerWidth)
- updateTouch()
- }, { passive: true })
+ window.addEventListener(
+ 'resize',
+ () => {
+ updateByWidth(window.innerWidth)
+ updateTouch()
+ },
+ { passive: true },
+ )
}
/**
diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts
index d167c26..618dd55 100644
--- a/frontend/src/locales/messages.ts
+++ b/frontend/src/locales/messages.ts
@@ -55,8 +55,16 @@ export default {
usernamePlaceholder: { zh: '请输入用户名', en: 'Enter your username', ja: 'ユーザー名を入力してください' },
passwordPlaceholder: { zh: '请输入密码', en: 'Enter your password', ja: 'パスワードを入力してください' },
submit: { zh: '登录', en: 'Sign In', ja: 'ログイン' },
- toRegister: { zh: '没有账号?去注册', en: "Don't have an account? Sign up", ja: 'アカウントをお持ちでない方は新規登録' },
- fillRequired: { zh: '请填写用户名和密码', en: 'Please enter username and password', ja: 'ユーザー名とパスワードを入力してください' },
+ toRegister: {
+ zh: '没有账号?去注册',
+ en: "Don't have an account? Sign up",
+ ja: 'アカウントをお持ちでない方は新規登録',
+ },
+ fillRequired: {
+ zh: '请填写用户名和密码',
+ en: 'Please enter username and password',
+ ja: 'ユーザー名とパスワードを入力してください',
+ },
loggingIn: { zh: '正在登录,请稍候…', en: 'Signing in, please wait…', ja: 'ログイン中です。少々お待ちください…' },
success: { zh: '登录成功', en: 'Signed in successfully', ja: 'ログインしました' },
failed: { zh: '登录失败', en: 'Sign in failed', ja: 'ログインに失敗しました' },
@@ -72,14 +80,34 @@ export default {
usernamePlaceholder: { zh: '3-50个字符', en: '3-50 characters', ja: '3~50文字' },
nicknamePlaceholder: { zh: '可选', en: 'Optional', ja: '任意' },
passwordPlaceholder: { zh: '至少6个字符', en: 'At least 6 characters', ja: '6文字以上' },
- confirmPasswordPlaceholder: { zh: '再次输入密码', en: 'Enter the password again', ja: 'パスワードをもう一度入力してください' },
+ confirmPasswordPlaceholder: {
+ zh: '再次输入密码',
+ en: 'Enter the password again',
+ ja: 'パスワードをもう一度入力してください',
+ },
submit: { zh: '注册', en: 'Sign Up', ja: '新規登録' },
toLogin: { zh: '已有账号?去登录', en: 'Already have an account? Sign in', ja: 'アカウントをお持ちの方はログイン' },
- fillRequired: { zh: '请填写用户名和密码', en: 'Please enter username and password', ja: 'ユーザー名とパスワードを入力してください' },
- usernameMin: { zh: '用户名至少3个字符', en: 'Username must be at least 3 characters', ja: 'ユーザー名は3文字以上で入力してください' },
- passwordMin: { zh: '密码至少6个字符', en: 'Password must be at least 6 characters', ja: 'パスワードは6文字以上で入力してください' },
+ fillRequired: {
+ zh: '请填写用户名和密码',
+ en: 'Please enter username and password',
+ ja: 'ユーザー名とパスワードを入力してください',
+ },
+ usernameMin: {
+ zh: '用户名至少3个字符',
+ en: 'Username must be at least 3 characters',
+ ja: 'ユーザー名は3文字以上で入力してください',
+ },
+ passwordMin: {
+ zh: '密码至少6个字符',
+ en: 'Password must be at least 6 characters',
+ ja: 'パスワードは6文字以上で入力してください',
+ },
passwordMismatch: { zh: '两次密码不一致', en: 'The two passwords do not match', ja: 'パスワードが一致しません' },
- success: { zh: '注册成功,请登录', en: 'Registered successfully, please sign in', ja: '登録しました。ログインしてください' },
+ success: {
+ zh: '注册成功,请登录',
+ en: 'Registered successfully, please sign in',
+ ja: '登録しました。ログインしてください',
+ },
failed: { zh: '注册失败', en: 'Registration failed', ja: '登録に失敗しました' },
},
@@ -106,9 +134,21 @@ export default {
database: {
title: { zh: '数据库设置', en: 'Database Settings', ja: 'データベース設定' },
setupTitle: { zh: '初始化数据库', en: 'Initialize Database', ja: 'データベースの初期化' },
- intro: { zh: '可选择本地 SQLite 文件数据库或 MySQL 数据库。保存后立即生效并自动建表,原有数据库的数据保持不变。', en: 'Choose a local SQLite file database or a MySQL database. Changes take effect immediately with automatic table creation; data in the previous database is preserved.', ja: 'ローカルの SQLite ファイルデータベースまたは MySQL を選択できます。保存すると即時に反映され、テーブルが自動作成されます。以前のデータベースのデータは保持されます。' },
- setupIntro: { zh: '首次使用请先完成数据库配置。推荐零配置的 SQLite(本地文件),也可以连接已有的 MySQL。', en: 'Please configure a database before first use. SQLite (local file) requires no setup; you may also connect to an existing MySQL.', ja: '初回利用の前にデータベースを設定してください。設定不要の SQLite(ローカルファイル)を推奨します。既存の MySQL に接続することもできます。' },
- setupHint: { zh: '配置完成后即可登录使用;如需修改,可稍后在「数据库设置」中调整。', en: 'After configuration you can sign in. You can adjust it later in Database Settings.', ja: '設定後はログインして利用できます。後で「データベース設定」から変更できます。' },
+ intro: {
+ zh: '可选择本地 SQLite 文件数据库或 MySQL 数据库。保存后立即生效并自动建表,原有数据库的数据保持不变。',
+ en: 'Choose a local SQLite file database or a MySQL database. Changes take effect immediately with automatic table creation; data in the previous database is preserved.',
+ ja: 'ローカルの SQLite ファイルデータベースまたは MySQL を選択できます。保存すると即時に反映され、テーブルが自動作成されます。以前のデータベースのデータは保持されます。',
+ },
+ setupIntro: {
+ zh: '首次使用请先完成数据库配置。推荐零配置的 SQLite(本地文件),也可以连接已有的 MySQL。',
+ en: 'Please configure a database before first use. SQLite (local file) requires no setup; you may also connect to an existing MySQL.',
+ ja: '初回利用の前にデータベースを設定してください。設定不要の SQLite(ローカルファイル)を推奨します。既存の MySQL に接続することもできます。',
+ },
+ setupHint: {
+ zh: '配置完成后即可登录使用;如需修改,可稍后在「数据库设置」中调整。',
+ en: 'After configuration you can sign in. You can adjust it later in Database Settings.',
+ ja: '設定後はログインして利用できます。後で「データベース設定」から変更できます。',
+ },
configured: { zh: '已配置', en: 'Configured', ja: '設定済み' },
notConfigured: { zh: '未配置', en: 'Not configured', ja: '未設定' },
currentType: { zh: '当前类型', en: 'Current type', ja: '現在の種類' },
@@ -117,29 +157,57 @@ export default {
type: { zh: '数据库类型', en: 'Database type', ja: 'データベースの種類' },
sqlitePath: { zh: '数据库文件', en: 'Database file', ja: 'データベースファイル' },
sqlitePathPlaceholder: { zh: '例如 data/ledgerly.db', en: 'e.g. data/ledgerly.db', ja: '例:data/ledgerly.db' },
- sqliteHint: { zh: 'SQLite 为本地单文件数据库,路径相对于服务运行目录;建议使用 data 目录并做好持久化挂载。', en: 'SQLite is a single local file; the path is relative to the server working directory. Use a data directory with persistent storage.', ja: 'SQLite はローカルの単一ファイルです。パスはサーバーの作業ディレクトリからの相対パスです。data ディレクトリと永続化ストレージの利用を推奨します。' },
- sqlitePathRequired: { zh: '请填写 SQLite 数据库文件路径', en: 'Please enter the SQLite database file path', ja: 'SQLite データベースファイルのパスを入力してください' },
- mysqlRequired: { zh: '请填写主机地址、数据库名与用户名', en: 'Please fill in host, database and username', ja: 'ホスト、データベース名、ユーザー名を入力してください' },
+ sqliteHint: {
+ zh: 'SQLite 为本地单文件数据库,路径相对于服务运行目录;建议使用 data 目录并做好持久化挂载。',
+ en: 'SQLite is a single local file; the path is relative to the server working directory. Use a data directory with persistent storage.',
+ ja: 'SQLite はローカルの単一ファイルです。パスはサーバーの作業ディレクトリからの相対パスです。data ディレクトリと永続化ストレージの利用を推奨します。',
+ },
+ sqlitePathRequired: {
+ zh: '请填写 SQLite 数据库文件路径',
+ en: 'Please enter the SQLite database file path',
+ ja: 'SQLite データベースファイルのパスを入力してください',
+ },
+ mysqlRequired: {
+ zh: '请填写主机地址、数据库名与用户名',
+ en: 'Please fill in host, database and username',
+ ja: 'ホスト、データベース名、ユーザー名を入力してください',
+ },
host: { zh: '主机地址', en: 'Host', ja: 'ホスト' },
port: { zh: '端口', en: 'Port', ja: 'ポート' },
database: { zh: '数据库名', en: 'Database', ja: 'データベース名' },
username: { zh: '用户名', en: 'Username', ja: 'ユーザー名' },
password: { zh: '密码', en: 'Password', ja: 'パスワード' },
passwordPlaceholder: { zh: '请输入密码', en: 'Enter password', ja: 'パスワードを入力してください' },
- passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged', ja: '設定済み。空欄のままにすると変更されません' },
+ passwordKeep: {
+ zh: '已配置,留空表示不修改',
+ en: 'Configured; leave blank to keep unchanged',
+ ja: '設定済み。空欄のままにすると変更されません',
+ },
test: { zh: '测试连接', en: 'Test connection', ja: '接続テスト' },
save: { zh: '保存并应用', en: 'Save & Apply', ja: '保存して適用' },
testSuccess: { zh: '连接成功', en: 'Connection succeeded', ja: '接続に成功しました' },
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
- saveSuccess: { zh: '数据库配置已保存并生效', en: 'Database configuration saved and applied', ja: 'データベース設定を保存して適用しました' },
+ saveSuccess: {
+ zh: '数据库配置已保存并生效',
+ en: 'Database configuration saved and applied',
+ ja: 'データベース設定を保存して適用しました',
+ },
saveFailed: { zh: '数据库配置失败', en: 'Failed to configure database', ja: 'データベース設定に失敗しました' },
testingTitle: { zh: '正在测试数据库连接', en: 'Testing database connection', ja: 'データベース接続をテスト中' },
applyingTitle: { zh: '正在应用数据库配置', en: 'Applying database configuration', ja: 'データベース設定を適用中' },
- streamHint: { zh: '后台正在处理,请保持窗口打开;完成后请点击“关闭”。', en: 'Processing in the background. Please keep this window open; click "Close" when it is done.', ja: 'バックグラウンドで処理中です。このウィンドウを開いたままにしてください。完了後に「閉じる」をクリックしてください。' },
+ streamHint: {
+ zh: '后台正在处理,请保持窗口打开;完成后请点击“关闭”。',
+ en: 'Processing in the background. Please keep this window open; click "Close" when it is done.',
+ ja: 'バックグラウンドで処理中です。このウィンドウを開いたままにしてください。完了後に「閉じる」をクリックしてください。',
+ },
streamDone: { zh: '处理完成', en: 'Completed', ja: '完了しました' },
streamFailed: { zh: '处理失败', en: 'Failed', ja: '失敗しました' },
streamClose: { zh: '关闭', en: 'Close', ja: '閉じる' },
- loadFailed: { zh: '加载数据库状态失败', en: 'Failed to load database status', ja: 'データベース状態の読み込みに失敗しました' },
+ loadFailed: {
+ zh: '加载数据库状态失败',
+ en: 'Failed to load database status',
+ ja: 'データベース状態の読み込みに失敗しました',
+ },
},
// 系统设置
@@ -159,94 +227,242 @@ export default {
// 账号安全(修改密码)
account: {
oldPassword: { zh: '原密码', en: 'Current Password', ja: '現在のパスワード' },
- oldPasswordPlaceholder: { zh: '请输入当前密码', en: 'Enter your current password', ja: '現在のパスワードを入力してください' },
+ oldPasswordPlaceholder: {
+ zh: '请输入当前密码',
+ en: 'Enter your current password',
+ ja: '現在のパスワードを入力してください',
+ },
newPassword: { zh: '新密码', en: 'New Password', ja: '新しいパスワード' },
newPasswordPlaceholder: { zh: '至少6个字符', en: 'At least 6 characters', ja: '6文字以上' },
confirmPassword: { zh: '确认新密码', en: 'Confirm New Password', ja: '新しいパスワード(確認)' },
- confirmPasswordPlaceholder: { zh: '请再次输入新密码', en: 'Enter the new password again', ja: '新しいパスワードをもう一度入力してください' },
+ confirmPasswordPlaceholder: {
+ zh: '请再次输入新密码',
+ en: 'Enter the new password again',
+ ja: '新しいパスワードをもう一度入力してください',
+ },
generate: { zh: '随机生成', en: 'Generate', ja: 'ランダム生成' },
copy: { zh: '复制', en: 'Copy', ja: 'コピー' },
copied: { zh: '密码已复制', en: 'Password copied', ja: 'パスワードをコピーしました' },
- copyFailed: { zh: '复制失败,请手动选择', en: 'Copy failed, please select manually', ja: 'コピーに失敗しました。手動で選択してください' },
- hint: { zh: '修改成功后请使用新密码登录;随机生成的密码请及时妥善保存', en: 'After changing, sign in with the new password; keep generated passwords safe.', ja: '変更後は新しいパスワードでログインしてください。生成したパスワードは大切に保管してください。' },
+ copyFailed: {
+ zh: '复制失败,请手动选择',
+ en: 'Copy failed, please select manually',
+ ja: 'コピーに失敗しました。手動で選択してください',
+ },
+ hint: {
+ zh: '修改成功后请使用新密码登录;随机生成的密码请及时妥善保存',
+ en: 'After changing, sign in with the new password; keep generated passwords safe.',
+ ja: '変更後は新しいパスワードでログインしてください。生成したパスワードは大切に保管してください。',
+ },
submit: { zh: '修改密码', en: 'Change Password', ja: 'パスワードを変更' },
- oldRequired: { zh: '请输入原密码', en: 'Please enter your current password', ja: '現在のパスワードを入力してください' },
- newMin: { zh: '新密码至少6个字符', en: 'New password must be at least 6 characters', ja: '新しいパスワードは6文字以上で入力してください' },
- mismatch: { zh: '两次输入的新密码不一致', en: 'The two new passwords do not match', ja: '新しいパスワードが一致しません' },
- sameAsOld: { zh: '新密码不能与原密码相同', en: 'New password must differ from the current password', ja: '新しいパスワードは現在のパスワードと異なる必要があります' },
+ oldRequired: {
+ zh: '请输入原密码',
+ en: 'Please enter your current password',
+ ja: '現在のパスワードを入力してください',
+ },
+ newMin: {
+ zh: '新密码至少6个字符',
+ en: 'New password must be at least 6 characters',
+ ja: '新しいパスワードは6文字以上で入力してください',
+ },
+ mismatch: {
+ zh: '两次输入的新密码不一致',
+ en: 'The two new passwords do not match',
+ ja: '新しいパスワードが一致しません',
+ },
+ sameAsOld: {
+ zh: '新密码不能与原密码相同',
+ en: 'New password must differ from the current password',
+ ja: '新しいパスワードは現在のパスワードと異なる必要があります',
+ },
success: { zh: '密码修改成功', en: 'Password changed successfully', ja: 'パスワードを変更しました' },
failed: { zh: '密码修改失败', en: 'Failed to change password', ja: 'パスワードの変更に失敗しました' },
},
// 账单来源统计默认值
billSourceStats: {
- hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
+ hint: {
+ zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计',
+ en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.',
+ ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。',
+ },
included: { zh: '计入统计', en: 'Included', ja: '集計対象' },
excluded: { zh: '不计入统计', en: 'Excluded', ja: '集計対象外' },
save: { zh: '保存', en: 'Save', ja: '保存' },
- loadFailed: { zh: '加载账单来源统计默认值失败', en: 'Failed to load bill source stats defaults', ja: '請求元の集計既定値の読み込みに失敗しました' },
- saveSuccess: { zh: '账单来源统计默认值已保存', en: 'Bill source stats defaults saved', ja: '請求元の集計既定値を保存しました' },
- saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' },
+ loadFailed: {
+ zh: '加载账单来源统计默认值失败',
+ en: 'Failed to load bill source stats defaults',
+ ja: '請求元の集計既定値の読み込みに失敗しました',
+ },
+ saveSuccess: {
+ zh: '账单来源统计默认值已保存',
+ en: 'Bill source stats defaults saved',
+ ja: '請求元の集計既定値を保存しました',
+ },
+ saveFailed: {
+ zh: '保存账单来源统计默认值失败',
+ en: 'Failed to save bill source stats defaults',
+ ja: '請求元の集計既定値の保存に失敗しました',
+ },
},
// 外观设置
display: {
- hint: { zh: '选择「不计入统计」的记录在收支记录列表中的展示方式,修改后自动保存', en: 'Choose how records excluded from stats are shown in the transaction list; changes are saved automatically', ja: '集計対象外の記録を収支リストでどのように表示するかを選択します。変更は自動保存されます' },
+ hint: {
+ zh: '选择「不计入统计」的记录在收支记录列表中的展示方式,修改后自动保存',
+ en: 'Choose how records excluded from stats are shown in the transaction list; changes are saved automatically',
+ ja: '集計対象外の記録を収支リストでどのように表示するかを選択します。変更は自動保存されます',
+ },
label: { zh: '不计入统计的记录样式', en: 'Excluded record style', ja: '集計対象外の記録スタイル' },
strikethrough: { zh: '删除线(整行划线)', en: 'Strikethrough (whole row)', ja: '取り消し線(行全体)' },
- muted: { zh: '灰色标签(类型显示“不计入”,金额置灰)', en: 'Gray label (type shows "Excluded", amount grayed out)', ja: 'グレー表示(種類は「対象外」、金額はグレー)' },
+ muted: {
+ zh: '灰色标签(类型显示“不计入”,金额置灰)',
+ en: 'Gray label (type shows "Excluded", amount grayed out)',
+ ja: 'グレー表示(種類は「対象外」、金額はグレー)',
+ },
previewLabel: { zh: '预览', en: 'Preview', ja: 'プレビュー' },
previewCategory: { zh: '餐饮', en: 'Dining', ja: '食事' },
previewRemark: { zh: '示例备注', en: 'Sample remark', ja: 'サンプル備考' },
- saveFailed: { zh: '保存外观设置失败', en: 'Failed to save appearance settings', ja: '外観設定の保存に失敗しました' },
- loadFailed: { zh: '加载外观设置失败', en: 'Failed to load appearance settings', ja: '外観設定の読み込みに失敗しました' },
- hiddenDataLabel: { zh: '显示已隐藏备注的记录', en: 'Show records with hidden remarks', ja: '備考を非表示にした記録を表示' },
- hiddenDataHint: { zh: '关闭时,已隐藏备注的记录不会出现在收支记录列表与导出中', en: 'When off, records with hidden remarks are excluded from the transaction list and export', ja: 'オフの場合、備考を非表示にした記録は収支リストとエクスポートに表示されません' },
- hiddenDataSaved: { zh: '已更新隐藏备注记录的显示设置', en: 'Hidden remark record display updated', ja: '非表示備考の記録表示を更新しました' },
- hiddenDataFailed: { zh: '更新隐藏备注记录的显示设置失败', en: 'Failed to update hidden remark record display', ja: '非表示備考の記録表示の更新に失敗しました' },
+ saveFailed: {
+ zh: '保存外观设置失败',
+ en: 'Failed to save appearance settings',
+ ja: '外観設定の保存に失敗しました',
+ },
+ loadFailed: {
+ zh: '加载外观设置失败',
+ en: 'Failed to load appearance settings',
+ ja: '外観設定の読み込みに失敗しました',
+ },
+ hiddenDataLabel: {
+ zh: '显示已隐藏备注的记录',
+ en: 'Show records with hidden remarks',
+ ja: '備考を非表示にした記録を表示',
+ },
+ hiddenDataHint: {
+ zh: '关闭时,已隐藏备注的记录不会出现在收支记录列表与导出中',
+ en: 'When off, records with hidden remarks are excluded from the transaction list and export',
+ ja: 'オフの場合、備考を非表示にした記録は収支リストとエクスポートに表示されません',
+ },
+ hiddenDataSaved: {
+ zh: '已更新隐藏备注记录的显示设置',
+ en: 'Hidden remark record display updated',
+ ja: '非表示備考の記録表示を更新しました',
+ },
+ hiddenDataFailed: {
+ zh: '更新隐藏备注记录的显示设置失败',
+ en: 'Failed to update hidden remark record display',
+ ja: '非表示備考の記録表示の更新に失敗しました',
+ },
pageSizeLabel: { zh: '表格每页条数选项', en: 'Table page size options', ja: 'テーブルの1ページ件数オプション' },
- pageSizeHint: { zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100', en: 'Page size choices shared by all table pagination; changes are saved automatically. Defaults to 10 / 20 / 50 / 100 when unset', ja: 'すべてのテーブルで共通の1ページ件数候補。変更は自動保存されます。未設定時は 10 / 20 / 50 / 100 を使用' },
+ pageSizeHint: {
+ zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100',
+ en: 'Page size choices shared by all table pagination; changes are saved automatically. Defaults to 10 / 20 / 50 / 100 when unset',
+ ja: 'すべてのテーブルで共通の1ページ件数候補。変更は自動保存されます。未設定時は 10 / 20 / 50 / 100 を使用',
+ },
defaultPageSizeLabel: { zh: '默认每页条数', en: 'Default page size', ja: '既定の1ページ件数' },
- pageSizeEmpty: { zh: '请至少填写一个每页条数', en: 'Enter at least one page size', ja: '1ページ件数を1つ以上入力してください' },
- pageSizeFailed: { zh: '保存每页条数选项失败', en: 'Failed to save page size options', ja: '1ページ件数オプションの保存に失敗しました' },
+ pageSizeEmpty: {
+ zh: '请至少填写一个每页条数',
+ en: 'Enter at least one page size',
+ ja: '1ページ件数を1つ以上入力してください',
+ },
+ pageSizeFailed: {
+ zh: '保存每页条数选项失败',
+ en: 'Failed to save page size options',
+ ja: '1ページ件数オプションの保存に失敗しました',
+ },
},
// 导航设置
nav: {
- hint: { zh: '调整左侧导航的显示顺序与显隐;拖动手柄可排序,隐藏后仅移除菜单入口,页面仍可通过地址访问', en: 'Adjust the order and visibility of the left navigation. Drag the handle to reorder; hidden items are removed from the menu but remain accessible by URL', ja: '左側ナビゲーションの並び順と表示・非表示を調整します。ハンドルをドラッグして並べ替えでき、非表示にしても URL からアクセスできます' },
+ hint: {
+ zh: '调整左侧导航的显示顺序与显隐;拖动手柄可排序,隐藏后仅移除菜单入口,页面仍可通过地址访问',
+ en: 'Adjust the order and visibility of the left navigation. Drag the handle to reorder; hidden items are removed from the menu but remain accessible by URL',
+ ja: '左側ナビゲーションの並び順と表示・非表示を調整します。ハンドルをドラッグして並べ替えでき、非表示にしても URL からアクセスできます',
+ },
dragHint: { zh: '拖动手柄调整顺序', en: 'Drag the handle to reorder', ja: 'ハンドルをドラッグして並べ替え' },
show: { zh: '显示', en: 'Show', ja: '表示' },
hide: { zh: '隐藏', en: 'Hide', ja: '非表示' },
resetDefault: { zh: '恢复默认', en: 'Reset to default', ja: '既定に戻す' },
- resetSuccess: { zh: '已恢复默认导航设置', en: 'Navigation reset to default', ja: 'ナビゲーションを既定に戻しました' },
- loadFailed: { zh: '加载导航设置失败', en: 'Failed to load navigation settings', ja: 'ナビゲーション設定の読み込みに失敗しました' },
- saveFailed: { zh: '保存导航设置失败', en: 'Failed to save navigation settings', ja: 'ナビゲーション設定の保存に失敗しました' },
- hideLastForbidden: { zh: '至少保留一个可见导航', en: 'Keep at least one navigation item visible', ja: 'ナビゲーションを1つ以上表示してください' },
+ resetSuccess: {
+ zh: '已恢复默认导航设置',
+ en: 'Navigation reset to default',
+ ja: 'ナビゲーションを既定に戻しました',
+ },
+ loadFailed: {
+ zh: '加载导航设置失败',
+ en: 'Failed to load navigation settings',
+ ja: 'ナビゲーション設定の読み込みに失敗しました',
+ },
+ saveFailed: {
+ zh: '保存导航设置失败',
+ en: 'Failed to save navigation settings',
+ ja: 'ナビゲーション設定の保存に失敗しました',
+ },
+ hideLastForbidden: {
+ zh: '至少保留一个可见导航',
+ en: 'Keep at least one navigation item visible',
+ ja: 'ナビゲーションを1つ以上表示してください',
+ },
},
// 注册设置
registration: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
- enabledHint: { zh: '关闭后新用户无法注册;系统无任何用户时仍可创建首个账号', en: 'When off, new users cannot sign up; the first account can still be created when there are no users.', ja: 'オフにすると新規登録できません。ユーザーが一人もいない場合は最初のアカウントを作成できます。' },
- firstUserHint: { zh: '当前系统暂无用户,可直接注册首个账号', en: 'No users yet — you can register the first account directly.', ja: '現在ユーザーがいないため、最初のアカウントを直接登録できます。' },
+ enabledHint: {
+ zh: '关闭后新用户无法注册;系统无任何用户时仍可创建首个账号',
+ en: 'When off, new users cannot sign up; the first account can still be created when there are no users.',
+ ja: 'オフにすると新規登録できません。ユーザーが一人もいない場合は最初のアカウントを作成できます。',
+ },
+ firstUserHint: {
+ zh: '当前系统暂无用户,可直接注册首个账号',
+ en: 'No users yet — you can register the first account directly.',
+ ja: '現在ユーザーがいないため、最初のアカウントを直接登録できます。',
+ },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '注册设置已保存', en: 'Registration settings saved', ja: '登録設定を保存しました' },
- saveFailed: { zh: '保存注册设置失败', en: 'Failed to save registration settings', ja: '登録設定の保存に失敗しました' },
- loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' },
+ saveFailed: {
+ zh: '保存注册设置失败',
+ en: 'Failed to save registration settings',
+ ja: '登録設定の保存に失敗しました',
+ },
+ loadFailed: {
+ zh: '加载注册状态失败',
+ en: 'Failed to load registration status',
+ ja: '登録状態の読み込みに失敗しました',
+ },
},
// 定时通知
notification: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
- intro: { zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。', en: 'Get reminded via email or ServerChan to import new bills on your own schedule.', ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。' },
+ intro: {
+ zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。',
+ en: 'Get reminded via email or ServerChan to import new bills on your own schedule.',
+ ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。',
+ },
linkUrl: { zh: '通知链接地址', en: 'Notification link URL', ja: '通知リンク URL' },
- linkUrlPlaceholder: { zh: '如 https://finance.rosercode.cn/bill-import', en: 'e.g. https://finance.rosercode.cn/bill-import', ja: '例:https://finance.rosercode.cn/bill-import' },
- linkUrlSaveSuccess: { zh: '通知链接地址已保存', en: 'Notification link URL saved', ja: '通知リンク URL を保存しました' },
- linkUrlSaveFailed: { zh: '保存通知链接地址失败', en: 'Failed to save notification link URL', ja: '通知リンク URL の保存に失敗しました' },
+ linkUrlPlaceholder: {
+ zh: '如 https://finance.rosercode.cn/bill-import',
+ en: 'e.g. https://finance.rosercode.cn/bill-import',
+ ja: '例:https://finance.rosercode.cn/bill-import',
+ },
+ linkUrlSaveSuccess: {
+ zh: '通知链接地址已保存',
+ en: 'Notification link URL saved',
+ ja: '通知リンク URL を保存しました',
+ },
+ linkUrlSaveFailed: {
+ zh: '保存通知链接地址失败',
+ en: 'Failed to save notification link URL',
+ ja: '通知リンク URL の保存に失敗しました',
+ },
enabled: { zh: '启用定时提醒', en: 'Enable reminders', ja: '定期リマインダーを有効化' },
- enabledHint: { zh: '开启后按下方周期自动发送提醒', en: 'Sends reminders automatically based on the schedule below', ja: '有効にすると以下の周期で自動送信します' },
+ enabledHint: {
+ zh: '开启后按下方周期自动发送提醒',
+ en: 'Sends reminders automatically based on the schedule below',
+ ja: '有効にすると以下の周期で自動送信します',
+ },
frequency: { zh: '提醒周期', en: 'Frequency', ja: '通知周期' },
freqDaily: { zh: '每天', en: 'Daily', ja: '毎日' },
freqWeekly: { zh: '每周', en: 'Weekly', ja: '毎週' },
@@ -261,11 +477,19 @@ export default {
dayOfMonthSuffix: { zh: '日', en: 'day', ja: '日' },
sendTime: { zh: '提醒时间', en: 'Time', ja: '通知時刻' },
sendTimePlaceholder: { zh: '例如 09:00', en: 'e.g. 09:00', ja: '例:09:00' },
- sendTimeInvalid: { zh: '提醒时间格式应为 HH:mm', en: 'Time must be in HH:mm format', ja: '時刻は HH:mm 形式で入力してください' },
+ sendTimeInvalid: {
+ zh: '提醒时间格式应为 HH:mm',
+ en: 'Time must be in HH:mm format',
+ ja: '時刻は HH:mm 形式で入力してください',
+ },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '通知设置已保存', en: 'Notification settings saved', ja: '通知設定を保存しました' },
sendNow: { zh: '立即发送', en: 'Send now', ja: '今すぐ送信' },
- sendNowSuccess: { zh: '已发送 {count} 个渠道', en: 'Sent to {count} channel(s)', ja: '{count} 件のチャネルに送信しました' },
+ sendNowSuccess: {
+ zh: '已发送 {count} 个渠道',
+ en: 'Sent to {count} channel(s)',
+ ja: '{count} 件のチャネルに送信しました',
+ },
sendNowFailed: { zh: '发送提醒失败', en: 'Failed to send reminder', ja: 'リマインダーの送信に失敗しました' },
channels: { zh: '推送渠道', en: 'Channels', ja: '通知チャネル' },
addChannel: { zh: '添加渠道', en: 'Add channel', ja: 'チャネルを追加' },
@@ -280,27 +504,55 @@ export default {
smtpHost: { zh: 'SMTP 服务器', en: 'SMTP host', ja: 'SMTP サーバー' },
smtpPort: { zh: 'SMTP 端口', en: 'SMTP port', ja: 'SMTP ポート' },
username: { zh: '邮箱账号', en: 'Email account', ja: 'メールアカウント' },
- usernameRequired: { zh: '请填写邮箱账号', en: 'Please enter the email account', ja: 'メールアカウントを入力してください' },
+ usernameRequired: {
+ zh: '请填写邮箱账号',
+ en: 'Please enter the email account',
+ ja: 'メールアカウントを入力してください',
+ },
sendKey: { zh: 'SendKey', en: 'SendKey', ja: 'SendKey' },
- sendKeyPlaceholder: { zh: '在 sct.ftqq.com 获取的 SendKey', en: 'SendKey obtained from sct.ftqq.com', ja: 'sct.ftqq.com で取得した SendKey' },
- sendKeyRequired: { zh: '请填写 Server 酱 SendKey', en: 'Please enter the ServerChan SendKey', ja: 'ServerChan の SendKey を入力してください' },
+ sendKeyPlaceholder: {
+ zh: '在 sct.ftqq.com 获取的 SendKey',
+ en: 'SendKey obtained from sct.ftqq.com',
+ ja: 'sct.ftqq.com で取得した SendKey',
+ },
+ sendKeyRequired: {
+ zh: '请填写 Server 酱 SendKey',
+ en: 'Please enter the ServerChan SendKey',
+ ja: 'ServerChan の SendKey を入力してください',
+ },
password: { zh: '授权码', en: 'Auth code', ja: '認証コード' },
passwordPlaceholder: { zh: '邮箱 SMTP 授权码', en: 'SMTP authorization code', ja: 'SMTP 認証コード' },
- passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep', ja: '設定済み。空欄なら変更しません' },
+ passwordKeep: {
+ zh: '已配置,留空表示不修改',
+ en: 'Configured; leave blank to keep',
+ ja: '設定済み。空欄なら変更しません',
+ },
to: { zh: '收件人', en: 'Recipient', ja: '宛先' },
toPlaceholder: { zh: '默认与账号相同', en: 'Defaults to the account', ja: '既定ではアカウントと同じ' },
ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSL を使用' },
test: { zh: '测试', en: 'Test', ja: 'テスト' },
testSuccess: { zh: '测试消息已发送', en: 'Test message sent', ja: 'テストメッセージを送信しました' },
testFailed: { zh: '测试发送失败', en: 'Test failed', ja: 'テスト送信に失敗しました' },
- confirmDeleteChannel: { zh: '确认删除渠道「{name}」?', en: 'Delete channel "{name}"?', ja: 'チャネル「{name}」を削除しますか?' },
+ confirmDeleteChannel: {
+ zh: '确认删除渠道「{name}」?',
+ en: 'Delete channel "{name}"?',
+ ja: 'チャネル「{name}」を削除しますか?',
+ },
saveChannelSuccess: { zh: '渠道已保存', en: 'Channel saved', ja: 'チャネルを保存しました' },
- noChannels: { zh: '暂无渠道,请先添加', en: 'No channels yet, please add one', ja: 'チャネルがありません。追加してください' },
+ noChannels: {
+ zh: '暂无渠道,请先添加',
+ en: 'No channels yet, please add one',
+ ja: 'チャネルがありません。追加してください',
+ },
logs: { zh: '最近发送记录', en: 'Recent deliveries', ja: '最近の送信履歴' },
success: { zh: '成功', en: 'Success', ja: '成功' },
failed: { zh: '失败', en: 'Failed', ja: '失敗' },
noLogs: { zh: '暂无发送记录', en: 'No delivery records yet', ja: '送信履歴はまだありません' },
- loadFailed: { zh: '加载通知设置失败', en: 'Failed to load notification settings', ja: '通知設定の読み込みに失敗しました' },
+ loadFailed: {
+ zh: '加载通知设置失败',
+ en: 'Failed to load notification settings',
+ ja: '通知設定の読み込みに失敗しました',
+ },
},
// 模型使用情况
@@ -322,7 +574,11 @@ export default {
result: { zh: '结果', en: 'Result', ja: '結果' },
level: { zh: '级别', en: 'Level', ja: 'レベル' },
date: { zh: '日期', en: 'Date', ja: '日付' },
- datePlaceholder: { zh: 'yyyy-MM-dd,留空为当前', en: 'yyyy-MM-dd, blank for current', ja: 'yyyy-MM-dd、空欄で現在' },
+ datePlaceholder: {
+ zh: 'yyyy-MM-dd,留空为当前',
+ en: 'yyyy-MM-dd, blank for current',
+ ja: 'yyyy-MM-dd、空欄で現在',
+ },
range1h: { zh: '最近1小时', en: 'Last 1h', ja: '直近1時間' },
range6h: { zh: '最近6小时', en: 'Last 6h', ja: '直近6時間' },
range24h: { zh: '最近24小时', en: 'Last 24h', ja: '直近24時間' },
@@ -353,7 +609,11 @@ export default {
retention180: { zh: '180 天', en: '180 days', ja: '180日' },
retentionForever: { zh: '永久保留', en: 'Forever', ja: '永久保持' },
retentionSaved: { zh: '日志保留策略已保存', en: 'Log retention saved', ja: 'ログ保持を保存しました' },
- retentionFailed: { zh: '保存日志保留策略失败', en: 'Failed to save log retention', ja: 'ログ保持の保存に失敗しました' },
+ retentionFailed: {
+ zh: '保存日志保留策略失败',
+ en: 'Failed to save log retention',
+ ja: 'ログ保持の保存に失敗しました',
+ },
loadFailed: { zh: '加载日志失败', en: 'Failed to load logs', ja: 'ログの読み込みに失敗しました' },
},
@@ -383,14 +643,30 @@ export default {
filterCategory: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
filterChannel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
filterTag: { zh: '标签', en: 'Tag', ja: 'タグ' },
- aiSearchPlaceholder: { zh: '用一句话描述统计需求,如「上个月餐饮花了多少」', en: 'Describe what to analyze, e.g. "How much did I spend on dining last month?"', ja: '集計したい内容を文章で入力(例:先月の飲食費は?)' },
+ aiSearchPlaceholder: {
+ zh: '用一句话描述统计需求,如「上个月餐饮花了多少」',
+ en: 'Describe what to analyze, e.g. "How much did I spend on dining last month?"',
+ ja: '集計したい内容を文章で入力(例:先月の飲食費は?)',
+ },
aiSearchButton: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' },
aiSearchEmpty: { zh: '请输入统计需求', en: 'Please enter a query', ja: '検索内容を入力してください' },
- aiSearchApplied: { zh: '已按 AI 解析结果更新统计', en: 'Statistics updated from AI parsing', ja: 'AI の解析結果で統計を更新しました' },
+ aiSearchApplied: {
+ zh: '已按 AI 解析结果更新统计',
+ en: 'Statistics updated from AI parsing',
+ ja: 'AI の解析結果で統計を更新しました',
+ },
aiSearchFailed: { zh: 'AI 统计搜索失败', en: 'AI statistics search failed', ja: 'AI 統計検索に失敗しました' },
aiExample1: { zh: '上个月餐饮花了多少', en: 'How much on dining last month?', ja: '先月の飲食費は?' },
- aiExample2: { zh: '近半年交通支出趋势,按周看', en: 'Transport spending trend over the last 6 months, weekly', ja: '直近半年の交通費の推移を週別で' },
- aiExample3: { zh: '今年各类支出占比', en: 'Expense breakdown by category this year', ja: '今年のカテゴリ別支出割合' },
+ aiExample2: {
+ zh: '近半年交通支出趋势,按周看',
+ en: 'Transport spending trend over the last 6 months, weekly',
+ ja: '直近半年の交通費の推移を週別で',
+ },
+ aiExample3: {
+ zh: '今年各类支出占比',
+ en: 'Expense breakdown by category this year',
+ ja: '今年のカテゴリ別支出割合',
+ },
minAmount: { zh: '最小金额', en: 'Min Amount', ja: '最小金額' },
maxAmount: { zh: '最大金额', en: 'Max Amount', ja: '最大金額' },
remark: { zh: '备注搜索', en: 'Search Remark', ja: '備考検索' },
@@ -404,7 +680,11 @@ export default {
maxExpense: { zh: '最大单笔支出', en: 'Largest Expense', ja: '最大の支出' },
avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense', ja: '平均支出' },
trend: { zh: '收支趋势', en: 'Income & Expense Trend', ja: '収支の推移' },
- trendClickHint: { zh: '点击柱子查看对应明细', en: 'Click a bar to view matching records', ja: '棒をクリックすると該当明細を表示' },
+ trendClickHint: {
+ zh: '点击柱子查看对应明细',
+ en: 'Click a bar to view matching records',
+ ja: '棒をクリックすると該当明細を表示',
+ },
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
@@ -418,8 +698,16 @@ export default {
// 日历
calendar: {
title: { zh: '日历', en: 'Calendar', ja: 'カレンダー' },
- hint: { zh: '点击日期查看当天交易明细', en: "Click a date to view that day's transactions", ja: '日付をクリックするとその日の明細を表示します' },
- loadFailed: { zh: '加载日历数据失败', en: 'Failed to load calendar data', ja: 'カレンダーデータの読み込みに失敗しました' },
+ hint: {
+ zh: '点击日期查看当天交易明细',
+ en: "Click a date to view that day's transactions",
+ ja: '日付をクリックするとその日の明細を表示します',
+ },
+ loadFailed: {
+ zh: '加载日历数据失败',
+ en: 'Failed to load calendar data',
+ ja: 'カレンダーデータの読み込みに失敗しました',
+ },
transactionCount: { zh: '交易笔数', en: 'Transactions', ja: '取引件数' },
today: { zh: '今天', en: 'Today', ja: '今日' },
viewMonth: { zh: '月视图', en: 'Month', ja: '月' },
@@ -435,7 +723,11 @@ export default {
detailTitle: { zh: '{date} 交易明细', en: 'Transactions on {date}', ja: '{date} の取引明細' },
detailEmpty: { zh: '当天没有交易记录', en: 'No transactions on this day', ja: 'この日の取引はありません' },
detailLoadFailed: { zh: '加载明细失败', en: 'Failed to load details', ja: '明細の読み込みに失敗しました' },
- detailMore: { zh: '仅显示前 {shown} 条,共 {total} 条', en: 'Showing first {shown} of {total}', ja: '先頭 {shown} 件を表示(全 {total} 件)' },
+ detailMore: {
+ zh: '仅显示前 {shown} 条,共 {total} 条',
+ en: 'Showing first {shown} of {total}',
+ ja: '先頭 {shown} 件を表示(全 {total} 件)',
+ },
viewInTransactions: { zh: '在收支记录中查看', en: 'View in Transactions', ja: '収支記録で表示' },
},
@@ -464,11 +756,19 @@ export default {
activeFilters: { zh: '已激活:', en: 'Active:', ja: '適用中:' },
clearAll: { zh: '清空全部', en: 'Clear all', ja: 'すべてクリア' },
exampleLabel: { zh: '示例', en: 'Examples', ja: '例' },
- summary: { zh: '总计 {total} 条 · 第 {page} / {pages} 页 · 每页 {size} 条', en: 'Total {total} · Page {page} / {pages} · {size} per page', ja: '合計 {total} 件 · {page} / {pages} ページ · 1ページ {size} 件' },
+ summary: {
+ zh: '总计 {total} 条 · 第 {page} / {pages} 页 · 每页 {size} 条',
+ en: 'Total {total} · Page {page} / {pages} · {size} per page',
+ ja: '合計 {total} 件 · {page} / {pages} ページ · 1ページ {size} 件',
+ },
pageExpense: { zh: '本页支出', en: 'Page Expense', ja: 'このページの支出' },
pageIncome: { zh: '本页收入', en: 'Page Income', ja: 'このページの収入' },
pageBalance: { zh: '本页结余', en: 'Page Balance', ja: 'このページの収支' },
- pageExcluded: { zh: '(不计入统计:支出 {expense} · 收入 {income})', en: '(Excluded: expense {expense} · income {income})', ja: '(集計対象外:支出 {expense} · 収入 {income})' },
+ pageExcluded: {
+ zh: '(不计入统计:支出 {expense} · 收入 {income})',
+ en: '(Excluded: expense {expense} · income {income})',
+ ja: '(集計対象外:支出 {expense} · 収入 {income})',
+ },
column: {
index: { zh: '序号', en: 'No.', ja: 'No.' },
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
@@ -500,8 +800,16 @@ export default {
addRecordTitle: { zh: '新增记录', en: 'Add Record', ja: '記録を追加' },
batchTitle: { zh: '一键设置分类与统计', en: 'Batch Set Category & Stats', ja: 'カテゴリと集計を一括設定' },
batchScope: { zh: '作用范围', en: 'Scope', ja: '対象範囲' },
- batchScopeHintSelected: { zh: '将对已勾选的 {count} 条记录设置分类与计入统计', en: 'Apply category and stats settings to the {count} selected record(s)', ja: '選択した {count} 件の記録にカテゴリと集計設定を適用します' },
- batchScopeHintPage: { zh: '未勾选记录,将对当前表格展示的 {count} 条记录设置分类与计入统计', en: 'No selection; apply to the {count} records shown in the table', ja: '未選択のため、現在表示中の {count} 件の記録に適用します' },
+ batchScopeHintSelected: {
+ zh: '将对已勾选的 {count} 条记录设置分类与计入统计',
+ en: 'Apply category and stats settings to the {count} selected record(s)',
+ ja: '選択した {count} 件の記録にカテゴリと集計設定を適用します',
+ },
+ batchScopeHintPage: {
+ zh: '未勾选记录,将对当前表格展示的 {count} 条记录设置分类与计入统计',
+ en: 'No selection; apply to the {count} records shown in the table',
+ ja: '未選択のため、現在表示中の {count} 件の記録に適用します',
+ },
expenseCategory: { zh: '支出分类', en: 'Expense Category', ja: '支出カテゴリ' },
incomeCategory: { zh: '收入分类', en: 'Income Category', ja: '収入カテゴリ' },
notSetExpense: { zh: '不设置支出分类', en: 'No expense category', ja: '支出カテゴリを設定しない' },
@@ -516,8 +824,16 @@ export default {
exclude: { zh: '不计入统计', en: 'Exclude from Stats', ja: '集計から除外' },
},
includeInStats: { zh: '计入统计', en: 'In Stats', ja: '集計対象' },
- includeInStatsHint: { zh: '关闭后不纳入月度统计(如借款、转账等)', en: 'When off, excluded from monthly stats (e.g. loans, transfers)', ja: 'オフにすると月次集計に含まれません(借入・振替など)' },
- deleteConfirm: { zh: '确定要删除这条记录吗?', en: 'Are you sure you want to delete this record?', ja: 'この記録を削除してもよろしいですか?' },
+ includeInStatsHint: {
+ zh: '关闭后不纳入月度统计(如借款、转账等)',
+ en: 'When off, excluded from monthly stats (e.g. loans, transfers)',
+ ja: 'オフにすると月次集計に含まれません(借入・振替など)',
+ },
+ deleteConfirm: {
+ zh: '确定要删除这条记录吗?',
+ en: 'Are you sure you want to delete this record?',
+ ja: 'この記録を削除してもよろしいですか?',
+ },
selectCategory: { zh: '请选择分类', en: 'Please select a category', ja: 'カテゴリを選択してください' },
validAmount: { zh: '请输入有效金额', en: 'Please enter a valid amount', ja: '有効な金額を入力してください' },
selectCategoryPlaceholder: { zh: '请选择分类', en: 'Select a category', ja: 'カテゴリを選択' },
@@ -529,20 +845,48 @@ export default {
newCategoryButton: { zh: '新建分类', en: 'New Category', ja: 'カテゴリを新規作成' },
newTagButton: { zh: '新建标签', en: 'New Tag', ja: 'タグを新規作成' },
newChannelButton: { zh: '新建渠道', en: 'New Channel', ja: 'チャネルを新規作成' },
- noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data', ja: 'この記録には元の明細データがありません' },
+ noRawData: {
+ zh: '该记录没有原始账单数据',
+ en: 'This record has no raw bill data',
+ ja: 'この記録には元の明細データがありません',
+ },
remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden', ja: '備考を非表示にしました' },
remarkShownSuccess: { zh: '已取消隐藏', en: 'Remark unhidden', ja: '備考の非表示を解除しました' },
statsIncluded: { zh: '已计入统计', en: 'Included in stats', ja: '集計に含めました' },
statsExcluded: { zh: '已排除统计', en: 'Excluded from stats', ja: '集計から除外しました' },
- noRecordsToSet: { zh: '当前表格没有可设置的记录', en: 'No records available in the current table', ja: '現在のテーブルに設定できる記録がありません' },
- batchNothingSelected: { zh: '请至少选择分类、计入统计或标签', en: 'Please select a category, stats option or tag', ja: 'カテゴリ、集計設定、タグのいずれかを選択してください' },
- batchUpdated: { zh: '已更新 {count} 条记录 | 已更新 {count} 条记录', en: 'Updated {count} record | Updated {count} records', ja: '{count} 件を更新しました' },
+ noRecordsToSet: {
+ zh: '当前表格没有可设置的记录',
+ en: 'No records available in the current table',
+ ja: '現在のテーブルに設定できる記録がありません',
+ },
+ batchNothingSelected: {
+ zh: '请至少选择分类、计入统计或标签',
+ en: 'Please select a category, stats option or tag',
+ ja: 'カテゴリ、集計設定、タグのいずれかを選択してください',
+ },
+ batchUpdated: {
+ zh: '已更新 {count} 条记录 | 已更新 {count} 条记录',
+ en: 'Updated {count} record | Updated {count} records',
+ ja: '{count} 件を更新しました',
+ },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
- batchDeleteNone: { zh: '请先选择要删除的记录', en: 'Please select records to delete', ja: '削除する記録を選択してください' },
- batchDeleteConfirm: { zh: '确定删除选中的 {count} 条记录吗?', en: 'Delete the {count} selected record(s)?', ja: '選択した {count} 件を削除しますか?' },
+ batchDeleteNone: {
+ zh: '请先选择要删除的记录',
+ en: 'Please select records to delete',
+ ja: '削除する記録を選択してください',
+ },
+ batchDeleteConfirm: {
+ zh: '确定删除选中的 {count} 条记录吗?',
+ en: 'Delete the {count} selected record(s)?',
+ ja: '選択した {count} 件を削除しますか?',
+ },
batchDeleted: { zh: '已删除 {count} 条记录', en: 'Deleted {count} record(s)', ja: '{count} 件を削除しました' },
exportConfirmTitle: { zh: '导出确认', en: 'Export Confirmation', ja: 'エクスポート確認' },
- exportConfirm: { zh: '确定要按当前筛选条件导出账单吗?', en: 'Export bills with the current filters?', ja: '現在の絞り込み条件で明細をエクスポートしますか?' },
+ exportConfirm: {
+ zh: '确定要按当前筛选条件导出账单吗?',
+ en: 'Export bills with the current filters?',
+ ja: '現在の絞り込み条件で明細をエクスポートしますか?',
+ },
exportSuccess: { zh: '导出成功', en: 'Exported successfully', ja: 'エクスポートしました' },
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
exportFileName: { zh: '交易记录', en: 'transactions', ja: '取引記録' },
@@ -555,7 +899,11 @@ export default {
viewCount: { zh: '{count} 个关系', en: '{count} relation(s)', ja: '{count} 件の関連' },
none: { zh: '-', en: '-', ja: '-' },
createFromSelection: { zh: '建立关系', en: 'Create Relation', ja: '関連を作成' },
- selectAtLeastTwo: { zh: '请至少勾选两条账单', en: 'Please select at least two bills', ja: '明細を2件以上選択してください' },
+ selectAtLeastTwo: {
+ zh: '请至少勾选两条账单',
+ en: 'Please select at least two bills',
+ ja: '明細を2件以上選択してください',
+ },
title: { zh: '账单关系', en: 'Bill Relations', ja: '明細の関連' },
create: { zh: '新建关系', en: 'New Relation', ja: '関連を作成' },
createTitle: { zh: '新建账单关系', en: 'New Bill Relation', ja: '明細の関連を作成' },
@@ -565,7 +913,11 @@ export default {
typeParentChild: { zh: '父子关系', en: 'Parent-Child', ja: '親子関係' },
typeSibling: { zh: '同级关系', en: 'Sibling', ja: '同級関係' },
name: { zh: '关系名称', en: 'Relation Name', ja: '関連名' },
- namePlaceholder: { zh: '可选,如「交通卡充值」', en: 'Optional, e.g. "Transit card top-up"', ja: '任意(例:交通カードのチャージ)' },
+ namePlaceholder: {
+ zh: '可选,如「交通卡充值」',
+ en: 'Optional, e.g. "Transit card top-up"',
+ ja: '任意(例:交通カードのチャージ)',
+ },
remark: { zh: '备注', en: 'Remark', ja: '備考' },
members: { zh: '关联账单', en: 'Related Bills', ja: '関連する明細' },
addMember: { zh: '添加账单', en: 'Add Bill', ja: '明細を追加' },
@@ -574,9 +926,17 @@ export default {
roleChild: { zh: '子账单', en: 'Child', ja: '子' },
roleMember: { zh: '成员', en: 'Member', ja: 'メンバー' },
edgeLabel: { zh: '关联', en: 'Linked', ja: '関連' },
- minTwo: { zh: '关系组至少包含两条账单', en: 'A relation group needs at least two bills', ja: '関連グループには最低2件の明細が必要です' },
+ minTwo: {
+ zh: '关系组至少包含两条账单',
+ en: 'A relation group needs at least two bills',
+ ja: '関連グループには最低2件の明細が必要です',
+ },
needParent: { zh: '请指定一条父账单', en: 'Please select one parent bill', ja: '親の明細を1件指定してください' },
- deleteConfirm: { zh: '确定删除该关系组?仅解除关联,不会删除账单', en: 'Delete this relation group? Only the relation is removed, not the bills', ja: 'この関連グループを削除しますか?明細は削除されません' },
+ deleteConfirm: {
+ zh: '确定删除该关系组?仅解除关联,不会删除账单',
+ en: 'Delete this relation group? Only the relation is removed, not the bills',
+ ja: 'この関連グループを削除しますか?明細は削除されません',
+ },
saveFailed: { zh: '保存账单关系失败', en: 'Failed to save the relation', ja: '関連の保存に失敗しました' },
loadFailed: { zh: '加载账单关系失败', en: 'Failed to load relations', ja: '関連の読み込みに失敗しました' },
deleteFailed: { zh: '删除账单关系失败', en: 'Failed to delete the relation', ja: '関連の削除に失敗しました' },
@@ -608,11 +968,19 @@ export default {
type: { zh: '类型', en: 'Type', ja: '種類' },
icon: { zh: '图标', en: 'Icon', ja: 'アイコン' },
iconPlaceholder: { zh: '点击选择 Emoji', en: 'Click to pick an emoji', ja: 'クリックして絵文字を選択' },
- emojiInputPlaceholder: { zh: '自定义输入 Emoji 或文字', en: 'Custom emoji or text', ja: '絵文字または文字を自由に入力' },
+ emojiInputPlaceholder: {
+ zh: '自定义输入 Emoji 或文字',
+ en: 'Custom emoji or text',
+ ja: '絵文字または文字を自由に入力',
+ },
clearIcon: { zh: '清除图标', en: 'Clear icon', ja: 'アイコンをクリア' },
sort: { zh: '排序', en: 'Order', ja: '並び順' },
nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name', ja: 'カテゴリ名を入力してください' },
- deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?', ja: 'このカテゴリを削除してもよろしいですか?' },
+ deleteConfirm: {
+ zh: '确定要删除这个分类吗?',
+ en: 'Are you sure you want to delete this category?',
+ ja: 'このカテゴリを削除してもよろしいですか?',
+ },
},
// 标签管理
@@ -627,7 +995,11 @@ export default {
color: { zh: '颜色', en: 'Color', ja: '色' },
sort: { zh: '排序', en: 'Order', ja: '並び順' },
nameRequired: { zh: '请输入标签名称', en: 'Please enter a tag name', ja: 'タグ名を入力してください' },
- deleteConfirm: { zh: '删除后将从所有交易记录中移除该标签,确定删除吗?', en: 'This tag will be removed from all transactions. Delete it?', ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?' },
+ deleteConfirm: {
+ zh: '删除后将从所有交易记录中移除该标签,确定删除吗?',
+ en: 'This tag will be removed from all transactions. Delete it?',
+ ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?',
+ },
},
// 交易渠道管理(用户自定义)
@@ -642,8 +1014,16 @@ export default {
usage: { zh: '使用账单数', en: 'Used by', ja: '使用件数' },
usageCount: { zh: '{count} 笔账单', en: '{count} record(s)', ja: '{count} 件' },
unused: { zh: '未使用', en: 'Unused', ja: '未使用' },
- deleteConfirm: { zh: '确定删除渠道「{name}」吗?', en: 'Delete channel "{name}"?', ja: 'チャネル「{name}」を削除しますか?' },
- hint: { zh: '自定义渠道可用于记账与筛选;删除前需确保没有任何账单使用该渠道,改名不影响历史账单。', en: 'Custom channels can be used in entries and filters. A channel can only be deleted when no transaction uses it; renaming does not affect existing records.', ja: 'カスタムチャネルは記帳や絞り込みに利用できます。取引で使用中のチャネルは削除できません。名前の変更は既存の記録に影響しません。' },
+ deleteConfirm: {
+ zh: '确定删除渠道「{name}」吗?',
+ en: 'Delete channel "{name}"?',
+ ja: 'チャネル「{name}」を削除しますか?',
+ },
+ hint: {
+ zh: '自定义渠道可用于记账与筛选;删除前需确保没有任何账单使用该渠道,改名不影响历史账单。',
+ en: 'Custom channels can be used in entries and filters. A channel can only be deleted when no transaction uses it; renaming does not affect existing records.',
+ ja: 'カスタムチャネルは記帳や絞り込みに利用できます。取引で使用中のチャネルは削除できません。名前の変更は既存の記録に影響しません。',
+ },
manageEntry: { zh: '渠道管理', en: 'Channels', ja: 'チャネル管理' },
},
@@ -658,7 +1038,11 @@ export default {
nameRequired: { zh: '请输入规则名称', en: 'Please enter a rule name', ja: 'ルール名を入力してください' },
condition: { zh: '匹配条件', en: 'Conditions', ja: '一致条件' },
keyword: { zh: '关键词', en: 'Keyword', ja: 'キーワード' },
- keywordPlaceholder: { zh: '匹配备注/交易对方/商品名称,包含即命中', en: 'Matches remark / counterparty / product (contains)', ja: '備考/取引相手/商品名に含まれれば一致' },
+ keywordPlaceholder: {
+ zh: '匹配备注/交易对方/商品名称,包含即命中',
+ en: 'Matches remark / counterparty / product (contains)',
+ ja: '備考/取引相手/商品名に含まれれば一致',
+ },
amountRange: { zh: '金额区间', en: 'Amount Range', ja: '金額範囲' },
typeAny: { zh: '不限类型', en: 'Any type', ja: '種類を問わない' },
targetCategory: { zh: '目标分类', en: 'Target Category', ja: '対象カテゴリ' },
@@ -671,17 +1055,41 @@ export default {
summaryAmountMin: { zh: '≥ {min}', en: '≥ {min}', ja: '≥ {min}' },
summaryAmountMax: { zh: '≤ {max}', en: '≤ {max}', ja: '≤ {max}' },
summaryNone: { zh: '无条件', en: 'No condition', ja: '条件なし' },
- deleteConfirm: { zh: '确定要删除这个规则吗?', en: 'Are you sure you want to delete this rule?', ja: 'このルールを削除してもよろしいですか?' },
- loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
+ deleteConfirm: {
+ zh: '确定要删除这个规则吗?',
+ en: 'Are you sure you want to delete this rule?',
+ ja: 'このルールを削除してもよろしいですか?',
+ },
+ loadCategoriesFailed: {
+ zh: '加载分类失败',
+ en: 'Failed to load categories',
+ ja: 'カテゴリの読み込みに失敗しました',
+ },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
- searchPlaceholder: { zh: '搜索规则名称或关键词', en: 'Search name or keyword', ja: 'ルール名またはキーワードを検索' },
+ searchPlaceholder: {
+ zh: '搜索规则名称或关键词',
+ en: 'Search name or keyword',
+ ja: 'ルール名またはキーワードを検索',
+ },
status: { zh: '启用状态', en: 'Status', ja: '有効状態' },
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
- batchDeleteNone: { zh: '请先选择要删除的规则', en: 'Please select rules to delete', ja: '削除するルールを選択してください' },
- batchDeleteConfirm: { zh: '确定删除选中的 {count} 条规则吗?', en: 'Delete the {count} selected rule(s)?', ja: '選択した {count} 件のルールを削除しますか?' },
- batchDeleted: { zh: '已删除 {count} 条规则', en: 'Deleted {count} rule(s)', ja: '{count} 件のルールを削除しました' },
+ batchDeleteNone: {
+ zh: '请先选择要删除的规则',
+ en: 'Please select rules to delete',
+ ja: '削除するルールを選択してください',
+ },
+ batchDeleteConfirm: {
+ zh: '确定删除选中的 {count} 条规则吗?',
+ en: 'Delete the {count} selected rule(s)?',
+ ja: '選択した {count} 件のルールを削除しますか?',
+ },
+ batchDeleted: {
+ zh: '已删除 {count} 条规则',
+ en: 'Deleted {count} rule(s)',
+ ja: '{count} 件のルールを削除しました',
+ },
},
// 账单导入
@@ -696,27 +1104,79 @@ export default {
billTypeCMB: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
billTypeBOCOM: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
billFile: { zh: '账单文件:', en: 'Bill file:', ja: '明細ファイル:' },
- dropText: { zh: '点击或拖拽文件到此处', en: 'Click or drag a file here', ja: 'クリックまたはドラッグでファイルを選択' },
- fileHint: { zh: '仅支持{label}导出的账单文件或 zip 压缩包({exts})', en: 'Only {label} bill files or zip archives are supported ({exts})', ja: '{label}からエクスポートした明細ファイルまたは zip 圧縮ファイルのみ対応({exts})' },
+ dropText: {
+ zh: '点击或拖拽文件到此处',
+ en: 'Click or drag a file here',
+ ja: 'クリックまたはドラッグでファイルを選択',
+ },
+ fileHint: {
+ zh: '仅支持{label}导出的账单文件或 zip 压缩包({exts})',
+ en: 'Only {label} bill files or zip archives are supported ({exts})',
+ ja: '{label}からエクスポートした明細ファイルまたは zip 圧縮ファイルのみ対応({exts})',
+ },
extSeparator: { zh: '、', en: ', ', ja: '、' },
- exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.', ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。' },
- exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.', ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。' },
- exportHintMEITUAN: { zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。', en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.', ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。' },
- exportHintTAOBAO: { zh: '导出路径:淘宝网页版 → 我的淘宝 → 我的订单 → 已买到的宝贝 → 导出订单,下载 xlsx。', en: 'Export path: Taobao web → My Taobao → My Orders → Purchased Items → Export Orders, download as xlsx.', ja: 'エクスポート手順:Taobao ウェブ版 → マイ淘宝 → マイ注文 → 購入済み商品 → 注文をエクスポート で xlsx をダウンロード。' },
- exportHintJD: { zh: '导出路径:京东 App → 我的 → 设置 → 账单 → 右上角「…」→ 账单导出,设置接收邮箱后提交,从邮件下载 CSV(解压密码见邮件说明)。', en: 'Export path: JD App → Me → Settings → Bills → top-right "…" → Bill Export, set a receiving email and submit, then download the CSV from the email (archive password is in the email).', ja: 'エクスポート手順:JD アプリ → マイ → 設定 → 請求 → 右上の「…」→ 請求エクスポート で受信メールを設定して送信し、メールから CSV をダウンロード(解凍パスワードはメールに記載)。' },
- exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,支持 CSV 或 PDF 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records; CSV or PDF is supported (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細。CSV または PDF 形式に対応(圧縮する場合は解凍パスワードを設定)。' },
- exportHintBOCOM: { zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。', en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).', ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。' },
+ exportHintALIPAY: {
+ zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。',
+ en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.',
+ ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。',
+ },
+ exportHintWECHAT: {
+ zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。',
+ en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.',
+ ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。',
+ },
+ exportHintMEITUAN: {
+ zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。',
+ en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.',
+ ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。',
+ },
+ exportHintTAOBAO: {
+ zh: '导出路径:淘宝网页版 → 我的淘宝 → 我的订单 → 已买到的宝贝 → 导出订单,下载 xlsx。',
+ en: 'Export path: Taobao web → My Taobao → My Orders → Purchased Items → Export Orders, download as xlsx.',
+ ja: 'エクスポート手順:Taobao ウェブ版 → マイ淘宝 → マイ注文 → 購入済み商品 → 注文をエクスポート で xlsx をダウンロード。',
+ },
+ exportHintJD: {
+ zh: '导出路径:京东 App → 我的 → 设置 → 账单 → 右上角「…」→ 账单导出,设置接收邮箱后提交,从邮件下载 CSV(解压密码见邮件说明)。',
+ en: 'Export path: JD App → Me → Settings → Bills → top-right "…" → Bill Export, set a receiving email and submit, then download the CSV from the email (archive password is in the email).',
+ ja: 'エクスポート手順:JD アプリ → マイ → 設定 → 請求 → 右上の「…」→ 請求エクスポート で受信メールを設定して送信し、メールから CSV をダウンロード(解凍パスワードはメールに記載)。',
+ },
+ exportHintCMB: {
+ zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,支持 CSV 或 PDF 格式导出(如需压缩包请设置解压密码)。',
+ en: 'Export path: China Merchants Bank App → Me → All → Transaction Records; CSV or PDF is supported (set an archive password if zipped).',
+ ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細。CSV または PDF 形式に対応(圧縮する場合は解凍パスワードを設定)。',
+ },
+ exportHintBOCOM: {
+ zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。',
+ en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).',
+ ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。',
+ },
parse: { zh: '解析', en: 'Parse', ja: '解析' },
- expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' },
- incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):', ja: '収入の既定カテゴリ(任意):' },
+ expenseDefaultCategory: {
+ zh: '支出默认分类(可不选):',
+ en: 'Default expense category (optional):',
+ ja: '支出の既定カテゴリ(任意):',
+ },
+ incomeDefaultCategory: {
+ zh: '收入默认分类(可不选):',
+ en: 'Default income category (optional):',
+ ja: '収入の既定カテゴリ(任意):',
+ },
defaultTags: { zh: '默认标签(可不选):', en: 'Default tags (optional):', ja: '既定タグ(任意):' },
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected', ja: '未選択の場合は分類なし' },
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected', ja: '未選択の場合はタグなし' },
selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' },
setIncluded: { zh: '设为计入', en: 'Set Included', ja: '集計に含める' },
setExcluded: { zh: '设为不计入', en: 'Set Excluded', ja: '集計から除外' },
- previewSummary: { zh: '共 {total} 条,筛选后 {filtered} 条,已勾选 {checked} 条', en: 'Total {total}, {filtered} after filtering, {checked} selected', ja: '全 {total} 件、絞り込み後 {filtered} 件、選択済み {checked} 件' },
- searchPlaceholder: { zh: '搜索交易对方/商品/单号', en: 'Search counterparty / product / order no.', ja: '取引相手/商品/注文番号を検索' },
+ previewSummary: {
+ zh: '共 {total} 条,筛选后 {filtered} 条,已勾选 {checked} 条',
+ en: 'Total {total}, {filtered} after filtering, {checked} selected',
+ ja: '全 {total} 件、絞り込み後 {filtered} 件、選択済み {checked} 件',
+ },
+ searchPlaceholder: {
+ zh: '搜索交易对方/商品/单号',
+ en: 'Search counterparty / product / order no.',
+ ja: '取引相手/商品/注文番号を検索',
+ },
typePlaceholder: { zh: '类型', en: 'Type', ja: '種類' },
statusPlaceholder: { zh: '状态', en: 'Status', ja: 'ステータス' },
typeFilter: {
@@ -733,7 +1193,11 @@ export default {
notImportable: { zh: '不可导入', en: 'Not Importable', ja: 'インポート不可' },
prevStep: { zh: '上一步', en: 'Previous', ja: '前へ' },
confirmImport: { zh: '确认导入({count})', en: 'Confirm Import ({count})', ja: 'インポートを確定({count})' },
- resultSummary: { zh: '导入完成:成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条', en: 'Import complete: {imported} succeeded, {skipped} skipped, {failed} failed', ja: 'インポート完了:成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件' },
+ resultSummary: {
+ zh: '导入完成:成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条',
+ en: 'Import complete: {imported} succeeded, {skipped} skipped, {failed} failed',
+ ja: 'インポート完了:成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件',
+ },
done: { zh: '完成', en: 'Done', ja: '完了' },
column: {
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
@@ -745,30 +1209,70 @@ export default {
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' },
includeInStats: { zh: '计入统计', en: 'Include in Stats', ja: '集計に含める' },
},
- errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' },
- selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
+ errorFileType: {
+ zh: '账单类型「{label}」仅支持 {exts} 格式的文件',
+ en: 'Bill type "{label}" only supports files in {exts} format',
+ ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています',
+ },
+ selectFileFirst: {
+ zh: '请先选择账单文件',
+ en: 'Please select a bill file first',
+ ja: '先に明細ファイルを選択してください',
+ },
parseFailed: { zh: '解析失败', en: 'Parse failed', ja: '解析に失敗しました' },
- selectAtLeastOne: { zh: '请至少勾选一条记录', en: 'Please select at least one record', ja: '少なくとも1件の記録を選択してください' },
+ selectAtLeastOne: {
+ zh: '请至少勾选一条记录',
+ en: 'Please select at least one record',
+ ja: '少なくとも1件の記録を選択してください',
+ },
importFailed: { zh: '导入失败', en: 'Import failed', ja: 'インポートに失敗しました' },
- loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
+ loadCategoriesFailed: {
+ zh: '加载分类失败',
+ en: 'Failed to load categories',
+ ja: 'カテゴリの読み込みに失敗しました',
+ },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
passwordTitle: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
- passwordTip: { zh: '该压缩包已加密,请输入解压密码。', en: 'This archive is encrypted. Please enter the password.', ja: 'この圧縮ファイルは暗号化されています。パスワードを入力してください。' },
+ passwordTip: {
+ zh: '该压缩包已加密,请输入解压密码。',
+ en: 'This archive is encrypted. Please enter the password.',
+ ja: 'この圧縮ファイルは暗号化されています。パスワードを入力してください。',
+ },
passwordPlaceholder: { zh: '请输入解压密码', en: 'Enter the archive password', ja: 'パスワードを入力してください' },
- passwordRequired: { zh: '请输入解压密码', en: 'Please enter the archive password', ja: 'パスワードを入力してください' },
+ passwordRequired: {
+ zh: '请输入解压密码',
+ en: 'Please enter the archive password',
+ ja: 'パスワードを入力してください',
+ },
passwordWrong: { zh: '解压密码错误', en: 'Incorrect archive password', ja: 'パスワードが正しくありません' },
},
// AI 智能建议
ai: {
- intro: { zh: '启用后,可在收支记录页使用 AI 智能建议(一键设置中推荐分类与标签)、智能记账与 AI 查询;调用量与 Token 消耗可在「模型使用情况」查看。', en: 'Once enabled, you can use AI suggestions (categories & tags in Batch Set), Smart Entry, and AI Query on the Transactions page; call counts and token usage are available under "Model Usage".', ja: '有効にすると、収支記録ページで AI 提案(一括設定でカテゴリとタグを推薦)、スマート入力、AI 検索を利用できます。呼び出し回数とトークン消費は「モデル利用状況」で確認できます。' },
+ intro: {
+ zh: '启用后,可在收支记录页使用 AI 智能建议(一键设置中推荐分类与标签)、智能记账与 AI 查询;调用量与 Token 消耗可在「模型使用情况」查看。',
+ en: 'Once enabled, you can use AI suggestions (categories & tags in Batch Set), Smart Entry, and AI Query on the Transactions page; call counts and token usage are available under "Model Usage".',
+ ja: '有効にすると、収支記録ページで AI 提案(一括設定でカテゴリとタグを推薦)、スマート入力、AI 検索を利用できます。呼び出し回数とトークン消費は「モデル利用状況」で確認できます。',
+ },
enabledOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
enabledOff: { zh: '未启用', en: 'Disabled', ja: '無効' },
enabled: { zh: '启用 AI 建议', en: 'Enable AI suggestions', ja: 'AI 提案を有効にする' },
- enabledHint: { zh: '关闭后不会发起任何 AI 请求', en: 'No AI requests are sent when disabled', ja: 'オフにすると AI リクエストは送信されません' },
+ enabledHint: {
+ zh: '关闭后不会发起任何 AI 请求',
+ en: 'No AI requests are sent when disabled',
+ ja: 'オフにすると AI リクエストは送信されません',
+ },
apiKey: { zh: 'API Key', en: 'API Key', ja: 'API Key' },
- apiKeyPlaceholder: { zh: '请输入 DeepSeek API Key', en: 'Enter your DeepSeek API Key', ja: 'DeepSeek API Key を入力してください' },
- apiKeyKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged', ja: '設定済み。空欄のままにすると変更されません' },
+ apiKeyPlaceholder: {
+ zh: '请输入 DeepSeek API Key',
+ en: 'Enter your DeepSeek API Key',
+ ja: 'DeepSeek API Key を入力してください',
+ },
+ apiKeyKeep: {
+ zh: '已配置,留空表示不修改',
+ en: 'Configured; leave blank to keep unchanged',
+ ja: '設定済み。空欄のままにすると変更されません',
+ },
baseUrl: { zh: '接口地址', en: 'Base URL', ja: 'エンドポイント' },
model: { zh: '模型', en: 'Model', ja: 'モデル' },
visionModel: { zh: '视觉模型', en: 'Vision model', ja: 'ビジョンモデル' },
@@ -788,22 +1292,58 @@ export default {
},
suggestTitle: { zh: 'AI 分类与标签建议', en: 'AI category & tag suggestions', ja: 'AI によるカテゴリとタグの提案' },
suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…', ja: '分析中です。しばらくお待ちください…' },
- suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions', ja: 'AI 提案の生成に失敗しました' },
- noSuggestion: { zh: 'AI 未返回有效建议', en: 'AI returned no valid suggestions', ja: 'AI から有効な提案が返されませんでした' },
+ suggestFailed: {
+ zh: 'AI 建议生成失败',
+ en: 'Failed to generate AI suggestions',
+ ja: 'AI 提案の生成に失敗しました',
+ },
+ noSuggestion: {
+ zh: 'AI 未返回有效建议',
+ en: 'AI returned no valid suggestions',
+ ja: 'AI から有効な提案が返されませんでした',
+ },
applyAll: { zh: '应用勾选({count})', en: 'Apply selected ({count})', ja: '選択項目を適用({count})' },
- applyNone: { zh: '请至少勾选一条建议', en: 'Please select at least one suggestion', ja: '少なくとも1件の提案を選択してください' },
- aggregated: { zh: '已聚合为 {groups} 类、共 {count} 条记录', en: 'Aggregated into {groups} groups, {count} records in total', ja: '{groups} グループ、合計 {count} 件の記録に集約しました' },
- applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions', ja: '{count} 件の提案を適用しました' },
+ applyNone: {
+ zh: '请至少勾选一条建议',
+ en: 'Please select at least one suggestion',
+ ja: '少なくとも1件の提案を選択してください',
+ },
+ aggregated: {
+ zh: '已聚合为 {groups} 类、共 {count} 条记录',
+ en: 'Aggregated into {groups} groups, {count} records in total',
+ ja: '{groups} グループ、合計 {count} 件の記録に集約しました',
+ },
+ applySuccess: {
+ zh: '已应用 {count} 条建议',
+ en: 'Applied {count} suggestions',
+ ja: '{count} 件の提案を適用しました',
+ },
oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied', ja: 'AI が提案したカテゴリを適用しました' },
channelApplied: { zh: '已应用 AI 推荐渠道', en: 'AI channel applied', ja: 'AI が提案したチャネルを適用しました' },
- noChannelSuggestion: { zh: '未找到合适的渠道建议', en: 'No channel suggestion', ja: '適切なチャネル提案が見つかりません' },
+ noChannelSuggestion: {
+ zh: '未找到合适的渠道建议',
+ en: 'No channel suggestion',
+ ja: '適切なチャネル提案が見つかりません',
+ },
tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied', ja: 'AI が提案したタグを適用しました' },
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions', ja: '提案の適用に失敗しました' },
- applyHint: { zh: '将覆盖分类并追加标签;标记「新建」的分类/标签会在确认后创建', en: 'Categories will be overwritten and tags appended; items marked "New" will be created after confirmation.', ja: 'カテゴリは上書き、タグは追加されます。「新規」の項目は確定後に作成されます。' },
+ applyHint: {
+ zh: '将覆盖分类并追加标签;标记「新建」的分类/标签会在确认后创建',
+ en: 'Categories will be overwritten and tags appended; items marked "New" will be created after confirmation.',
+ ja: 'カテゴリは上書き、タグは追加されます。「新規」の項目は確定後に作成されます。',
+ },
newBadge: { zh: '新建', en: 'New', ja: '新規' },
applyNewTitle: { zh: '确认新建', en: 'Confirm creation', ja: '新規作成の確認' },
- applyNewConfirm: { zh: '将新建 {categories} 个分类、{tags} 个标签,确认应用?', en: 'Will create {categories} category(ies) and {tags} tag(s). Continue?', ja: '{categories} 件のカテゴリと {tags} 件のタグを作成します。適用しますか?' },
- needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.', ja: 'AI が有効になっていません。「AI 設定」で設定してください。' },
+ applyNewConfirm: {
+ zh: '将新建 {categories} 个分类、{tags} 个标签,确认应用?',
+ en: 'Will create {categories} category(ies) and {tags} tag(s). Continue?',
+ ja: '{categories} 件のカテゴリと {tags} 件のタグを作成します。適用しますか?',
+ },
+ needConfig: {
+ zh: 'AI 未启用,请先在「AI 设置」中配置',
+ en: 'AI is not enabled. Please configure it in AI Settings first.',
+ ja: 'AI が有効になっていません。「AI 設定」で設定してください。',
+ },
decorate: {
emojiButton: { zh: 'AI 推荐', en: 'AI Pick', ja: 'AI におまかせ' },
colorButton: { zh: 'AI 推荐', en: 'AI Pick', ja: 'AI におまかせ' },
@@ -813,79 +1353,171 @@ export default {
rule: {
entry: { zh: 'AI 生成规则', en: 'AI Generate Rules', ja: 'AI でルール生成' },
title: { zh: 'AI 生成规则建议', en: 'AI Rule Suggestions', ja: 'AI ルール提案' },
- hint: { zh: 'AI 将依据所选记录的已有标签与分类,生成分类与标签规则建议,可编辑后勾选创建', en: 'AI derives rules from the tags and categories of the selected records; edit and select to create.', ja: 'AI が選択した記録の既存タグとカテゴリからルールを提案します。編集して選択後に作成できます。' },
- selectHint: { zh: '选择要用于生成规则的已打标签记录(默认全选当前页,已选 {count} 条)', en: 'Select tagged records to derive rules (current page selected by default, {count} selected)', ja: 'ルール生成に使うタグ付き記録を選択(既定で現在ページを全選択、選択中 {count} 件)' },
+ hint: {
+ zh: 'AI 将依据所选记录的已有标签与分类,生成分类与标签规则建议,可编辑后勾选创建',
+ en: 'AI derives rules from the tags and categories of the selected records; edit and select to create.',
+ ja: 'AI が選択した記録の既存タグとカテゴリからルールを提案します。編集して選択後に作成できます。',
+ },
+ selectHint: {
+ zh: '选择要用于生成规则的已打标签记录(默认全选当前页,已选 {count} 条)',
+ en: 'Select tagged records to derive rules (current page selected by default, {count} selected)',
+ ja: 'ルール生成に使うタグ付き記録を選択(既定で現在ページを全選択、選択中 {count} 件)',
+ },
searchPlaceholder: { zh: '搜索备注', en: 'Search remark', ja: '備考を検索' },
selectPageAll: { zh: '全选本页', en: 'Select page', ja: 'このページを全選択' },
clearSelection: { zh: '清空选择', en: 'Clear selection', ja: '選択をクリア' },
generate: { zh: '生成规则', en: 'Generate rules', ja: 'ルールを生成' },
back: { zh: '返回上一步', en: 'Back', ja: '戻る' },
noTagged: { zh: '暂无可用的已打标签记录', en: 'No tagged records available', ja: 'タグ付きの記録がありません' },
- loadRecordsFailed: { zh: '加载已打标签记录失败', en: 'Failed to load tagged records', ja: 'タグ付き記録の読み込みに失敗しました' },
+ loadRecordsFailed: {
+ zh: '加载已打标签记录失败',
+ en: 'Failed to load tagged records',
+ ja: 'タグ付き記録の読み込みに失敗しました',
+ },
reason: { zh: '理由', en: 'Reason', ja: '理由' },
newPrefix: { zh: '新建:', en: 'New: ', ja: '新規:' },
apply: { zh: '创建勾选规则', en: 'Create selected', ja: '選択したルールを作成' },
- selectRequired: { zh: '请至少勾选一条记录或规则', en: 'Please select at least one record or rule', ja: '少なくとも1件の記録またはルールを選択してください' },
+ selectRequired: {
+ zh: '请至少勾选一条记录或规则',
+ en: 'Please select at least one record or rule',
+ ja: '少なくとも1件の記録またはルールを選択してください',
+ },
applied: { zh: '已创建 {count} 条规则', en: 'Created {count} rule(s)', ja: '{count} 件のルールを作成しました' },
failed: { zh: 'AI 规则生成失败', en: 'Failed to generate AI rules', ja: 'AI ルールの生成に失敗しました' },
applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' },
duplicate: { zh: '已存在', en: 'Exists', ja: '既存' },
- allDuplicate: { zh: '生成的规则均已存在,无需创建', en: 'All generated rules already exist', ja: '生成されたルールはすべて既存です' },
+ allDuplicate: {
+ zh: '生成的规则均已存在,无需创建',
+ en: 'All generated rules already exist',
+ ja: '生成されたルールはすべて既存です',
+ },
},
category: {
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
- placeholder: { zh: '描述用途,如:日常通勤打车地铁', en: 'Describe it, e.g. daily commute by taxi and metro', ja: '用途を説明してください(例:日常の通勤でタクシーと地下鉄)' },
+ placeholder: {
+ zh: '描述用途,如:日常通勤打车地铁',
+ en: 'Describe it, e.g. daily commute by taxi and metro',
+ ja: '用途を説明してください(例:日常の通勤でタクシーと地下鉄)',
+ },
button: { zh: 'AI 生成', en: 'Generate', ja: 'AI で生成' },
textRequired: { zh: '请输入分类描述', en: 'Please enter a description', ja: 'カテゴリの説明を入力してください' },
- applied: { zh: '已生成分类,请确认后保存', en: 'Category generated, please confirm and save', ja: 'カテゴリを生成しました。確認して保存してください' },
+ applied: {
+ zh: '已生成分类,请确认后保存',
+ en: 'Category generated, please confirm and save',
+ ja: 'カテゴリを生成しました。確認して保存してください',
+ },
failed: { zh: 'AI 生成分类失败', en: 'Failed to generate category', ja: 'AI によるカテゴリ生成に失敗しました' },
},
newCategory: {
button: { zh: 'AI 新建', en: 'AI New', ja: 'AI で新規作成' },
hint: { zh: 'AI 建议新分类:', en: 'AI suggests:', ja: 'AI の提案する新カテゴリ:' },
save: { zh: '保存并选用', en: 'Save & Select', ja: '保存して選択' },
- saved: { zh: '已创建并选用新分类', en: 'Category created and selected', ja: '新しいカテゴリを作成して選択しました' },
- exists: { zh: '已存在同名分类,已为你选中', en: 'A same-name category exists and has been selected', ja: '同名のカテゴリが存在するため選択しました' },
- needInfo: { zh: '请先填写备注或金额,便于生成分类', en: 'Please fill in a remark or amount first', ja: 'カテゴリ生成のため、先に備考または金額を入力してください' },
+ saved: {
+ zh: '已创建并选用新分类',
+ en: 'Category created and selected',
+ ja: '新しいカテゴリを作成して選択しました',
+ },
+ exists: {
+ zh: '已存在同名分类,已为你选中',
+ en: 'A same-name category exists and has been selected',
+ ja: '同名のカテゴリが存在するため選択しました',
+ },
+ needInfo: {
+ zh: '请先填写备注或金额,便于生成分类',
+ en: 'Please fill in a remark or amount first',
+ ja: 'カテゴリ生成のため、先に備考または金額を入力してください',
+ },
},
newChannel: {
button: { zh: 'AI 新建', en: 'AI New', ja: 'AI で新規作成' },
hint: { zh: 'AI 建议新渠道:', en: 'AI suggests:', ja: 'AI の提案する新チャネル:' },
save: { zh: '保存并选用', en: 'Save & Select', ja: '保存して選択' },
- saved: { zh: '已创建并选用新渠道', en: 'Channel created and selected', ja: '新しいチャネルを作成して選択しました' },
- exists: { zh: '已存在同名渠道,已为你选中', en: 'A same-name channel exists and has been selected', ja: '同名のチャネルが存在するため選択しました' },
- needInfo: { zh: '请先填写备注或金额,便于生成渠道', en: 'Please fill in a remark or amount first', ja: 'チャネル生成のため、先に備考または金額を入力してください' },
- failed: { zh: 'AI 未能生成渠道,请重试', en: 'AI failed to generate a channel', ja: 'AI がチャネルを生成できませんでした' },
+ saved: {
+ zh: '已创建并选用新渠道',
+ en: 'Channel created and selected',
+ ja: '新しいチャネルを作成して選択しました',
+ },
+ exists: {
+ zh: '已存在同名渠道,已为你选中',
+ en: 'A same-name channel exists and has been selected',
+ ja: '同名のチャネルが存在するため選択しました',
+ },
+ needInfo: {
+ zh: '请先填写备注或金额,便于生成渠道',
+ en: 'Please fill in a remark or amount first',
+ ja: 'チャネル生成のため、先に備考または金額を入力してください',
+ },
+ failed: {
+ zh: 'AI 未能生成渠道,请重试',
+ en: 'AI failed to generate a channel',
+ ja: 'AI がチャネルを生成できませんでした',
+ },
},
newTag: {
button: { zh: 'AI 新建', en: 'AI New', ja: 'AI で新規作成' },
hint: { zh: 'AI 建议新标签:', en: 'AI suggests tag:', ja: 'AI の提案する新タグ:' },
save: { zh: '保存并选用', en: 'Save & Select', ja: '保存して選択' },
saved: { zh: '已创建并选用新标签', en: 'Tag created and selected', ja: '新しいタグを作成して選択しました' },
- exists: { zh: '已存在同名标签,已为你选中', en: 'A same-name tag exists and has been selected', ja: '同名のタグが存在するため選択しました' },
- needInfo: { zh: '请先填写备注或金额,便于生成标签', en: 'Please fill in a remark or amount first', ja: 'タグ生成のため、先に備考または金額を入力してください' },
+ exists: {
+ zh: '已存在同名标签,已为你选中',
+ en: 'A same-name tag exists and has been selected',
+ ja: '同名のタグが存在するため選択しました',
+ },
+ needInfo: {
+ zh: '请先填写备注或金额,便于生成标签',
+ en: 'Please fill in a remark or amount first',
+ ja: 'タグ生成のため、先に備考または金額を入力してください',
+ },
failed: { zh: 'AI 生成标签失败', en: 'Failed to generate tag', ja: 'AI によるタグ生成に失敗しました' },
},
relation: {
button: { zh: 'AI 生成', en: 'AI Generate', ja: 'AI 生成' },
- needMembers: { zh: '请先选择至少两条关联账单', en: 'Please select at least two related bills first', ja: '関連する明細を2件以上選択してください' },
- failed: { zh: 'AI 未能生成关系名称,请重试', en: 'AI failed to generate a relation name', ja: 'AI が関連名を生成できませんでした' },
+ needMembers: {
+ zh: '请先选择至少两条关联账单',
+ en: 'Please select at least two related bills first',
+ ja: '関連する明細を2件以上選択してください',
+ },
+ failed: {
+ zh: 'AI 未能生成关系名称,请重试',
+ en: 'AI failed to generate a relation name',
+ ja: 'AI が関連名を生成できませんでした',
+ },
},
query: {
- placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month', ja: '自然言語で検索(例:先月の食費はいくら)' },
- examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat', ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い' },
+ placeholder: {
+ zh: '用自然语言查询,如:上个月餐饮花了多少',
+ en: 'Ask in natural language, e.g. dining expenses last month',
+ ja: '自然言語で検索(例:先月の食費はいくら)',
+ },
+ examples: {
+ zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付',
+ en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat',
+ ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い',
+ },
button: { zh: 'AI 查询', en: 'AI Query', ja: 'AI 検索' },
entry: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' },
title: { zh: 'AI 智能查询', en: 'AI Smart Query', ja: 'AI スマート検索' },
- hint: { zh: '用自然语言描述查询条件,AI 会解析为筛选条件并搜索。', en: 'Describe the query in natural language; AI will turn it into filters and search.', ja: '自然言語で検索条件を説明すると、AI がフィルターに変換して検索します。' },
+ hint: {
+ zh: '用自然语言描述查询条件,AI 会解析为筛选条件并搜索。',
+ en: 'Describe the query in natural language; AI will turn it into filters and search.',
+ ja: '自然言語で検索条件を説明すると、AI がフィルターに変換して検索します。',
+ },
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
submitHint: { zh: 'Ctrl + Enter 提交', en: 'Ctrl + Enter to submit', ja: 'Ctrl + Enter で送信' },
failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' },
resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result', ja: 'AI 解析結果' },
apply: { zh: '应用筛选', en: 'Apply filters', ja: 'フィルターを適用' },
- applied: { zh: '已应用 AI 查询筛选条件', en: 'AI query filters applied', ja: 'AI 検索のフィルターを適用しました' },
- noCondition: { zh: '未识别到可用的筛选条件,请换一种说法', en: 'No usable filter conditions recognized, please rephrase', ja: '利用できる条件を認識できませんでした。別の言い方をしてください' },
+ applied: {
+ zh: '已应用 AI 查询筛选条件',
+ en: 'AI query filters applied',
+ ja: 'AI 検索のフィルターを適用しました',
+ },
+ noCondition: {
+ zh: '未识别到可用的筛选条件,请换一种说法',
+ en: 'No usable filter conditions recognized, please rephrase',
+ ja: '利用できる条件を認識できませんでした。別の言い方をしてください',
+ },
type: { zh: '类型', en: 'Type', ja: '種類' },
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
tag: { zh: '标签', en: 'Tag', ja: 'タグ' },
@@ -901,9 +1533,21 @@ export default {
sql: {
entry: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
title: { zh: '智能查数', en: 'Smart Data Query', ja: 'スマート集計' },
- hint: { zh: '用自然语言描述你想查的内容,AI 会生成只读查询并展示结果;只能查询你本人的数据。', en: 'Describe what you want to query; AI generates a read-only query and shows the result. Only your own data can be queried.', ja: '知りたい内容を自然言語で説明すると、AI が読み取り専用クエリを生成して結果を表示します。自分のデータのみ参照できます。' },
- placeholder: { zh: '例如:上个月一共花了多少钱', en: 'e.g. How much did I spend last month in total?', ja: '例:先月の支出合計はいくら?' },
- examples: { zh: '上个月一共花了多少钱|按分类统计今年的支出|今年收入最高的一个月|各渠道的支出占比|本月支出最高的5笔', en: 'How much did I spend last month?|Expenses by category this year|Month with highest income this year|Spending share by channel|Top 5 expenses this month', ja: '先月の支出合計|今年のカテゴリ別支出|今年の収入が最も多い月|チャネル別支出の割合|今月の支出トップ5' },
+ hint: {
+ zh: '用自然语言描述你想查的内容,AI 会生成只读查询并展示结果;只能查询你本人的数据。',
+ en: 'Describe what you want to query; AI generates a read-only query and shows the result. Only your own data can be queried.',
+ ja: '知りたい内容を自然言語で説明すると、AI が読み取り専用クエリを生成して結果を表示します。自分のデータのみ参照できます。',
+ },
+ placeholder: {
+ zh: '例如:上个月一共花了多少钱',
+ en: 'e.g. How much did I spend last month in total?',
+ ja: '例:先月の支出合計はいくら?',
+ },
+ examples: {
+ zh: '上个月一共花了多少钱|按分类统计今年的支出|今年收入最高的一个月|各渠道的支出占比|本月支出最高的5笔',
+ en: 'How much did I spend last month?|Expenses by category this year|Month with highest income this year|Spending share by channel|Top 5 expenses this month',
+ ja: '先月の支出合計|今年のカテゴリ別支出|今年の収入が最も多い月|チャネル別支出の割合|今月の支出トップ5',
+ },
submit: { zh: '查询', en: 'Query', ja: '検索' },
submitting: { zh: '正在查询,请稍候…', en: 'Querying, please wait…', ja: '検索中です。しばらくお待ちください…' },
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
@@ -914,7 +1558,11 @@ export default {
copySuccess: { zh: '已复制', en: 'Copied', ja: 'コピーしました' },
empty: { zh: '查询没有返回结果', en: 'The query returned no result', ja: '結果がありません' },
rowCount: { zh: '共 {count} 行', en: '{count} row(s)', ja: '{count} 行' },
- truncated: { zh: '结果超过 {max} 行,仅展示前 {max} 行', en: 'More than {max} rows; only the first {max} are shown', ja: '{max} 行を超えるため、先頭 {max} 行のみ表示します' },
+ truncated: {
+ zh: '结果超过 {max} 行,仅展示前 {max} 行',
+ en: 'More than {max} rows; only the first {max} are shown',
+ ja: '{max} 行を超えるため、先頭 {max} 行のみ表示します',
+ },
viewTable: { zh: '表格', en: 'Table', ja: 'テーブル' },
viewBar: { zh: '柱状图', en: 'Bar chart', ja: '棒グラフ' },
viewPie: { zh: '饼图', en: 'Pie chart', ja: '円グラフ' },
@@ -924,34 +1572,94 @@ export default {
copyQuestion: { zh: '复制提问', en: 'Copy question', ja: '質問をコピー' },
historySearch: { zh: '搜索历史提问', en: 'Search history', ja: '履歴を検索' },
historyNoMatch: { zh: '无匹配结果', en: 'No matching history', ja: '一致する履歴がありません' },
- needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
+ needConfig: {
+ zh: 'AI 未启用,请先在「设置」中开启 AI。',
+ en: 'AI is not enabled. Please enable it in Settings first.',
+ ja: 'AI が有効になっていません。「設定」で有効にしてください。',
+ },
},
record: {
button: { zh: '智能记账', en: 'Smart Entry', ja: 'スマート入力' },
title: { zh: 'AI 智能记账', en: 'AI Smart Entry', ja: 'AI スマート入力' },
- hint: { zh: '用一句话描述这笔收支,AI 会帮你填好表单,确认后再保存。', en: 'Describe the transaction in one sentence; AI fills the form for you to confirm.', ja: 'この収支を一文で説明すると、AI がフォームを入力します。確認してから保存してください。' },
- placeholder: { zh: '例如:昨天中午吃饭 35 支付宝', en: 'e.g. Lunch yesterday 35 Alipay', ja: '例:昨日の昼食 35 Alipay' },
+ hint: {
+ zh: '用一句话描述这笔收支,AI 会帮你填好表单,确认后再保存。',
+ en: 'Describe the transaction in one sentence; AI fills the form for you to confirm.',
+ ja: 'この収支を一文で説明すると、AI がフォームを入力します。確認してから保存してください。',
+ },
+ placeholder: {
+ zh: '例如:昨天中午吃饭 35 支付宝',
+ en: 'e.g. Lunch yesterday 35 Alipay',
+ ja: '例:昨日の昼食 35 Alipay',
+ },
parse: { zh: '解析', en: 'Parse', ja: '解析' },
- emptyInput: { zh: '请输入记账内容', en: 'Please enter the transaction description', ja: '記帳内容を入力してください' },
+ emptyInput: {
+ zh: '请输入记账内容',
+ en: 'Please enter the transaction description',
+ ja: '記帳内容を入力してください',
+ },
failed: { zh: 'AI 记账解析失败', en: 'Failed to parse the transaction', ja: 'AI 記帳の解析に失敗しました' },
textTab: { zh: '文字记账', en: 'Text Entry', ja: 'テキスト入力' },
imageTab: { zh: '图片记账', en: 'Image Entry', ja: '画像入力' },
- imageHint: { zh: '上传一至多张账单或支付截图(最多 5 张),AI 会一次性识别其中的每一笔交易,确认后导入。', en: 'Upload one to several bill or payment screenshots (up to 5); AI recognizes every transaction in one pass for you to confirm and import.', ja: '請求書や支払いのスクリーンショットを1〜複数枚(最大5枚)アップロードすると、AI が各取引をまとめて認識します。確認して取り込んでください。' },
+ imageHint: {
+ zh: '上传一至多张账单或支付截图(最多 5 张),AI 会一次性识别其中的每一笔交易,确认后导入。',
+ en: 'Upload one to several bill or payment screenshots (up to 5); AI recognizes every transaction in one pass for you to confirm and import.',
+ ja: '請求書や支払いのスクリーンショットを1〜複数枚(最大5枚)アップロードすると、AI が各取引をまとめて認識します。確認して取り込んでください。',
+ },
upload: { zh: '上传图片(可多选)', en: 'Upload images (multiple)', ja: '画像をアップロード(複数可)' },
- uploadTip: { zh: '支持 JPEG/PNG/GIF/WebP,单张不超过 10MB,最多 5 张', en: 'JPEG/PNG/GIF/WebP, up to 10MB each, max 5 images', ja: 'JPEG/PNG/GIF/WebP、1枚10MBまで、最大5枚' },
- pasteHint: { zh: '也可直接按 Ctrl+V(Mac 为 ⌘V)粘贴剪贴板中的图片', en: 'You can also press Ctrl+V (⌘V on Mac) to paste an image from the clipboard', ja: 'Ctrl+V(Mac は ⌘V)でクリップボードの画像を貼り付けることもできます' },
- pasteSuccess: { zh: '已粘贴剪贴板中的图片', en: 'Pasted the image from clipboard', ja: 'クリップボードの画像を貼り付けました' },
+ uploadTip: {
+ zh: '支持 JPEG/PNG/GIF/WebP,单张不超过 10MB,最多 5 张',
+ en: 'JPEG/PNG/GIF/WebP, up to 10MB each, max 5 images',
+ ja: 'JPEG/PNG/GIF/WebP、1枚10MBまで、最大5枚',
+ },
+ pasteHint: {
+ zh: '也可直接按 Ctrl+V(Mac 为 ⌘V)粘贴剪贴板中的图片',
+ en: 'You can also press Ctrl+V (⌘V on Mac) to paste an image from the clipboard',
+ ja: 'Ctrl+V(Mac は ⌘V)でクリップボードの画像を貼り付けることもできます',
+ },
+ pasteSuccess: {
+ zh: '已粘贴剪贴板中的图片',
+ en: 'Pasted the image from clipboard',
+ ja: 'クリップボードの画像を貼り付けました',
+ },
parseImage: { zh: '识别图片', en: 'Recognize', ja: '画像を解析' },
- recognizing: { zh: '正在识别图片,请稍候…', en: 'Recognizing, please wait…', ja: '画像を解析中です。しばらくお待ちください…' },
+ recognizing: {
+ zh: '正在识别图片,请稍候…',
+ en: 'Recognizing, please wait…',
+ ja: '画像を解析中です。しばらくお待ちください…',
+ },
progressTitle: { zh: '识别进度', en: 'Progress', ja: '進捗' },
- imageFailed: { zh: 'AI 图片记账解析失败', en: 'Failed to parse the image', ja: 'AI 画像記帳の解析に失敗しました' },
- noRecord: { zh: '未从图片中识别到有效交易', en: 'No valid transactions recognized from the image', ja: '画像から有効な取引を認識できませんでした' },
+ imageFailed: {
+ zh: 'AI 图片记账解析失败',
+ en: 'Failed to parse the image',
+ ja: 'AI 画像記帳の解析に失敗しました',
+ },
+ noRecord: {
+ zh: '未从图片中识别到有效交易',
+ en: 'No valid transactions recognized from the image',
+ ja: '画像から有効な取引を認識できませんでした',
+ },
resultTitle: { zh: '图片记账结果', en: 'Image Entry Result', ja: '画像記帳の結果' },
- resultHint: { zh: '请核对识别结果,勾选后导入;可直接编辑类型、金额、分类与备注。', en: 'Review the results, edit type/amount/category/remark, then import the selected ones.', ja: '認識結果を確認し、種別・金額・カテゴリ・備考を編集して、選択した項目を取り込んでください。' },
- importSelected: { zh: '导入勾选({count})', en: 'Import selected ({count})', ja: '選択項目を取り込む({count})' },
- importSuccess: { zh: '已导入 {count} 条记录', en: 'Imported {count} record(s)', ja: '{count} 件を取り込みました' },
+ resultHint: {
+ zh: '请核对识别结果,勾选后导入;可直接编辑类型、金额、分类与备注。',
+ en: 'Review the results, edit type/amount/category/remark, then import the selected ones.',
+ ja: '認識結果を確認し、種別・金額・カテゴリ・備考を編集して、選択した項目を取り込んでください。',
+ },
+ importSelected: {
+ zh: '导入勾选({count})',
+ en: 'Import selected ({count})',
+ ja: '選択項目を取り込む({count})',
+ },
+ importSuccess: {
+ zh: '已导入 {count} 条记录',
+ en: 'Imported {count} record(s)',
+ ja: '{count} 件を取り込みました',
+ },
importFailed: { zh: '导入失败', en: 'Import failed', ja: '取り込みに失敗しました' },
- selectAtLeastOne: { zh: '请至少勾选一条记录', en: 'Please select at least one record', ja: '少なくとも1件の記録を選択してください' },
+ selectAtLeastOne: {
+ zh: '请至少勾选一条记录',
+ en: 'Please select at least one record',
+ ja: '少なくとも1件の記録を選択してください',
+ },
viewImage: { zh: '查看原图', en: 'View image', ja: '元画像を表示' },
col: {
source: { zh: '来源图', en: 'Source', ja: '元画像' },
@@ -967,7 +1675,11 @@ export default {
},
sourceImageLabel: { zh: '图 {index}', en: 'Image {index}', ja: '画像 {index}' },
duplicate: { zh: '疑似重复', en: 'Duplicate', ja: '重複の疑い' },
- maxImages: { zh: '单次最多上传 {count} 张图片', en: 'Up to {count} images per upload', ja: '1回に最大 {count} 枚までアップロードできます' },
+ maxImages: {
+ zh: '单次最多上传 {count} 张图片',
+ en: 'Up to {count} images per upload',
+ ja: '1回に最大 {count} 枚までアップロードできます',
+ },
},
column: {
record: { zh: '记录', en: 'Record', ja: '記録' },
@@ -1060,7 +1772,11 @@ export default {
config: {
name: { zh: '名称', en: 'Name', ja: '名称' },
host: { zh: 'IMAP 服务器', en: 'IMAP Server', ja: 'IMAPサーバー' },
- hostPlaceholder: { zh: '选择或输入 IMAP 服务器', en: 'Select or enter IMAP server', ja: 'IMAPサーバーを選択または入力' },
+ hostPlaceholder: {
+ zh: '选择或输入 IMAP 服务器',
+ en: 'Select or enter IMAP server',
+ ja: 'IMAPサーバーを選択または入力',
+ },
port: { zh: '端口', en: 'Port', ja: 'ポート' },
ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSLを使用' },
username: { zh: '邮箱账号', en: 'Email', ja: 'メールアドレス' },
@@ -1082,7 +1798,11 @@ export default {
check: { zh: '立即检查', en: 'Check Now', ja: '今すぐ確認' },
testSuccess: { zh: '连接成功', en: 'Connected', ja: '接続に成功しました' },
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
- checkResult: { zh: '新增 {count} 条待处理账单', en: '{count} new pending bill(s)', ja: '未処理の請求を {count} 件追加しました' },
+ checkResult: {
+ zh: '新增 {count} 条待处理账单',
+ en: '{count} new pending bill(s)',
+ ja: '未処理の請求を {count} 件追加しました',
+ },
checkLogTitle: { zh: '检查进度', en: 'Check Progress', ja: '確認の進行状況' },
checkRunning: { zh: '检查中…', en: 'Checking…', ja: '確認中…' },
checkClear: { zh: '清空', en: 'Clear', ja: 'クリア' },
@@ -1097,15 +1817,27 @@ export default {
senderRule: {
name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' },
patterns: { zh: '发件人匹配串', en: 'Sender Patterns', ja: '送信者パターン' },
- patternsHint: { zh: '每行一个,支持完整地址、@域名或关键字,包含匹配且忽略大小写', en: 'One per line; full address, @domain or keyword, case-insensitive contains', ja: '1行に1つ。完全なアドレス、@ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)' },
+ patternsHint: {
+ zh: '每行一个,支持完整地址、@域名或关键字,包含匹配且忽略大小写',
+ en: 'One per line; full address, @domain or keyword, case-insensitive contains',
+ ja: '1行に1つ。完全なアドレス、@ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)',
+ },
billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' },
archivePassword: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
- archivePasswordPlaceholder: { zh: '压缩包加密时填写', en: 'Fill if the archive is encrypted', ja: '暗号化されている場合に入力' },
+ archivePasswordPlaceholder: {
+ zh: '压缩包加密时填写',
+ en: 'Fill if the archive is encrypted',
+ ja: '暗号化されている場合に入力',
+ },
sortOrder: { zh: '优先级', en: 'Priority', ja: '優先度' },
defaultExpenseCategory: { zh: '默认支出分类', en: 'Default Expense Category', ja: '既定の支出カテゴリ' },
defaultIncomeCategory: { zh: '默认收入分类', en: 'Default Income Category', ja: '既定の収入カテゴリ' },
defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' },
- patternsRequired: { zh: '请至少填写一个发件人匹配串', en: 'Please enter at least one sender pattern', ja: '送信者パターンを1つ以上入力してください' },
+ patternsRequired: {
+ zh: '请至少填写一个发件人匹配串',
+ en: 'Please enter at least one sender pattern',
+ ja: '送信者パターンを1つ以上入力してください',
+ },
},
message: {
receivedAt: { zh: '接收时间', en: 'Received', ja: '受信日時' },
@@ -1137,10 +1869,18 @@ export default {
selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' },
setIncluded: { zh: '计入统计', en: 'Include', ja: '集計に含める' },
setExcluded: { zh: '不计入', en: 'Exclude', ja: '集計から除外' },
- previewSummary: { zh: '共 {total} 行,筛选后 {filtered} 行,已选 {checked} 行', en: '{total} rows, {filtered} after filter, {checked} selected', ja: '全 {total} 行、絞り込み後 {filtered} 行、選択 {checked} 行' },
+ previewSummary: {
+ zh: '共 {total} 行,筛选后 {filtered} 行,已选 {checked} 行',
+ en: '{total} rows, {filtered} after filter, {checked} selected',
+ ja: '全 {total} 行、絞り込み後 {filtered} 行、選択 {checked} 行',
+ },
confirmImport: { zh: '导入 {count} 条', en: 'Import {count}', ja: '{count} 件を取込' },
importSuccess: { zh: '导入完成', en: 'Import completed', ja: '取込が完了しました' },
- resultSummary: { zh: '成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条', en: 'Imported {imported}, skipped {skipped}, failed {failed}', ja: '成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件' },
+ resultSummary: {
+ zh: '成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条',
+ en: 'Imported {imported}, skipped {skipped}, failed {failed}',
+ ja: '成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件',
+ },
done: { zh: '完成', en: 'Done', ja: '完了' },
prevStep: { zh: '返回', en: 'Back', ja: '戻る' },
column: {
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index acd278a..1976481 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -154,9 +154,7 @@ router.beforeEach(async (to, _from, next) => {
// 仅允许站内路径回跳,避免开放重定向
const redirect = Array.isArray(to.query.redirect) ? to.query.redirect[0] : to.query.redirect
const target =
- typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
- ? redirect
- : '/calendar'
+ typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/calendar'
next(target)
return
}
diff --git a/frontend/src/utils/apiCrypto.ts b/frontend/src/utils/apiCrypto.ts
index 8b6c7a9..ae80aeb 100644
--- a/frontend/src/utils/apiCrypto.ts
+++ b/frontend/src/utils/apiCrypto.ts
@@ -10,13 +10,7 @@ export const ENVELOPE_MARKER = 'A256GCM'
export const SESSION_EXPIRED_CODE = 4201
/** 不参与加密的请求路径(SSE、文件上传下载、图片等) */
-const SKIP_PATHS = [
- '/crypto/',
- '/ai/image/',
- '/ai/record/image',
- '/bill-import/parse',
- '/transaction/export',
-]
+const SKIP_PATHS = ['/crypto/', '/ai/image/', '/ai/record/image', '/bill-import/parse', '/transaction/export']
/** 加密信封结构(与后端一致) */
export interface CryptoEnvelope {
@@ -42,11 +36,7 @@ let handshakePromise: Promise
| null = null
* @param responseType 响应类型
* @returns 跳过返回 true
*/
-export function shouldSkipCrypto(
- url: string | undefined,
- data: unknown,
- responseType: string | undefined
-): boolean {
+export function shouldSkipCrypto(url: string | undefined, data: unknown, responseType: string | undefined): boolean {
if (data instanceof FormData) return true
if (responseType === 'blob') return true
if (!url) return false
diff --git a/frontend/src/utils/navItems.ts b/frontend/src/utils/navItems.ts
index 2aee45c..fb88de8 100644
--- a/frontend/src/utils/navItems.ts
+++ b/frontend/src/utils/navItems.ts
@@ -1,7 +1,14 @@
// 侧边导航清单(单一数据源):定义各导航项的标识、文案、图标与默认显隐/顺序,供主布局与设置页共用
import { markRaw, type Component } from 'vue'
import {
- BarChartOutline, CalendarOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, SparklesOutline,
+ BarChartOutline,
+ CalendarOutline,
+ ListOutline,
+ FolderOpenOutline,
+ PricetagsOutline,
+ OptionsOutline,
+ MailOutline,
+ SparklesOutline,
} from '@vicons/ionicons5'
/** 导航项定义 */
@@ -20,12 +27,48 @@ export interface NavItemDef {
/** 默认导航项清单,数组顺序即默认展示顺序;图标用 markRaw 避免被响应式包装 */
export const NAV_ITEMS: NavItemDef[] = [
- { key: 'calendar', labelKey: 'layout.calendar', icon: markRaw(CalendarOutline), aiDependent: false, defaultVisible: true },
- { key: 'dashboard', labelKey: 'layout.dashboard', icon: markRaw(BarChartOutline), aiDependent: false, defaultVisible: true },
- { key: 'smart-query', labelKey: 'layout.sqlQuery', icon: markRaw(SparklesOutline), aiDependent: true, defaultVisible: true },
- { key: 'transactions', labelKey: 'layout.transactions', icon: markRaw(ListOutline), aiDependent: false, defaultVisible: true },
- { key: 'categories', labelKey: 'layout.categories', icon: markRaw(FolderOpenOutline), aiDependent: false, defaultVisible: true },
+ {
+ key: 'calendar',
+ labelKey: 'layout.calendar',
+ icon: markRaw(CalendarOutline),
+ aiDependent: false,
+ defaultVisible: true,
+ },
+ {
+ key: 'dashboard',
+ labelKey: 'layout.dashboard',
+ icon: markRaw(BarChartOutline),
+ aiDependent: false,
+ defaultVisible: true,
+ },
+ {
+ key: 'smart-query',
+ labelKey: 'layout.sqlQuery',
+ icon: markRaw(SparklesOutline),
+ aiDependent: true,
+ defaultVisible: true,
+ },
+ {
+ key: 'transactions',
+ labelKey: 'layout.transactions',
+ icon: markRaw(ListOutline),
+ aiDependent: false,
+ defaultVisible: true,
+ },
+ {
+ key: 'categories',
+ labelKey: 'layout.categories',
+ icon: markRaw(FolderOpenOutline),
+ aiDependent: false,
+ defaultVisible: true,
+ },
{ key: 'tags', labelKey: 'layout.tags', icon: markRaw(PricetagsOutline), aiDependent: false, defaultVisible: true },
{ key: 'rules', labelKey: 'layout.rules', icon: markRaw(OptionsOutline), aiDependent: false, defaultVisible: true },
- { key: 'email-bills', labelKey: 'layout.emailBills', icon: markRaw(MailOutline), aiDependent: false, defaultVisible: true },
+ {
+ key: 'email-bills',
+ labelKey: 'layout.emailBills',
+ icon: markRaw(MailOutline),
+ aiDependent: false,
+ defaultVisible: true,
+ },
]
diff --git a/frontend/src/utils/password.ts b/frontend/src/utils/password.ts
index e498915..3c3e7cf 100644
--- a/frontend/src/utils/password.ts
+++ b/frontend/src/utils/password.ts
@@ -27,7 +27,7 @@ function secureRandomInt(max: number): number {
// 取整倍数区间拒绝采样,避免取模带来的分布偏差
const limit = Math.floor(0xffffffff / max) * max
const buffer = new Uint32Array(1)
- let value = 0
+ let value: number
do {
crypto.getRandomValues(buffer)
value = buffer[0]
diff --git a/frontend/src/utils/render.ts b/frontend/src/utils/render.ts
index def5927..dc02c50 100644
--- a/frontend/src/utils/render.ts
+++ b/frontend/src/utils/render.ts
@@ -3,8 +3,5 @@ import { h } from 'vue'
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
export function renderCategoryLabel(option: any) {
- return h('span', null, [
- option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
- option.label,
- ])
+ return h('span', null, [option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null, option.label])
}
diff --git a/frontend/src/utils/transactionFilterPrefs.ts b/frontend/src/utils/transactionFilterPrefs.ts
index 4991bdd..047d0dc 100644
--- a/frontend/src/utils/transactionFilterPrefs.ts
+++ b/frontend/src/utils/transactionFilterPrefs.ts
@@ -41,13 +41,19 @@ export function loadTransactionFilterPrefs(): Partial {
result.filterDateRange = raw.filterDateRange as [number, number]
}
// filterIncludeInStats 为三态:'true'-计入、'false'-不计入、null-全部;显式 null 也需保留
- if (raw.filterIncludeInStats === 'true' || raw.filterIncludeInStats === 'false' || raw.filterIncludeInStats === null) {
+ if (
+ raw.filterIncludeInStats === 'true' ||
+ raw.filterIncludeInStats === 'false' ||
+ raw.filterIncludeInStats === null
+ ) {
result.filterIncludeInStats = raw.filterIncludeInStats
}
if (typeof raw.filterRemark === 'string') result.filterRemark = raw.filterRemark
// 金额支持显式 null(表示已清空),需与「键不存在」区分
- if (typeof raw.filterMinAmount === 'number' || raw.filterMinAmount === null) result.filterMinAmount = raw.filterMinAmount
- if (typeof raw.filterMaxAmount === 'number' || raw.filterMaxAmount === null) result.filterMaxAmount = raw.filterMaxAmount
+ if (typeof raw.filterMinAmount === 'number' || raw.filterMinAmount === null)
+ result.filterMinAmount = raw.filterMinAmount
+ if (typeof raw.filterMaxAmount === 'number' || raw.filterMaxAmount === null)
+ result.filterMaxAmount = raw.filterMaxAmount
if (typeof raw.pageSize === 'number') result.pageSize = raw.pageSize
if (typeof raw.sortValue === 'string') result.sortValue = raw.sortValue
if (Array.isArray(raw.filterTag)) result.filterTag = raw.filterTag
diff --git a/frontend/src/views/AiUsage.vue b/frontend/src/views/AiUsage.vue
index 5354f86..fec1bba 100644
--- a/frontend/src/views/AiUsage.vue
+++ b/frontend/src/views/AiUsage.vue
@@ -12,7 +12,7 @@ const { t } = useI18n()
-
+
{{ t('aiUsage.title') }}
diff --git a/frontend/src/views/BillImportModal.vue b/frontend/src/views/BillImportModal.vue
index 2fff21f..5128ee6 100644
--- a/frontend/src/views/BillImportModal.vue
+++ b/frontend/src/views/BillImportModal.vue
@@ -23,7 +23,7 @@ function handleClose() {
:show="props.show"
preset="card"
:title="t('import.title')"
- style="width: 960px; max-width: 92vw;"
+ style="width: 960px; max-width: 92vw"
:mask-closable="false"
@update:show="handleClose"
>
diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue
index 5f90bd9..7c4b180 100644
--- a/frontend/src/views/BillImportPanel.vue
+++ b/frontend/src/views/BillImportPanel.vue
@@ -4,7 +4,17 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
- useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NAlert, NText, NIcon, NInput, NModal,
+ useMessage,
+ NSpace,
+ NButton,
+ NSelect,
+ NUpload,
+ NUploadDragger,
+ NAlert,
+ NText,
+ NIcon,
+ NInput,
+ NModal,
} from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
@@ -94,7 +104,7 @@ async function loadBillTypes() {
const res: any = await getBillTypes()
billTypes.value = res.data || []
const codes = billTypes.value.map((item) => item.code)
- billType.value = codes.includes(billType.value) ? billType.value : (codes[0] || '')
+ billType.value = codes.includes(billType.value) ? billType.value : codes[0] || ''
} catch (e) {
// 静默处理,账单类型下拉保持为空
}
@@ -114,7 +124,9 @@ const archivePassword = ref('')
// 解析结果与导入结果
const rows = ref([])
-const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
+const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(
+ null,
+)
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
@@ -236,7 +248,7 @@ onMounted(() => {
{{ t('import.billType') }}
-
+
@@ -246,16 +258,16 @@ onMounted(() => {
:default-upload="false"
:max="1"
:accept="acceptTypes"
- style="margin-top: 8px;"
+ style="margin-top: 8px"
@change="handleFileChange"
>
-
+
-
{{ t('import.dropText') }}
-
+
{{ t('import.dropText') }}
+
{{ fileHint }}
@@ -269,7 +281,9 @@ onMounted(() => {
{{ t('common.cancel') }}
- {{ t('import.parse') }}
+ {{
+ t('import.parse')
+ }}
@@ -290,7 +304,7 @@ onMounted(() => {
v-model:show="passwordModalVisible"
preset="card"
:title="t('import.passwordTitle')"
- style="width: 420px; max-width: 92vw;"
+ style="width: 420px; max-width: 92vw"
:mask-closable="false"
>
diff --git a/frontend/src/views/Calendar.vue b/frontend/src/views/Calendar.vue
index b278f85..a888cfb 100644
--- a/frontend/src/views/Calendar.vue
+++ b/frontend/src/views/Calendar.vue
@@ -4,8 +4,20 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
- NCalendar, NCard, NSpace, NGrid, NGridItem, NStatistic, NModal, NSpin,
- NEmpty, NButton, NIcon, NSelect, NText, useMessage,
+ NCalendar,
+ NCard,
+ NSpace,
+ NGrid,
+ NGridItem,
+ NStatistic,
+ NModal,
+ NSpin,
+ NEmpty,
+ NButton,
+ NIcon,
+ NSelect,
+ NText,
+ useMessage,
} from 'naive-ui'
import { ChevronBackOutline, ChevronForwardOutline } from '@vicons/ionicons5'
import { getStatisticsGroup } from '../api/statistics'
@@ -147,9 +159,7 @@ const showTransactionDetail = ref(false)
const detailTransaction = ref(null)
/** 当前明细浮窗所查看日期的合计:与日历同源,不受明细列表条数上限影响 */
-const detailSummary = computed(
- () => dayMap.value[detailDate.value] ?? { income: 0, expense: 0, count: 0 },
-)
+const detailSummary = computed(() => dayMap.value[detailDate.value] ?? { income: 0, expense: 0, count: 0 })
/** 当前面板首日时间戳(NDatePicker 与 NCalendar defaultValue 共用) */
const panelTs = computed(() => new Date(panelYear.value, panelMonth.value - 1, 1).getTime())
@@ -412,10 +422,7 @@ function goToTransactions() {
const [y, m, d] = detailDate.value.split('-').map(Number)
if (!y || !m || !d) return
saveTransactionFilterPrefs({
- filterDateRange: [
- new Date(y, m - 1, d, 0, 0, 0).getTime(),
- new Date(y, m - 1, d, 23, 59, 59).getTime(),
- ],
+ filterDateRange: [new Date(y, m - 1, d, 0, 0, 0).getTime(), new Date(y, m - 1, d, 23, 59, 59).getTime()],
filterIncludeInStats: statsScopePref(),
filterType: [],
filterCategory: [],
@@ -492,21 +499,25 @@ onMounted(() => {
-
- {{ t('calendar.title') }}
- {{ t('calendar.hint') }}
+
+ {{ t('calendar.title') }}
+ {{ t('calendar.hint') }}
-
+
-
+
-
+
@@ -596,11 +607,7 @@ onMounted(() => {
-
+
{
>
-
+
- +{{ moneyCompact(d.income) }}
- -{{ moneyCompact(d.expense) }}
+
+ +{{ moneyCompact(d.income) }}
+
+
+ -{{ moneyCompact(d.expense) }}
+
+{{ money(d.income) }}
- -{{ money(d.expense) }}
+
+ -{{ money(d.expense) }}
+
{
@@ -654,16 +667,19 @@ onMounted(() => {
@click="openYearMonth(yearViewYear, m)"
>
{{ monthLabel(m) }}
-
+
- {{ t('calendar.income') }}+{{ money(s.income) }}
+ {{ t('calendar.income') }}+{{ money(s.income) }}
- {{ t('calendar.expense') }}-{{ money(s.expense) }}
+ {{ t('calendar.expense') }}-{{ money(s.expense) }}
- {{ t('calendar.balance') }}{{ money(s.income - s.expense) }}
+ {{ t('calendar.balance') }}{{ money(s.income - s.expense) }}
{{ t('calendar.noData') }}
@@ -681,7 +697,7 @@ onMounted(() => {
preset="card"
:title="t('calendar.detailTitle', { date: detailDate })"
class="detail-modal"
- style="max-width: 92vw;"
+ style="max-width: 92vw"
>
@@ -711,10 +727,7 @@ onMounted(() => {
{{ item.categoryName || t('transaction.uncategorized') }}
{{ getChannelLabel(item.channel) }}
-
+
{{ item.type === 'INCOME' ? '+' : '-' }}{{ money(item.amount) }}
@@ -725,7 +738,7 @@ onMounted(() => {
-
+
{{ t('calendar.detailMore', { shown: DETAIL_PAGE_SIZE, total: detailTotal }) }}
@@ -735,11 +748,7 @@ onMounted(() => {
-
+
@@ -824,7 +833,9 @@ onMounted(() => {
border-radius: 8px;
background: #fafafc;
cursor: pointer;
- transition: border-color 0.2s, background-color 0.2s;
+ transition:
+ border-color 0.2s,
+ background-color 0.2s;
}
.year-cell:hover {
border-color: #18a058;
diff --git a/frontend/src/views/CategoryManage.vue b/frontend/src/views/CategoryManage.vue
index 911b8ed..ce97a67 100644
--- a/frontend/src/views/CategoryManage.vue
+++ b/frontend/src/views/CategoryManage.vue
@@ -2,7 +2,19 @@
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
-import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
+import {
+ useMessage,
+ useDialog,
+ NButton,
+ NSpace,
+ NDataTable,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NInputNumber,
+ NSelect,
+} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getCategoryPage, createCategory, updateCategory, deleteCategory } from '../api/category'
import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
@@ -77,7 +89,8 @@ const typeOptions = computed(() => [
function createColumns(): DataTableColumns {
return [
{
- title: t('common.name'), key: 'name',
+ title: t('common.name'),
+ key: 'name',
render(row) {
// 名称前展示 Emoji 图标,未设置时占位
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
@@ -87,7 +100,9 @@ function createColumns(): DataTableColumns {
},
},
{
- title: t('category.icon'), key: 'icon', width: 100,
+ title: t('category.icon'),
+ key: 'icon',
+ width: 100,
render(row) {
return row.icon
? h('span', { style: { fontSize: '20px', lineHeight: '1' } }, row.icon)
@@ -96,14 +111,20 @@ function createColumns(): DataTableColumns {
},
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
{
- title: t('common.operation'), key: 'actions', width: 180,
+ title: t('common.operation'),
+ key: 'actions',
+ width: 180,
render(row) {
// 编辑和删除按钮
return [
h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
- h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
+ h(
+ NButton,
+ { size: 'small', type: 'error', onClick: () => handleDelete(row.id) },
+ { default: () => t('common.delete') },
+ ),
],
}),
]
@@ -296,8 +317,8 @@ onMounted(() => {
-
- {{ t('category.title') }}
+
+ {{ t('category.title') }}
{{ t('category.add') }}
@@ -316,7 +337,7 @@ onMounted(() => {
/>
- {{ t('category.incomeSection') }}
+ {{ t('category.incomeSection') }}
{
/>
-
+
-
-
+
+
{{ t('ai.category.button') }}
-
+
- {{ t('ai.decorate.emojiButton') }}
+ {{
+ t('ai.decorate.emojiButton')
+ }}
diff --git a/frontend/src/views/ChannelManage.vue b/frontend/src/views/ChannelManage.vue
index 0d1950a..5a57695 100644
--- a/frontend/src/views/ChannelManage.vue
+++ b/frontend/src/views/ChannelManage.vue
@@ -3,7 +3,20 @@
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
-import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NAlert, NTag, NIcon } from 'naive-ui'
+import {
+ useMessage,
+ useDialog,
+ NButton,
+ NSpace,
+ NDataTable,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NAlert,
+ NTag,
+ NIcon,
+} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
import { getChannelPage, createChannel, renameChannel, deleteChannel } from '../api/channel'
@@ -46,20 +59,32 @@ function createColumns(): DataTableColumns {
return [
{ title: t('channel.name'), key: 'name' },
{
- title: t('channel.usage'), key: 'transactionCount', width: 160,
+ title: t('channel.usage'),
+ key: 'transactionCount',
+ width: 160,
render(row) {
return row.transactionCount > 0
- ? h(NTag, { type: 'info', size: 'small' }, { default: () => t('channel.usageCount', { count: row.transactionCount }) })
+ ? h(
+ NTag,
+ { type: 'info', size: 'small' },
+ { default: () => t('channel.usageCount', { count: row.transactionCount }) },
+ )
: h('span', { style: { color: '#999' } }, t('channel.unused'))
},
},
{
- title: t('common.operation'), key: 'actions', width: 180,
+ title: t('common.operation'),
+ key: 'actions',
+ width: 180,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
- h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => t('common.delete') }),
+ h(
+ NButton,
+ { size: 'small', type: 'error', onClick: () => handleDelete(row) },
+ { default: () => t('common.delete') },
+ ),
],
})
},
@@ -165,19 +190,19 @@ onMounted(fetchData)
-
+
- {{ t('channel.title') }}
+ {{ t('channel.title') }}
{{ t('channel.add') }}
- {{ t('channel.hint') }}
+ {{ t('channel.hint') }}
-
+
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index e954595..9e0aded 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -3,7 +3,20 @@
import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
-import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NIcon, useMessage } from 'naive-ui'
+import {
+ NCard,
+ NGrid,
+ NGridItem,
+ NStatistic,
+ NSpace,
+ NSelect,
+ NDatePicker,
+ NInput,
+ NInputNumber,
+ NButton,
+ NIcon,
+ useMessage,
+} from 'naive-ui'
import { SparklesOutline } from '@vicons/ionicons5'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
@@ -88,11 +101,7 @@ const aiSearchResult = ref(null)
const showAiSearchModal = ref(false)
// AI 搜索示例(浮窗示例下拉)
-const aiExamples = computed(() => [
- t('dashboard.aiExample1'),
- t('dashboard.aiExample2'),
- t('dashboard.aiExample3'),
-])
+const aiExamples = computed(() => [t('dashboard.aiExample1'), t('dashboard.aiExample2'), t('dashboard.aiExample3')])
// 统计口径选项:仅计入 / 全部 / 仅不计入
const statsScopeOptions = computed(() => [
@@ -120,10 +129,7 @@ interface TagOption {
}
const tags = ref([])
// 标签筛选项:额外提供「无标签」选项(仅筛选使用)
-const tagOptions = computed(() => [
- { label: t('transaction.untagged'), value: 0 },
- ...tags.value,
-])
+const tagOptions = computed(() => [{ label: t('transaction.untagged'), value: 0 }, ...tags.value])
/** 分类选项数据结构 */
interface CategoryOption {
@@ -137,7 +143,7 @@ const categories = ref([]) // 分类选项列表
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: t('dashboard.uncategorized'), value: 0, type: '' },
- ...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
+ ...categories.value.filter((c) => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
/** 将分类筛选拆分为分类ID与未分类标记 */
@@ -172,9 +178,17 @@ const filterParams = computed(() => ({
// 统计结果
const summary = ref({
- totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0,
- dayCount: 0, avgDailyIncome: 0, avgDailyExpense: 0,
- maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
+ totalIncome: 0,
+ totalExpense: 0,
+ balance: 0,
+ transactionCount: 0,
+ dayCount: 0,
+ avgDailyIncome: 0,
+ avgDailyExpense: 0,
+ maxExpenseAmount: 0,
+ maxExpenseTime: null,
+ maxExpenseRemark: '',
+ avgExpenseAmount: 0,
})
const trendData = ref([]) // 趋势分组数据
const trendKeys = ref([]) // 趋势图当前时间桶键序列(与柱状图 x 轴一一对应)
@@ -238,7 +252,10 @@ function buildKeys(start: Date, end: Date, gran: string): string[] {
const seen = new Set()
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const k = isoWeekKey(d)
- if (!seen.has(k)) { seen.add(k); keys.push(k) }
+ if (!seen.has(k)) {
+ seen.add(k)
+ keys.push(k)
+ }
}
} else if (gran === 'month') {
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
@@ -248,7 +265,10 @@ function buildKeys(start: Date, end: Date, gran: string): string[] {
const seen = new Set()
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
const k = `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`
- if (!seen.has(k)) { seen.add(k); keys.push(k) }
+ if (!seen.has(k)) {
+ seen.add(k)
+ keys.push(k)
+ }
}
} else {
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) keys.push(String(y))
@@ -268,7 +288,12 @@ function updatePieChart() {
cursor: 'pointer',
data: categoryData.value
.filter((c: any) => c.type === 'EXPENSE')
- .map((c: any) => ({ name: c.name, value: Number(c.total), categoryId: c.key == null ? 0 : Number(c.key), navType: 'EXPENSE' })),
+ .map((c: any) => ({
+ name: c.name,
+ value: Number(c.total),
+ categoryId: c.key == null ? 0 : Number(c.key),
+ navType: 'EXPENSE',
+ })),
},
],
}
@@ -286,7 +311,12 @@ function updateIncomePieChart() {
cursor: 'pointer',
data: categoryData.value
.filter((c: any) => c.type === 'INCOME')
- .map((c: any) => ({ name: c.name, value: Number(c.total), categoryId: c.key == null ? 0 : Number(c.key), navType: 'INCOME' })),
+ .map((c: any) => ({
+ name: c.name,
+ value: Number(c.total),
+ categoryId: c.key == null ? 0 : Number(c.key),
+ navType: 'INCOME',
+ })),
},
],
}
@@ -304,7 +334,12 @@ function updateChannelChart() {
cursor: 'pointer',
data: channelData.value
.filter((p: any) => Number(p.expense) > 0)
- .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense), channelCode: p.key, navType: 'EXPENSE' })),
+ .map((p: any) => ({
+ name: getChannelLabel(p.key),
+ value: Number(p.expense),
+ channelCode: p.key,
+ navType: 'EXPENSE',
+ })),
},
],
}
@@ -322,7 +357,12 @@ function updateIncomeChannelChart() {
cursor: 'pointer',
data: channelData.value
.filter((p: any) => Number(p.income) > 0)
- .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income), channelCode: p.key, navType: 'INCOME' })),
+ .map((p: any) => ({
+ name: getChannelLabel(p.key),
+ value: Number(p.income),
+ channelCode: p.key,
+ navType: 'INCOME',
+ })),
},
],
}
@@ -650,9 +690,8 @@ function applySharedFilterPrefs() {
if (p.filterRemark !== undefined) filterRemark.value = p.filterRemark
// 计入统计三态反向映射为面板统计口径
if (p.filterIncludeInStats !== undefined) {
- filterStatsScope.value = p.filterIncludeInStats === 'true'
- ? 'INCLUDED'
- : p.filterIncludeInStats === 'false' ? 'EXCLUDED' : 'ALL'
+ filterStatsScope.value =
+ p.filterIncludeInStats === 'true' ? 'INCLUDED' : p.filterIncludeInStats === 'false' ? 'EXCLUDED' : 'ALL'
}
// 日期区间统一映射为自定义时间范围
if (p.filterDateRange) {
@@ -753,18 +792,20 @@ async function applyAiStatistics(result: AiStatisticsResult) {
const aiSearchEmpty = computed(() => {
const r = aiSearchResult.value
if (!r) return true
- return (r.types?.length ?? 0) === 0
- && (r.categoryIds?.length ?? 0) === 0
- && !r.uncategorized
- && (r.tagIds?.length ?? 0) === 0
- && !r.untagged
- && (r.channels?.length ?? 0) === 0
- && !r.startTime
- && r.minAmount == null
- && r.maxAmount == null
- && !r.remark
- && !r.statsScope
- && !r.dimension
+ return (
+ (r.types?.length ?? 0) === 0 &&
+ (r.categoryIds?.length ?? 0) === 0 &&
+ !r.uncategorized &&
+ (r.tagIds?.length ?? 0) === 0 &&
+ !r.untagged &&
+ (r.channels?.length ?? 0) === 0 &&
+ !r.startTime &&
+ r.minAmount == null &&
+ r.maxAmount == null &&
+ !r.remark &&
+ !r.statsScope &&
+ !r.dimension
+ )
})
/** 打开 AI 搜索弹窗 */
@@ -829,7 +870,23 @@ watch(rangeType, (v) => {
// 范围、粒度或过滤条件变化时重新加载
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
-watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterChannel, filterTag, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
+watch(
+ [
+ rangeType,
+ customRange,
+ granularity,
+ filterStatsScope,
+ filterType,
+ filterCategory,
+ filterChannel,
+ filterTag,
+ filterMinAmount,
+ filterMaxAmount,
+ filterRemark,
+ ],
+ savePrefs,
+ { deep: true },
+)
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
@@ -859,18 +916,20 @@ onMounted(async () => {
-
- {{ t('dashboard.title') }}
+
+ {{ t('dashboard.title') }}
-
+
- {{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}
+ {{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}
{{ t('dashboard.viewTransactions') }}
@@ -882,13 +941,13 @@ onMounted(async () => {
-
+
{
clearable
:max-tag-count="2"
:placeholder="t('dashboard.filterType')"
- style="width: 160px;"
+ style="width: 160px"
/>
{
:max-tag-count="2"
:render-label="renderCategoryLabel"
:placeholder="t('dashboard.filterCategory')"
- style="width: 200px;"
+ style="width: 200px"
/>
{
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterChannel')"
- style="width: 200px;"
+ style="width: 200px"
/>
{
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterTag')"
- style="width: 180px;"
+ style="width: 180px"
/>
{
:precision="2"
clearable
:placeholder="t('dashboard.minAmount')"
- style="width: 130px;"
+ style="width: 130px"
/>
- ~
+ ~
-
+
{{ t('dashboard.resetFilters') }}
-
+
-
+
+
-
+
-
@@ -986,7 +1048,7 @@ onMounted(async () => {
-
+
@@ -1001,7 +1063,7 @@ onMounted(async () => {
- {{ (summary.maxExpenseRemark || '').slice(0, 12) }}
+ {{ (summary.maxExpenseRemark || '').slice(0, 12) }}
@@ -1014,17 +1076,24 @@ onMounted(async () => {
-
+
{{ t('dashboard.trend') }}
- {{ t('dashboard.trendClickHint') }}
+ {{ t('dashboard.trendClickHint') }}
-
+
-
+
@@ -1097,7 +1166,9 @@ onMounted(async () => {
padding: 12px;
background: #fff;
border-radius: 10px;
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.04);
+ box-shadow:
+ 0 6px 24px rgba(0, 0, 0, 0.18),
+ 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.chart-action-menu__hint {
margin-bottom: 8px;
diff --git a/frontend/src/views/EmailBillManage.vue b/frontend/src/views/EmailBillManage.vue
index d474dda..336acac 100644
--- a/frontend/src/views/EmailBillManage.vue
+++ b/frontend/src/views/EmailBillManage.vue
@@ -4,20 +4,50 @@ import { ref, computed, h, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
- useMessage, useDialog, NSpace, NButton, NDataTable, NTag, NModal, NForm, NFormItem,
- NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane, NLog,
+ useMessage,
+ useDialog,
+ NSpace,
+ NButton,
+ NDataTable,
+ NTag,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NInputNumber,
+ NSwitch,
+ NSelect,
+ NAlert,
+ NText,
+ NCard,
+ NTabs,
+ NTabPane,
+ NLog,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
- pageEmailBillConfigs, createEmailBillConfig, updateEmailBillConfig, deleteEmailBillConfig,
- toggleEmailBillConfig, testEmailBillConfig,
- pageEmailBillSenderRules, createEmailBillSenderRule, updateEmailBillSenderRule,
- deleteEmailBillSenderRule, toggleEmailBillSenderRule,
- pageEmailBillMessages, parseEmailBillMessage, importEmailBillMessage,
- ignoreEmailBillMessage, deleteEmailBillMessage,
+ pageEmailBillConfigs,
+ createEmailBillConfig,
+ updateEmailBillConfig,
+ deleteEmailBillConfig,
+ toggleEmailBillConfig,
+ testEmailBillConfig,
+ pageEmailBillSenderRules,
+ createEmailBillSenderRule,
+ updateEmailBillSenderRule,
+ deleteEmailBillSenderRule,
+ toggleEmailBillSenderRule,
+ pageEmailBillMessages,
+ parseEmailBillMessage,
+ importEmailBillMessage,
+ ignoreEmailBillMessage,
+ deleteEmailBillMessage,
} from '../api/emailBill'
import type {
- EmailBillConfig, EmailBillSenderRule, EmailBillMessage, EmailBillConfigPayload,
+ EmailBillConfig,
+ EmailBillSenderRule,
+ EmailBillMessage,
+ EmailBillConfigPayload,
EmailBillSenderRulePayload,
} from '../api/emailBill'
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
@@ -123,10 +153,20 @@ const allTags = ref<{ label: string; value: number }[]>([])
async function loadOptions() {
try {
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
- getCategoryList('EXPENSE'), getCategoryList('INCOME'), getTagList(),
+ getCategoryList('EXPENSE'),
+ getCategoryList('INCOME'),
+ getTagList(),
])
- expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
- incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
+ expenseCategories.value = (expenseRes.data || []).map((c: any) => ({
+ label: c.name,
+ value: c.id,
+ icon: c.icon || '',
+ }))
+ incomeCategories.value = (incomeRes.data || []).map((c: any) => ({
+ label: c.name,
+ value: c.id,
+ icon: c.icon || '',
+ }))
allTags.value = (tagRes.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
@@ -161,9 +201,19 @@ let checkFinished = false
function emptyConfigForm(): EmailBillConfigPayload {
return {
- name: '', host: '', port: 993, ssl: true, username: '', password: '',
- folder: 'INBOX', enabled: false, expenseCategoryId: null, incomeCategoryId: null, tagIds: [],
- fetchScope: 'INCREMENT', fetchLimit: null,
+ name: '',
+ host: '',
+ port: 993,
+ ssl: true,
+ username: '',
+ password: '',
+ folder: 'INBOX',
+ enabled: false,
+ expenseCategoryId: null,
+ incomeCategoryId: null,
+ tagIds: [],
+ fetchScope: 'INCREMENT',
+ fetchLimit: null,
}
}
@@ -193,34 +243,70 @@ function statusTagType(status: string) {
const configColumns = computed>(() => [
{ title: t('emailBill.config.name'), key: 'name', width: 140, ellipsis: { tooltip: true } },
{
- title: t('emailBill.config.host'), key: 'host', width: 200,
+ title: t('emailBill.config.host'),
+ key: 'host',
+ width: 200,
render: (row) => `${row.host}:${row.port}${row.ssl ? ' (SSL)' : ''}`,
},
{ title: t('emailBill.config.username'), key: 'username', width: 180, ellipsis: { tooltip: true } },
{ title: t('emailBill.config.folder'), key: 'folder', width: 100 },
{
- title: t('emailBill.config.status'), key: 'status', width: 110,
- render: (row) => h(NTag, { size: 'small', type: statusTagType(row.status) as any }, {
- default: () => t(`emailBill.config.statusText.${row.status}`),
- }),
+ title: t('emailBill.config.status'),
+ key: 'status',
+ width: 110,
+ render: (row) =>
+ h(
+ NTag,
+ { size: 'small', type: statusTagType(row.status) as any },
+ {
+ default: () => t(`emailBill.config.statusText.${row.status}`),
+ },
+ ),
},
{
- title: t('emailBill.common.enabled'), key: 'enabled', width: 80,
- render: (row) => h(NSwitch, {
- value: row.enabled, size: 'small',
- onUpdateValue: (v: boolean) => handleToggleConfig(row, v),
- }),
+ title: t('emailBill.common.enabled'),
+ key: 'enabled',
+ width: 80,
+ render: (row) =>
+ h(NSwitch, {
+ value: row.enabled,
+ size: 'small',
+ onUpdateValue: (v: boolean) => handleToggleConfig(row, v),
+ }),
},
{
- title: t('emailBill.common.actions'), key: 'actions', width: 320,
- render: (row) => h(NSpace, { size: 4 }, {
- default: () => [
- h(NButton, { size: 'tiny', onClick: () => handleTestConfig(row) }, { default: () => t('emailBill.config.test') }),
- h(NButton, { size: 'tiny', onClick: () => handleCheckConfig(row) }, { default: () => t('emailBill.config.check') }),
- h(NButton, { size: 'tiny', onClick: () => openConfigModal(row) }, { default: () => t('emailBill.common.edit') }),
- h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDeleteConfig(row) }, { default: () => t('emailBill.common.delete') }),
- ],
- }),
+ title: t('emailBill.common.actions'),
+ key: 'actions',
+ width: 320,
+ render: (row) =>
+ h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () => [
+ h(
+ NButton,
+ { size: 'tiny', onClick: () => handleTestConfig(row) },
+ { default: () => t('emailBill.config.test') },
+ ),
+ h(
+ NButton,
+ { size: 'tiny', onClick: () => handleCheckConfig(row) },
+ { default: () => t('emailBill.config.check') },
+ ),
+ h(
+ NButton,
+ { size: 'tiny', onClick: () => openConfigModal(row) },
+ { default: () => t('emailBill.common.edit') },
+ ),
+ h(
+ NButton,
+ { size: 'tiny', type: 'error', onClick: () => handleDeleteConfig(row) },
+ { default: () => t('emailBill.common.delete') },
+ ),
+ ],
+ },
+ ),
},
])
@@ -254,11 +340,19 @@ function openConfigModal(row?: EmailBillConfig) {
if (row) {
configEditingId.value = row.id
configForm.value = {
- name: row.name, host: row.host, port: row.port, ssl: row.ssl, username: row.username,
- password: '', folder: row.folder, enabled: row.enabled,
- expenseCategoryId: row.expenseCategoryId, incomeCategoryId: row.incomeCategoryId,
+ name: row.name,
+ host: row.host,
+ port: row.port,
+ ssl: row.ssl,
+ username: row.username,
+ password: '',
+ folder: row.folder,
+ enabled: row.enabled,
+ expenseCategoryId: row.expenseCategoryId,
+ incomeCategoryId: row.incomeCategoryId,
tagIds: [...(row.tagIds || [])],
- fetchScope: row.fetchScope || 'INCREMENT', fetchLimit: row.fetchLimit,
+ fetchScope: row.fetchScope || 'INCREMENT',
+ fetchLimit: row.fetchLimit,
}
} else {
configEditingId.value = null
@@ -334,7 +428,7 @@ function handleCheckConfig(row: EmailBillConfig) {
if (event === 'line') {
checkLogs.value.push(data)
} else if (event === 'done') {
- let count = 0
+ let count: number
try {
count = JSON.parse(data).count ?? 0
} catch {
@@ -344,13 +438,15 @@ function handleCheckConfig(row: EmailBillConfig) {
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
} else if (event === 'failed') {
- let reason = ''
+ let reason: string
try {
reason = JSON.parse(data).message ?? ''
} catch {
reason = ''
}
- checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
+ checkLogs.value.push(
+ reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'),
+ )
finishCheck()
loadConfigs()
}
@@ -407,37 +503,68 @@ const rulePagination = computed(() => ({
function emptyRuleForm() {
return {
- name: '', enabled: true, sortOrder: 0, patternsText: '', billType: defaultBillType(),
- archivePassword: '', expenseCategoryId: null, incomeCategoryId: null, tagIds: [] as number[],
+ name: '',
+ enabled: true,
+ sortOrder: 0,
+ patternsText: '',
+ billType: defaultBillType(),
+ archivePassword: '',
+ expenseCategoryId: null,
+ incomeCategoryId: null,
+ tagIds: [] as number[],
}
}
const ruleColumns = computed>(() => [
{ title: t('emailBill.senderRule.name'), key: 'name', width: 140, ellipsis: { tooltip: true } },
{
- title: t('emailBill.senderRule.patterns'), key: 'senderPatterns', width: 220, ellipsis: { tooltip: true },
+ title: t('emailBill.senderRule.patterns'),
+ key: 'senderPatterns',
+ width: 220,
+ ellipsis: { tooltip: true },
render: (row) => (row.senderPatterns || []).join(', '),
},
{
- title: t('emailBill.senderRule.billType'), key: 'billType', width: 100,
+ title: t('emailBill.senderRule.billType'),
+ key: 'billType',
+ width: 100,
render: (row) => h(NTag, { size: 'small', type: 'info' }, { default: () => billTypeLabel(row.billType) }),
},
{ title: t('emailBill.senderRule.sortOrder'), key: 'sortOrder', width: 80 },
{
- title: t('emailBill.common.enabled'), key: 'enabled', width: 80,
- render: (row) => h(NSwitch, {
- value: row.enabled, size: 'small',
- onUpdateValue: (v: boolean) => handleToggleRule(row, v),
- }),
+ title: t('emailBill.common.enabled'),
+ key: 'enabled',
+ width: 80,
+ render: (row) =>
+ h(NSwitch, {
+ value: row.enabled,
+ size: 'small',
+ onUpdateValue: (v: boolean) => handleToggleRule(row, v),
+ }),
},
{
- title: t('emailBill.common.actions'), key: 'actions', width: 140,
- render: (row) => h(NSpace, { size: 4 }, {
- default: () => [
- h(NButton, { size: 'tiny', onClick: () => openRuleModal(row) }, { default: () => t('emailBill.common.edit') }),
- h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDeleteRule(row) }, { default: () => t('emailBill.common.delete') }),
- ],
- }),
+ title: t('emailBill.common.actions'),
+ key: 'actions',
+ width: 140,
+ render: (row) =>
+ h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () => [
+ h(
+ NButton,
+ { size: 'tiny', onClick: () => openRuleModal(row) },
+ { default: () => t('emailBill.common.edit') },
+ ),
+ h(
+ NButton,
+ { size: 'tiny', type: 'error', onClick: () => handleDeleteRule(row) },
+ { default: () => t('emailBill.common.delete') },
+ ),
+ ],
+ },
+ ),
},
])
@@ -471,10 +598,14 @@ function openRuleModal(row?: EmailBillSenderRule) {
if (row) {
ruleEditingId.value = row.id
ruleForm.value = {
- name: row.name, enabled: row.enabled, sortOrder: row.sortOrder,
- patternsText: (row.senderPatterns || []).join('\n'), billType: row.billType,
+ name: row.name,
+ enabled: row.enabled,
+ sortOrder: row.sortOrder,
+ patternsText: (row.senderPatterns || []).join('\n'),
+ billType: row.billType,
archivePassword: row.archivePassword || '',
- expenseCategoryId: row.expenseCategoryId, incomeCategoryId: row.incomeCategoryId,
+ expenseCategoryId: row.expenseCategoryId,
+ incomeCategoryId: row.incomeCategoryId,
tagIds: [...(row.tagIds || [])],
}
} else {
@@ -485,7 +616,10 @@ function openRuleModal(row?: EmailBillSenderRule) {
}
async function saveRule() {
- const patterns = ruleForm.value.patternsText.split('\n').map((s) => s.trim()).filter(Boolean)
+ const patterns = ruleForm.value.patternsText
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean)
if (patterns.length === 0) {
message.warning(t('emailBill.senderRule.patternsRequired'))
return
@@ -555,46 +689,79 @@ const messageTotal = ref(0)
const messageColumns = computed>(() => [
{
- title: t('emailBill.message.receivedAt'), key: 'receivedAt', width: 150,
+ title: t('emailBill.message.receivedAt'),
+ key: 'receivedAt',
+ width: 150,
render: (row) => (row.receivedAt ? row.receivedAt.replace('T', ' ').substring(0, 16) : '-'),
},
{ title: t('emailBill.message.fromAddress'), key: 'fromAddress', width: 180, ellipsis: { tooltip: true } },
{ title: t('emailBill.message.subject'), key: 'subject', ellipsis: { tooltip: true } },
{ title: t('emailBill.message.attachmentName'), key: 'attachmentName', width: 180, ellipsis: { tooltip: true } },
{
- title: t('emailBill.message.billType'), key: 'billType', width: 100,
+ title: t('emailBill.message.billType'),
+ key: 'billType',
+ width: 100,
render: (row) => billTypeLabel(row.billType),
},
{
- title: t('emailBill.message.status'), key: 'status', width: 100,
+ title: t('emailBill.message.status'),
+ key: 'status',
+ width: 100,
render: (row) => {
if (row.error) {
return h(NTag, { size: 'small', type: 'error' }, { default: () => row.error })
}
const type = row.status === 'CONFIRMED' ? 'success' : row.status === 'IGNORED' ? 'default' : 'warning'
- return h(NTag, { size: 'small', type: type as any }, {
- default: () => t(`emailBill.message.statusText.${row.status}`),
- })
+ return h(
+ NTag,
+ { size: 'small', type: type as any },
+ {
+ default: () => t(`emailBill.message.statusText.${row.status}`),
+ },
+ )
},
},
{
- title: t('emailBill.common.actions'), key: 'actions', width: 220,
- render: (row) => h(NSpace, { size: 4 }, {
- default: () => [
- h(NButton, {
- size: 'tiny', type: 'primary', disabled: row.status !== 'PULLED',
- onClick: () => openParseModal(row),
- }, { default: () => t('emailBill.message.parse') }),
- h(NButton, {
- size: 'tiny', disabled: row.status !== 'PULLED',
- onClick: () => handleIgnoreMessage(row),
- }, { default: () => t('emailBill.message.ignore') }),
- h(NButton, {
- size: 'tiny', type: 'error',
- onClick: () => handleDeleteMessage(row),
- }, { default: () => t('emailBill.common.delete') }),
- ],
- }),
+ title: t('emailBill.common.actions'),
+ key: 'actions',
+ width: 220,
+ render: (row) =>
+ h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () => [
+ h(
+ NButton,
+ {
+ size: 'tiny',
+ type: 'primary',
+ disabled: row.status !== 'PULLED',
+ onClick: () => openParseModal(row),
+ },
+ { default: () => t('emailBill.message.parse') },
+ ),
+ h(
+ NButton,
+ {
+ size: 'tiny',
+ disabled: row.status !== 'PULLED',
+ onClick: () => handleIgnoreMessage(row),
+ },
+ { default: () => t('emailBill.message.ignore') },
+ ),
+ h(
+ NButton,
+ {
+ size: 'tiny',
+ type: 'error',
+ onClick: () => handleDeleteMessage(row),
+ },
+ { default: () => t('emailBill.common.delete') },
+ ),
+ ],
+ },
+ ),
},
])
@@ -660,9 +827,13 @@ const parsePassword = ref('')
const parsing = ref(false)
const importing = ref(false)
const parsedRows = ref([])
-const parseResult = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
-
-
+const parseResult = ref<{
+ total: number
+ imported: number
+ skipped: number
+ failed: number
+ messages: string[]
+} | null>(null)
function openParseModal(row: EmailBillMessage) {
parseMessageRow.value = row
@@ -721,20 +892,27 @@ async function handleImport(payload: {
// ===== 路由与标签页同步 =====
// URL 子路径变化(如浏览器前进/后退)时同步标签页
-watch(() => route.params.tab, (tab) => {
- const target = (tab as string) || 'configs'
- if (target !== activeTab.value) {
- activeTab.value = target
- }
-})
+watch(
+ () => route.params.tab,
+ (tab) => {
+ const target = (tab as string) || 'configs'
+ if (target !== activeTab.value) {
+ activeTab.value = target
+ }
+ },
+)
// 标签页切换:记住偏好并同步 URL 子路径
-watch(activeTab, (tab) => {
- writeString(EMAIL_BILL_TAB_KEY, tab)
- if ((route.params.tab as string) !== tab) {
- router.push(`/email-bills/${tab}`)
- }
-}, { immediate: true })
+watch(
+ activeTab,
+ (tab) => {
+ writeString(EMAIL_BILL_TAB_KEY, tab)
+ if ((route.params.tab as string) !== tab) {
+ router.push(`/email-bills/${tab}`)
+ }
+ },
+ { immediate: true },
+)
// ===== 生命周期 =====
let timer: number | null = null
@@ -777,18 +955,14 @@ onUnmounted(() => {
@update:page="handleConfigPageChange"
@update:page-size="handleConfigPageSizeChange"
/>
-
+
{{ t('emailBill.config.checkRunning') }}
{{ t('emailBill.config.checkClear') }}
-
+
@@ -828,8 +1002,13 @@ onUnmounted(() => {
]"
clearable
:placeholder="t('emailBill.message.filterStatus')"
- style="width: 160px;"
- @update:value="() => { messagePage = 1; loadMessages() }"
+ style="width: 160px"
+ @update:value="
+ () => {
+ messagePage = 1
+ loadMessages()
+ }
+ "
/>
{{ t('emailBill.common.refresh') }}
@@ -843,10 +1022,20 @@ onUnmounted(() => {
remote
:scroll-x="960"
:pagination="{
- page: messagePage, pageSize: messageSize, itemCount: messageTotal,
- pageSizes: pageSizeOptions, showSizePicker: true,
- onUpdatePage: (p: number) => { messagePage = p; loadMessages() },
- onUpdatePageSize: (s: number) => { messageSize = s; messagePage = 1; loadMessages() },
+ page: messagePage,
+ pageSize: messageSize,
+ itemCount: messageTotal,
+ pageSizes: pageSizeOptions,
+ showSizePicker: true,
+ onUpdatePage: (p: number) => {
+ messagePage = p
+ loadMessages()
+ },
+ onUpdatePageSize: (s: number) => {
+ messageSize = s
+ messagePage = 1
+ loadMessages()
+ },
}"
/>
@@ -854,7 +1043,12 @@ onUnmounted(() => {
-
+
@@ -871,7 +1065,7 @@ onUnmounted(() => {
/>
-
+
@@ -880,30 +1074,56 @@ onUnmounted(() => {
-
+
-
+
-
+
-
+
-
+
{
:min="1"
:max="500"
:placeholder="t('emailBill.config.fetchLimitCount')"
- style="width: 140px;"
+ style="width: 140px"
/>
@@ -928,31 +1148,65 @@ onUnmounted(() => {
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -967,7 +1221,13 @@ onUnmounted(() => {
-
+
@@ -975,9 +1235,15 @@ onUnmounted(() => {
{{ t('emailBill.parse.billType') }}
-
+
{{ t('emailBill.parse.password') }}
-
+
{{ t('emailBill.common.cancel') }}
diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue
index f801565..bc1f128 100644
--- a/frontend/src/views/Layout.vue
+++ b/frontend/src/views/Layout.vue
@@ -4,12 +4,26 @@ import { h, computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'v
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
- NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown,
- NDrawer, NDrawerContent, NButton,
+ NLayout,
+ NLayoutSider,
+ NLayoutContent,
+ NLayoutHeader,
+ NMenu,
+ NSpace,
+ NIcon,
+ NDropdown,
+ NDrawer,
+ NDrawerContent,
+ NButton,
} from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import {
- DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, MenuOutline,
+ DocumentTextOutline,
+ SettingsOutline,
+ LogOutOutline,
+ ChevronDownOutline,
+ WalletOutline,
+ MenuOutline,
} from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -182,11 +196,14 @@ watch(isMobile, (mobile) => {
})
// 路由切换:先保存离开页的位置,再在 DOM 更新后恢复目标页的位置
-watch(() => route.path, (to, from) => {
- saveScroll(from)
- cancelRestore()
- nextTick(() => restoreScroll(to))
-})
+watch(
+ () => route.path,
+ (to, from) => {
+ saveScroll(from)
+ cancelRestore()
+ nextTick(() => restoreScroll(to))
+ },
+)
/** 预加载交易渠道列表,注册自定义渠道名称供全局展示解析 */
async function loadChannels() {
@@ -221,12 +238,7 @@ onBeforeUnmount(() => {
-
+
@@ -238,23 +250,13 @@ onBeforeUnmount(() => {
-
+
-
+
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index c7c12da..79bbc37 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -72,14 +72,26 @@ async function handleLogin() {
-
+
-
+
{{ t('login.submit') }}
- {{ t('login.toRegister') }}
+ {{
+ t('login.toRegister')
+ }}
diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue
index e18fb63..5bb4651 100644
--- a/frontend/src/views/Logs.vue
+++ b/frontend/src/views/Logs.vue
@@ -4,8 +4,22 @@ import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
- useMessage, NCard, NTabs, NTabPane, NDataTable, NPagination, NInput, NSelect,
- NButton, NSpace, NTag, NIcon, NInputNumber, NEmpty, NText, NSwitch,
+ useMessage,
+ NCard,
+ NTabs,
+ NTabPane,
+ NDataTable,
+ NPagination,
+ NInput,
+ NSelect,
+ NButton,
+ NSpace,
+ NTag,
+ NIcon,
+ NInputNumber,
+ NEmpty,
+ NText,
+ NSwitch,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
@@ -125,49 +139,83 @@ function statusTagType(status: number | null) {
/** 请求方法对应的标签颜色 */
function methodTagType(method: string | null) {
switch (method) {
- case 'GET': return 'info'
- case 'POST': return 'success'
- case 'PUT': return 'warning'
- case 'DELETE': return 'error'
- default: return 'default'
+ case 'GET':
+ return 'info'
+ case 'POST':
+ return 'success'
+ case 'PUT':
+ return 'warning'
+ case 'DELETE':
+ return 'error'
+ default:
+ return 'default'
}
}
/** 访问日志表格列 */
const httpColumns = computed>(() => [
{
- title: t('log.time'), key: 'createdAt', width: 165,
+ title: t('log.time'),
+ key: 'createdAt',
+ width: 165,
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
},
{
- title: t('log.method'), key: 'method', width: 90, align: 'center',
- render: (row) => (row.method
- ? h(NTag, { type: methodTagType(row.method), size: 'small', bordered: false }, { default: () => row.method })
- : '-'),
+ title: t('log.method'),
+ key: 'method',
+ width: 90,
+ align: 'center',
+ render: (row) =>
+ row.method
+ ? h(NTag, { type: methodTagType(row.method), size: 'small', bordered: false }, { default: () => row.method })
+ : '-',
},
{
- title: t('log.path'), key: 'path', minWidth: 180, ellipsis: { tooltip: true },
+ title: t('log.path'),
+ key: 'path',
+ minWidth: 180,
+ ellipsis: { tooltip: true },
},
{
- title: t('log.queryString'), key: 'queryString', minWidth: 140, ellipsis: { tooltip: true },
+ title: t('log.queryString'),
+ key: 'queryString',
+ minWidth: 140,
+ ellipsis: { tooltip: true },
render: (row) => row.queryString || '-',
},
{
- title: t('log.status'), key: 'status', width: 90, align: 'center',
- render: (row) => (row.status == null
- ? '-'
- : h(NTag, { type: statusTagType(row.status), size: 'small', bordered: false }, { default: () => String(row.status) })),
+ title: t('log.status'),
+ key: 'status',
+ width: 90,
+ align: 'center',
+ render: (row) =>
+ row.status == null
+ ? '-'
+ : h(
+ NTag,
+ { type: statusTagType(row.status), size: 'small', bordered: false },
+ { default: () => String(row.status) },
+ ),
},
{
- title: t('log.duration'), key: 'durationMs', width: 100, align: 'right',
+ title: t('log.duration'),
+ key: 'durationMs',
+ width: 100,
+ align: 'right',
render: (row) => (row.durationMs == null ? '-' : `${row.durationMs} ms`),
},
{
- title: t('log.user'), key: 'userId', width: 90, align: 'center',
+ title: t('log.user'),
+ key: 'userId',
+ width: 90,
+ align: 'center',
render: (row) => (row.userId == null ? t('log.anonymous') : `#${row.userId}`),
},
{
- title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
+ title: t('log.ip'),
+ key: 'ip',
+ width: 140,
+ ellipsis: { tooltip: true },
render: (row) => row.ip || '-',
},
])
@@ -175,28 +223,48 @@ const httpColumns = computed>(() => [
/** 登录日志表格列 */
const loginColumns = computed>(() => [
{
- title: t('log.time'), key: 'createdAt', width: 165,
+ title: t('log.time'),
+ key: 'createdAt',
+ width: 165,
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
},
{
- title: t('log.username'), key: 'username', width: 140,
+ title: t('log.username'),
+ key: 'username',
+ width: 140,
render: (row) => row.username || '-',
},
{
- title: t('log.result'), key: 'success', width: 90, align: 'center',
- render: (row) => h(NTag, { type: row.success ? 'success' : 'error', size: 'small', bordered: false },
- { default: () => (row.success ? t('log.success') : t('log.failed')) }),
+ title: t('log.result'),
+ key: 'success',
+ width: 90,
+ align: 'center',
+ render: (row) =>
+ h(
+ NTag,
+ { type: row.success ? 'success' : 'error', size: 'small', bordered: false },
+ { default: () => (row.success ? t('log.success') : t('log.failed')) },
+ ),
},
{
- title: t('log.message'), key: 'message', minWidth: 140, ellipsis: { tooltip: true },
+ title: t('log.message'),
+ key: 'message',
+ minWidth: 140,
+ ellipsis: { tooltip: true },
render: (row) => row.message || '-',
},
{
- title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
+ title: t('log.ip'),
+ key: 'ip',
+ width: 140,
+ ellipsis: { tooltip: true },
render: (row) => row.ip || '-',
},
{
- title: t('log.userAgent'), key: 'userAgent', minWidth: 180, ellipsis: { tooltip: true },
+ title: t('log.userAgent'),
+ key: 'userAgent',
+ minWidth: 180,
+ ellipsis: { tooltip: true },
render: (row) => row.userAgent || '-',
},
])
@@ -331,37 +399,44 @@ watch(appRealtime, (enabled) => {
})
// URL 子路径变化(如浏览器前进/后退)时同步标签页
-watch(() => route.params.tab, (tab) => {
- const target = (tab as string) || 'http'
- if (target !== activeTab.value) {
- activeTab.value = target
- }
-})
+watch(
+ () => route.params.tab,
+ (tab) => {
+ const target = (tab as string) || 'http'
+ if (target !== activeTab.value) {
+ activeTab.value = target
+ }
+ },
+)
// 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接
-watch(activeTab, (tab) => {
- // 保存当前标签页偏好,供下次点击「系统日志」时恢复
- writeString(LOGS_TAB_KEY, tab)
- if (tab === 'http') {
- loadHttp()
- } else if (tab === 'login') {
- loadLogin()
- } else if (tab === 'app') {
- if (appRealtime.value) {
- startRealtime()
- } else {
- loadApp()
+watch(
+ activeTab,
+ (tab) => {
+ // 保存当前标签页偏好,供下次点击「系统日志」时恢复
+ writeString(LOGS_TAB_KEY, tab)
+ if (tab === 'http') {
+ loadHttp()
+ } else if (tab === 'login') {
+ loadLogin()
+ } else if (tab === 'app') {
+ if (appRealtime.value) {
+ startRealtime()
+ } else {
+ loadApp()
+ }
}
- }
- // 离开运行日志时关闭 SSE 连接
- if (tab !== 'app') {
- stopRealtime()
- }
- // URL 未反映当前标签时更新,避免与路由同步互相触发
- if ((route.params.tab as string) !== tab) {
- router.push(`/logs/${tab}`)
- }
-}, { immediate: true })
+ // 离开运行日志时关闭 SSE 连接
+ if (tab !== 'app') {
+ stopRealtime()
+ }
+ // URL 未反映当前标签时更新,避免与路由同步互相触发
+ if ((route.params.tab as string) !== tab) {
+ router.push(`/logs/${tab}`)
+ }
+ },
+ { immediate: true },
+)
onUnmounted(stopRealtime)
@@ -417,7 +492,7 @@ function lineClass(line: string) {
@@ -427,12 +502,12 @@ function lineClass(line: string) {
-
+
@@ -473,7 +548,7 @@ function lineClass(line: string) {
-
+
-
+
@@ -527,7 +602,7 @@ function lineClass(line: string) {
-
+
-
+
-
+
@@ -589,16 +659,14 @@ function lineClass(line: string) {
{{ t('log.realtime') }}
-
- {{ t('log.file') }}: {{ appData?.file || '-' }}
-
+ {{ t('log.file') }}: {{ appData?.file || '-' }}
{{ t('log.totalLines', { count: appData?.total ?? 0 }) }}
-
+
{{ line }}
diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue
index 9a0791a..aecad62 100644
--- a/frontend/src/views/Register.vue
+++ b/frontend/src/views/Register.vue
@@ -71,10 +71,20 @@ async function handleRegister() {
-
+
-
+
{{ t('register.submit') }}
diff --git a/frontend/src/views/RuleManage.vue b/frontend/src/views/RuleManage.vue
index eec4ae8..433a7c4 100644
--- a/frontend/src/views/RuleManage.vue
+++ b/frontend/src/views/RuleManage.vue
@@ -3,8 +3,21 @@
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
- useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem,
- NInput, NInputNumber, NSelect, NSwitch, NTag, NText, NCard,
+ useMessage,
+ useDialog,
+ NButton,
+ NSpace,
+ NDataTable,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NInputNumber,
+ NSelect,
+ NSwitch,
+ NTag,
+ NText,
+ NCard,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getRulePage, createRule, updateRule, deleteRule, batchDeleteRules, toggleRuleEnabled } from '../api/rule'
@@ -144,17 +157,29 @@ const tagOptions = computed(() => tags.value.map((tag) => ({ label: tag.label, v
function conditionTags(row: RuleItem) {
const result: any[] = []
if (row.type) {
- result.push(h(NTag, { size: 'small', type: row.type === 'INCOME' ? 'success' : 'error' }, {
- default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
- }))
+ result.push(
+ h(
+ NTag,
+ { size: 'small', type: row.type === 'INCOME' ? 'success' : 'error' },
+ {
+ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
+ },
+ ),
+ )
}
if (row.keyword) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryKeyword', { kw: row.keyword }) }))
}
if (row.minAmount != null && row.maxAmount != null) {
- result.push(h(NTag, { size: 'small' }, {
- default: () => t('rule.summaryAmountRange', { min: row.minAmount, max: row.maxAmount }),
- }))
+ result.push(
+ h(
+ NTag,
+ { size: 'small' },
+ {
+ default: () => t('rule.summaryAmountRange', { min: row.minAmount, max: row.maxAmount }),
+ },
+ ),
+ )
} else if (row.minAmount != null) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryAmountMin', { min: row.minAmount }) }))
} else if (row.maxAmount != null) {
@@ -204,35 +229,54 @@ const columns = computed>(() => [
if (list.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
- return h(NSpace, { size: 4 }, {
- default: () => list.map((tag) => h(NTag, {
- size: 'small',
- color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
- }, { default: () => tag.name })),
- })
+ return h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () =>
+ list.map((tag) =>
+ h(
+ NTag,
+ {
+ size: 'small',
+ color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
+ },
+ { default: () => tag.name },
+ ),
+ ),
+ },
+ )
},
},
{
title: t('rule.enabled'),
key: 'enabled',
width: 80,
- render: (row) => h(NSwitch, {
- value: row.enabled,
- size: 'small',
- onUpdateValue: (v: boolean) => { handleToggleEnabled(row, v) },
- }),
+ render: (row) =>
+ h(NSwitch, {
+ value: row.enabled,
+ size: 'small',
+ onUpdateValue: (v: boolean) => {
+ handleToggleEnabled(row, v)
+ },
+ }),
},
{ title: t('common.sort'), key: 'sortOrder', width: 70 },
{
title: t('common.operation'),
key: 'actions',
width: 150,
- render: (row) => h(NSpace, null, {
- default: () => [
- h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
- h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
- ],
- }),
+ render: (row) =>
+ h(NSpace, null, {
+ default: () => [
+ h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
+ h(
+ NButton,
+ { size: 'small', type: 'error', onClick: () => handleDelete(row.id) },
+ { default: () => t('common.delete') },
+ ),
+ ],
+ }),
},
])
@@ -249,31 +293,47 @@ const aiRuleColumns = computed>(() => [
title: t('rule.name'),
key: 'name',
width: 240,
- render: (row) => h(NSpace, { size: 4, align: 'center', wrap: false }, {
- default: () => [
- h(NInput, {
- value: row.name,
- size: 'small',
- disabled: !!row.duplicate,
- onUpdateValue: (v: string) => { row.name = v },
- }),
- row.duplicate ? h(NTag, { size: 'small', type: 'warning' }, {
- default: () => t('ai.rule.duplicate'),
- }) : null,
- ],
- }),
+ render: (row) =>
+ h(
+ NSpace,
+ { size: 4, align: 'center', wrap: false },
+ {
+ default: () => [
+ h(NInput, {
+ value: row.name,
+ size: 'small',
+ disabled: !!row.duplicate,
+ onUpdateValue: (v: string) => {
+ row.name = v
+ },
+ }),
+ row.duplicate
+ ? h(
+ NTag,
+ { size: 'small', type: 'warning' },
+ {
+ default: () => t('ai.rule.duplicate'),
+ },
+ )
+ : null,
+ ],
+ },
+ ),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 160,
- render: (row) => h(NInput, {
- value: row.keyword || '',
- size: 'small',
- placeholder: '—',
- disabled: !!row.duplicate,
- onUpdateValue: (v: string) => { row.keyword = v || null },
- }),
+ render: (row) =>
+ h(NInput, {
+ value: row.keyword || '',
+ size: 'small',
+ placeholder: '—',
+ disabled: !!row.duplicate,
+ onUpdateValue: (v: string) => {
+ row.keyword = v || null
+ },
+ }),
},
{
title: t('common.type'),
@@ -302,7 +362,7 @@ const aiRuleColumns = computed>(() => [
render: (row) => {
const names = [
...(row.tagNames || []),
- ...((row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`)),
+ ...(row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`),
]
return names.length > 0 ? names.join('、') : '—'
},
@@ -362,12 +422,23 @@ const aiCandidateColumns = computed>(() => [
if (!row.tags || row.tags.length === 0) {
return '—'
}
- return h(NSpace, { size: 4 }, {
- default: () => row.tags.map((tag) => h(NTag, {
- size: 'small',
- color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
- }, { default: () => tag.name })),
- })
+ return h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () =>
+ row.tags.map((tag) =>
+ h(
+ NTag,
+ {
+ size: 'small',
+ color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
+ },
+ { default: () => tag.name },
+ ),
+ ),
+ },
+ )
},
},
])
@@ -509,19 +580,21 @@ async function handleAiRuleApply() {
}
aiRuleApplying.value = true
try {
- const res: any = await applyAiRules(selected.map((row) => ({
- name: row.name,
- enabled: true,
- type: row.type,
- keyword: row.keyword,
- minAmount: row.minAmount,
- maxAmount: row.maxAmount,
- categoryId: row.categoryId,
- newCategoryName: row.newCategoryName,
- newCategoryIcon: row.newCategoryIcon,
- tagIds: row.tagIds,
- newTags: row.newTags,
- })))
+ const res: any = await applyAiRules(
+ selected.map((row) => ({
+ name: row.name,
+ enabled: true,
+ type: row.type,
+ keyword: row.keyword,
+ minAmount: row.minAmount,
+ maxAmount: row.maxAmount,
+ categoryId: row.categoryId,
+ newCategoryName: row.newCategoryName,
+ newCategoryIcon: row.newCategoryIcon,
+ tagIds: row.tagIds,
+ newTags: row.newTags,
+ })),
+ )
message.success(t('ai.rule.applied', { count: res.data ?? selected.length }))
showAiRuleModal.value = false
await fetchData()
@@ -747,14 +820,17 @@ function handleBatchDelete() {
}
// 收支类型变化时,若已选分类类型不匹配则清空,避免保存非法组合
-watch(() => form.value.type, (type) => {
- if (form.value.categoryId && type) {
- const category = categories.value.find((c) => c.value === form.value.categoryId)
- if (category && category.type !== type) {
- form.value.categoryId = null
+watch(
+ () => form.value.type,
+ (type) => {
+ if (form.value.categoryId && type) {
+ const category = categories.value.find((c) => c.value === form.value.categoryId)
+ if (category && category.type !== type) {
+ form.value.categoryId = null
+ }
}
- }
-})
+ },
+)
// 组件挂载时加载规则、分类、标签与 AI 启用状态
onMounted(() => {
@@ -768,8 +844,8 @@ onMounted(() => {
-
- {{ t('rule.title') }}
+
+ {{ t('rule.title') }}
{{ t('ai.rule.entry') }}
@@ -780,13 +856,13 @@ onMounted(() => {
-
+
{
:options="enabledFilterOptions"
clearable
:placeholder="t('rule.status')"
- style="width: 120px;"
+ style="width: 120px"
/>
{{ t('transaction.search') }}
{{ t('transaction.clearAll') }}
@@ -823,7 +899,12 @@ onMounted(() => {
/>
-
+
@@ -840,9 +921,21 @@ onMounted(() => {
-
- ~
-
+
+ ~
+
@@ -882,16 +975,16 @@ onMounted(() => {
v-model:show="showAiRuleModal"
preset="card"
:title="t('ai.rule.title')"
- style="width: min(1200px, 94vw);"
+ style="width: min(1200px, 94vw)"
>
-
+
{
:options="aiCandidateTypeOptions"
clearable
:placeholder="t('dashboard.filterType')"
- style="width: 120px;"
+ style="width: 120px"
/>
{
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterTag')"
- style="width: 200px;"
+ style="width: 200px"
/>
{{ t('transaction.search') }}
{{ t('transaction.clearAll') }}
-
-
+
+
{{ t('ai.rule.selectHint', { count: checkedCandidateKeys.length }) }}
@@ -943,7 +1036,7 @@ onMounted(() => {
@update:page="handleCandidatePageChange"
@update:page-size="handleCandidatePageSizeChange"
/>
-
+
{{ t('common.cancel') }}
{
-
+
{{ aiRuleSummary || t('ai.rule.hint') }}
{
:scroll-x="1100"
size="small"
/>
-
+
{{ t('ai.rule.back') }}
{{ t('common.cancel') }}
diff --git a/frontend/src/views/SqlQuery.vue b/frontend/src/views/SqlQuery.vue
index dcf6826..f44c8d8 100644
--- a/frontend/src/views/SqlQuery.vue
+++ b/frontend/src/views/SqlQuery.vue
@@ -3,8 +3,22 @@
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
- NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
- NIcon, NInput, NSpin, NSpace, NStatistic, NText, useMessage,
+ NAlert,
+ NButton,
+ NCard,
+ NCollapse,
+ NCollapseItem,
+ NDataTable,
+ NEmpty,
+ NGrid,
+ NGridItem,
+ NIcon,
+ NInput,
+ NSpin,
+ NSpace,
+ NStatistic,
+ NText,
+ useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
@@ -105,7 +119,11 @@ const filteredHistory = computed(() => {
})
// 示例问题
-const exampleList = computed(() => t('ai.sql.examples').split('|').filter((item) => item.trim()))
+const exampleList = computed(() =>
+ t('ai.sql.examples')
+ .split('|')
+ .filter((item) => item.trim()),
+)
// 当前结果行
const resultRows = computed(() => result.value?.rows ?? [])
@@ -124,9 +142,9 @@ const pagination = computed(() => ({
// 单行结果视为统计值,用统计卡片展示
const isScalar = computed(() => !!result.value && result.value.rows.length === 1)
// 多行且含数值列时可切换图表
-const canChart = computed(() => !!result.value
- && result.value.rows.length > 1
- && result.value.columns.some((col) => col.type === 'NUMBER'))
+const canChart = computed(
+ () => !!result.value && result.value.rows.length > 1 && result.value.columns.some((col) => col.type === 'NUMBER'),
+)
const hasRows = computed(() => resultRows.value.length > 0)
// 统计卡片列数随断点收敛:手机单列、平板两列、桌面最多四列;图表高度同步降低
@@ -176,14 +194,16 @@ const chartOption = computed(() => {
return {
tooltip: { trigger: 'item' },
legend: { type: 'scroll', bottom: 0 },
- series: [{
- type: 'pie',
- radius: ['35%', '65%'],
- data: rows.map((row) => ({
- name: String(row[dimension.key] ?? ''),
- value: Number(row[measure.key]),
- })),
- }],
+ series: [
+ {
+ type: 'pie',
+ radius: ['35%', '65%'],
+ data: rows.map((row) => ({
+ name: String(row[dimension.key] ?? ''),
+ value: Number(row[measure.key]),
+ })),
+ },
+ ],
}
}
if (!measures.length) return null
@@ -303,10 +323,7 @@ const formattedSql = computed(() => {
/** HTML 转义,用于高亮回退分支,避免以 v-html 渲染未转义文本 */
function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
+ return text.replace(/&/g, '&').replace(//g, '>')
}
// SQL 语法高亮(highlight.js 输出已转义;回退分支手动转义后仍安全)
@@ -395,7 +412,7 @@ onMounted(async () => {
- {{ t('ai.sql.hint') }}
+ {{ t('ai.sql.hint') }}
{
@keydown="handleKeydown"
/>
-
+
- {{ t('ai.sql.examplesLabel') }}
+ {{ t('ai.sql.examplesLabel') }}
{{ ex }}
@@ -420,15 +437,15 @@ onMounted(async () => {
-
+
-
+
@@ -437,19 +454,10 @@ onMounted(async () => {
{{ t('ai.sql.clearHistory') }}
-
+
-
+
{{ item.query }}
{{ formatHistoryTime(item.createdAt) }}
@@ -465,11 +473,11 @@ onMounted(async () => {
-
-
+
+
-
+
@@ -477,16 +485,17 @@ onMounted(async () => {
{{ t('ai.sql.copySql') }}
+
-
+
{{ result.explanation }}
-
+
{{ t('ai.sql.truncated', { max: 1000 }) }}
@@ -504,8 +513,8 @@ onMounted(async () => {
-
- {{ t('ai.sql.rowCount', { count: result?.rowCount }) }}
+
+ {{ t('ai.sql.rowCount', { count: result?.rowCount }) }}
{{ t('ai.sql.viewTable') }}
@@ -529,7 +538,13 @@ onMounted(async () => {
size="small"
:scroll-x="960"
/>
-
+
@@ -577,7 +592,10 @@ onMounted(async () => {
border-radius: 8px;
background: #fafafa;
cursor: pointer;
- transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
+ transition:
+ border-color 0.2s,
+ background 0.2s,
+ box-shadow 0.2s;
}
.history-item:hover {
border-color: #18a058;
diff --git a/frontend/src/views/TagManage.vue b/frontend/src/views/TagManage.vue
index d2cf21a..fdaf58c 100644
--- a/frontend/src/views/TagManage.vue
+++ b/frontend/src/views/TagManage.vue
@@ -2,7 +2,20 @@
// 标签管理组件:展示标签列表,支持新增、编辑、删除标签
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
-import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
+import {
+ useMessage,
+ useDialog,
+ NButton,
+ NSpace,
+ NDataTable,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NInputNumber,
+ NColorPicker,
+ NTag,
+} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getTagPage, createTag, updateTag, deleteTag } from '../api/tag'
import { getAiConfig, suggestColor } from '../api/ai'
@@ -51,29 +64,46 @@ const aiLoading = ref(false) // AI 推荐请求状态
// 预设色板:覆盖常用区分色,便于快速选择
const colorSwatches = [
- '#18a058', '#2080f0', '#f0a020', '#d03050', '#7c3aed', '#13c2c2',
- '#eb2f96', '#fa8c16', '#2f54eb', '#52c41a', '#8c8c8c', '#000000',
+ '#18a058',
+ '#2080f0',
+ '#f0a020',
+ '#d03050',
+ '#7c3aed',
+ '#13c2c2',
+ '#eb2f96',
+ '#fa8c16',
+ '#2f54eb',
+ '#52c41a',
+ '#8c8c8c',
+ '#000000',
]
/** 创建表格列配置 */
function createColumns(): DataTableColumns {
return [
{
- title: t('tag.label'), key: 'name',
+ title: t('tag.label'),
+ key: 'name',
render(row) {
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
},
},
{
- title: t('tag.color'), key: 'color', width: 160,
+ title: t('tag.color'),
+ key: 'color',
+ width: 160,
render(row) {
// 色块 + 色值,直观展示标签颜色
const color = row.color || '#2080f0'
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
h('span', {
style: {
- display: 'inline-block', width: '16px', height: '16px', borderRadius: '4px',
- background: color, border: '1px solid rgba(0, 0, 0, 0.1)',
+ display: 'inline-block',
+ width: '16px',
+ height: '16px',
+ borderRadius: '4px',
+ background: color,
+ border: '1px solid rgba(0, 0, 0, 0.1)',
},
}),
h('span', { style: { color: '#666' } }, color),
@@ -82,12 +112,18 @@ function createColumns(): DataTableColumns {
},
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
{
- title: t('common.operation'), key: 'actions', width: 180,
+ title: t('common.operation'),
+ key: 'actions',
+ width: 180,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
- h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
+ h(
+ NButton,
+ { size: 'small', type: 'error', onClick: () => handleDelete(row.id) },
+ { default: () => t('common.delete') },
+ ),
],
})
},
@@ -219,8 +255,8 @@ onMounted(() => {
-
- {{ t('tag.title') }}
+
+ {{ t('tag.title') }}
{{ t('tag.add') }}
@@ -238,12 +274,19 @@ onMounted(() => {
/>
-
+
-
+
- {{ t('ai.decorate.colorButton') }}
+ {{
+ t('ai.decorate.colorButton')
+ }}
diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue
index 7bb60e3..1cc82b9 100644
--- a/frontend/src/views/TransactionList.vue
+++ b/frontend/src/views/TransactionList.vue
@@ -3,10 +3,42 @@
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
-import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NDivider, NColorPicker } from 'naive-ui'
+import {
+ useMessage,
+ useDialog,
+ NButton,
+ NSpace,
+ NDataTable,
+ NTag,
+ NModal,
+ NForm,
+ NFormItem,
+ NInput,
+ NInputNumber,
+ NSelect,
+ NDatePicker,
+ NPagination,
+ NCard,
+ NSwitch,
+ NIcon,
+ NPopover,
+ NAutoComplete,
+ NDivider,
+ NColorPicker,
+} from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, StatsChartOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
-import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, getChannels, batchDeleteTransactions } from '../api/transaction'
+import {
+ getTransactionPage,
+ createTransaction,
+ updateTransaction,
+ deleteTransaction,
+ exportTransaction,
+ toggleTransactionStats,
+ toggleRemarkHidden,
+ getChannels,
+ batchDeleteTransactions,
+} from '../api/transaction'
import { suggestByAi, suggestOneByAi, queryByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
import { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
@@ -174,8 +206,18 @@ const newChannelSaving = ref(false) // 新建渠道保存状态
const newChannelForm = ref<{ name: string }>({ name: '' })
// 新建标签可选预设色板(与标签管理页保持一致)
const newTagColorSwatches = [
- '#18a058', '#2080f0', '#f0a020', '#d03050', '#7c3aed', '#13c2c2',
- '#eb2f96', '#fa8c16', '#2f54eb', '#52c41a', '#8c8c8c', '#000000',
+ '#18a058',
+ '#2080f0',
+ '#f0a020',
+ '#d03050',
+ '#7c3aed',
+ '#13c2c2',
+ '#eb2f96',
+ '#fa8c16',
+ '#2f54eb',
+ '#52c41a',
+ '#8c8c8c',
+ '#000000',
]
const aiSuggestions = ref([]) // AI 建议列表
const aiQuerying = ref(false) // AI 查询解析状态
@@ -221,9 +263,7 @@ const typeOptions = computed(() => [
const channelCodes = ref([])
// 交易渠道选项:由后端返回的编码映射为当前语言标签
-const channelOptions = computed(() =>
- channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
-)
+const channelOptions = computed(() => channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })))
// 筛选条件状态
const filterType = ref([]) // 类型筛选(多选)
@@ -265,7 +305,7 @@ function clearMoreFilters() {
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: t('transaction.uncategorized'), value: 0, type: '' },
- ...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
+ ...categories.value.filter((c) => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
// 标签筛选项
@@ -313,12 +353,17 @@ function loadFilterPrefs() {
if (p.filterMinAmount !== undefined) filterMinAmount.value = p.filterMinAmount
if (p.filterMaxAmount !== undefined) filterMaxAmount.value = p.filterMaxAmount
// 迁移旧筛选偏好中的每页条数到统一的表格分页偏好(新机制尚无值时)
- if (typeof p.pageSize === 'number' && Number.isInteger(p.pageSize) && p.pageSize > 0
- && readTablePageSize('transaction') === null) {
+ if (
+ typeof p.pageSize === 'number' &&
+ Number.isInteger(p.pageSize) &&
+ p.pageSize > 0 &&
+ readTablePageSize('transaction') === null
+ ) {
writeTablePageSize('transaction', p.pageSize)
pageSize.value = p.pageSize
}
- if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
+ if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue))
+ sortValue.value = p.sortValue
if (p.filterTag) filterTag.value = p.filterTag
}
@@ -340,7 +385,18 @@ function saveFilterPrefs() {
// 筛选条件变化时自动保存用户偏好
watch(
- [filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, sortValue, filterTag],
+ [
+ filterType,
+ filterCategory,
+ filterChannel,
+ filterDateRange,
+ filterIncludeInStats,
+ filterRemark,
+ filterMinAmount,
+ filterMaxAmount,
+ sortValue,
+ filterTag,
+ ],
saveFilterPrefs,
{ deep: true },
)
@@ -371,23 +427,24 @@ function handleRemarkKeydown(e: KeyboardEvent) {
/** 分类已选项(多选标签)渲染:图标 + 名称 */
function renderCategoryTag({ option }: { option: any }) {
- return h('span', null, [
- option.icon ? h('span', { style: { marginRight: '4px' } }, option.icon) : null,
- option.label,
- ])
+ return h('span', null, [option.icon ? h('span', { style: { marginRight: '4px' } }, option.icon) : null, option.label])
}
/** 渲染备注单元格内的小图标按钮 */
function renderRemarkAction(label: string, icon: any, onClick: () => void) {
- return h(NButton, {
- size: 'tiny',
- quaternary: true,
- style: { marginLeft: '6px', flexShrink: 0 },
- onClick,
- }, {
- icon: () => h(NIcon, { size: 14 }, { default: () => h(icon) }),
- default: () => label,
- })
+ return h(
+ NButton,
+ {
+ size: 'tiny',
+ quaternary: true,
+ style: { marginLeft: '6px', flexShrink: 0 },
+ onClick,
+ },
+ {
+ icon: () => h(NIcon, { size: 14 }, { default: () => h(icon) }),
+ default: () => label,
+ },
+ )
}
/** 临时展开某行的备注原文(仅本格,不改变隐藏标记) */
@@ -435,13 +492,18 @@ function createColumns(): DataTableColumns {
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
// 序号列:按分页计算全局序号
- title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
+ title: t('transaction.column.index'),
+ key: 'index',
+ width: 70,
+ align: 'center',
render(_row, index) {
return (page.value - 1) * pageSize.value + index + 1
},
},
{
- title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
+ title: t('transaction.column.transactionTime'),
+ key: 'transactionTime',
+ width: 170,
render(row) {
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
@@ -449,51 +511,76 @@ function createColumns(): DataTableColumns {
},
},
{
- title: t('transaction.column.type'), key: 'type', width: 90,
+ title: t('transaction.column.type'),
+ key: 'type',
+ width: 90,
render(row) {
// 灰色标签样式下,不计入统计的记录类型显示为「不计入」
if (muted && !row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('transaction.notIncluded') })
}
- return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') })
+ return h(
+ NTag,
+ { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
+ { default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
+ )
},
},
{
- title: t('transaction.column.category'), key: 'categoryName', width: 120,
+ title: t('transaction.column.category'),
+ key: 'categoryName',
+ width: 120,
render(row) {
const name = row.categoryName || t('transaction.uncategorized')
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
},
},
{
- title: t('transaction.column.amount'), key: 'amount', width: 120,
+ title: t('transaction.column.amount'),
+ key: 'amount',
+ width: 120,
render(row) {
// 灰色标签样式下,不计入统计的记录金额置灰且不显示正负号
if (muted && !row.includeInStats) {
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, row.amount.toFixed(2))
}
- return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
- `${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
+ return h(
+ 'span',
+ { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
+ `${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`,
+ )
},
},
{
- title: t('transaction.column.channel'), key: 'channel', width: 100,
- render(row) { return getChannelLabel(row.channel) },
+ title: t('transaction.column.channel'),
+ key: 'channel',
+ width: 100,
+ render(row) {
+ return getChannelLabel(row.channel)
+ },
},
{
- title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
+ title: t('transaction.column.remark'),
+ key: 'remark',
+ minWidth: 220,
render(row) {
const hidden = !!row.remarkHidden
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
const nodes: any[] = [
- h('span', {
- class: 'remark-text',
- title: collapsed ? t('transaction.remarkHidden') : (row.remark || ''),
- }, collapsed ? '******' : (row.remark || '-')),
+ h(
+ 'span',
+ {
+ class: 'remark-text',
+ title: collapsed ? t('transaction.remarkHidden') : row.remark || '',
+ },
+ collapsed ? '******' : row.remark || '-',
+ ),
]
if (collapsed) {
nodes.push(renderRemarkAction(t('transaction.remarkView'), EyeOutline, () => revealRemark(row.id)))
- nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)))
+ nodes.push(
+ renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)),
+ )
} else if (hidden) {
nodes.push(renderRemarkAction(t('transaction.remarkCollapse'), EyeOffOutline, () => concealRemark(row.id)))
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOutline, () => setRemarkHidden(row, false)))
@@ -504,17 +591,32 @@ function createColumns(): DataTableColumns {
},
},
{
- title: t('transaction.column.tags'), key: 'tags', minWidth: 140,
+ title: t('transaction.column.tags'),
+ key: 'tags',
+ minWidth: 140,
render(row) {
if (!row.tags || row.tags.length === 0) return '-'
- return h(NSpace, { size: 4 }, {
- default: () => row.tags.map((tag) =>
- h(NTag, { size: 'small', color: { color: tag.color || '#2080f0', textColor: '#fff' } }, { default: () => tag.name })),
- })
+ return h(
+ NSpace,
+ { size: 4 },
+ {
+ default: () =>
+ row.tags.map((tag) =>
+ h(
+ NTag,
+ { size: 'small', color: { color: tag.color || '#2080f0', textColor: '#fff' } },
+ { default: () => tag.name },
+ ),
+ ),
+ },
+ )
},
},
{
- title: t('transaction.column.includeInStats'), key: 'includeInStats', width: 100, align: 'center',
+ title: t('transaction.column.includeInStats'),
+ key: 'includeInStats',
+ width: 100,
+ align: 'center',
render(row) {
return h(NSwitch, {
value: row.includeInStats,
@@ -523,23 +625,39 @@ function createColumns(): DataTableColumns {
},
},
{
- title: t('transaction.relation.column'), key: 'relationGroupCount', width: 110, align: 'center',
+ title: t('transaction.relation.column'),
+ key: 'relationGroupCount',
+ width: 110,
+ align: 'center',
render(row) {
if (!row.relationGroupCount) {
return h('span', { style: { color: '#ccc' } }, t('transaction.relation.none'))
}
- return h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) },
- { default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) })
+ return h(
+ NButton,
+ { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) },
+ { default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) },
+ )
},
},
{
- title: t('transaction.column.actions'), key: 'actions', width: 240,
+ title: t('transaction.column.actions'),
+ key: 'actions',
+ width: 240,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
- h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
- h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
+ h(
+ NButton,
+ { size: 'small', onClick: () => openDetail(row) },
+ { default: () => t('transaction.detail.button') },
+ ),
+ h(
+ NButton,
+ { size: 'small', type: 'error', onClick: () => handleDelete(row.id) },
+ { default: () => t('common.delete') },
+ ),
],
})
},
@@ -702,7 +820,16 @@ function onRelationEditorSaved() {
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
- form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
+ form.value = {
+ categoryId: null,
+ type: 'EXPENSE',
+ amount: null,
+ channel: '',
+ remark: '',
+ transactionTime: Date.now(),
+ includeInStats: true,
+ tagIds: [],
+ }
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
@@ -1181,7 +1308,9 @@ async function handleCreateCategory() {
})
await fetchCategories()
// 排序值相同,取同名同类型中的最后一个(最新创建)
- const created = [...categories.value].reverse().find((c) => c.label === name && c.type === newCategoryForm.value.type)
+ const created = [...categories.value]
+ .reverse()
+ .find((c) => c.label === name && c.type === newCategoryForm.value.type)
if (created) {
form.value.categoryId = created.value
// 同步收支类型,避免新建分类与当前账单类型不一致导致选中项不可见
@@ -1332,10 +1461,9 @@ function applyAiQuery() {
if (r.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterChannel.value = r.channels || []
- filterDateRange.value = r.startTime && r.endTime
- ? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
- : null
- filterIncludeInStats.value = r.includeInStats == null ? null : (r.includeInStats ? 'true' : 'false')
+ filterDateRange.value =
+ r.startTime && r.endTime ? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()] : null
+ filterIncludeInStats.value = r.includeInStats == null ? null : r.includeInStats ? 'true' : 'false'
filterMinAmount.value = r.minAmount ?? null
filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || ''
@@ -1351,17 +1479,19 @@ function applyAiQuery() {
const aiQueryEmpty = computed(() => {
const r = aiQueryResult.value
if (!r) return true
- return (r.types?.length ?? 0) === 0
- && (r.categoryIds?.length ?? 0) === 0
- && !r.uncategorized
- && (r.tagIds?.length ?? 0) === 0
- && !r.untagged
- && (r.channels?.length ?? 0) === 0
- && !r.startTime
- && r.minAmount == null
- && r.maxAmount == null
- && !r.remark
- && r.includeInStats == null
+ return (
+ (r.types?.length ?? 0) === 0 &&
+ (r.categoryIds?.length ?? 0) === 0 &&
+ !r.uncategorized &&
+ (r.tagIds?.length ?? 0) === 0 &&
+ !r.untagged &&
+ (r.channels?.length ?? 0) === 0 &&
+ !r.startTime &&
+ r.minAmount == null &&
+ r.maxAmount == null &&
+ !r.remark &&
+ r.includeInStats == null
+ )
})
/** AI 查询示例(浮窗示例下拉) */
@@ -1375,18 +1505,21 @@ const activeFilterTags = computed(() => {
tags.push({
key: `type:${v}`,
label: v === 'INCOME' ? t('common.types.income') : t('common.types.expense'),
- clear: () => { filterType.value = filterType.value.filter((x) => x !== v) },
+ clear: () => {
+ filterType.value = filterType.value.filter((x) => x !== v)
+ },
})
})
filterCategory.value.forEach((id) => {
- const name = id === 0
- ? t('transaction.uncategorized')
- : categories.value.find((c) => c.value === id)?.label || String(id)
+ const name =
+ id === 0 ? t('transaction.uncategorized') : categories.value.find((c) => c.value === id)?.label || String(id)
tags.push({
key: `category:${id}`,
label: name,
- clear: () => { filterCategory.value = filterCategory.value.filter((x) => x !== id) },
+ clear: () => {
+ filterCategory.value = filterCategory.value.filter((x) => x !== id)
+ },
})
})
@@ -1394,7 +1527,9 @@ const activeFilterTags = computed(() => {
tags.push({
key: 'date',
label: `${formatDateLabel(filterDateRange.value[0])} ~ ${formatDateLabel(filterDateRange.value[1])}`,
- clear: () => { filterDateRange.value = null },
+ clear: () => {
+ filterDateRange.value = null
+ },
})
}
@@ -1402,7 +1537,9 @@ const activeFilterTags = computed(() => {
tags.push({
key: 'remark',
label: `${t('transaction.column.remark')}: ${filterRemark.value.trim()}`,
- clear: () => { filterRemark.value = '' },
+ clear: () => {
+ filterRemark.value = ''
+ },
})
}
@@ -1410,26 +1547,31 @@ const activeFilterTags = computed(() => {
tags.push({
key: `channel:`,
label: getChannelLabel(v),
- clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
+ clear: () => {
+ filterChannel.value = filterChannel.value.filter((x) => x !== v)
+ },
})
})
filterTag.value.forEach((id) => {
- const name = id === 0
- ? t('transaction.untagged')
- : (allTags.value.find((tag) => tag.id === id)?.name || String(id))
+ const name = id === 0 ? t('transaction.untagged') : allTags.value.find((tag) => tag.id === id)?.name || String(id)
tags.push({
key: `tag:${id}`,
label: name,
- clear: () => { filterTag.value = filterTag.value.filter((x) => x !== id) },
+ clear: () => {
+ filterTag.value = filterTag.value.filter((x) => x !== id)
+ },
})
})
if (filterIncludeInStats.value !== null) {
tags.push({
key: 'includeInStats',
- label: filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
- clear: () => { filterIncludeInStats.value = null },
+ label:
+ filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
+ clear: () => {
+ filterIncludeInStats.value = null
+ },
})
}
@@ -1437,7 +1579,10 @@ const activeFilterTags = computed(() => {
tags.push({
key: 'amount',
label: `${t('transaction.amountRange')}: ${filterMinAmount.value ?? 0} ~ ${filterMaxAmount.value ?? '∞'}`,
- clear: () => { filterMinAmount.value = null; filterMaxAmount.value = null },
+ clear: () => {
+ filterMinAmount.value = null
+ filterMaxAmount.value = null
+ },
})
}
@@ -1540,12 +1685,12 @@ onMounted(() => {
-
- {{ t('transaction.title') }}
+
+ {{ t('transaction.title') }}
- {{ t('transaction.sortLabel') }}
-
-
+ {{ t('transaction.sortLabel') }}
+
+
{{ t('transaction.importBill') }}
{{ t('channel.manageEntry') }}
{{ t('transaction.exportExcel') }}
@@ -1572,7 +1717,7 @@ onMounted(() => {
-
+
{
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.type')"
- style="width: 160px;"
+ style="width: 160px"
/>
{
filterable
:max-tag-count="2"
:placeholder="t('transaction.column.category')"
- style="width: 200px;"
- />
-
+
{
:input-props="{ onKeydown: handleRemarkKeydown }"
clearable
:placeholder="t('transaction.column.remark')"
- style="width: 180px;"
+ style="width: 180px"
@update:value="remarkHistory.handleInput"
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
@@ -1622,18 +1762,18 @@ onMounted(() => {
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.channel')"
- style="width: 180px;"
+ style="width: 180px"
/>
{{ t('transaction.moreFilter') }}
-
+
{{ moreFilterCount }}
-
+
{{ t('transaction.column.tags') }}
{
filterable
:max-tag-count="2"
:placeholder="t('common.all')"
- style="width: 100%;"
+ style="width: 100%"
/>
@@ -1654,7 +1794,7 @@ onMounted(() => {
:options="includeInStatsOptions"
clearable
:placeholder="t('common.all')"
- style="width: 100%;"
+ style="width: 100%"
/>
@@ -1666,16 +1806,16 @@ onMounted(() => {
:precision="2"
clearable
:placeholder="t('common.min')"
- style="width: 110px;"
+ style="width: 110px"
/>
- ~
+ ~
@@ -1697,14 +1837,8 @@ onMounted(() => {
- {{ t('transaction.activeFilters') }}
-
+ {{ t('transaction.activeFilters') }}
+
{{ tag.label }}
{{ t('transaction.clearAll') }}
@@ -1713,7 +1847,7 @@ onMounted(() => {
-
+
{{ t('transaction.batchMode') }}
{{ t('transaction.exitBatchMode') }}
{{ t('transaction.batchSet') }}
@@ -1726,34 +1860,45 @@ onMounted(() => {
-
-
+
+
{{ t('transaction.summary', { total, page, pages: totalPages, size: pageSize }) }}
-
- {{ t('transaction.pageExpense') }} {{ pageSummary.expense.toFixed(2) }}
- · {{ t('transaction.pageIncome') }} {{ pageSummary.income.toFixed(2) }}
- · {{ t('transaction.pageBalance') }} {{ pageSummary.balance.toFixed(2) }}
-
- {{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
+
+ {{ t('transaction.pageExpense') }} {{ pageSummary.expense.toFixed(2) }} ·
+ {{ t('transaction.pageIncome') }} {{ pageSummary.income.toFixed(2) }} ·
+ {{ t('transaction.pageBalance') }}
+ {{
+ pageSummary.balance.toFixed(2)
+ }}
+
+ {{
+ t('transaction.pageExcluded', {
+ expense: pageExcludedSummary.expense.toFixed(2),
+ income: pageExcludedSummary.income.toFixed(2),
+ })
+ }}
-
+
{
-
+
-
- {{ t('ai.pickButton') }}
- {{ t('ai.newCategory.button') }}
- {{ t('transaction.newCategoryButton') }}
+
+ {{
+ t('ai.pickButton')
+ }}
+ {{
+ t('ai.newCategory.button')
+ }}
+ {{ t('transaction.newCategoryButton') }}
-
+
{{ t('ai.newCategory.hint') }}
- {{ pendingCategory.icon }} {{ pendingCategory.name }}
- {{ t('ai.newCategory.save') }}
+ {{ pendingCategory.icon }} {{ pendingCategory.name }}
+ {{
+ t('ai.newCategory.save')
+ }}
{{ t('common.cancel') }}
-
+
-
- {{ t('ai.pickButton') }}
- {{ t('ai.newChannel.button') }}
- {{ t('transaction.newChannelButton') }}
+
+ {{ t('ai.pickButton') }}
+ {{ t('ai.newChannel.button') }}
+ {{ t('transaction.newChannelButton') }}
-
+
{{ t('ai.newChannel.hint') }}
- {{ pendingChannel.name }}
- {{ t('ai.newChannel.save') }}
+ {{ pendingChannel.name }}
+ {{
+ t('ai.newChannel.save')
+ }}
{{ t('common.cancel') }}
-
+
@@ -1820,33 +2033,64 @@ onMounted(() => {
clearable
filterable
:placeholder="t('transaction.tagPlaceholder')"
- style="flex: 1; min-width: 160px;"
+ style="flex: 1; min-width: 160px"
/>
- {{ t('ai.pickButton') }}
- {{ t('ai.newTag.button') }}
- {{ t('transaction.newTagButton') }}
+ {{ t('ai.pickButton') }}
+ {{
+ t('ai.newTag.button')
+ }}
+ {{ t('transaction.newTagButton') }}
-
+
{{ t('ai.newTag.hint') }}
{{ pendingTag.name }}
- {{ t('ai.newTag.save') }}
+ {{
+ t('ai.newTag.save')
+ }}
{{ t('common.cancel') }}
- {{ t('transaction.includeInStatsHint') }}
+ {{ t('transaction.includeInStatsHint') }}
{{ t('common.confirm') }}
-
+
-
+
@@ -1859,30 +2103,54 @@ onMounted(() => {
:clear-text="t('category.clearIcon')"
/>
- {{ t('common.confirm') }}
+ {{
+ t('common.confirm')
+ }}
-
+
-
+
- {{ t('common.confirm') }}
+ {{
+ t('common.confirm')
+ }}
-
+
-
+
- {{ t('common.confirm') }}
+ {{
+ t('common.confirm')
+ }}
@@ -1998,8 +2266,8 @@ onMounted(() => {
text-decoration: line-through;
}
/* 类型、金额列保持现状,不划删除线 */
-:deep(.excluded-row .n-data-table-td[data-col-key="type"]),
-:deep(.excluded-row .n-data-table-td[data-col-key="amount"]) {
+:deep(.excluded-row .n-data-table-td[data-col-key='type']),
+:deep(.excluded-row .n-data-table-td[data-col-key='amount']) {
text-decoration: none;
}
/* 备注单元格为块级结构,需单独划线以保持整行效果 */
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 1ffef60..d32ff68 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,7 +1,4 @@
{
"files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
diff --git a/gradle/checkstyle/checkstyle.xml b/gradle/checkstyle/checkstyle.xml
new file mode 100644
index 0000000..a8e9ca7
--- /dev/null
+++ b/gradle/checkstyle/checkstyle.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gradle/checkstyle/suppressions.xml b/gradle/checkstyle/suppressions.xml
new file mode 100644
index 0000000..bca16a3
--- /dev/null
+++ b/gradle/checkstyle/suppressions.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/gradle/spotbugs/exclude.xml b/gradle/spotbugs/exclude.xml
new file mode 100644
index 0000000..2bf3853
--- /dev/null
+++ b/gradle/spotbugs/exclude.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/cn/rosercode/ledgerly/config/app/AppConfigStore.java b/src/main/java/cn/rosercode/ledgerly/config/app/AppConfigStore.java
index 03f27c4..398a82c 100644
--- a/src/main/java/cn/rosercode/ledgerly/config/app/AppConfigStore.java
+++ b/src/main/java/cn/rosercode/ledgerly/config/app/AppConfigStore.java
@@ -67,7 +67,10 @@ public class AppConfigStore {
public void save(AppConfig config) {
Path file = getConfigFile();
try {
- Files.createDirectories(file.getParent());
+ Path parent = file.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
log.info("应用配置已保存到 {}", file.toAbsolutePath());
} catch (IOException e) {
diff --git a/src/main/java/cn/rosercode/ledgerly/config/datasource/DatabaseConfigStore.java b/src/main/java/cn/rosercode/ledgerly/config/datasource/DatabaseConfigStore.java
index eb03f01..2562e70 100644
--- a/src/main/java/cn/rosercode/ledgerly/config/datasource/DatabaseConfigStore.java
+++ b/src/main/java/cn/rosercode/ledgerly/config/datasource/DatabaseConfigStore.java
@@ -81,7 +81,10 @@ public class DatabaseConfigStore {
public void save(DatabaseConfig config) {
Path file = getConfigFile();
try {
- Files.createDirectories(file.getParent());
+ Path parent = file.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
log.info("数据库配置已保存到 {}", file.toAbsolutePath());
} catch (IOException e) {
diff --git a/src/main/java/cn/rosercode/ledgerly/controller/AppConfigController.java b/src/main/java/cn/rosercode/ledgerly/controller/AppConfigController.java
index 313a39f..e1e5aa1 100644
--- a/src/main/java/cn/rosercode/ledgerly/controller/AppConfigController.java
+++ b/src/main/java/cn/rosercode/ledgerly/controller/AppConfigController.java
@@ -68,7 +68,10 @@ public class AppConfigController {
@Operation(summary = "查询日志保留天数", description = "读取访问日志与登录日志的自动清理保留天数")
public Result getLogRetention() {
Integer days = appConfigStore.load().getLogRetentionDays();
- return Result.success(days == null ? AppConfig.DEFAULT_LOG_RETENTION_DAYS : days);
+ if (days == null) {
+ days = AppConfig.DEFAULT_LOG_RETENTION_DAYS;
+ }
+ return Result.success(days);
}
/**
diff --git a/src/main/java/cn/rosercode/ledgerly/email/EmailBillStorage.java b/src/main/java/cn/rosercode/ledgerly/email/EmailBillStorage.java
index 81605d7..c7a2383 100644
--- a/src/main/java/cn/rosercode/ledgerly/email/EmailBillStorage.java
+++ b/src/main/java/cn/rosercode/ledgerly/email/EmailBillStorage.java
@@ -40,7 +40,10 @@ public class EmailBillStorage {
String relative = configId + "/" + uid + "/" + index + "_" + sanitize(fileName);
Path target = ROOT.resolve(relative);
try {
- Files.createDirectories(target.getParent());
+ Path parent = target.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
Files.write(target, content);
return relative;
} catch (IOException e) {
@@ -103,7 +106,8 @@ public class EmailBillStorage {
if (fileName == null || fileName.isBlank()) {
return "attachment";
}
- String name = Paths.get(fileName).getFileName().toString();
+ Path namePath = Paths.get(fileName).getFileName();
+ String name = namePath == null ? fileName : namePath.toString();
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
}
}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java
index 3926d79..174c143 100644
--- a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java
@@ -65,7 +65,10 @@ public class LauncherConfigStore {
*/
public void save(LauncherConfig config) {
try {
- Files.createDirectories(file.getParent());
+ Path parent = file.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
} catch (IOException e) {
// 配置保存失败不影响主流程,仅忽略
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java
index 13ba3ea..96795d0 100644
--- a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java
@@ -130,7 +130,10 @@ public final class LauncherIconProvider {
log.warn("下载启动器图标失败:HTTP {}", response.statusCode());
return;
}
- Files.createDirectories(cacheFile.getParent());
+ Path parent = cacheFile.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
Path temp = cacheFile.resolveSibling(CACHE_FILE + ".tmp");
Files.write(temp, response.body());
Files.move(temp, cacheFile, StandardCopyOption.REPLACE_EXISTING);
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java b/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java
index 0b01ba0..bf41efa 100644
--- a/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java
@@ -38,7 +38,10 @@ public class SingleInstanceGuard implements AutoCloseable {
*/
public static SingleInstanceGuard acquire(Path home) throws IOException {
Path lockFile = home.resolve("config").resolve(LOCK_FILE);
- Files.createDirectories(lockFile.getParent());
+ Path parent = lockFile.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
FileChannel channel = FileChannel.open(lockFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = channel.tryLock();
diff --git a/src/main/java/cn/rosercode/ledgerly/notification/NotificationSenderFactory.java b/src/main/java/cn/rosercode/ledgerly/notification/NotificationSenderFactory.java
index 741f568..a01101d 100644
--- a/src/main/java/cn/rosercode/ledgerly/notification/NotificationSenderFactory.java
+++ b/src/main/java/cn/rosercode/ledgerly/notification/NotificationSenderFactory.java
@@ -38,10 +38,13 @@ public class NotificationSenderFactory {
* @throws BusinessException 类型不支持时抛出
*/
public NotificationSender get(String type) {
+ if (type == null) {
+ throw new BusinessException("不支持的通知渠道类型:null");
+ }
NotificationChannelType channelType;
try {
channelType = NotificationChannelType.valueOf(type);
- } catch (IllegalArgumentException | NullPointerException e) {
+ } catch (IllegalArgumentException e) {
throw new BusinessException("不支持的通知渠道类型:" + type);
}
NotificationSender sender = senders.get(channelType);
diff --git a/src/main/java/cn/rosercode/ledgerly/service/AiImageStorage.java b/src/main/java/cn/rosercode/ledgerly/service/AiImageStorage.java
index e3e4f83..70a5e1a 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/AiImageStorage.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/AiImageStorage.java
@@ -47,7 +47,10 @@ public class AiImageStorage {
Path relative = Paths.get(ROOT, String.valueOf(userId), month, fileName);
Path target = AppHome.getHome().resolve(relative);
try {
- Files.createDirectories(target.getParent());
+ Path parent = target.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
Files.write(target, content);
} catch (IOException e) {
log.error("保存 AI 记账图片失败: userId={}, path={}", userId, target, e);
diff --git a/src/main/java/cn/rosercode/ledgerly/service/AppLogStreamService.java b/src/main/java/cn/rosercode/ledgerly/service/AppLogStreamService.java
index 1922452..4a0a90e 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/AppLogStreamService.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/AppLogStreamService.java
@@ -94,7 +94,7 @@ public class AppLogStreamService {
/** 尚未构成完整行的字节缓冲 */
private final ByteArrayOutputStream pending = new ByteArrayOutputStream();
- private ScheduledFuture> future;
+ private volatile ScheduledFuture> future;
private volatile boolean closed = false;
StreamTask(SseEmitter emitter, Path file, String levelFilter, String keywordFilter,
@@ -195,7 +195,7 @@ public class AppLogStreamService {
}
emitCompleteLines();
}
- } catch (Exception e) {
+ } catch (IOException | RuntimeException e) {
// 发送失败通常意味着连接已断开
stop();
}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
index a7589e8..415865d 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
@@ -244,8 +244,8 @@ public class BillImportServiceImpl implements BillImportService {
transaction.setRemark(truncate(buildRemark(item.getCounterparty(), item.getProduct()), 255));
transaction.setExternalNo(item.getExternalNo());
transaction.setTransactionTime(item.getTransactionTime());
- transaction.setIncludeInStats(item.getIncludeInStats() == null
- ? sourceStatsDefault : item.getIncludeInStats());
+ Boolean includeInStats = item.getIncludeInStats();
+ transaction.setIncludeInStats(includeInStats == null ? Boolean.valueOf(sourceStatsDefault) : includeInStats);
transaction.setRawData(item.getRawData());
pending.add(transaction);
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
index 7dfa11f..7396a80 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
@@ -290,8 +290,9 @@ public class DatabaseServiceImpl implements DatabaseService {
Path rawPath = Paths.get(config.getSqlitePath());
Path path = (rawPath.isAbsolute() ? rawPath : AppHome.getHome().resolve(rawPath)).normalize();
try {
- if (path.getParent() != null) {
- Files.createDirectories(path.getParent());
+ Path parent = path.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
}
} catch (Exception e) {
throw new BusinessException("创建 SQLite 数据库目录失败:" + e.getMessage());
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/StatisticsServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/StatisticsServiceImpl.java
index c0c2314..0e62fcb 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/StatisticsServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/StatisticsServiceImpl.java
@@ -465,7 +465,9 @@ public class StatisticsServiceImpl implements StatisticsService {
return custom;
}
// 兼容历史 BANK 渠道
- if ("BANK".equals(code)) return "银行卡";
+ if ("BANK".equals(code)) {
+ return "银行卡";
+ }
return ChannelType.labelOf(code);
}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionGroupServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionGroupServiceImpl.java
index 7167630..d131760 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionGroupServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionGroupServiceImpl.java
@@ -386,7 +386,8 @@ public class TransactionGroupServiceImpl implements TransactionGroupService {
entity.setGroupId(groupId);
entity.setTransactionId(item.getTransactionId());
entity.setRole(item.getRole());
- entity.setSortOrder(item.getSortOrder() == null ? 0 : item.getSortOrder());
+ Integer sortOrder = item.getSortOrder();
+ entity.setSortOrder(sortOrder == null ? Integer.valueOf(0) : sortOrder);
transactionGroupItemMapper.insert(entity);
}
}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/UserServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/UserServiceImpl.java
index d6ac14a..beffbef 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/UserServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/UserServiceImpl.java
@@ -186,6 +186,7 @@ public class UserServiceImpl implements UserService {
logService.recordLoginLog(loginLog);
} catch (Exception e) {
// 登录日志记录失败不应影响登录流程
+ log.debug("记录登录日志失败:{}", e.getMessage());
}
}
diff --git a/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java b/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java
index 22d19b1..1f6326e 100644
--- a/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java
+++ b/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java
@@ -108,7 +108,10 @@ public class RsaKeyManager {
keyFile.setPrivateKey(Base64.getEncoder().encodeToString(this.privateKey.getEncoded()));
keyFile.setCreatedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
- Files.createDirectories(file.getParent());
+ Path parent = file.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), keyFile);
log.info("已生成接口加密 RSA 密钥并保存到 {},keyId={}", file.toAbsolutePath(), keyId);
} catch (Exception e) {