feat: 新增账号管理功能,完善权限控制与用户信息同步
1. 新增用户管理后台接口与前端页面,支持管理员增删改查用户账号 2. 新增RBAC权限控制系统,按角色分配菜单与操作权限 3. 优化登录响应与用户信息接口,返回完整权限码与用户数据 4. 新增密码安全校验,提示纯数字密码风险 5. 移除废弃的注册接口,统一用户管理逻辑 6. 更新前端路由与侧边栏权限过滤逻辑
This commit is contained in:
@@ -67,64 +67,14 @@ func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ok(w, map[string]any{
|
||||
"token": token,
|
||||
"expireAt": expireAt,
|
||||
"user": map[string]any{"id": u.ID, "username": u.Username, "realName": u.RealName, "role": u.Role},
|
||||
"user": map[string]any{
|
||||
"id": u.ID, "username": u.Username, "realName": u.RealName,
|
||||
"role": u.Role, "dept": u.Dept, "permissionCodes": permissionsForRole(u.Role),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func registerHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RealName string `json:"realName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
fail(w, http.StatusBadRequest, "用户名和密码必填")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
fail(w, http.StatusBadRequest, "密码至少 6 位")
|
||||
return
|
||||
}
|
||||
exists, _ := ctx.EntClient.User.Query().Where(user.UsernameEQ(req.Username)).Exist(ctx0())
|
||||
if exists {
|
||||
fail(w, http.StatusConflict, "用户名已存在")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := ctx.EntClient.User.Create().
|
||||
SetUsername(req.Username).
|
||||
SetPassword(string(hash)).
|
||||
SetNillableRealName(strPtr(req.RealName)).
|
||||
SetRole(roleOrDefault(req.Role)).
|
||||
Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{"id": u.ID, "username": u.Username})
|
||||
}
|
||||
}
|
||||
|
||||
func roleOrDefault(r string) string {
|
||||
switch r {
|
||||
case "admin", "operator", "inspector":
|
||||
return r
|
||||
default:
|
||||
return "operator"
|
||||
}
|
||||
}
|
||||
|
||||
// changePasswordHandler POST /api/user/change-password(需登录 JWT)
|
||||
// 当前登录用户修改自己的密码;登录人由中间件写入的 X-Username 头确定。
|
||||
func changePasswordHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -104,6 +105,8 @@ func authInterceptor(ctx *svc.ServiceContext) func(next http.HandlerFunc) http.H
|
||||
}
|
||||
renewIfNearExpiry(ctx, w, claims)
|
||||
r.Header.Set("X-Username", claims.Username)
|
||||
r.Header.Set("X-Role", claims.Role)
|
||||
r.Header.Set("X-UserID", strconv.Itoa(claims.UserID))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/user"
|
||||
"bj_power_wms/internal/svc"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 密码最小长度(注册 / 改密 / 重置统一)
|
||||
const minPasswordLen = 6
|
||||
|
||||
// rolePermissions 角色 → 菜单/功能权限码。
|
||||
// 与前端 MainLayout 的菜单 code 一一对应;admin 拥有全部权限(含 user:manage)。
|
||||
var rolePermissions = map[string][]string{
|
||||
"admin": {
|
||||
"dashboard:view", "inbound:view", "outbound:view", "inventory:view",
|
||||
"inspection:view", "stocktake:view", "semi:view", "package:view",
|
||||
"ledger:view", "zone:view", "material:view", "user:manage",
|
||||
},
|
||||
"operator": {
|
||||
"dashboard:view", "inbound:view", "outbound:view", "inventory:view",
|
||||
"stocktake:view", "semi:view", "package:view",
|
||||
"ledger:view", "zone:view", "material:view",
|
||||
},
|
||||
"inspector": {
|
||||
"dashboard:view", "inspection:view", "inventory:view",
|
||||
"stocktake:view", "ledger:view", "zone:view", "material:view",
|
||||
},
|
||||
}
|
||||
|
||||
// permissionsForRole 取角色对应的权限码列表(未知角色按 operator 处理)
|
||||
func permissionsForRole(role string) []string {
|
||||
if p, ok := rolePermissions[role]; ok {
|
||||
return p
|
||||
}
|
||||
return rolePermissions["operator"]
|
||||
}
|
||||
|
||||
// roleOrDefault 规范化角色值,非法值回退 operator
|
||||
func roleOrDefault(r string) string {
|
||||
switch r {
|
||||
case "admin", "operator", "inspector":
|
||||
return r
|
||||
default:
|
||||
return "operator"
|
||||
}
|
||||
}
|
||||
|
||||
// roleFromRequest 取当前登录用户角色(鉴权中间件已在 X-Role 写入)
|
||||
func roleFromRequest(r *http.Request) string {
|
||||
return strings.TrimSpace(r.Header.Get("X-Role"))
|
||||
}
|
||||
|
||||
// uidFromRequest 取当前登录用户ID(鉴权中间件已在 X-UserID 写入)
|
||||
func uidFromRequestWMS(r *http.Request) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-UserID")))
|
||||
return n
|
||||
}
|
||||
|
||||
// requireAdmin 仅管理员可访问(用于账号管理类接口)
|
||||
func requireAdmin(next func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if roleFromRequest(r) != "admin" {
|
||||
fail(w, http.StatusForbidden, "仅管理员可操作")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// userInfoHandler GET /api/user/info 当前登录用户信息 + 权限码
|
||||
func userInfoHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.Header.Get("X-Username")
|
||||
u, err := ctx.EntClient.User.Query().Where(user.UsernameEQ(username)).Only(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusUnauthorized, "用户不存在")
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"realName": u.RealName,
|
||||
"role": u.Role,
|
||||
"dept": u.Dept,
|
||||
"isActive": u.IsActive,
|
||||
"permissionCodes": permissionsForRole(u.Role),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// userDTO 脱敏后的用户视图(不含密码哈希)
|
||||
func userDTO(u *ent.User) map[string]any {
|
||||
return map[string]any{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"realName": u.RealName,
|
||||
"role": u.Role,
|
||||
"dept": u.Dept,
|
||||
"phone": u.Phone,
|
||||
"isActive": u.IsActive,
|
||||
"lastLoginAt": u.LastLoginAt,
|
||||
"createdAt": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// listUsersHandler GET /api/user/list 管理员查看全部用户
|
||||
func listUsersHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := ctx.EntClient.User.Query().Order(ent.Asc("id")).All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, u := range list {
|
||||
out = append(out, userDTO(u))
|
||||
}
|
||||
ok(w, map[string]any{"list": out})
|
||||
})
|
||||
}
|
||||
|
||||
// createUserHandler POST /api/user/create 管理员新建账号
|
||||
func createUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RealName string `json:"realName"`
|
||||
Role string `json:"role"`
|
||||
Dept string `json:"dept"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
fail(w, http.StatusBadRequest, "用户名和密码必填")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < minPasswordLen {
|
||||
fail(w, http.StatusBadRequest, "密码长度至少 6 位")
|
||||
return
|
||||
}
|
||||
exists, _ := ctx.EntClient.User.Query().Where(user.UsernameEQ(req.Username)).Exist(ctx0())
|
||||
if exists {
|
||||
fail(w, http.StatusConflict, "用户名已存在")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "密码加密失败")
|
||||
return
|
||||
}
|
||||
role := roleOrDefault(req.Role)
|
||||
u, err := ctx.EntClient.User.Create().
|
||||
SetUsername(req.Username).
|
||||
SetPassword(string(hash)).
|
||||
SetNillableRealName(strPtr(req.RealName)).
|
||||
SetRole(role).
|
||||
SetNillableDept(strPtr(req.Dept)).
|
||||
Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(w, userDTO(u))
|
||||
})
|
||||
}
|
||||
|
||||
// updateUserHandler POST /api/user/update 管理员编辑账号(可选重置密码)
|
||||
func updateUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
RealName string `json:"realName"`
|
||||
Role string `json:"role"`
|
||||
Dept string `json:"dept"`
|
||||
IsActive *bool `json:"isActive"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil || req.ID <= 0 {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
target, err := ctx.EntClient.User.Get(ctx0(), req.ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
upd := target.Update()
|
||||
if req.RealName != "" {
|
||||
upd.SetRealName(req.RealName)
|
||||
}
|
||||
if req.Role != "" {
|
||||
upd.SetRole(roleOrDefault(req.Role))
|
||||
}
|
||||
if req.Dept != "" {
|
||||
upd.SetNillableDept(strPtr(req.Dept))
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
upd.SetIsActive(*req.IsActive)
|
||||
}
|
||||
// 可选重置密码:填写且 >=6 位才生效
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < minPasswordLen {
|
||||
fail(w, http.StatusBadRequest, "密码长度至少 6 位")
|
||||
return
|
||||
}
|
||||
hash, e2 := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if e2 != nil {
|
||||
fail(w, http.StatusInternalServerError, "密码加密失败")
|
||||
return
|
||||
}
|
||||
upd.SetPassword(string(hash))
|
||||
}
|
||||
u, err := upd.Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(w, userDTO(u))
|
||||
})
|
||||
}
|
||||
|
||||
// deleteUserHandler POST /api/user/delete 管理员删除账号
|
||||
func deleteUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil || req.ID <= 0 {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
// 禁止删除自己
|
||||
if uidFromRequestWMS(r) == req.ID {
|
||||
fail(w, http.StatusBadRequest, "不能删除当前登录账号")
|
||||
return
|
||||
}
|
||||
target, err := ctx.EntClient.User.Get(ctx0(), req.ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
// 禁止删除最后一个管理员
|
||||
if target.Role == "admin" {
|
||||
n, _ := ctx.EntClient.User.Query().Where(user.RoleEQ("admin")).Count(ctx0())
|
||||
if n <= 1 {
|
||||
fail(w, http.StatusBadRequest, "至少保留一个管理员账号")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := ctx.EntClient.User.DeleteOneID(req.ID).Exec(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(w, nil)
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,6 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
[]rest.Route{
|
||||
{Method: http.MethodGet, Path: "/api/health", Handler: healthHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/auth/login", Handler: loginHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/auth/register", Handler: registerHandler(ctx)},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -23,6 +22,11 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{Method: http.MethodPost, Path: "/api/user/change-password", Handler: changePasswordHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/user/info", Handler: userInfoHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/user/list", Handler: listUsersHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/user/create", Handler: createUserHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/user/update", Handler: updateUserHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/user/delete", Handler: deleteUserHandler(ctx)},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -6,15 +6,28 @@ import {
|
||||
HomeFilled, Download, Upload, Search, CircleCheck, List,
|
||||
Box, Tickets, Monitor, EditPen, Fold, Expand, Location, Goods
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getUser, logout } from '../utils/auth'
|
||||
import { getUser, getToken, logout, setSession } from '../utils/auth'
|
||||
import request from '../utils/request'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const user = getUser()
|
||||
const userName = computed(() => user?.realName || user?.username || '未登录')
|
||||
// 当前登录用户(含 permissionCodes),挂载时从 /user/info 刷新
|
||||
const me = ref(getUser() || {})
|
||||
const userName = computed(() => me.value?.realName || me.value?.username || '未登录')
|
||||
const pageTitle = computed(() => route.meta?.title || '')
|
||||
|
||||
// 菜单按权限码过滤(与 MES 的 defaultMenus + permissionCodes 机制一致)
|
||||
const permissionCodes = computed(() => me.value?.permissionCodes || [])
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const u = await request.get('/user/info')
|
||||
me.value = u
|
||||
setSession(getToken(), u) // 刷新本地用户(含最新 permissionCodes/角色)
|
||||
} catch { /* 用登录时写入的 user 兜底 */ }
|
||||
}
|
||||
|
||||
// ===== 响应式侧边栏:桌面可折叠,移动端抽屉 =====
|
||||
const isCollapse = ref(false) // 桌面端折叠(仅图标)
|
||||
const isMobile = ref(false) // 移动端(≤767px)
|
||||
@@ -44,23 +57,30 @@ watch(() => route.path, () => {
|
||||
onMounted(() => {
|
||||
onScreenChange()
|
||||
window.addEventListener('resize', onScreenChange)
|
||||
loadMe()
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', onScreenChange))
|
||||
|
||||
const menus = [
|
||||
{ path: '/', title: '工作台', icon: HomeFilled },
|
||||
{ path: '/inbound', title: '入库管理', icon: Download },
|
||||
{ path: '/outbound', title: '备料出库', icon: Upload },
|
||||
{ path: '/inventory', title: '库存查询', icon: Search },
|
||||
{ path: '/inspection', title: '质量检验', icon: CircleCheck },
|
||||
{ path: '/stocktake', title: '库存盘点', icon: List },
|
||||
{ path: '/semi', title: '半成品/成品', icon: Box },
|
||||
{ path: '/package', title: '装箱管理', icon: Box },
|
||||
{ path: '/ledger', title: '备料台账', icon: Tickets },
|
||||
{ path: '/zone', title: '区域维护', icon: Location },
|
||||
{ path: '/material', title: '物料档案', icon: Goods }
|
||||
const defaultMenus = [
|
||||
{ path: '/', title: '工作台', icon: HomeFilled, code: 'dashboard:view' },
|
||||
{ path: '/inbound', title: '入库管理', icon: Download, code: 'inbound:view' },
|
||||
{ path: '/outbound', title: '备料出库', icon: Upload, code: 'outbound:view' },
|
||||
{ path: '/inventory', title: '库存查询', icon: Search, code: 'inventory:view' },
|
||||
{ path: '/inspection', title: '质量检验', icon: CircleCheck, code: 'inspection:view' },
|
||||
{ path: '/stocktake', title: '库存盘点', icon: List, code: 'stocktake:view' },
|
||||
{ path: '/semi', title: '半成品/成品', icon: Box, code: 'semi:view' },
|
||||
{ path: '/package', title: '装箱管理', icon: Box, code: 'package:view' },
|
||||
{ path: '/ledger', title: '备料台账', icon: Tickets, code: 'ledger:view' },
|
||||
{ path: '/zone', title: '区域维护', icon: Location, code: 'zone:view' },
|
||||
{ path: '/material', title: '物料档案', icon: Goods, code: 'material:view' },
|
||||
{ path: '/users', title: '账号管理', icon: EditPen, code: 'user:manage' }
|
||||
]
|
||||
|
||||
// 按当前用户权限码过滤菜单(无 code 的常驻;user:manage 仅管理员可见)
|
||||
const menus = computed(() =>
|
||||
defaultMenus.filter((m) => !m.code || permissionCodes.value.includes(m.code))
|
||||
)
|
||||
|
||||
// 打开当前页面帮助(各页面 PageHelp 监听 wms:open-page-help 事件弹出抽屉)
|
||||
function openHelp() {
|
||||
window.dispatchEvent(new CustomEvent('wms:open-page-help'))
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="form.newPassword" type="password" show-password
|
||||
placeholder="至少 6 位" />
|
||||
<el-alert v-if="pwdAllNum" type="warning" :closable="false" style="margin-top:6px;width:100%"
|
||||
title="密码为纯数字,安全性较低,建议包含字母与数字组合" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password
|
||||
@@ -25,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { logout } from '../utils/auth'
|
||||
@@ -34,6 +36,7 @@ import request from '../utils/request'
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const pwdAllNum = computed(() => form.newPassword.length > 0 && /^[\d]+$/.test(form.newPassword))
|
||||
|
||||
async function submit() {
|
||||
if (!form.oldPassword || !form.newPassword) return ElMessage.warning('请填写旧密码和新密码')
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
import { fmtTime } from '../utils/format'
|
||||
|
||||
const ROLE_LABELS = { admin: '管理员', operator: '库房操作员', inspector: '质检员' }
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'operator', label: '库房操作员' },
|
||||
{ value: 'inspector', label: '质检员' }
|
||||
]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = reactive({ current: 1, size: 10 })
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.get('/user/list')
|
||||
list.value = data?.list || []
|
||||
total.value = list.value.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 新增 / 编辑 弹窗 ---------- */
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(0)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ username: '', realName: '', role: 'operator', dept: '', password: '' })
|
||||
|
||||
const pwdAllNum = computed(() => /^[\d]+$/.test(form.password))
|
||||
function resetForm() {
|
||||
editingId.value = 0
|
||||
Object.assign(form, { username: '', realName: '', role: 'operator', dept: '', password: '' })
|
||||
}
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
resetForm()
|
||||
editingId.value = row.id
|
||||
form.username = row.username
|
||||
form.realName = row.realName || ''
|
||||
form.role = row.role || 'operator'
|
||||
form.dept = row.dept || ''
|
||||
form.password = '' // 编辑时不填 = 不改密码
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.username.trim()) return ElMessage.warning('请输入用户名')
|
||||
if (!editingId.value && !form.password) return ElMessage.warning('请输入初始密码')
|
||||
if (form.password && form.password.length < 6) return ElMessage.warning('密码长度至少 6 位')
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const payload = {
|
||||
id: editingId.value,
|
||||
realName: form.realName.trim() || undefined,
|
||||
role: form.role,
|
||||
dept: form.dept.trim() || undefined,
|
||||
isActive: undefined
|
||||
}
|
||||
if (form.password) payload.password = form.password // 填写则重置密码
|
||||
await request.post('/user/update', payload)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await request.post('/user/create', {
|
||||
username: form.username.trim(),
|
||||
realName: form.realName.trim() || undefined,
|
||||
role: form.role,
|
||||
dept: form.dept.trim() || undefined,
|
||||
password: form.password
|
||||
})
|
||||
ElMessage.success('账号创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadUsers()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row) {
|
||||
try {
|
||||
await request.post('/user/update', { id: row.id, isActive: !row.isActive })
|
||||
ElMessage.success(row.isActive ? '已停用' : '已启用')
|
||||
await loadUsers()
|
||||
} catch { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
async function removeUser(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除账号「${row.username}」?该操作不可恢复。`, '删除确认', {
|
||||
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await request.post('/user/delete', { id: row.id })
|
||||
ElMessage.success('已删除')
|
||||
await loadUsers()
|
||||
} catch { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never" class="mb12">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>账号管理</b>
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px">仅管理员可访问;角色决定菜单与功能权限</span>
|
||||
</div>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增账号</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table :data="list" border size="small" v-loading="loading" empty-text="暂无账号">
|
||||
<el-table-column prop="id" label="ID" width="64" align="center" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="realName" label="姓名" min-width="110">
|
||||
<template #default="{ row }">{{ row.realName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'inspector' ? 'warning' : 'primary'" size="small">
|
||||
{{ ROLE_LABELS[row.role] || row.role }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dept" label="部门" min-width="100">
|
||||
<template #default="{ row }">{{ row.dept || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.isActive" @change="toggleActive(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" width="150" align="center">
|
||||
<template #default="{ row }">{{ row.lastLoginAt ? fmtTime(row.lastLoginAt) : '从未' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeUser(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑账号' : '新增账号'" width="520px"
|
||||
append-to-body destroy-on-close @closed="resetForm">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" :disabled="!!editingId" placeholder="登录名(创建后不可改)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.realName" placeholder="显示名(可空)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" required>
|
||||
<el-select v-model="form.role" style="width:100%">
|
||||
<el-option v-for="o in roleOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-input v-model="form.dept" placeholder="部门(可空)" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="editingId ? '重置密码' : '初始密码'" required>
|
||||
<el-input v-model="form.password" type="password" show-password
|
||||
:placeholder="editingId ? '不填则保持原密码(至少6位)' : '至少 6 位'" />
|
||||
<el-alert v-if="pwdAllNum" type="warning" :closable="false" style="margin-top:6px"
|
||||
title="密码为纯数字,安全性较低,建议包含字母与数字组合" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb12 { margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -20,7 +20,8 @@ const routes = [
|
||||
{ path: 'ledger', name: 'Ledger', component: () => import('../pages/Ledger.vue'), meta: { title: '备料台账' } },
|
||||
{ path: 'zone', name: 'Zone', component: () => import('../pages/BaseData.vue'), props: { mode: 'zone' }, meta: { title: '区域维护' } },
|
||||
{ path: 'material', name: 'Material', component: () => import('../pages/BaseData.vue'), props: { mode: 'material' }, meta: { title: '物料档案' } },
|
||||
{ path: 'change-password', name: 'ChangePassword', component: () => import('../pages/ChangePassword.vue'), meta: { title: '修改密码' } }
|
||||
{ path: 'change-password', name: 'ChangePassword', component: () => import('../pages/ChangePassword.vue'), meta: { title: '修改密码' } },
|
||||
{ path: 'users', name: 'UserManage', component: () => import('../pages/UserManage.vue'), meta: { title: '账号管理' } }
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>库房客户端</title>
|
||||
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DIFB8zKz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-hDjIvo.css">
|
||||
<script type="module" crossorigin src="/assets/index-BSN0nj-v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-6GjZD8CN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user