diff --git a/bj_power_wms/internal/handler/auth.go b/bj_power_wms/internal/handler/auth.go index 7978f76..53c2eee 100644 --- a/bj_power_wms/internal/handler/auth.go +++ b/bj_power_wms/internal/handler/auth.go @@ -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 { diff --git a/bj_power_wms/internal/handler/jwt.go b/bj_power_wms/internal/handler/jwt.go index 6c254e0..ba639a8 100644 --- a/bj_power_wms/internal/handler/jwt.go +++ b/bj_power_wms/internal/handler/jwt.go @@ -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) }) } diff --git a/bj_power_wms/internal/handler/rbac.go b/bj_power_wms/internal/handler/rbac.go new file mode 100644 index 0000000..ee37b70 --- /dev/null +++ b/bj_power_wms/internal/handler/rbac.go @@ -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) + }) +} diff --git a/bj_power_wms/internal/handler/routes.go b/bj_power_wms/internal/handler/routes.go index 954c576..5cd24b0 100644 --- a/bj_power_wms/internal/handler/routes.go +++ b/bj_power_wms/internal/handler/routes.go @@ -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)}, }, ) diff --git a/bj_power_wms_client/frontend/src/layouts/MainLayout.vue b/bj_power_wms_client/frontend/src/layouts/MainLayout.vue index e4e5d1f..3a73080 100644 --- a/bj_power_wms_client/frontend/src/layouts/MainLayout.vue +++ b/bj_power_wms_client/frontend/src/layouts/MainLayout.vue @@ -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')) diff --git a/bj_power_wms_client/frontend/src/pages/ChangePassword.vue b/bj_power_wms_client/frontend/src/pages/ChangePassword.vue index 0857798..08a5f70 100644 --- a/bj_power_wms_client/frontend/src/pages/ChangePassword.vue +++ b/bj_power_wms_client/frontend/src/pages/ChangePassword.vue @@ -11,6 +11,8 @@ + + + + + + + + 账号管理 + 仅管理员可访问;角色决定菜单与功能权限 + + 新增账号 + + + + + + + + + {{ row.realName || '-' }} + + + + + {{ ROLE_LABELS[row.role] || row.role }} + + + + + {{ row.dept || '-' }} + + + + + + + + {{ row.lastLoginAt ? fmtTime(row.lastLoginAt) : '从未' }} + + + + 编辑 + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 保存 + + + + + + diff --git a/bj_power_wms_client/frontend/src/router/index.js b/bj_power_wms_client/frontend/src/router/index.js index 4ed8a72..339e957 100644 --- a/bj_power_wms_client/frontend/src/router/index.js +++ b/bj_power_wms_client/frontend/src/router/index.js @@ -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: '/' } diff --git a/bj_power_wms_client/web/static/index.html b/bj_power_wms_client/web/static/index.html index 1802b0d..0b5f6a0 100644 --- a/bj_power_wms_client/web/static/index.html +++ b/bj_power_wms_client/web/static/index.html @@ -6,8 +6,8 @@ 库房客户端 - - + +