feat: 用户自助修改密码与 AI 查询统计筛选增强
- 设置页新增「账号安全」面板,当前登录用户校验原密码后可修改自己的密码 - 新增 PUT /api/user/password 接口,BCrypt 校验原密码,新旧相同与长度非法均拒绝 - 新增随机密码工具,一键生成 12 位大小写字母与数字组合密码(排除易混淆字符) - 随机密码明文展示并支持复制,非安全上下文回退 execCommand 方案 - 新增账号安全相关中英日三语文案 - AI 查询与统计结果新增 untagged/uncategorized 字段,提示词同步说明互斥规则 - 统计面板与交易列表支持「无标签」筛选项,AI 结果可回填无标签/未分类条件 - 备注已隐藏的交易不参与备注关键字搜索,避免隐藏内容被检索命中 - 交易列表操作列将「编辑」调整至「详情」之前
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
// 账号安全面板:当前登录用户修改密码,支持一键生成随机密码并显示明文
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace, NIcon, NText,
|
||||
} from 'naive-ui'
|
||||
import { KeyOutline, RefreshOutline, CopyOutline, SaveOutline } from '@vicons/ionicons5'
|
||||
import { changePassword } from '../api/user'
|
||||
import { generateRandomPassword } from '../utils/password'
|
||||
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
// 生成随机密码后临时明文显示,便于用户查看与复制
|
||||
const revealed = ref(false)
|
||||
// 最近一次生成的随机密码,用于判断用户是否已手动修改
|
||||
const generated = ref('')
|
||||
|
||||
// 用户手动修改新密码后恢复遮挡显示
|
||||
watch(() => form.value.newPassword, (val) => {
|
||||
if (val !== generated.value) {
|
||||
revealed.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** 生成随机密码并填充到新密码与确认密码,同时明文显示 */
|
||||
function handleGenerate() {
|
||||
const pwd = generateRandomPassword()
|
||||
form.value.newPassword = pwd
|
||||
form.value.confirmPassword = pwd
|
||||
generated.value = pwd
|
||||
revealed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板。
|
||||
* 优先使用 Clipboard API;在非安全上下文(如通过 IP 访问的 HTTP 页面)
|
||||
* 该 API 不可用,回退为临时 textarea + execCommand 方案。
|
||||
*/
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch (e) {
|
||||
// Clipboard API 被拒绝,继续尝试回退方案
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-9999px'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制当前新密码到剪贴板 */
|
||||
async function handleCopy() {
|
||||
if (!form.value.newPassword) return
|
||||
const ok = await copyText(form.value.newPassword)
|
||||
if (ok) {
|
||||
message.success(t('account.copied'))
|
||||
} else {
|
||||
message.error(t('account.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验并提交修改密码 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.oldPassword) {
|
||||
message.warning(t('account.oldRequired'))
|
||||
return
|
||||
}
|
||||
if (form.value.newPassword.length < 6) {
|
||||
message.warning(t('account.newMin'))
|
||||
return
|
||||
}
|
||||
if (form.value.newPassword !== form.value.confirmPassword) {
|
||||
message.warning(t('account.mismatch'))
|
||||
return
|
||||
}
|
||||
if (form.value.newPassword === form.value.oldPassword) {
|
||||
message.warning(t('account.sameAsOld'))
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
oldPassword: form.value.oldPassword,
|
||||
newPassword: form.value.newPassword,
|
||||
})
|
||||
message.success(t('account.success'))
|
||||
// 提交成功后清空表单并恢复遮挡
|
||||
form.value.oldPassword = ''
|
||||
form.value.newPassword = ''
|
||||
form.value.confirmPassword = ''
|
||||
generated.value = ''
|
||||
revealed.value = false
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('account.failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="config-card">
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="KeyOutline" />
|
||||
<span>{{ t('settings.accountSection') }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('account.oldPassword')">
|
||||
<NInput
|
||||
v-model:value="form.oldPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('account.oldPasswordPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('account.newPassword')">
|
||||
<NSpace :size="8" style="width: 100%;">
|
||||
<NInput
|
||||
v-model:value="form.newPassword"
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
show-password-on="click"
|
||||
:placeholder="t('account.newPasswordPlaceholder')"
|
||||
style="flex: 1;"
|
||||
/>
|
||||
<NButton secondary @click="handleGenerate">
|
||||
<template #icon>
|
||||
<NIcon :component="RefreshOutline" />
|
||||
</template>
|
||||
{{ t('account.generate') }}
|
||||
</NButton>
|
||||
<NButton secondary :disabled="!form.newPassword" @click="handleCopy">
|
||||
<template #icon>
|
||||
<NIcon :component="CopyOutline" />
|
||||
</template>
|
||||
{{ t('account.copy') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('account.confirmPassword')">
|
||||
<NInput
|
||||
v-model:value="form.confirmPassword"
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
show-password-on="click"
|
||||
:placeholder="t('account.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NText depth="3" style="font-size: 12px;">{{ t('account.hint') }}</NText>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
|
||||
<NButton type="primary" :loading="loading" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('account.submit') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -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: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -110,7 +110,11 @@ interface TagOption {
|
||||
value: number
|
||||
}
|
||||
const tags = ref<TagOption[]>([])
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 系统设置页:上下分区展示数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
|
||||
// 系统设置页:上下分区展示账号安全、数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountSecurityPanel from '../components/AccountSecurityPanel.vue'
|
||||
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
||||
@@ -14,6 +15,7 @@ const { t } = useI18n()
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2 class="page-title">{{ t('settings.title') }}</h2>
|
||||
<AccountSecurityPanel />
|
||||
<DatabaseConfigPanel />
|
||||
<RegistrationConfigPanel />
|
||||
<BillSourceStatsPanel />
|
||||
|
||||
@@ -608,8 +608,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
|
||||
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') }),
|
||||
],
|
||||
})
|
||||
@@ -1666,7 +1666,9 @@ function applyAiQuery() {
|
||||
filterMinAmount.value = r.minAmount ?? null
|
||||
filterMaxAmount.value = r.maxAmount ?? null
|
||||
filterRemark.value = r.remark || ''
|
||||
filterTag.value = r.tagIds || []
|
||||
const tagIds = [...(r.tagIds || [])]
|
||||
if (r.untagged) tagIds.push(0)
|
||||
filterTag.value = tagIds
|
||||
handleSearch()
|
||||
// 成功提示优先展示 AI 识别的查询意图
|
||||
message.success(r.summary || t('ai.query.applied'))
|
||||
@@ -1680,6 +1682,7 @@ const aiQueryEmpty = computed(() => {
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& !r.uncategorized
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& !r.untagged
|
||||
&& (r.channels?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
|
||||
Reference in New Issue
Block a user