From 0e4a38fd6fb7f496b6506c23eddf6c58ce89c1e5 Mon Sep 17 00:00:00 2001 From: halo Date: Wed, 8 Jul 2026 16:11:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86(=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5/?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E8=A7=A3=E9=94=81/=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=87=8D=E7=BD=AE)=E4=B8=8E=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E7=99=BB=E5=BD=95=E5=BC=BA=E5=88=B6=E6=94=B9=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mock-server/data/menu.js | 3 +- mock-server/data/roles.js | 23 +++ mock-server/data/users.js | 10 + mock-server/routes/auth.js | 4 +- mock-server/routes/system.js | 159 +++++++++++++++- src/api/system.js | 13 ++ src/stores/auth.js | 10 +- src/utils/validators.js | 33 ++++ src/views/login/Login.vue | 70 ++++++- src/views/system/user/UserList.vue | 294 +++++++++++++++++++++++++++++ 10 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 mock-server/data/roles.js create mode 100644 src/utils/validators.js create mode 100644 src/views/system/user/UserList.vue diff --git a/mock-server/data/menu.js b/mock-server/data/menu.js index 73bf63a..3e6d747 100644 --- a/mock-server/data/menu.js +++ b/mock-server/data/menu.js @@ -18,7 +18,8 @@ export const menuTree = [ { code: 'user:delete', name: '删除' }, { code: 'user:lock', name: '锁定' }, { code: 'user:unlock', name: '解锁' }, - { code: 'user:resetPwd', name: '密码重置' } + { code: 'user:changePwd', name: '密码修改' }, + { code: 'user:resetPwd', name: '密码重置并发送短信' } ] }, { diff --git a/mock-server/data/roles.js b/mock-server/data/roles.js new file mode 100644 index 0000000..4727ab9 --- /dev/null +++ b/mock-server/data/roles.js @@ -0,0 +1,23 @@ +import { menuTree, flattenButtons } from './menu.js' + +// 角色数据以扁平列表存储;role_admin 为内置系统管理员角色,不可删除/改名 +export const roles = [ + { + id: 'role_admin', + name: '【内置】系统管理员', + orgId: 'org001', + builtin: true, + status: 'enabled', + menuIds: ['system', 'sys_user', 'sys_role', 'sys_org'], + buttonCodes: flattenButtons(menuTree), + dataScope: 'all', + createdAt: '2026-01-01 00:00:00' + } +] + +let seq = roles.length + +export function nextRoleId() { + seq += 1 + return `role${String(seq).padStart(3, '0')}` +} diff --git a/mock-server/data/users.js b/mock-server/data/users.js index bf215a7..862d337 100644 --- a/mock-server/data/users.js +++ b/mock-server/data/users.js @@ -9,6 +9,16 @@ export const users = [ roleIds: ['role_admin'], status: 'enabled', // enabled | locked failCount: 0, + mustChangePassword: false, + lastLoginAt: '', createdAt: '2026-01-01 00:00:00' } ] + +let seq = users.length + +export function nextUserId() { + seq += 1 + return `u${String(seq).padStart(3, '0')}` +} + diff --git a/mock-server/routes/auth.js b/mock-server/routes/auth.js index 71b21ed..bb39dfb 100644 --- a/mock-server/routes/auth.js +++ b/mock-server/routes/auth.js @@ -42,6 +42,7 @@ router.post('/login', (req, res) => { } if (code !== FIXED_SMS_CODE) return res.json({ code: 40003, msg: '验证码错误或已过期', data: null }) user.failCount = 0 + user.lastLoginAt = new Date().toISOString().slice(0, 19).replace('T', ' ') const accessToken = crypto.randomUUID() const refreshToken = crypto.randomUUID() @@ -60,7 +61,8 @@ router.post('/login', (req, res) => { accessToken, userInfo: { id: user.id, username: user.username, realName: user.realName, orgId: user.orgId, roleIds: user.roleIds }, menuTree, - buttonCodes: flattenButtons(menuTree) + buttonCodes: flattenButtons(menuTree), + mustChangePassword: !!user.mustChangePassword } }) }) diff --git a/mock-server/routes/system.js b/mock-server/routes/system.js index e3fc281..ab5d0ac 100644 --- a/mock-server/routes/system.js +++ b/mock-server/routes/system.js @@ -1,11 +1,35 @@ import express from 'express' +import crypto from 'crypto' import { orgs, nextOrgId, buildOrgTree, collectDescendantIds } from '../data/orgs.js' -import { users } from '../data/users.js' +import { users, nextUserId } from '../data/users.js' +import { roles } from '../data/roles.js' import { requireAuth } from '../middleware/requireAuth.js' const router = express.Router() router.use(requireAuth) +function orgName(orgId) { + return orgs.find((o) => o.id === orgId)?.name || '' +} + +function roleNames(roleIds) { + return (roleIds || []).map((id) => roles.find((r) => r.id === id)?.name).filter(Boolean) +} + +function nowStr() { + return new Date().toISOString().slice(0, 19).replace('T', ' ') +} + +function genInitialPassword() { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' + const lower = 'abcdefghijkmnpqrstuvwxyz' + const digit = '0123456789' + const all = upper + lower + digit + let pwd = upper[Math.floor(Math.random() * upper.length)] + digit[Math.floor(Math.random() * digit.length)] + for (let i = 0; i < 6; i += 1) pwd += all[Math.floor(Math.random() * all.length)] + return pwd +} + // ---------------- 机构管理 ---------------- router.get('/org/list', (req, res) => { @@ -96,4 +120,137 @@ router.delete('/org/:id', (req, res) => { res.json({ code: 200, msg: 'ok', data: null }) }) +// ---------------- 用户管理 ---------------- + +router.get('/user/list', (req, res) => { + const { orgId, username, phone, status, roleId, page = 1, pageSize = 10 } = req.query || {} + let list = users + if (orgId) list = list.filter((u) => u.orgId === orgId) + if (username) list = list.filter((u) => u.username.includes(username)) + if (phone) list = list.filter((u) => u.phone.includes(phone)) + if (status) list = list.filter((u) => u.status === status) + if (roleId) list = list.filter((u) => u.roleIds.includes(roleId)) + + const total = list.length + const start = (Number(page) - 1) * Number(pageSize) + const pageList = list.slice(start, start + Number(pageSize)).map((u) => ({ + id: u.id, + username: u.username, + realName: u.realName, + phone: u.phone, + orgId: u.orgId, + orgName: orgName(u.orgId), + roleIds: u.roleIds, + roleNames: roleNames(u.roleIds), + status: u.status, + createdAt: u.createdAt, + lastLoginAt: u.lastLoginAt + })) + res.json({ code: 200, msg: 'ok', data: { list: pageList, total } }) +}) + +router.post('/user', (req, res) => { + const { username, phone, realName, orgId, roleIds } = req.body || {} + if (!username) return res.json({ code: 40010, msg: '请输入用户名', data: null }) + if (!/^1\d{10}$/.test(phone || '')) return res.json({ code: 40011, msg: '请输入正确的11位手机号', data: null }) + if (users.some((u) => u.phone === phone)) return res.json({ code: 40012, msg: '该手机号已被注册', data: null }) + if (!orgId || !orgs.some((o) => o.id === orgId)) return res.json({ code: 40013, msg: '请选择有效的所属机构', data: null }) + if (!roleIds?.length) return res.json({ code: 40014, msg: '请选择用户角色', data: null }) + + const initialPassword = genInitialPassword() + const user = { + id: nextUserId(), + username, + password: initialPassword, + realName: realName || '', + phone, + orgId, + roleIds, + status: 'enabled', + failCount: 0, + mustChangePassword: true, + lastLoginAt: '', + createdAt: nowStr() + } + users.push(user) + res.json({ code: 200, msg: 'ok', data: { id: user.id, initialPassword } }) +}) + +router.put('/user/:id', (req, res) => { + const { id } = req.params + const { phone, realName, orgId, roleIds } = req.body || {} + const user = users.find((u) => u.id === id) + if (!user) return res.json({ code: 40404, msg: '用户不存在', data: null }) + if (!/^1\d{10}$/.test(phone || '')) return res.json({ code: 40011, msg: '请输入正确的11位手机号', data: null }) + if (users.some((u) => u.id !== id && u.phone === phone)) { + return res.json({ code: 40012, msg: '该手机号已被注册', data: null }) + } + if (!orgId || !orgs.some((o) => o.id === orgId)) return res.json({ code: 40013, msg: '请选择有效的所属机构', data: null }) + if (!roleIds?.length) return res.json({ code: 40014, msg: '请选择用户角色', data: null }) + + user.phone = phone + user.realName = realName || '' + user.orgId = orgId + user.roleIds = roleIds + res.json({ code: 200, msg: 'ok', data: null }) +}) + +router.delete('/user/:id', (req, res) => { + const { id } = req.params + const index = users.findIndex((u) => u.id === id) + if (index === -1) return res.json({ code: 40404, msg: '用户不存在', data: null }) + users.splice(index, 1) + res.json({ code: 200, msg: 'ok', data: null }) +}) + +router.put('/user/:id/lock', (req, res) => { + const user = users.find((u) => u.id === req.params.id) + if (!user) return res.json({ code: 40404, msg: '用户不存在', data: null }) + user.status = 'locked' + res.json({ code: 200, msg: 'ok', data: null }) +}) + +router.put('/user/:id/unlock', (req, res) => { + const user = users.find((u) => u.id === req.params.id) + if (!user) return res.json({ code: 40404, msg: '用户不存在', data: null }) + user.status = 'enabled' + user.failCount = 0 + res.json({ code: 200, msg: 'ok', data: null }) +}) + +router.put('/user/:id/password', (req, res) => { + const { newPassword } = req.body || {} + const user = users.find((u) => u.id === req.params.id) + if (!user) return res.json({ code: 40404, msg: '用户不存在', data: null }) + const strongEnough = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/.test(newPassword || '') + if (!strongEnough) return res.json({ code: 40015, msg: '密码需8位以上,且包含大小写字母和数字', data: null }) + user.password = newPassword + user.mustChangePassword = false + res.json({ code: 200, msg: 'ok', data: null }) +}) + +router.put('/user/:id/password/reset', (req, res) => { + const user = users.find((u) => u.id === req.params.id) + if (!user) return res.json({ code: 40404, msg: '用户不存在', data: null }) + user.password = '123456' + user.mustChangePassword = true + console.log(`[mock-server] 密码已重置,短信通知已发送至 ${user.phone}: 初始密码 123456`) + res.json({ code: 200, msg: 'ok', data: null }) +}) + +// ---------------- 角色管理(简版,供用户管理下拉使用;完整 CRUD 见后续路由) ---------------- + +router.get('/role/list', (req, res) => { + const { status } = req.query || {} + let list = roles + if (status) list = list.filter((r) => r.status === status) + res.json({ + code: 200, + msg: 'ok', + data: list.map((r) => ({ id: r.id, name: r.name, orgId: r.orgId, status: r.status, builtin: r.builtin })) + }) +}) + export default router + + diff --git a/src/api/system.js b/src/api/system.js index 452368d..b928a5e 100644 --- a/src/api/system.js +++ b/src/api/system.js @@ -5,3 +5,16 @@ export const fetchOrgListApi = (params) => request.get('/system/org/list', { par export const createOrgApi = (data) => request.post('/system/org', data) export const updateOrgApi = (id, data) => request.put(`/system/org/${id}`, data) export const deleteOrgApi = (id) => request.delete(`/system/org/${id}`) + +// ---------------- 用户管理 ---------------- +export const fetchUserListApi = (params) => request.get('/system/user/list', { params }) +export const createUserApi = (data) => request.post('/system/user', data) +export const updateUserApi = (id, data) => request.put(`/system/user/${id}`, data) +export const deleteUserApi = (id) => request.delete(`/system/user/${id}`) +export const lockUserApi = (id) => request.put(`/system/user/${id}/lock`) +export const unlockUserApi = (id) => request.put(`/system/user/${id}/unlock`) +export const changeUserPasswordApi = (id, newPassword) => request.put(`/system/user/${id}/password`, { newPassword }) +export const resetUserPasswordApi = (id) => request.put(`/system/user/${id}/password/reset`) + +// ---------------- 角色管理 ---------------- +export const fetchRoleListApi = (params) => request.get('/system/role/list', { params }) diff --git a/src/stores/auth.js b/src/stores/auth.js index 2572417..b02c8c3 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -7,7 +7,8 @@ export const useAuthStore = defineStore('auth', { userInfo: null, menuTree: [], buttonCodes: [], - routesReady: false + routesReady: false, + mustChangePassword: false }), actions: { setAccessToken(token) { @@ -17,11 +18,12 @@ export const useAuthStore = defineStore('auth', { async login(payload) { const res = await loginApi(payload) if (res.data.code === 200) { - const { accessToken, userInfo, menuTree, buttonCodes } = res.data.data + const { accessToken, userInfo, menuTree, buttonCodes, mustChangePassword } = res.data.data this.setAccessToken(accessToken) this.userInfo = userInfo this.menuTree = menuTree this.buttonCodes = buttonCodes + this.mustChangePassword = !!mustChangePassword } return res.data }, @@ -43,11 +45,15 @@ export const useAuthStore = defineStore('auth', { this.menuTree = [] this.buttonCodes = [] this.routesReady = false + this.mustChangePassword = false localStorage.removeItem('access_token') }, markRoutesReady() { this.routesReady = true }, + markPasswordChanged() { + this.mustChangePassword = false + }, hasButton(code) { return this.buttonCodes.includes(code) } diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 0000000..6e1b6cf --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,33 @@ +const PHONE_REGEX = /^1\d{10}$/ +// 密码强度:8 位以上,同时包含大写字母、小写字母、数字 +const PASSWORD_REGEX = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/ + +export function isValidPhone(phone) { + return PHONE_REGEX.test(phone || '') +} + +export function isValidPasswordStrength(password) { + return PASSWORD_REGEX.test(password || '') +} + +export function phoneValidatorRule() { + return { + validator: (_rule, value) => { + if (!value) return Promise.reject(new Error('请输入手机号')) + if (!isValidPhone(value)) return Promise.reject(new Error('请输入正确的11位手机号')) + return Promise.resolve() + } + } +} + +export function passwordStrengthValidatorRule() { + return { + validator: (_rule, value) => { + if (!value) return Promise.reject(new Error('请输入密码')) + if (!isValidPasswordStrength(value)) { + return Promise.reject(new Error('密码需8位以上,且包含大小写字母和数字')) + } + return Promise.resolve() + } + } +} diff --git a/src/views/login/Login.vue b/src/views/login/Login.vue index 67355cd..26b6211 100644 --- a/src/views/login/Login.vue +++ b/src/views/login/Login.vue @@ -55,6 +55,31 @@ + + + + + + + + + + + + @@ -68,6 +93,8 @@ import { sendSmsApi } from '@/api/auth' import { getBaseUrl, setBaseUrlOverride } from '@/api/request' import { useAuthStore } from '@/stores/auth' import { installDynamicRoutes, collectFirstPath } from '@/router/dynamic' +import { changeUserPasswordApi } from '@/api/system' +import { isValidPasswordStrength } from '@/utils/validators' const route = useRoute() const router = useRouter() @@ -81,6 +108,10 @@ const loginLoading = ref(false) const baseUrlModalOpen = ref(false) const baseUrlInput = ref(getBaseUrl()) +const forceChangePwdOpen = ref(false) +const changePwdLoading = ref(false) +const pwdForm = reactive({ newPassword: '', confirmPassword: '' }) + async function handleSendSms() { if (!form.username || !form.password) { message.warning('请先填写账号和密码') @@ -98,21 +129,52 @@ async function handleSendSms() { } } +function goToFirstPage() { + installDynamicRoutes(router, authStore.menuTree) + authStore.markRoutesReady() + const target = route.query.redirect || collectFirstPath(authStore.menuTree) || '/403' + router.replace(target) +} + async function handleLogin() { loginLoading.value = true try { const res = await authStore.login(form) if (res.code === 200) { - installDynamicRoutes(router, authStore.menuTree) - authStore.markRoutesReady() - const target = route.query.redirect || collectFirstPath(authStore.menuTree) || '/403' - router.replace(target) + if (authStore.mustChangePassword) { + forceChangePwdOpen.value = true + } else { + goToFirstPage() + } } } finally { loginLoading.value = false } } +async function handleForceChangePassword() { + if (!isValidPasswordStrength(pwdForm.newPassword)) { + message.error('密码需8位以上,且包含大小写字母和数字') + return + } + if (pwdForm.newPassword !== pwdForm.confirmPassword) { + message.error('两次输入的密码不一致') + return + } + changePwdLoading.value = true + try { + const res = await changeUserPasswordApi(authStore.userInfo.id, pwdForm.newPassword) + if (res.data.code === 200) { + message.success('密码修改成功') + authStore.markPasswordChanged() + forceChangePwdOpen.value = false + goToFirstPage() + } + } finally { + changePwdLoading.value = false + } +} + function handleSaveBaseUrl() { setBaseUrlOverride(baseUrlInput.value) baseUrlModalOpen.value = false diff --git a/src/views/system/user/UserList.vue b/src/views/system/user/UserList.vue new file mode 100644 index 0000000..0caef33 --- /dev/null +++ b/src/views/system/user/UserList.vue @@ -0,0 +1,294 @@ + + + + +