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