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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 正常
+ 已锁定
+
+
+
+
+ {{ r.name }}
+
+
+
+
+
+
+ 新增用户
+
+
+
+
+ {{ index + 1 }}
+ {{ record.roleNames.join('、') }}
+
+
+
+
+
+ 修改
+
+ 删除
+
+
+ 锁定
+
+
+ 解锁
+
+ 密码修改
+
+ 密码重置并发送短信
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ r.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+