This commit is contained in:
@@ -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 位新密码边界、确认密码不一致、旧密码为空、成功及失败响应、重复点击,以及菜单位置与重新打开后的密码清空。运行相关测试、类型检查和受影响文件的代码检查。
|
||||
@@ -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 不产生水平溢出,且可访问全部输入、取消及确认操作
|
||||
17
openspec/changes/add-self-service-password-dialog/tasks.md
Normal file
17
openspec/changes/add-self-service-password-dialog/tasks.md
Normal file
@@ -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 运行相关测试、类型检查及受影响文件代码检查,检查菜单顺序、键盘操作和窄屏布局。
|
||||
301
scripts/tests/changePassword.test.mjs
Normal file
301
scripts/tests/changePassword.test.mjs
Normal file
@@ -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
|
||||
})
|
||||
107
scripts/tests/passwordDialogPreview.mjs
Normal file
107
scripts/tests/passwordDialogPreview.mjs
Normal file
@@ -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(
|
||||
'<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>离线修改密码验证</title></head><body><div id="app"></div><script type="module" src="/__password-preview.js"></script></body></html>'
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
vue()
|
||||
]
|
||||
})
|
||||
await server.listen()
|
||||
server.printUrls()
|
||||
const stop = async () => {
|
||||
await server.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
99
scripts/tests/realNameTime.test.mjs
Normal file
99
scripts/tests/realNameTime.test.mjs
Normal file
@@ -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)'
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -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<BaseResponse> {
|
||||
return this.put<BaseResponse>('/api/auth/password', params)
|
||||
const { old_password, new_password } = params
|
||||
return this.put<BaseResponse>('/api/auth/password', { old_password, new_password })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="t('changePassword.title')"
|
||||
width="min(460px, calc(100vw - 32px))"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:before-close="handleBeforeClose"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:disabled="submitting"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<ElFormItem :label="t('changePassword.oldPassword')" prop="oldPassword">
|
||||
<ElInput
|
||||
v-model="formData.oldPassword"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('changePassword.oldPasswordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('changePassword.newPassword')" prop="newPassword">
|
||||
<ElInput
|
||||
v-model="formData.newPassword"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('changePassword.newPasswordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('changePassword.confirmPassword')" prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="formData.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('changePassword.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<button type="submit" hidden />
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton :disabled="submitting" @click="visible = false">
|
||||
{{ t('common.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { AuthService } from '@/api/modules/auth'
|
||||
import { clearRememberedCredentials } from '@/utils/auth/rememberPassword'
|
||||
import { ApiStatus } from '@/utils/http/status'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const formData = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
oldPassword: [
|
||||
{ required: true, message: t('changePassword.oldPasswordPlaceholder'), trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: t('changePassword.newPasswordPlaceholder'), trigger: 'blur' },
|
||||
{ min: 8, max: 32, message: t('changePassword.passwordLength'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('changePassword.confirmPasswordPlaceholder'), trigger: 'blur' },
|
||||
{ min: 8, max: 32, message: t('changePassword.passwordLength'), trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value !== formData.newPassword) {
|
||||
callback(new Error(t('changePassword.passwordMismatch')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) resetForm()
|
||||
})
|
||||
watch(
|
||||
() => formData.newPassword,
|
||||
() => {
|
||||
if (formData.confirmPassword) {
|
||||
void formRef.value?.validateField('confirmPassword').catch(() => undefined)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleBeforeClose = (done: () => void) => {
|
||||
if (!submitting.value) done()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value || submitting.value) return
|
||||
// 在异步校验前加锁,避免快速点击产生多个请求。
|
||||
submitting.value = true
|
||||
try {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid || !visible.value) return
|
||||
|
||||
const response = await AuthService.changePassword({
|
||||
old_password: formData.oldPassword,
|
||||
new_password: formData.newPassword
|
||||
})
|
||||
if (response.code !== ApiStatus.success) {
|
||||
ElMessage.error(response.msg || t('changePassword.failed'))
|
||||
return
|
||||
}
|
||||
clearRememberedCredentials()
|
||||
ElMessage.success(t('changePassword.success'))
|
||||
visible.value = false
|
||||
resetForm()
|
||||
} catch {
|
||||
// HTTP 错误及认证过期由统一请求层处理,不重复提示或输出密码。
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -158,6 +158,12 @@
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
||||
</li>
|
||||
<li class="password-menu-item">
|
||||
<button type="button" class="password-menu-button" @click="openChangePassword">
|
||||
<ElIcon class="password-menu-icon"><Key /></ElIcon>
|
||||
<span class="menu-txt">{{ $t('topBar.user.changePassword') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li @click="lockScreen()">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
|
||||
@@ -176,6 +182,7 @@
|
||||
<ArtWorkTab />
|
||||
|
||||
<art-notification v-model="showNotice" />
|
||||
<ChangePasswordDialog v-model="showChangePassword" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 @@
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
@use './mobile';
|
||||
|
||||
.password-menu-item {
|
||||
padding: 0 !important;
|
||||
|
||||
.password-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.password-menu-icon {
|
||||
justify-content: flex-start;
|
||||
width: 25px;
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "取消",
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface RefreshTokenData {
|
||||
|
||||
// 修改密码参数
|
||||
export interface ChangePasswordParams {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
1
src/types/components.d.ts
vendored
1
src/types/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export * from './format'
|
||||
export * from './realNameTime'
|
||||
export * from './validate'
|
||||
export * from './calculate'
|
||||
export * from './voucher'
|
||||
|
||||
18
src/utils/business/realNameTime.ts
Normal file
18
src/utils/business/realNameTime.ts
Normal file
@@ -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))
|
||||
}
|
||||
@@ -117,9 +117,7 @@
|
||||
<ElDescriptionsItem
|
||||
label="实名时间"
|
||||
label-class-name="asset-label asset-label--realname-time"
|
||||
>{{
|
||||
cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-'
|
||||
}}</ElDescriptionsItem
|
||||
>{{ formatRealNameTime(cardInfo?.real_name_at) }}</ElDescriptionsItem
|
||||
>
|
||||
<ElDescriptionsItem
|
||||
label="实名同步时间"
|
||||
@@ -254,11 +252,7 @@
|
||||
<ElDescriptionsItem
|
||||
label="实名时间"
|
||||
label-class-name="asset-label asset-label--realname-time"
|
||||
>{{
|
||||
selectedDeviceStatusCard?.real_name_at
|
||||
? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at)
|
||||
: '-'
|
||||
}}</ElDescriptionsItem
|
||||
>{{ formatRealNameTime(selectedDeviceStatusCard?.real_name_at) }}</ElDescriptionsItem
|
||||
>
|
||||
<ElDescriptionsItem
|
||||
label="在线状态"
|
||||
@@ -469,7 +463,7 @@
|
||||
<div class="binding-card-field">
|
||||
<span class="binding-card-field-label">实名时间</span>
|
||||
<span class="binding-card-field-value">
|
||||
{{ bindingCard.real_name_at ? formatAssetInfoTime(bindingCard.real_name_at) : '-' }}
|
||||
{{ formatRealNameTime(bindingCard.real_name_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="binding-card-field">
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user