diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts
index 3a43562..8216b36 100644
--- a/frontend/src/api/ai.ts
+++ b/frontend/src/api/ai.ts
@@ -152,8 +152,12 @@ export interface AiStatisticsResult {
types: string[]
categoryIds: number[]
categoryNames: string[]
+ /** 是否仅筛选未分类记录 */
+ uncategorized: boolean
tagIds: number[]
tagNames: string[]
+ /** 是否仅筛选无标签记录;与 tagIds 互斥 */
+ untagged: boolean
channels: string[]
startTime: string | null
endTime: string | null
@@ -220,6 +224,8 @@ export interface AiQueryResult {
remark: string | null
tagIds: number[]
tagNames: string[]
+ /** 是否筛选无标签记录;与 tagIds 互斥 */
+ untagged: boolean
unmatched: string[]
}
diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts
index 114d1d1..5a46bf1 100644
--- a/frontend/src/api/user.ts
+++ b/frontend/src/api/user.ts
@@ -16,6 +16,11 @@ export function getUserInfo() {
return request.get('/user/info')
}
+/** 修改当前登录用户密码 */
+export function changePassword(data: { oldPassword: string; newPassword: string }) {
+ return request.put('/user/password', data)
+}
+
/** 注册状态 */
export interface RegistrationStatus {
enabled: boolean
diff --git a/frontend/src/components/AccountSecurityPanel.vue b/frontend/src/components/AccountSecurityPanel.vue
new file mode 100644
index 0000000..7fbbc49
--- /dev/null
+++ b/frontend/src/components/AccountSecurityPanel.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+ {{ t('settings.accountSection') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('account.generate') }}
+
+
+
+
+
+ {{ t('account.copy') }}
+
+
+
+
+
+
+
+
+ {{ t('account.hint') }}
+
+
+
+
+
+
+ {{ t('account.submit') }}
+
+
+
+
diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts
index 583386e..78cbb99 100644
--- a/frontend/src/locales/messages.ts
+++ b/frontend/src/locales/messages.ts
@@ -136,6 +136,7 @@ export default {
// 系统设置
settings: {
title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
+ accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' },
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
@@ -145,6 +146,28 @@ export default {
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
},
+ // 账号安全(修改密码)
+ account: {
+ oldPassword: { zh: '原密码', en: '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: '新しいパスワードをもう一度入力してください' },
+ 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: '変更後は新しいパスワードでログインしてください。生成したパスワードは大切に保管してください。' },
+ 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: '新しいパスワードは現在のパスワードと異なる必要があります' },
+ 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: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
diff --git a/frontend/src/utils/password.ts b/frontend/src/utils/password.ts
new file mode 100644
index 0000000..e498915
--- /dev/null
+++ b/frontend/src/utils/password.ts
@@ -0,0 +1,49 @@
+// 随机密码工具:生成强度较高且便于输入的密码
+// 字符集排除易混淆字符(0/O/o/1/l/I),避免用户手动输入时出错
+const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
+const LOWER = 'abcdefghijkmnpqrstuvwxyz'
+const DIGIT = '23456789'
+const CHARSET = UPPER + LOWER + DIGIT
+
+/** 生成指定长度的随机密码,默认 12 位,保证同时包含大小写字母与数字 */
+export function generateRandomPassword(length = 12): string {
+ const size = Math.max(6, length)
+ const chars: string[] = [randomChar(UPPER), randomChar(LOWER), randomChar(DIGIT)]
+ // 补齐剩余位数后打乱,避免固定位置出现固定字符类型
+ while (chars.length < size) {
+ chars.push(randomChar(CHARSET))
+ }
+ return shuffle(chars).join('')
+}
+
+/** 从字符集中安全随机取一个字符 */
+function randomChar(charset: string): string {
+ return charset[secureRandomInt(charset.length)]
+}
+
+/** 使用 crypto 生成 [0, max) 的随机整数,crypto 不可用时回退 Math.random */
+function secureRandomInt(max: number): number {
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
+ // 取整倍数区间拒绝采样,避免取模带来的分布偏差
+ const limit = Math.floor(0xffffffff / max) * max
+ const buffer = new Uint32Array(1)
+ let value = 0
+ do {
+ crypto.getRandomValues(buffer)
+ value = buffer[0]
+ } while (value >= limit)
+ return value % max
+ }
+ return Math.floor(Math.random() * max)
+}
+
+/** Fisher-Yates 洗牌 */
+function shuffle(arr: string[]): string[] {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = secureRandomInt(i + 1)
+ const tmp = arr[i]
+ arr[i] = arr[j]
+ arr[j] = tmp
+ }
+ return arr
+}
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index ff08c17..d33ea62 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -110,7 +110,11 @@ interface TagOption {
value: number
}
const tags = ref([])
-const tagOptions = computed(() => tags.value)
+// 标签筛选项:额外提供「无标签」选项(仅筛选使用)
+const tagOptions = computed(() => [
+ { label: t('transaction.untagged'), value: 0 },
+ ...tags.value,
+])
/** 分类选项数据结构 */
interface CategoryOption {
@@ -136,13 +140,22 @@ function buildCategoryFilter() {
}
}
+/** 将标签筛选拆分为标签ID与无标签标记 */
+function buildTagFilter() {
+ const ids = filterTag.value.filter((v) => v !== 0)
+ return {
+ tagIds: ids.length > 0 ? ids : undefined,
+ untagged: filterTag.value.includes(0) ? true : undefined,
+ }
+}
+
/** 统计接口的过滤条件 */
const filterParams = computed(() => ({
statsScope: filterStatsScope.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
- tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
+ ...buildTagFilter(),
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
@@ -565,9 +578,13 @@ async function applyAiStatistics(result: AiStatisticsResult) {
filterStatsScope.value = result.statsScope
}
filterType.value = result.types || []
- filterCategory.value = result.categoryIds || []
+ const categoryIds = [...(result.categoryIds || [])]
+ if (result.uncategorized) categoryIds.push(0)
+ filterCategory.value = categoryIds
filterChannel.value = result.channels || []
- filterTag.value = result.tagIds || []
+ const tagIds = [...(result.tagIds || [])]
+ if (result.untagged) tagIds.push(0)
+ filterTag.value = tagIds
filterMinAmount.value = result.minAmount
filterMaxAmount.value = result.maxAmount
filterRemark.value = result.remark || ''
@@ -581,7 +598,9 @@ const aiSearchEmpty = computed(() => {
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
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index 5a6f27e..b7922f4 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -1,6 +1,7 @@