From a383b1b01e06ff83cb6dc9a37289c84e5f5ce023 Mon Sep 17 00:00:00 2001
From: sheng <905537351@qq.com>
Date: Wed, 24 Jun 2026 15:34:38 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E8=A1=A8=E5=8D=95=E4=BA=A4=E4=BA=92=E5=92=8C=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E6=9F=A5=E8=AF=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/用户管理模块表单UX验证.md | 22 ++++
src/components/page-dialog-form/index.vue | 9 +-
src/locales/en.json | 2 +
src/locales/zh-chs.json | 2 +
.../user-management/user/index.vue | 122 +++++++++++++++---
5 files changed, 137 insertions(+), 20 deletions(-)
create mode 100644 docs/用户管理模块表单UX验证.md
diff --git a/docs/用户管理模块表单UX验证.md b/docs/用户管理模块表单UX验证.md
new file mode 100644
index 00000000..0d733908
--- /dev/null
+++ b/docs/用户管理模块表单UX验证.md
@@ -0,0 +1,22 @@
+# 用户管理模块表单 UX 验证
+
+## Findings
+
+- **High** `src/views/system-administration/user-management/user/index.vue`: 已修复新增/编辑弹窗表单模型未在 `data()` 中声明的问题。该问题会导致 Vue 2 根级动态属性不具备可靠响应式,输入框和下拉框在弹窗中表现为无法正常录入或选择。
+- **Medium** `src/views/system-administration/user-management/user/index.vue`: 已补充状态查询项,查询参数会随 `username`、`nickname` 一起传给列表接口,用于按启用/停用筛选用户。
+- **Medium** `src/views/system-administration/user-management/user/index.vue`: 已将账号重复校验、确认密码一致性校验接入 Element 表单规则,错误会出现在对应字段附近,而不是只依赖提交后的全局提示。
+- **Medium** `src/views/system-administration/user-management/user/index.vue`: 当前用户数据模型没有手机号、邮箱字段,接口封装也没有对应字段或独立校验接口。因此手机号/邮箱格式校验无法在不扩展前后端契约的情况下落地。建议后续先确认字段设计,再补充表单项、列表列、接口字段和格式规则。
+- **Low** `src/components/page-dialog-form/index.vue`: 已补充 `show-password` 透传,并修复自定义 validator 没有 `message` 时被无条件翻译的问题,避免后续自定义表单校验不稳定。
+
+## Evidence
+
+- Browser: source-only。当前页面依赖登录态和后端接口,未做真实浏览器端新增用户提交。
+- Source checks: 用户弹窗字段状态、表单规则、状态查询、权限按钮声明已核对。
+- Build checks: 已通过 `eslint`、locale JSON 解析和生产构建验证语法与编译兼容性。
+- Confidence limits: 账号重复检查复用现有列表接口,并按用户名精确匹配;如果后端列表接口只做模糊查询或分页限制异常,最终仍需后端唯一约束兜底。
+
+## Suggested Shape
+
+- 保持“账号/密码/确认密码/用户组/姓名/出入证编号/状态”的当前录入顺序,必填项优先,状态默认启用。
+- 若业务确认需要手机号/邮箱,应放在姓名之后、出入证编号之前,并使用字段级格式提示。
+- 删除、批量删除、重置密码、启停用等操作保持二次确认;接口失败时不应显示成功提示。
diff --git a/src/components/page-dialog-form/index.vue b/src/components/page-dialog-form/index.vue
index 97a55ef9..fe29cd78 100644
--- a/src/components/page-dialog-form/index.vue
+++ b/src/components/page-dialog-form/index.vue
@@ -66,6 +66,7 @@
v-model="formData[col.prop]"
:placeholder="$t(col.placeholder)"
:type="col.inputType || 'text'"
+ :show-password="!!col.showPassword"
:autosize="col.autosize"
:clearable="col.clearable !== false"
:disabled="!!col.disabled"
@@ -255,7 +256,13 @@ export default {
const rules = this.rules || {}
const translated = {}
for (const [field, validators] of Object.entries(rules)) {
- translated[field] = validators.map(v => ({ ...v, message: this.$t(v.message) }))
+ translated[field] = validators.map(v => {
+ const rule = { ...v }
+ if (rule.message) {
+ rule.message = this.$t(rule.message)
+ }
+ return rule
+ })
}
return translated
}
diff --git a/src/locales/en.json b/src/locales/en.json
index 56309fc1..3cdb174e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2344,6 +2344,7 @@
"status": "Status",
"enable": "Enabled",
"disable": "Disabled",
+ "select_status": "Please select status",
"user_group": "User Group",
"login_ip": "Last Login IP",
"last_login_time": "Last Login Time",
@@ -2370,6 +2371,7 @@
"password_not_match": "Passwords do not match",
"password_length": "Length should be 6 to 64 characters",
"username_length": "Length should be 3 to 20 characters",
+ "username_exists": "Username already exists. Please use another username",
"cannot_delete_self": "Cannot delete own account",
"cannot_operate_self": "Cannot operate own account",
"batch_delete": "Batch Delete",
diff --git a/src/locales/zh-chs.json b/src/locales/zh-chs.json
index 4378aae9..8d112eae 100644
--- a/src/locales/zh-chs.json
+++ b/src/locales/zh-chs.json
@@ -2344,6 +2344,7 @@
"status": "状态",
"enable": "启用",
"disable": "禁用",
+ "select_status": "请选择状态",
"user_group": "用户组",
"login_ip": "上次登录IP",
"last_login_time": "上次登录时间",
@@ -2370,6 +2371,7 @@
"password_not_match": "两次输入的密码不一致",
"password_length": "长度在 6 到 64 个字符",
"username_length": "长度在 3 到 20 个字符",
+ "username_exists": "账号已存在,请更换账号",
"cannot_delete_self": "不能删除自己的账号",
"cannot_operate_self": "不能操作自己的账号",
"batch_delete": "批量删除",
diff --git a/src/views/system-administration/user-management/user/index.vue b/src/views/system-administration/user-management/user/index.vue
index b8d12118..034097eb 100644
--- a/src/views/system-administration/user-management/user/index.vue
+++ b/src/views/system-administration/user-management/user/index.vue
@@ -21,6 +21,18 @@
@keyup.enter.native="onSearch"
/>
+
+
+
+
+
+
{{ $t(key('search')) }}
@@ -46,7 +58,7 @@
@selection-change="onSelect"
>
-
+
{{ $t(key('enable')) }}
@@ -79,7 +91,6 @@
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
-import { confirmMixin } from '@/composables/useConfirmHandle'
import { getRoleAll } from '@/api/system-administration/role'
import {
getUserList,
@@ -99,10 +110,43 @@ const ownUserId = () => localStorage.getItem('user_id')
export default {
name: 'system-administration-user',
components: { PageTable, PageDialogForm },
- mixins: [i18nMixin('page.system_administration.user_management.user'), confirmMixin],
+ mixins: [i18nMixin('page.system_administration.user_management.user')],
data () {
const key = this.key.bind(this)
const $t = this.$t.bind(this)
+ const normalizeList = res => {
+ const data = res && res.data !== undefined ? res.data : res
+ if (Array.isArray(data)) return data
+ if (data && Array.isArray(data.list)) return data.list
+ if (data && Array.isArray(data.data)) return data.data
+ if (data && data.data && Array.isArray(data.data.data)) return data.data.data
+ return []
+ }
+ const validateUsernameUnique = async (rule, value, callback) => {
+ const username = String(value || '').trim()
+ if (!username || username.length < 3) {
+ callback()
+ return
+ }
+ try {
+ const res = await getUserList({ username, page_no: 1, page_size: 10000 })
+ const exists = normalizeList(res).some(item => {
+ const sameName = String(item.username || '') === username
+ const sameUser = this.handleType === 'edit' && String(item.user_id) === String(this.editId)
+ return sameName && !sameUser
+ })
+ callback(exists ? new Error($t(key('username_exists'))) : undefined)
+ } catch {
+ callback()
+ }
+ }
+ const validatePasswordConfirm = (rule, value, callback) => {
+ if (this.handleType === 'create' && value && value !== this.formData.password) {
+ callback(new Error($t(key('password_not_match'))))
+ return
+ }
+ callback()
+ }
return {
loading: false,
submitting: false,
@@ -112,12 +156,21 @@ export default {
dialogTitle: '',
editId: '',
handleType: 'create',
- search: { username: '', nickname: '' },
+ search: { username: '', nickname: '', status: '' },
pagination: { current: 1, size: 10, total: 0 },
roleOptions: [],
columns: [],
toolbarButtons: [],
rowButtons: [],
+ formData: {
+ username: '',
+ password: '',
+ password_confirm: '',
+ role_id: '',
+ nickname: '',
+ pass_number: '',
+ status: '1'
+ },
baseFormCols: {
create: [
[{ type: 'input', prop: 'username', label: key('username'), placeholder: key('enter_username'), clearable: true, style: { width: '90%' } }],
@@ -139,7 +192,8 @@ export default {
baseRules: {
username: [
{ required: true, message: key('enter_username'), trigger: 'blur' },
- { min: 3, max: 20, message: key('username_length'), trigger: 'blur' }
+ { min: 3, max: 20, message: key('username_length'), trigger: 'blur' },
+ { validator: validateUsernameUnique, trigger: 'blur' }
],
password: [
{ required: true, message: key('enter_password'), trigger: 'blur' },
@@ -147,10 +201,14 @@ export default {
],
password_confirm: [
{ required: true, message: key('enter_confirm_password'), trigger: 'blur' },
- { min: 6, max: 64, message: key('password_length'), trigger: 'blur' }
+ { min: 6, max: 64, message: key('password_length'), trigger: 'blur' },
+ { validator: validatePasswordConfirm, trigger: ['blur', 'change'] }
],
role_id: [
{ required: true, message: key('select_user_group'), trigger: 'change' }
+ ],
+ status: [
+ { required: true, message: key('select_status'), trigger: 'change' }
]
}
}
@@ -168,7 +226,8 @@ export default {
if (this.handleType === 'edit') {
return {
username: this.baseRules.username,
- role_id: this.baseRules.role_id
+ role_id: this.baseRules.role_id,
+ status: this.baseRules.status
}
}
return this.baseRules
@@ -255,10 +314,18 @@ export default {
async initRoleOptions () {
try {
const res = await getRoleAll()
- const data = Array.isArray(res) ? res : (res.data || [])
+ const data = this.normalizeResponse(res).list
this.roleOptions = data.map(item => ({ value: item.id, label: item.name }))
} catch { /* 忽略 */ }
},
+ normalizeResponse (res) {
+ const data = res && res.data !== undefined ? res.data : res
+ if (Array.isArray(data)) return { list: data, total: data.length }
+ if (data && Array.isArray(data.list)) return { list: data.list, total: Number(data.count || data.total || data.list.length) }
+ if (data && Array.isArray(data.data)) return { list: data.data, total: Number(data.count || data.total || data.data.length) }
+ if (data && data.data && Array.isArray(data.data.data)) return { list: data.data.data, total: Number(data.data.count || data.data.total || data.data.data.length) }
+ return { list: [], total: 0 }
+ },
async fetchData () {
this.loading = true
try {
@@ -267,10 +334,9 @@ export default {
page_no: this.pagination.current,
page_size: this.pagination.size
})
- const list = Array.isArray(res) ? res : (res.data || [])
- const total = Array.isArray(res) ? res.length : (res.count || 0)
- this.tableData = list
- this.pagination.total = total
+ const data = this.normalizeResponse(res)
+ this.tableData = data.list
+ this.pagination.total = data.total
} finally {
this.loading = false
}
@@ -280,7 +346,7 @@ export default {
this.fetchData()
},
onReset () {
- this.search = { username: '', nickname: '' }
+ this.search = { username: '', nickname: '', status: '' }
this.pagination.current = 1
this.fetchData()
},
@@ -377,16 +443,34 @@ export default {
} catch { /* 拦截器已处理 */ }
}).catch(() => {})
},
+ async confirmAction (message, action) {
+ try {
+ await this.$confirm(
+ this.$t(message),
+ this.$t(this.key('prompt')),
+ {
+ confirmButtonText: this.$t(this.key('confirm')),
+ cancelButtonText: this.$t(this.key('cancel')),
+ type: 'warning',
+ closeOnClickModal: false
+ }
+ )
+ } catch {
+ return false
+ }
+ await action()
+ return true
+ },
async handleDelete (row) {
if (String(row.user_id) === ownUserId()) {
this.$message.warning(this.$t(this.key('cannot_delete_self')))
return
}
- const cancelled = await this.$confirmAction(
- { message: this.key('confirm_delete'), title: this.key('prompt') },
+ const ok = await this.confirmAction(
+ this.key('confirm_delete'),
() => deleteUser({ id: [row.user_id] })
)
- if (cancelled) return
+ if (!ok) return
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
@@ -405,11 +489,11 @@ export default {
this.$message.warning(this.$t(this.key('select_rows_first')))
return
}
- const cancelled = await this.$confirmAction(
- { message: this.key('confirm_batch_delete'), title: this.key('prompt') },
+ const ok = await this.confirmAction(
+ this.key('confirm_batch_delete'),
() => batchDeleteUser({ id: rows.map(row => row.user_id) })
)
- if (cancelled) return
+ if (!ok) return
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
},