feat: 用户自助修改密码与 AI 查询统计筛选增强

- 设置页新增「账号安全」面板,当前登录用户校验原密码后可修改自己的密码
- 新增 PUT /api/user/password 接口,BCrypt 校验原密码,新旧相同与长度非法均拒绝
- 新增随机密码工具,一键生成 12 位大小写字母与数字组合密码(排除易混淆字符)
- 随机密码明文展示并支持复制,非安全上下文回退 execCommand 方案
- 新增账号安全相关中英日三语文案
- AI 查询与统计结果新增 untagged/uncategorized 字段,提示词同步说明互斥规则
- 统计面板与交易列表支持「无标签」筛选项,AI 结果可回填无标签/未分类条件
- 备注已隐藏的交易不参与备注关键字搜索,避免隐藏内容被检索命中
- 交易列表操作列将「编辑」调整至「详情」之前
This commit is contained in:
2026-09-22 10:49:01 +08:00
parent be7e27805d
commit 66088e8c7e
20 changed files with 408 additions and 14 deletions
@@ -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>