fix: 实名时间+8和修改密码新增
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 13m9s

This commit is contained in:
luo
2026-10-09 10:52:39 +08:00
parent 6e8f9cf015
commit abd1467482
16 changed files with 878 additions and 15 deletions

View 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
})

View 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)

View 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)'
)
)
})