diff --git a/openspec/changes/add-self-service-password-dialog/proposal.md b/openspec/changes/add-self-service-password-dialog/proposal.md new file mode 100644 index 0000000..65accc4 --- /dev/null +++ b/openspec/changes/add-self-service-password-dialog/proposal.md @@ -0,0 +1,27 @@ +# Change: 新增当前用户修改密码弹窗 + +状态:用户已确认执行(2026-10-09),按本提案实施。 + +## Why + +当前顶部用户菜单只有“个人中心”和“锁定屏幕”,缺少当前登录用户修改密码的入口。统一认证接口已有 `PUT /api/auth/password`,但前端参数类型仍使用不符合接口约定的驼峰字段。 + +## What Changes + +- 在顶部用户菜单“个人中心”和“锁定屏幕”之间新增“修改密码”,点击后打开弹窗,并关闭用户菜单浮层。 +- 弹窗包含旧密码、新密码、确认新密码;输入隐藏并允许切换可见,新密码及确认新密码长度为 8–32 位,确认密码必须一致。旧密码必填,不套用新密码长度限制,以兼容已有密码。 +- 复用 Element Plus 表单、弹窗及主题,提供字段校验、取消、确认、提交中状态和重复提交保护,兼容窄屏及键盘操作。 +- 复用 `AuthService.changePassword`,修正参数类型,仅发送 `old_password` 和 `new_password`;确认密码不发送给后端,Bearer 认证沿用现有请求层。 +- 成功后提示并关闭弹窗、清空密码和旧的记住密码凭证;不主动退出登录。失败时保持弹窗,由现有请求层展示后端错误,401 沿用现有认证过期处理。 +- 补齐中文及英文菜单与表单文案,以及参数映射、密码长度边界、密码一致性、成功/失败与重复提交验证。 + +## Impact + +- Affected specs: `user-auth`(当前无已归档的能力规范,本提案新增增量规范)。 +- Affected code: `src/components/core/layouts/art-header-bar/index.vue`、新增修改密码弹窗组件、`src/types/api/auth.ts`、`src/api/modules/auth.ts`、`src/locales/langs/zh.json`、`src/locales/langs/en.json`。 +- 不涉及管理员给其他账号重置密码,不修改锁屏密码,不引入新依赖,不调用测试环境真实密码修改接口。 +- 本次另行完成的资产实名时间修正属于现有展示问题修复:仅指定三个 `real_name_at` 展示位置在现有逻辑基础上加 8 小时,不属于本提案的审批范围。 + +## Validation + +审批后验证 7、8、32、33 位新密码边界、确认密码不一致、旧密码为空、成功及失败响应、重复点击,以及菜单位置与重新打开后的密码清空。运行相关测试、类型检查和受影响文件的代码检查。 diff --git a/openspec/changes/add-self-service-password-dialog/specs/user-auth/spec.md b/openspec/changes/add-self-service-password-dialog/specs/user-auth/spec.md new file mode 100644 index 0000000..260db2b --- /dev/null +++ b/openspec/changes/add-self-service-password-dialog/specs/user-auth/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Self-service password menu entry + +系统 SHALL 在顶部用户菜单“个人中心”和“锁定屏幕”之间提供“修改密码”入口,点击后显示当前登录用户的修改密码弹窗,不跳转页面。 + +#### Scenario: 打开修改密码弹窗 + +- **WHEN** 当前登录用户点击顶部菜单“修改密码” +- **THEN** 系统 MUST 关闭用户菜单浮层并显示修改密码弹窗 +- **AND** 弹窗 MUST 包含旧密码、新密码、确认新密码、取消及确认操作 +- **AND** 输入 MUST 默认隐藏密码并支持切换可见 + +### Requirement: Password form validation + +系统 SHALL 校验旧密码必填、新密码及确认新密码长度为 8–32 位且两次新密码一致,在校验通过前不得发送修改密码请求。系统 MUST NOT 自动裁剪或修改用户输入的密码,也不得对旧密码新增长度限制。 + +#### Scenario: 密码长度边界 + +- **WHEN** 新密码或确认新密码长度为 7 或 33 位 +- **THEN** 表单 MUST 提示长度要求并阻止提交 +- **AND** 长度为 8 或 32 位且其他校验通过时 MUST 允许提交 + +#### Scenario: 必填及一致性校验 + +- **WHEN** 任一密码字段为空,或者两次新密码不一致 +- **THEN** 表单 MUST 展示对应字段错误并阻止提交 + +### Requirement: Unified authentication password contract + +系统 SHALL 通过现有统一认证请求层调用 `PUT /api/auth/password`,请求体 MUST 仅包含 `old_password` 和 `new_password`,Bearer 认证 MUST 沿用当前登录会话,确认密码 MUST NOT 发送到服务端。 + +#### Scenario: 提交有效表单 + +- **WHEN** 用户提交通过校验的密码表单 +- **THEN** 系统 MUST 发送旧密码至 `old_password`、新密码至 `new_password` +- **AND** 请求方法 MUST 为 PUT,请求路径 MUST 为 `/api/auth/password` +- **AND** 请求 MUST 沿用现有请求层的 Bearer 认证 + +### Requirement: Password submission lifecycle + +系统 SHALL 在提交期间提供加载状态并阻止重复提交;成功后提示、关闭弹窗并清空敏感字段及记住密码凭证;普通失败时保留弹窗并展示后端错误;认证过期 MUST 沿用现有请求层处理。成功后系统 MUST NOT 主动退出登录或改变锁屏密码。 + +#### Scenario: 重复点击提交 + +- **WHEN** 修改密码请求尚未完成时用户再次确认 +- **THEN** 系统 MUST 保持加载状态且不得发送重复请求 + +#### Scenario: 修改成功 + +- **WHEN** 修改密码请求成功 +- **THEN** 系统 MUST 提示成功并关闭弹窗 +- **AND** 系统 MUST 清空全部密码字段及保存的旧登录凭证 +- **AND** 系统 MUST 保留当前登录状态,不修改锁屏密码 + +#### Scenario: 修改失败 + +- **WHEN** 接口返回普通错误,如旧密码错误或服务器错误 +- **THEN** 系统 MUST 通过现有请求层展示错误并保留弹窗 +- **AND** 系统 MUST 解除提交中状态以允许用户更正后重试 + +#### Scenario: 取消后重新打开 + +- **WHEN** 用户取消或关闭弹窗后重新打开 +- **THEN** 所有密码字段及校验状态 MUST 为空 + +### Requirement: Password dialog interface consistency + +修改密码弹窗 SHALL 复用现有 Element Plus 组件和主题,支持中英文、键盘操作及窄屏展示,密码输入 MUST 具备可识别标签和可见校验反馈。 + +#### Scenario: 窄屏及键盘操作 + +- **WHEN** 用户在窄屏设备或通过键盘打开弹窗 +- **THEN** 弹窗 MUST 不产生水平溢出,且可访问全部输入、取消及确认操作 diff --git a/openspec/changes/add-self-service-password-dialog/tasks.md b/openspec/changes/add-self-service-password-dialog/tasks.md new file mode 100644 index 0000000..46b989d --- /dev/null +++ b/openspec/changes/add-self-service-password-dialog/tasks.md @@ -0,0 +1,17 @@ +## 1. 审批 + +- [x] 1.1 用户确认修改密码弹窗提案后开始实现。 + +## 2. 实现 + +- [x] 2.1 修正 `ChangePasswordParams`,仅包含 `old_password` 和 `new_password`,复用 `PUT /api/auth/password`。 +- [x] 2.2 新增修改密码弹窗:旧密码必填、新密码及确认密码 8–32 位、两次新密码一致,支持密码显隐。 +- [x] 2.3 加入加载状态、重复提交保护、成功提示及关闭、关闭后清空密码;成功时清除旧的记住密码凭证。 +- [x] 2.4 在“个人中心”和“锁定屏幕”之间增加入口,关闭菜单浮层后显示弹窗,补充中英文文案。 + +## 3. 验证 + +- [x] 3.1 验证密码长度 7、8、32、33 位边界、必填项和确认密码不一致。 +- [x] 3.2 验证请求方法、路径、Bearer 认证及下划线参数,确认请求不含确认密码。 +- [x] 3.3 验证成功、失败、认证过期、重复提交和重新打开弹窗的状态清理。 +- [x] 3.4 运行相关测试、类型检查及受影响文件代码检查,检查菜单顺序、键盘操作和窄屏布局。 diff --git a/scripts/tests/changePassword.test.mjs b/scripts/tests/changePassword.test.mjs new file mode 100644 index 0000000..d247cfd --- /dev/null +++ b/scripts/tests/changePassword.test.mjs @@ -0,0 +1,301 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { effectScope, nextTick, reactive } from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' +import ts from 'typescript' + +const componentPath = 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue' +const componentSource = await readFile(componentPath, 'utf8') +const messages = [] +const requests = [] +let rememberedClears = 0 +let localStateClears = 0 +let response = { status: 200, data: { code: 0, msg: 'ok', data: null } } +let pendingRequest + +function moduleUrl(source, imports = {}) { + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } + }) + const resolved = outputText.replace(/from ['"]([^'"]+)['"]/g, (_match, name) => { + return `from '${imports[name] || import.meta.resolve(name)}'` + }) + return `data:text/javascript;base64,${Buffer.from(resolved).toString('base64')}` +} + +// 真实请求层使用模拟 Axios adapter:不会访问网络或修改真实账号密码。 +const axios = (await import('axios')).default +const previousAdapter = axios.defaults.adapter + +globalThis.__passwordTest = { + message: (type, value) => messages.push({ type, value }), + clearRemembered: () => rememberedClears++, + store: { + accessToken: 'mock-access-token', + refreshToken: '', + clearLocalState: () => localStateClears++ + } +} +const uiUrl = moduleUrl(` + export const ElButton = {}, ElDialog = {}, ElForm = {}, ElFormItem = {}, ElInput = {} + export const ElMessage = { + success: (value) => globalThis.__passwordTest.message('success', value), + error: (value) => globalThis.__passwordTest.message('error', value) + } +`) +const storeUrl = moduleUrl('export const useUserStore = () => globalThis.__passwordTest.store') +const statusUrl = moduleUrl(await readFile('src/utils/http/status.ts', 'utf8')) +const errorUrl = moduleUrl(await readFile('src/utils/business/apiError.ts', 'utf8')) +const idUrl = moduleUrl(await readFile('src/utils/business/id.ts', 'utf8')) +const httpSource = (await readFile('src/utils/http/index.ts', 'utf8')) + .replace('import.meta.env.DEV', 'true') + .replace('import.meta.env.VITE_API_URL', "''") +const httpUrl = moduleUrl(httpSource, { + 'element-plus': uiUrl, + '@/store/modules/user': storeUrl, + './status': statusUrl, + '@/utils/business/apiError': errorUrl, + '@/utils/business/id': idUrl +}) +// 导入时才创建 Axios 实例,因此临时保留模拟 adapter 到模块初始化结束。 +const mockAdapter = async (config) => { + requests.push(config) + if (pendingRequest) await pendingRequest + const result = { ...response, headers: { 'content-type': 'application/json' }, config } + if (response.status >= 400) { + throw new axios.AxiosError('Mock HTTP error', 'ERR_BAD_RESPONSE', config, undefined, result) + } + return result +} +axios.defaults.adapter = mockAdapter +await import(httpUrl) +axios.defaults.adapter = previousAdapter +const baseUrl = moduleUrl(await readFile('src/api/BaseService.ts', 'utf8'), { + '@/utils/http': httpUrl +}) +const authUrl = moduleUrl(await readFile('src/api/modules/auth.ts', 'utf8'), { + '../BaseService': baseUrl +}) +const { AuthService } = await import(authUrl) +const { descriptor } = parse(componentSource, { filename: componentPath }) +const script = compileScript(descriptor, { id: 'password-test' }) +const { default: Dialog } = await import( + moduleUrl(script.content, { + 'element-plus': uiUrl, + 'vue-i18n': moduleUrl('export const useI18n = () => ({ t: (key) => key })'), + '@/api/modules/auth': authUrl, + '@/utils/http/status': statusUrl, + '@/utils/auth/rememberPassword': moduleUrl( + 'export const clearRememberedCredentials = () => globalThis.__passwordTest.clearRemembered()' + ) + }) +) + +function createDialog() { + response = { status: 200, data: { code: 0, msg: 'ok', data: null } } + pendingRequest = undefined + requests.length = 0 + messages.length = 0 + rememberedClears = 0 + const props = reactive({ modelValue: true }) + const scope = effectScope() + const state = scope.run(() => + Dialog.setup(props, { + expose: () => {}, + emit: (_event, value) => { + props.modelValue = value + } + }) + ) + let clearedValidation = 0 + async function validateField(field) { + const value = state.formData[field] + for (const rule of state.rules.value[field]) { + if (rule.required && !value) throw new Error(rule.message) + if (rule.min !== undefined && value.length < rule.min) throw new Error(rule.message) + if (rule.max !== undefined && value.length > rule.max) throw new Error(rule.message) + if (rule.validator) { + await new Promise((resolve, reject) => { + rule.validator(rule, value, (error) => (error ? reject(error) : resolve())) + }) + } + } + } + state.formRef.value = { + validate: async () => { + for (const field of Object.keys(state.rules.value)) await validateField(field) + return true + }, + validateField, + clearValidate: () => clearedValidation++ + } + Object.assign(state.formData, { + oldPassword: 'old', + newPassword: 'new-password', + confirmPassword: 'new-password' + }) + return { state, props, scope, validationClears: () => clearedValidation } +} + +test('密码长度 7/33 位阻止提交,8/32 位允许提交,旧密码不受新长度限制', async () => { + for (const length of [7, 8, 32, 33]) { + const { state, props, scope } = createDialog() + state.formData.newPassword = 'a'.repeat(length) + state.formData.confirmPassword = 'a'.repeat(length) + await state.handleSubmit() + assert.equal(requests.length, length === 8 || length === 32 ? 1 : 0) + assert.equal(props.modelValue, length !== 8 && length !== 32) + assert.equal(state.submitting.value, false) + scope.stop() + } +}) + +test('密码必填、一致性及确认密码长度校验失败不发送请求', async () => { + for (const invalid of [ + { oldPassword: '' }, + { newPassword: '' }, + { confirmPassword: '' }, + { confirmPassword: 'different-password' }, + { confirmPassword: 'a'.repeat(7) }, + { confirmPassword: 'a'.repeat(33) } + ]) { + const { state, scope } = createDialog() + Object.assign(state.formData, invalid) + await state.handleSubmit() + assert.equal(requests.length, 0) + assert.equal(state.submitting.value, false) + scope.stop() + } +}) + +test('修改成功发送下划线参数及 Bearer 认证、保留密码空格、清理表单但不退出登录', async () => { + const { state, props, scope } = createDialog() + state.formData.oldPassword = ' old ' + state.formData.newPassword = state.formData.confirmPassword = ' new-password ' + await state.handleSubmit() + assert.equal(requests[0].method, 'put') + assert.equal(requests[0].url, '/api/auth/password') + assert.equal(requests[0].headers.get('Authorization'), 'Bearer mock-access-token') + assert.deepEqual(JSON.parse(requests[0].data), { + old_password: ' old ', + new_password: ' new-password ' + }) + assert.equal(rememberedClears, 1) + assert.equal(localStateClears, 0) + assert.equal(props.modelValue, false) + assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' }) + assert.deepEqual(messages, [{ type: 'success', value: 'changePassword.success' }]) + scope.stop() +}) + +test('服务层丢弃确认密码和其他额外字段', async () => { + createDialog().scope.stop() + await AuthService.changePassword({ + old_password: 'old', + new_password: 'new-password', + confirmPassword: 'unused' + }) + assert.deepEqual(JSON.parse(requests[0].data), { + old_password: 'old', + new_password: 'new-password' + }) +}) + +test('校验或请求未完成时重复提交只产生一次请求,并阻止关闭', async () => { + const { state, props, scope } = createDialog() + let finishRequest + pendingRequest = new Promise((resolve) => { + finishRequest = resolve + }) + const first = state.handleSubmit() + await state.handleSubmit() + await nextTick() + assert.equal(state.submitting.value, true) + let closed = false + state.handleBeforeClose(() => { + closed = true + }) + assert.equal(closed, false) + assert.equal(props.modelValue, true) + finishRequest() + await first + assert.equal(requests.length, 1) + scope.stop() +}) + +test('HTTP 400/500 错误保留弹窗和输入,统一请求层提示一次且允许重试', async () => { + for (const status of [400, 500]) { + const { state, props, scope } = createDialog() + response = { status, data: { code: status, msg: '旧密码错误', data: {} } } + await state.handleSubmit() + assert.equal(props.modelValue, true) + assert.equal(state.formData.oldPassword, 'old') + assert.equal(state.submitting.value, false) + assert.equal(rememberedClears, 0) + assert.equal(messages.length, 1) + assert.equal(messages[0].type, 'error') + response = { status: 200, data: { code: 0, msg: 'ok', data: null } } + await state.handleSubmit() + assert.equal(props.modelValue, false) + assert.equal(requests.length, 2) + scope.stop() + } +}) + +test('HTTP 200 业务错误不会误报成功或清除保存的凭证', async () => { + const { state, props, scope } = createDialog() + response = { status: 200, data: { code: 1001, msg: '旧密码错误', data: {} } } + await state.handleSubmit() + assert.equal(props.modelValue, true) + assert.equal(rememberedClears, 0) + assert.deepEqual(messages, [{ type: 'error', value: '旧密码错误' }]) + scope.stop() +}) + +test('关闭后重新打开清空密码及校验状态', async () => { + const { state, props, scope, validationClears } = createDialog() + state.visible.value = false + state.resetForm() + props.modelValue = true + await nextTick() + assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' }) + assert.ok(validationClears() > 0) + assert.equal(state.submitting.value, false) + scope.stop() +}) + +test('菜单顺序、显隐、标签、键盘提交、窄屏宽度及中英文文案完整', async () => { + const header = await readFile('src/components/core/layouts/art-header-bar/index.vue', 'utf8') + assert.ok(header.indexOf('topBar.user.userCenter') < header.indexOf('topBar.user.changePassword')) + assert.ok(header.indexOf('topBar.user.changePassword') < header.indexOf('topBar.user.lockScreen')) + assert.match(header, /userMenuPopover\.value\?\.hide\(\)[\s\S]*showChangePassword\.value = true/) + assert.equal(componentSource.match(/show-password/g)?.length, 3) + assert.match(componentSource, /width="min\(460px, calc\(100vw - 32px\)\)"/) + assert.match(componentSource, /label-position="top"/) + assert.match(componentSource, /@submit\.prevent="handleSubmit"/) + assert.match(header, /:focus-visible/) + for (const language of ['zh', 'en']) { + const dictionary = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8')) + for (const [, key] of componentSource.matchAll(/t\('changePassword\.([^']+)'\)/g)) { + assert.ok(dictionary.changePassword[key], `${language}: ${key}`) + } + assert.ok(dictionary.topBar.user.changePassword) + } +}) + +test('HTTP 401 沿用统一认证过期处理,仅提示一次并清理本地登录状态', async () => { + const { state, scope } = createDialog() + response = { status: 401, data: { code: 401, msg: 'unauthorized', data: {} } } + await state.handleSubmit() + assert.equal(state.submitting.value, false) + assert.equal(rememberedClears, 0) + assert.deepEqual(messages, [{ type: 'error', value: '登录已过期,请重新登录' }]) + await new Promise((resolve) => setTimeout(resolve, 1050)) + assert.equal(localStateClears, 1) + scope.stop() +}) + +test.after(() => { + delete globalThis.__passwordTest +}) diff --git a/scripts/tests/passwordDialogPreview.mjs b/scripts/tests/passwordDialogPreview.mjs new file mode 100644 index 0000000..fdecee0 --- /dev/null +++ b/scripts/tests/passwordDialogPreview.mjs @@ -0,0 +1,107 @@ +// 仅供本地视觉 QA:使用模拟认证服务,绝不调用真实修改密码接口。 +// 运行:node scripts/tests/passwordDialogPreview.mjs +import { createServer } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const previewEntry = ` + import { createApp, h, ref } from 'vue' + import { createI18n } from 'vue-i18n' + import { ElButton, ElSelect, ElOption } from 'element-plus' + import '/src/assets/styles/el-light.scss' + import '/src/assets/styles/el-dark.scss' + import '/src/assets/styles/el-ui.scss' + import Dialog from '/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue' + import zh from '/src/locales/langs/zh.json' + import en from '/src/locales/langs/en.json' + const i18n = createI18n({ legacy: false, locale: 'zh', messages: { zh, en } }) + window.passwordPreviewRequests = [] + window.passwordPreviewResult = 'success' + createApp({ + setup() { + const visible = ref(false) + const dark = ref(false) + return () => h('main', { style: 'padding:24px;font-family:sans-serif' }, [ + h('h2', '修改密码弹窗 · 离线验证'), + h('p', '此页面不连接真实接口。密码输入仅用于本地模拟验证。'), + h(ElButton, { onClick: () => visible.value = true }, () => '修改密码'), + h(ElButton, { onClick: () => { + dark.value = !dark.value + document.documentElement.classList.toggle('dark', dark.value) + } }, () => '切换主题'), + h(ElButton, { onClick: () => { + i18n.global.locale.value = i18n.global.locale.value === 'zh' ? 'en' : 'zh' + } }, () => '切换语言'), + h(ElSelect, { + modelValue: window.passwordPreviewResult, + 'onUpdate:modelValue': (value) => window.passwordPreviewResult = value, + style: 'width:140px;margin-left:12px' + }, () => ['success', 'error'].map(value => h(ElOption, { value, label: value }))), + h(Dialog, { modelValue: visible.value, 'onUpdate:modelValue': value => visible.value = value }) + ]) + } + }).use(i18n).mount('#app') +` + +const server = await createServer({ + root, + configFile: false, + resolve: { + alias: { + '@': fileURLToPath(new URL('../../src', import.meta.url)), + '@styles': fileURLToPath(new URL('../../src/assets/styles', import.meta.url)) + } + }, + optimizeDeps: { noDiscovery: true, include: ['vue', 'element-plus', 'vue-i18n'] }, + css: { + preprocessorOptions: { + scss: { + additionalData: '@use "@styles/variables.scss" as *; @use "@styles/mixin.scss" as *;' + } + } + }, + server: { host: '127.0.0.1', port: 5198, strictPort: true }, + plugins: [ + { + name: 'offline-password-preview', + enforce: 'pre', + resolveId(id) { + if (id === '/__password-preview.js') return '\0password-preview-entry' + if (id.endsWith('/api/modules/auth')) return '\0password-preview-auth' + }, + load(id) { + if (id === '\0password-preview-entry') return previewEntry + if (id === '\0password-preview-auth') { + return `export const AuthService = { + async changePassword(params) { + window.passwordPreviewRequests.push(params) + await new Promise(resolve => setTimeout(resolve, 500)) + return window.passwordPreviewResult === 'error' + ? { code: 1001, msg: '旧密码错误(模拟)' } + : { code: 0, msg: 'ok' } + } + }` + } + }, + configureServer(viteServer) { + viteServer.middlewares.use((req, res, next) => { + if (req.url !== '/') return next() + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end( + '离线修改密码验证
' + ) + }) + } + }, + vue() + ] +}) +await server.listen() +server.printUrls() +const stop = async () => { + await server.close() + process.exit(0) +} +process.on('SIGINT', stop) +process.on('SIGTERM', stop) diff --git a/scripts/tests/realNameTime.test.mjs b/scripts/tests/realNameTime.test.mjs new file mode 100644 index 0000000..b55b855 --- /dev/null +++ b/scripts/tests/realNameTime.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import ts from 'typescript' + +function toModuleUrl(source) { + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } + }) + return `data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}` +} + +const formatModuleUrl = toModuleUrl(await readFile('src/utils/business/format.ts', 'utf8')) +const { formatDateTime } = await import(formatModuleUrl) +const realNameTimeSource = await readFile('src/utils/business/realNameTime.ts', 'utf8') +const { formatRealNameTime } = await import( + toModuleUrl(realNameTimeSource.replace("from './format'", `from '${formatModuleUrl}'`)) +) + +test('实名时间在现有时区换算基础上额外增加 8 小时', () => { + const cases = [ + ['2026-10-09T01:02:03Z', '2026-10-09 17:02:03'], + ['2026-10-09T01:02:03+00:00', '2026-10-09 17:02:03'], + ['2026-10-09T01:02:03+08:00', '2026-10-09 09:02:03'], + ['2026-10-09T01:02:03-05:00', '2026-10-09 22:02:03'], + ['2026-10-09 01:02:03', '2026-10-09 09:02:03'], + ['2026-10-09T01:02:03', '2026-10-09 09:02:03'], + ['2026-10-09', '2026-10-09 08:00:00'] + ] + + for (const [input, expected] of cases) { + assert.equal(formatRealNameTime(input), expected, input) + } +}) + +test('实名时间支持秒、毫秒时间戳及 Date 对象', () => { + const timestamp = Date.parse('2026-10-09T01:02:03Z') + for (const input of [ + timestamp, + timestamp / 1000, + String(timestamp), + String(timestamp / 1000), + new Date(timestamp) + ]) { + assert.equal(formatRealNameTime(input), '2026-10-09 17:02:03') + } +}) + +test('实名时间加 8 小时正确处理跨日、跨年和闰日', () => { + assert.equal(formatRealNameTime('2026-10-09 20:30:00'), '2026-10-10 04:30:00') + assert.equal(formatRealNameTime('2026-12-31 20:30:00'), '2027-01-01 04:30:00') + assert.equal(formatRealNameTime('2024-02-28 20:30:00'), '2024-02-29 04:30:00') + assert.equal(formatRealNameTime('2024-02-29 20:30:00'), '2024-03-01 04:30:00') +}) + +test('实名时间为空或无效时保留占位符', () => { + for (const input of [undefined, null, '', ' ', 'invalid', NaN, new Date(NaN)]) { + assert.equal(formatRealNameTime(input), '-') + } +}) + +test('通用时间格式化逻辑保持不变', () => { + assert.equal(formatDateTime('2026-10-09T01:02:03Z'), '2026-10-09 09:02:03') + assert.equal(formatDateTime('2026-10-09 01:02:03'), '2026-10-09 01:02:03') +}) + +test('实名时间展示不依赖运行环境时区', () => { + const originalTimezone = process.env.TZ + try { + for (const timezone of ['UTC', 'Asia/Shanghai', 'America/New_York']) { + process.env.TZ = timezone + assert.equal(formatRealNameTime('2026-10-09 01:02:03'), '2026-10-09 09:02:03') + assert.equal(formatRealNameTime('2026-10-09T01:02:03Z'), '2026-10-09 17:02:03') + } + } finally { + if (originalTimezone === undefined) delete process.env.TZ + else process.env.TZ = originalTimezone + } +}) + +test('资产信息仅在卡信息、设备信息及绑定卡列表的实名时间使用额外偏移', async () => { + const component = await readFile( + 'src/views/asset-management/asset-information/components/BasicInfoCard.vue', + 'utf8' + ) + + assert.ok(component.includes('formatRealNameTime(cardInfo?.real_name_at)')) + assert.ok(component.includes('formatRealNameTime(selectedDeviceStatusCard?.real_name_at)')) + assert.ok(component.includes('formatRealNameTime(bindingCard.real_name_at)')) + assert.equal(component.match(/formatRealNameTime\(/g)?.length, 3) + assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_real_name_check_at)')) + assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_card_status_check_at)')) + assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_data_check_at)')) + assert.ok( + component.includes( + 'const formatAssetInfoTime = (value?: string | null) => formatDateTime(value)' + ) + ) +}) diff --git a/src/api/modules/auth.ts b/src/api/modules/auth.ts index 17111f9..784b0e4 100644 --- a/src/api/modules/auth.ts +++ b/src/api/modules/auth.ts @@ -6,7 +6,6 @@ import { BaseService } from '../BaseService' import type { LoginParams, LoginData, - UserInfo, UserInfoResponse, RefreshTokenParams, RefreshTokenData, @@ -51,7 +50,8 @@ export class AuthService extends BaseService { * @param params 修改密码参数 */ static changePassword(params: ChangePasswordParams): Promise { - return this.put('/api/auth/password', params) + const { old_password, new_password } = params + return this.put('/api/auth/password', { old_password, new_password }) } /** diff --git a/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue new file mode 100644 index 0000000..2dff229 --- /dev/null +++ b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/components/core/layouts/art-header-bar/index.vue b/src/components/core/layouts/art-header-bar/index.vue index c51350b..597f5e8 100644 --- a/src/components/core/layouts/art-header-bar/index.vue +++ b/src/components/core/layouts/art-header-bar/index.vue @@ -158,6 +158,12 @@ {{ $t('topBar.user.userCenter') }} +
  • + +
  • {{ $t('topBar.user.lockScreen') }} @@ -176,6 +182,7 @@ + @@ -185,7 +192,9 @@ import { useSettingStore } from '@/store/modules/setting' import { useUserStore } from '@/store/modules/user' import { useFullscreen } from '@vueuse/core' - import { ElMessageBox } from 'element-plus' + import { ElIcon, ElMessageBox } from 'element-plus' + import { Key } from '@element-plus/icons-vue' + import ChangePasswordDialog from './ChangePasswordDialog.vue' import { HOME_PAGE } from '@/router/routesAlias' import { useI18n } from 'vue-i18n' import { mittBus } from '@/utils/sys' @@ -217,6 +226,7 @@ const { menuList } = storeToRefs(useMenuStore()) const showNotice = ref(false) + const showChangePassword = ref(false) const userMenuPopover = ref() const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT) @@ -348,6 +358,11 @@ mittBus.emit('openLockScreen') } + const openChangePassword = () => { + userMenuPopover.value?.hide() + showChangePassword.value = true + } + const closeUserMenu = () => { setTimeout(() => { userMenuPopover.value.hide() @@ -358,4 +373,32 @@ diff --git a/src/locales/langs/en.json b/src/locales/langs/en.json index 7351ec7..b1d149b 100644 --- a/src/locales/langs/en.json +++ b/src/locales/langs/en.json @@ -5,12 +5,26 @@ }, "user": { "userCenter": "User center", + "changePassword": "Change password", "docs": "Document", "github": "Github", "lockScreen": "Lock screen", "logout": "Log out" } }, + "changePassword": { + "title": "Change password", + "oldPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "oldPasswordPlaceholder": "Enter your current password", + "newPasswordPlaceholder": "Enter a new password (8–32 characters)", + "confirmPasswordPlaceholder": "Enter your new password again", + "passwordLength": "Password must be 8–32 characters long", + "passwordMismatch": "The new passwords do not match", + "success": "Password changed successfully", + "failed": "Unable to change password. Please try again" + }, "common": { "tips": "Prompt", "cancel": "Cancel", diff --git a/src/locales/langs/zh.json b/src/locales/langs/zh.json index 70740b1..8bea0db 100644 --- a/src/locales/langs/zh.json +++ b/src/locales/langs/zh.json @@ -5,12 +5,26 @@ }, "user": { "userCenter": "个人中心", + "changePassword": "修改密码", "docs": "使用文档", "github": "Github", "lockScreen": "锁定屏幕", "logout": "退出登录" } }, + "changePassword": { + "title": "修改密码", + "oldPassword": "旧密码", + "newPassword": "新密码", + "confirmPassword": "确认新密码", + "oldPasswordPlaceholder": "请输入旧密码", + "newPasswordPlaceholder": "请输入 8–32 位新密码", + "confirmPasswordPlaceholder": "请再次输入新密码", + "passwordLength": "密码长度须为 8–32 位", + "passwordMismatch": "两次输入的新密码不一致", + "success": "密码修改成功", + "failed": "密码修改失败,请重试" + }, "common": { "tips": "提示", "cancel": "取消", diff --git a/src/types/api/auth.ts b/src/types/api/auth.ts index 036780d..7854ee6 100644 --- a/src/types/api/auth.ts +++ b/src/types/api/auth.ts @@ -63,7 +63,6 @@ export interface RefreshTokenData { // 修改密码参数 export interface ChangePasswordParams { - oldPassword: string - newPassword: string - confirmPassword: string + old_password: string + new_password: string } diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 6680882..34143d1 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -80,6 +80,7 @@ declare module 'vue' { BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default'] CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default'] CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default'] + ChangePasswordDialog: typeof import('./../components/core/layouts/art-header-bar/ChangePasswordDialog.vue')['default'] ChunkErrorBoundary: typeof import('./../components/core/others/ChunkErrorBoundary.vue')['default'] CodeGeneratorButton: typeof import('./../components/business/CodeGeneratorButton.vue')['default'] ColorSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default'] diff --git a/src/utils/business/index.ts b/src/utils/business/index.ts index def9e04..345659b 100644 --- a/src/utils/business/index.ts +++ b/src/utils/business/index.ts @@ -3,6 +3,7 @@ */ export * from './format' +export * from './realNameTime' export * from './validate' export * from './calculate' export * from './voucher' diff --git a/src/utils/business/realNameTime.ts b/src/utils/business/realNameTime.ts new file mode 100644 index 0000000..eac2e4f --- /dev/null +++ b/src/utils/business/realNameTime.ts @@ -0,0 +1,18 @@ +import { formatDateTime } from './format' + +const REAL_NAME_TIME_OFFSET_MS = 8 * 60 * 60 * 1000 + +/** + * 实名时间在现有东八区展示逻辑的基础上额外增加 8 小时。 + * 仅用于实名时间,其他时间字段继续使用 formatDateTime。 + */ +export function formatRealNameTime(date: string | number | Date | undefined | null): string { + const formatted = formatDateTime(date) + if (formatted === '-') return '-' + + // 先保留原格式化结果,再按明确的东八区偏移解析,避免依赖浏览器本地时区。 + const displayDate = new Date(`${formatted.replace(' ', 'T')}+08:00`) + if (Number.isNaN(displayDate.getTime())) return '-' + + return formatDateTime(new Date(displayDate.getTime() + REAL_NAME_TIME_OFFSET_MS)) +} diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 6b036bf..9a5401b 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -117,9 +117,7 @@ {{ - cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-' - }}{{ formatRealNameTime(cardInfo?.real_name_at) }} {{ - selectedDeviceStatusCard?.real_name_at - ? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at) - : '-' - }}{{ formatRealNameTime(selectedDeviceStatusCard?.real_name_at) }} 实名时间 - {{ bindingCard.real_name_at ? formatAssetInfoTime(bindingCard.real_name_at) : '-' }} + {{ formatRealNameTime(bindingCard.real_name_at) }}
    @@ -667,6 +661,7 @@ import { JULY_PERMISSIONS } from '@/config/constants' import { useUserStore } from '@/store/modules/user' import { formatDateTime } from '@/utils/business/format' + import { formatRealNameTime } from '@/utils/business/realNameTime' import { getExpiryEstimateText, getExpiryEstimateTooltip,