From 66088e8c7eb34c85d141b2688264d5222071096e Mon Sep 17 00:00:00 2001 From: rosercode Date: Tue, 22 Sep 2026 10:49:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=87=AA=E5=8A=A9?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=AF=86=E7=A0=81=E4=B8=8E=20AI=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=BB=9F=E8=AE=A1=E7=AD=9B=E9=80=89=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=20-=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=AE=89=E5=85=A8=E3=80=8D=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=8C=E5=BD=93=E5=89=8D=E7=99=BB=E5=BD=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8E=9F=E5=AF=86=E7=A0=81=E5=90=8E=E5=8F=AF?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=87=AA=E5=B7=B1=E7=9A=84=E5=AF=86=E7=A0=81?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20PUT=20/api/user/password=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8CBCrypt=20=E6=A0=A1=E9=AA=8C=E5=8E=9F?= =?UTF-8?q?=E5=AF=86=E7=A0=81=EF=BC=8C=E6=96=B0=E6=97=A7=E7=9B=B8=E5=90=8C?= =?UTF-8?q?=E4=B8=8E=E9=95=BF=E5=BA=A6=E9=9D=9E=E6=B3=95=E5=9D=87=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=20-=20=E6=96=B0=E5=A2=9E=E9=9A=8F=E6=9C=BA=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=B7=A5=E5=85=B7=EF=BC=8C=E4=B8=80=E9=94=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=2012=20=E4=BD=8D=E5=A4=A7=E5=B0=8F=E5=86=99=E5=AD=97?= =?UTF-8?q?=E6=AF=8D=E4=B8=8E=E6=95=B0=E5=AD=97=E7=BB=84=E5=90=88=E5=AF=86?= =?UTF-8?q?=E7=A0=81=EF=BC=88=E6=8E=92=E9=99=A4=E6=98=93=E6=B7=B7=E6=B7=86?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=EF=BC=89=20-=20=E9=9A=8F=E6=9C=BA=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E6=98=8E=E6=96=87=E5=B1=95=E7=A4=BA=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=8D=E5=88=B6=EF=BC=8C=E9=9D=9E=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=9B=9E=E9=80=80=20execCommand=20?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20-=20=E6=96=B0=E5=A2=9E=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E7=9B=B8=E5=85=B3=E4=B8=AD=E8=8B=B1=E6=97=A5?= =?UTF-8?q?=E4=B8=89=E8=AF=AD=E6=96=87=E6=A1=88=20-=20AI=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E4=B8=8E=E7=BB=9F=E8=AE=A1=E7=BB=93=E6=9E=9C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20untagged/uncategorized=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=90=8C=E6=AD=A5=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E4=BA=92=E6=96=A5=E8=A7=84=E5=88=99=20-=20=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E9=9D=A2=E6=9D=BF=E4=B8=8E=E4=BA=A4=E6=98=93=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E3=80=8C=E6=97=A0=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E3=80=8D=E7=AD=9B=E9=80=89=E9=A1=B9=EF=BC=8CAI=20=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=8F=AF=E5=9B=9E=E5=A1=AB=E6=97=A0=E6=A0=87=E7=AD=BE?= =?UTF-8?q?/=E6=9C=AA=E5=88=86=E7=B1=BB=E6=9D=A1=E4=BB=B6=20-=20=E5=A4=87?= =?UTF-8?q?=E6=B3=A8=E5=B7=B2=E9=9A=90=E8=97=8F=E7=9A=84=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E4=B8=8D=E5=8F=82=E4=B8=8E=E5=A4=87=E6=B3=A8=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E5=AD=97=E6=90=9C=E7=B4=A2=EF=BC=8C=E9=81=BF=E5=85=8D=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E5=86=85=E5=AE=B9=E8=A2=AB=E6=A3=80=E7=B4=A2=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=20-=20=E4=BA=A4=E6=98=93=E5=88=97=E8=A1=A8=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=88=97=E5=B0=86=E3=80=8C=E7=BC=96=E8=BE=91=E3=80=8D?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=87=B3=E3=80=8C=E8=AF=A6=E6=83=85=E3=80=8D?= =?UTF-8?q?=E4=B9=8B=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/ai.ts | 6 + frontend/src/api/user.ts | 5 + .../src/components/AccountSecurityPanel.vue | 185 ++++++++++++++++++ frontend/src/locales/messages.ts | 23 +++ frontend/src/utils/password.ts | 49 +++++ frontend/src/views/Dashboard.vue | 27 ++- frontend/src/views/Settings.vue | 4 +- frontend/src/views/TransactionList.vue | 7 +- .../finance/controller/UserController.java | 14 ++ .../finance/dto/ChangePasswordRequest.java | 23 +++ .../finance/service/UserService.java | 9 + .../service/impl/AiQueryServiceImpl.java | 4 + .../service/impl/AiStatisticsServiceImpl.java | 5 + .../service/impl/StatisticsServiceImpl.java | 3 +- .../service/impl/TransactionServiceImpl.java | 12 +- .../finance/service/impl/UserServiceImpl.java | 31 +++ .../rosercode/finance/vo/AiQueryResultVO.java | 3 + .../finance/vo/AiStatisticsResultVO.java | 6 + .../resources/prompts/ai-query-system.tpl | 3 +- .../prompts/ai-statistics-system.tpl | 3 +- 20 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/AccountSecurityPanel.vue create mode 100644 frontend/src/utils/password.ts create mode 100644 src/main/java/cn/rosercode/finance/dto/ChangePasswordRequest.java 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 @@ + + + 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 @@