up
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# 2026-04-29 工作日志
|
||||||
|
|
||||||
|
- 修复 SysAdminView.vue 第 849 行 `<img ... />` 占位符 bug(上一轮 i18n 修改误删内容),恢复为 `<img :src="usersStore.getAvatarUrlFromUserID(adminId)" class="w-5 h-5 rounded-full" alt="avatar" />`
|
||||||
|
- 在 `en.json` 的 `message` 节点补充缺失的 `"sysadmin": "System Admin"` 翻译
|
||||||
|
- 全面重新分析代码结构,更新并精简 MEMORY.md(整合后端 main.go 启动流程、apiSysAdmin 完整路由、前端路由守卫逻辑、stores/user.js isSysAdmin 机制等)
|
||||||
|
- 将 SysAdminView.vue 拆分为三个子组件:`src/views/sysadmin/UsersTab.vue`(用户管理+详情弹窗)、`GroupsTab.vue`(用户组+添加/移除成员)、`LogsTab.vue`(登录失败日志);父组件改用 v-show 保持子组件挂载,UsersTab 自身 onMounted 加载数据,Groups/Logs 由父组件 watch(activeTab) 懒加载
|
||||||
|
- 将 SysAdminView.vue 整体移入 `src/views/sysadmin/` 目录,并更新 router/index.js 中的引用路径为 `@/views/sysadmin/SysAdminView.vue`
|
||||||
+277
-269
@@ -1,182 +1,220 @@
|
|||||||
# MEMORY.md - 长效记忆
|
# MEMORY.md - 长效记忆
|
||||||
|
|
||||||
## 项目架构
|
> 最后更新: 2026-04-29
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
**项目名称**: OPS 运营管理系统
|
**项目名称**: OPS 运营管理系统
|
||||||
**技术栈**: Vue 3 + TypeScript (前端) / Go + Gin + GORM (后端)
|
**技术栈**: Vue 3 + Vite (前端 Web) / uni-app (移动端) / Go + Gin + GORM (后端)
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
```
|
```
|
||||||
ops2/
|
ops2/
|
||||||
├── backend/my_work/ # Go 后端(端口 8080)
|
├── backend/my_work/ # Go 后端(端口由 config.yaml 决定,默认 8080)
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── ops_vue_js/ # Vue 3 Web 前端
|
│ ├── ops_vue_js/ # Vue 3 Web 前端
|
||||||
│ └── ops2_uniapp/ # uni-app 移动端(待开发)
|
│ └── ops2_uniapp/ # uni-app 移动端
|
||||||
└── DOC/
|
└── DOC/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 后端架构
|
## 后端架构
|
||||||
|
|
||||||
### 入口: `backend/my_work/main.go`
|
### 启动流程 (`main.go`)
|
||||||
- 配置读取: `data/config.yaml`,无则复制 `defConfig/configTemp.yaml`
|
1. 检查 `./data/config.yaml`,不存在则复制 `./defConfig/configTemp.yaml`
|
||||||
- 支持 SQLite/MySQL/PostgreSQL
|
2. `configed` 必须为 `true` 才能启动
|
||||||
- 按顺序初始化路由:User → Files → Schedule → Purchase → WorkOrder → Warehouse
|
3. 初始化顺序:`ReturnInit` → `ApiUserInit` → `ApiFilesInit` → `ApiScheduleInit` → `ApiPurchaseInit` → `ApiWorkOrderInit` → `ApiWarehouseInit` → `BindsInit`
|
||||||
|
4. 静态文件服务 `./dist`,所有非 `/api` 请求转发给前端 HTML
|
||||||
|
5. 支持 TLS(证书路径在 config 中配置)
|
||||||
|
6. 版本信息通过 `-ldflags -X` 编译注入(`GitVersion / GitCommit / BuildTime`)
|
||||||
|
|
||||||
### 核心模块 (`backend/my_work/routers/`)
|
### 路由根 (`api.go`)
|
||||||
| 文件 | 用途 |
|
```
|
||||||
|------|------|
|
/api
|
||||||
| `apiUsers.go` | 用户认证(登录/注册/Cookie) |
|
├── /static → ApiStatic
|
||||||
| `apiFiles.go` | 文件上传/管理 |
|
├── /users → ApiUser
|
||||||
| `apiSchedule.go` | 日程排班 |
|
├── /files → ApiFiles
|
||||||
| `apiPurchase.go` | 采购订单 |
|
├── /purchase → ApiPurchase
|
||||||
| `apiWorkOrder.go` | 工单管理 |
|
├── /schedule → ApiSchedule
|
||||||
| `apiWarehouse.go` | 仓库管理(容器+物品) |
|
├── /work_order → ApiWorkOrder
|
||||||
| `apiStatic.go` | 静态资源 |
|
├── /warehouse → ApiWarehouse
|
||||||
|
└── /admin → ApiSysAdmin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求/响应格式
|
||||||
|
```json
|
||||||
|
// 请求体
|
||||||
|
{ "userCookieValue": "xxx", "data": { ...业务参数 } }
|
||||||
|
|
||||||
|
// 响应体
|
||||||
|
{ "err_code": 0, "err_msg": "apiOK", "return": { ... } }
|
||||||
|
```
|
||||||
|
错误码从 `./defConfig/errorCodes.json` 读取,常用:`apiOK`=0,`userNoLogin`=-44,`permission_denied`,`parameErr`,`dbErr`
|
||||||
|
|
||||||
|
### 认证机制
|
||||||
|
- 登录成功返回 Cookie 对象(存 `TabUserCookie` 表)
|
||||||
|
- 后续请求通过 JSON body 中 `userCookieValue` 传递
|
||||||
|
- Cookie 过期后端返回 err_code=-44,前端拦截器自动处理
|
||||||
|
- `AuthenticationAuthority(ctx)` → 分离 cookie 和 data,验证返回 `(isAuth bool, user TabUser, data map)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 用户认证模块 (`apiUsers.go`)
|
## 用户认证模块 (`apiUsers.go`)
|
||||||
|
|
||||||
### 核心函数
|
### 数据表
|
||||||
- `AuthenticationAuthorityFromCookie(c string)` - 验证 Cookie 并返回用户
|
| 表 | 说明 |
|
||||||
- `AuthenticationAuthority(ctx)` - 通用认证函数,分离 Cookie 和 data
|
|---|---|
|
||||||
- `GetUserInfoFromUserID(userID uint)` - 通过 ID 获取用户详情
|
| `TabUser` | 用户(Name 唯一索引) |
|
||||||
|
| `TabUserGroups` | 用户组 |
|
||||||
|
| `TabUserGroupBinds` | 用户-组绑定 |
|
||||||
|
| `TabUserInfo` | 用户详情(头像/昵称/性别/生日/语言等) |
|
||||||
|
| `TabUserCookie` | 登录 Cookie(ExpiresAt,Remember 字段) |
|
||||||
|
| `TabUserLoginFailLog` | 登录失败日志(24小时内聚合,累计 Count) |
|
||||||
|
|
||||||
### 用户组
|
### 初始化
|
||||||
- 自动创建 `admins` 组和 `admin` 用户(默认密码:adminpassword)
|
- 自动创建 `admins` 组 + `admin` 用户(默认密码 `adminpassword`)
|
||||||
- 各功能模块独立创建管理员组:`purchase_admin`、`work_order_admin`、`schedule_admin`、`warehouse_admin`
|
- 密码:加盐哈希,支持 `text` / `md5` / `md5salt`(config 指定)
|
||||||
|
|
||||||
|
### 权限缓存(内存)
|
||||||
|
各模块维护各自的管理员 ID 列表:
|
||||||
|
| 变量 | 所属模块 | 刷新函数 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sysAdmins []uint` | apiUsers.go | `updateSysAdminsCash()` |
|
||||||
|
| `scheduleAdmins []uint` | apiSchedule.go | `ScheduleUpdateAdminsCash()` |
|
||||||
|
| `purchaseAdmins []uint` | apiPurchase.go | `PurchaseUpdateAdminsCash()` |
|
||||||
|
| `workOrderAdmins []uint` | apiWorkOrder.go | `WorkOrderUpdateAdminsCash()` |
|
||||||
|
| `warehouseAdmins []uint` | apiWarehouse.go | `WarehouseUpdateAdminsCash()` |
|
||||||
|
|
||||||
|
**修改用户组时自动刷新缓存**(`apiSysAdmin.go` 的 `add_group_member` 和 `remove_group_member`):
|
||||||
|
```go
|
||||||
|
switch group.Name {
|
||||||
|
case "admins": updateSysAdminsCash()
|
||||||
|
case "schedule_admin": ScheduleUpdateAdminsCash()
|
||||||
|
case "purchase_admin": PurchaseUpdateAdminsCash()
|
||||||
|
case "work_order_admin": WorkOrderUpdateAdminsCash()
|
||||||
|
case "warehouse_admin": WarehouseUpdateAdminsCash()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### API 路由 (`/api/users/*`)
|
### API 路由 (`/api/users/*`)
|
||||||
| 路由 | 用途 |
|
| 路由 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `POST /login` | 用户登录(返回 Cookie) |
|
| `POST /login` | 登录(返回 Cookie 对象,含 Remember) |
|
||||||
| `POST /register` | 用户注册 |
|
| `POST /register` | 注册 |
|
||||||
| `POST /getinfo` | 获取当前用户信息 |
|
| `POST /getinfo` | 获取当前用户信息(含 isSysAdmin 字段) |
|
||||||
| `POST /changePassword` | 修改密码 |
|
| `POST /changePassword` | 修改密码(oldpass/newpass) |
|
||||||
| `POST /changeEmail` | 修改邮箱 |
|
| `POST /changeEmail` | 修改邮箱 |
|
||||||
| `POST /updateAvatar` | 更新头像(FormData 上传) |
|
| `POST /updateAvatar` | 更新头像(FormData) |
|
||||||
| `POST /updateInfo` | 更新用户详情 |
|
| `POST /updateInfo` | 更新详情(firstName/username/birthdate/gender/region/language) |
|
||||||
| `GET /getuserinfo/:id` | 获取指定用户信息 |
|
| `GET /getuserinfo/:id` | 获取指定用户信息 |
|
||||||
| `GET/POST /test` | 测试接口 |
|
|
||||||
|
|
||||||
### 密码机制
|
---
|
||||||
- 密码加盐哈希(Salt + Hash)
|
|
||||||
- 支持 `text` / `md5` / `md5salt` 三种哈希类型(配置指定)
|
|
||||||
|
|
||||||
## 文件管理模块 (`apiFiles.go`)
|
## 系统管理模块 (`apiSysAdmin.go`)
|
||||||
|
|
||||||
### 数据表
|
**路由前缀**: `/api/admin/*`,全部要求 `SysAdminCheck`
|
||||||
- `TabFileInfo_` - 文件元数据(SHA256 哈希为唯一标识)
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 路由 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Sha256` | 文件哈希(主键/索引) |
|
| `POST /sysadmins` | 获取系统管理员 ID 列表 |
|
||||||
|
| `POST /users` | 用户列表(分页+搜索,返回含头像路径) |
|
||||||
|
| `POST /groups` | 用户组列表(含成员数 + 前5个成员ID) |
|
||||||
|
| `POST /group_members` | 指定组的成员列表(分页) |
|
||||||
|
| `POST /user_detail` | 用户详情(基本信息 + userinfo) |
|
||||||
|
| `POST /reset_user_password` | 重置密码(同时注销该用户所有 cookie) |
|
||||||
|
| `POST /add_group_member` | 添加用户到组(含缓存刷新) |
|
||||||
|
| `POST /remove_group_member` | 从组移除用户(含缓存刷新) |
|
||||||
|
| `POST /login_fail_logs` | 登录失败日志(分页+搜索 username/IP) |
|
||||||
|
|
||||||
|
`SysAdminCheck(userID)` 直接查内存 `sysAdmins` 列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件管理模块 (`api_Files.go`)
|
||||||
|
|
||||||
|
### 数据表 `TabFileInfo`
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Sha256` | 唯一标识,去重键 |
|
||||||
| `Name` | 原始文件名 |
|
| `Name` | 原始文件名 |
|
||||||
| `Path` | 存储路径 |
|
| `Path` | 存储路径 |
|
||||||
| `Mime` | MIME 类型 |
|
| `Mime` | MIME 类型 |
|
||||||
| `Type` | 文件类型(image/video/pdf 等) |
|
| `Type` | image/video/pdf 等 |
|
||||||
| `Const` | 引用计数(同文件多次上传只存一份) |
|
| `Const` | 引用计数 |
|
||||||
|
|
||||||
### API 路由 (`/api/files/*`)
|
|
||||||
| 路由 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `POST /upload/image` | 上传图片(FormData,含 SHA256 去重) |
|
|
||||||
| `GET /:mode/:hash` | 获取文件(mode=get 下载,mode=download 预览) |
|
|
||||||
|
|
||||||
### 存储结构
|
### 存储结构
|
||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
├── static/avatar/ # 用户头像
|
├── static/avatar/ # 用户头像(/api/static/avatar/:filename)
|
||||||
└── upload/
|
└── upload/
|
||||||
├── image/ # 图片(以 SHA256 命名)
|
├── image/ # 以 SHA256 命名
|
||||||
├── video/
|
├── video/
|
||||||
├── music/
|
├── music/
|
||||||
└── pdf/
|
└── pdf/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 日程排班模块 (`apiSchedule.go`)
|
### API (`/api/files/*`)
|
||||||
|
| 路由 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /upload/image` | 上传图片(FormData + SHA256 去重) |
|
||||||
|
| `GET /:mode/:hash` | `get`=下载(带文件名),`download`=预览 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 日程模块 (`apiSchedule.go`)
|
||||||
|
|
||||||
### 数据表
|
### 数据表
|
||||||
| 表 | 用途 |
|
- `TabSchedule`(软删除):Title / StartDate / EndDate / BgColor(默认#3788d9) / Remark
|
||||||
|
- `TabScheduleLog`:操作日志
|
||||||
|
|
||||||
|
### API (`/api/schedule/*`)
|
||||||
|
| 路由 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TabSchedule` | 日程(软删除) |
|
| `POST /getevents` | 按日期范围查询(`start_date <= end AND end_date >= start`) |
|
||||||
| `TabScheduleLog` | 操作日志 |
|
| `POST /addevent` | 新增 |
|
||||||
|
| `POST /editevent` | 编辑 |
|
||||||
|
| `POST /deleevent` | 软删除 |
|
||||||
|
|
||||||
### 日程结构
|
---
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `Title` | 日程标题 |
|
|
||||||
| `StartDate` | 开始日期(YYYY-MM-DD) |
|
|
||||||
| `EndDate` | 结束日期(YYYY-MM-DD) |
|
|
||||||
| `BgColor` | 背景颜色(默认 #3788d9) |
|
|
||||||
| `Remark` | 备注 |
|
|
||||||
|
|
||||||
### API 路由 (`/api/schedule/*`)
|
## 采购模块 (`apiPurchase.go`)
|
||||||
| 路由 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `POST /getevents` | 获取日程列表(按日期范围) |
|
|
||||||
| `POST /addevent` | 新增日程 |
|
|
||||||
| `POST `/editevent`` | 编辑日程 |
|
|
||||||
| `POST /deleevent` | 删除日程(软删除) |
|
|
||||||
|
|
||||||
### 查询逻辑
|
|
||||||
```sql
|
|
||||||
WHERE start_date <= :end AND end_date >= :start
|
|
||||||
```
|
|
||||||
|
|
||||||
## 静态资源模块 (`apiStatic.go`)
|
|
||||||
|
|
||||||
| 路由 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `GET /static/avatar/:filename` | 获取用户头像 |
|
|
||||||
|
|
||||||
### 数据库模型 (`backend/my_work/models/sql.go`)
|
|
||||||
- `TabUser_` - 用户
|
|
||||||
- `TabUserGroups_` - 用户组
|
|
||||||
- `TabUserInfo_` - 用户详情
|
|
||||||
- `TabCookie_` - 登录 Cookie(有效期 604800 秒)
|
|
||||||
- `APIRequestLog_` - API 日志
|
|
||||||
|
|
||||||
### 仓库模块核心表 (`apiWarehouse.go`)
|
|
||||||
- `TabWarehouseContainer` - 容器(树形,最多5层嵌套)
|
|
||||||
- `TabWarehouseItem` - 物品
|
|
||||||
- `TabWarehouseItemCommit` - 物品移动记录
|
|
||||||
- `TabWarehouseLog` - 操作日志
|
|
||||||
- `TabWarehouseItemWorkOrderBind` - 物品-工单关联
|
|
||||||
|
|
||||||
## 采购模块 (`backend/my_work/routers/apiPurchase.go`)
|
|
||||||
|
|
||||||
### 数据表
|
### 数据表
|
||||||
| 表 | 用途 |
|
| 表 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TabPurchaseOrder` | 采购订单(软删除) |
|
| `TabPurchaseOrder` | 采购订单(软删除) |
|
||||||
| `TabPurchaseCosts` | 费用明细(单价/运费,支持多币种) |
|
| `TabPurchaseCosts` | 费用明细(单价/运费,多币种) |
|
||||||
| `TabPurchaseFileBind` | 图片关联 |
|
| `TabPurchaseFileBind` | 图片关联 |
|
||||||
| `TabPurchaseCommit` | 状态变更记录 |
|
| `TabPurchaseCommit` | 状态变更记录 |
|
||||||
| `TabPurchaseLog` | 操作日志 |
|
| `TabPurchaseLog` | 操作日志 |
|
||||||
|
|
||||||
### 订单状态流程
|
### 状态流
|
||||||
```
|
```
|
||||||
pending(待处理) → ordered(已下单) → arrived(已到达) → received(已收件)
|
pending → ordered → arrived → received
|
||||||
↓
|
↓
|
||||||
lost(丢件) / returned(退件)
|
lost / returned
|
||||||
```
|
```
|
||||||
|
|
||||||
### 货币类型
|
### 货币:`1-CNY / 2-MOP / 3-HKD / 4-USD`
|
||||||
`1-CNY` / `2-MOP` / `3-HKD` / `4-USD`
|
|
||||||
|
|
||||||
### API 路由 (`/api/purchase/*`)
|
### API (`/api/purchase/*`)
|
||||||
| 路由 | 用途 |
|
| 路由 | 用途 |
|
||||||
|------|------|
|
|---|---|
|
||||||
| `POST /getorder` | 获取订单详情(含费用、图片、状态记录、关联工单) |
|
| `POST /getorder` | 订单详情(含费用/图片/状态记录/关联工单) |
|
||||||
| `POST /getorders` | 获取订单列表(支持搜索、分页、状态筛选) |
|
| `POST /getorders` | 列表(搜索/分页/状态筛选) |
|
||||||
| `POST /addorder` | 新增订单 |
|
| `POST /addorder` | 新增 |
|
||||||
| `POST /updateorder` | 编辑订单(含费用、图片重建) |
|
| `POST /updateorder` | 编辑(费用/图片重建) |
|
||||||
| `POST /deleteorder` | 删除订单 |
|
| `POST /deleteorder` | 删除 |
|
||||||
| `POST /updatestatus` | 更新订单状态(可附评论/图片) |
|
| `POST /updatestatus` | 更新状态(可附评论/图片) |
|
||||||
| `POST /delete_commit` | 删除状态记录 |
|
| `POST /delete_commit` | 删除状态记录 |
|
||||||
| `POST /getordercount` | 统计各状态数量 |
|
| `POST /getordercount` | 各状态数量统计 |
|
||||||
| `POST /search_work_orders` | 搜索工单(用于关联) |
|
| `POST /search_work_orders` | 搜索工单(用于关联) |
|
||||||
|
|
||||||
## 工单模块 (`backend/my_work/routers/apiWorkOrder.go`)
|
---
|
||||||
|
|
||||||
|
## 工单模块 (`apiWorkOrder.go`)
|
||||||
|
|
||||||
### 数据表
|
### 数据表
|
||||||
| 表 | 用途 |
|
| 表 | 用途 |
|
||||||
@@ -184,191 +222,161 @@ pending(待处理) → ordered(已下单) → arrived(已到达) → received(
|
|||||||
| `TabWorkOrder` | 工单(软删除) |
|
| `TabWorkOrder` | 工单(软删除) |
|
||||||
| `TabWorkOrderFileBind` | 工单图片关联 |
|
| `TabWorkOrderFileBind` | 工单图片关联 |
|
||||||
| `TabWorkOrderCommit` | 进度记录 |
|
| `TabWorkOrderCommit` | 进度记录 |
|
||||||
| `TabWorkOrderLog` | 操作日志 |
|
|
||||||
| `TabWorkOrderCommitFileBind` | 进度关联图片 |
|
| `TabWorkOrderCommitFileBind` | 进度关联图片 |
|
||||||
| `TabWorkOrderPurchaseOrderBind` | 工单-采购订单关联 |
|
| `TabWorkOrderPurchaseOrderBind` | 工单-采购订单关联(含 CommitID) |
|
||||||
|
| `TabWorkOrderLog` | 操作日志 |
|
||||||
|
|
||||||
### 工单状态流程
|
### 状态流
|
||||||
```
|
```
|
||||||
pending(待处理) → checked(已检查) → parts_ordered(已下单零件) → repaired(已维修) → returned(已送还)
|
pending → checked → parts_ordered → repaired → returned
|
||||||
↓
|
↓
|
||||||
unrepairable(无法维修)
|
unrepairable
|
||||||
```
|
```
|
||||||
|
|
||||||
### 关联关系
|
### 特殊逻辑
|
||||||
- 工单 ↔ 仓库物品 (`TabWarehouseItemWorkOrderBind`)
|
- 状态变为 `returned` 时,自动移除物品的容器绑定(`ContainerID = nil`)
|
||||||
- 工单 ↔ 采购订单 (`TabWorkOrderPurchaseOrderBind`)
|
- 工单 ↔ 物品:`TabWarehouseItemWorkOrderBind`
|
||||||
- 特殊逻辑:状态变更为 `returned` 时,自动移除物品的容器绑定
|
- 工单 ↔ 采购:`TabWorkOrderPurchaseOrderBind`
|
||||||
|
|
||||||
### API 路由 (`/api/work_order/*`)
|
### API (`/api/work_order/*`)
|
||||||
| 路由 | 用途 |
|
| 路由 | 用途 |
|
||||||
|------|------|
|
|---|---|
|
||||||
| `POST /add` | 新增工单(可关联物品) |
|
| `POST /add` | 新增(可关联物品) |
|
||||||
| `POST /update` | 编辑工单 |
|
| `POST /update` | 编辑 |
|
||||||
| `POST /list` | 获取工单列表 |
|
| `POST /list` | 列表 |
|
||||||
| `POST /get` | 获取工单详情(含图片、进度、关联物品/采购订单) |
|
| `POST /get` | 详情(含图片/进度/关联物品/采购) |
|
||||||
| `POST /commit` | 提交进度(更新状态,可关联采购订单) |
|
| `POST /commit` | 提交进度(更新状态,可关联采购) |
|
||||||
| `POST /delete` | 删除工单 |
|
| `POST /delete` | 删除 |
|
||||||
| `POST /delete_commit` | 删除进度 |
|
| `POST /delete_commit` | 删除进度 |
|
||||||
| `POST /count` | 统计各状态数量 |
|
| `POST /count` | 各状态统计 |
|
||||||
| `POST /search_purchase_orders` | 搜索采购订单(用于关联) |
|
| `POST /search_purchase_orders` | 搜索采购订单(用于关联) |
|
||||||
|
|
||||||
## 前端架构
|
---
|
||||||
|
|
||||||
### Web 前端 (`frontend/ops_vue_js/`)
|
## 仓库模块 (`apiWarehouse.go`)
|
||||||
|
|
||||||
**技术栈**: Vue 3 + Vite 7 + Pinia + Vue Router + Vue I18n
|
### 数据表
|
||||||
|
| 表 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `TabWarehouseContainer` | 容器(树形,最多5层,ParentID=nil为顶级) |
|
||||||
|
| `TabWarehouseItem` | 物品(ContainerID=nil表示未入库) |
|
||||||
|
| `TabWarehouseItemCommit` | 物品移动记录 |
|
||||||
|
| `TabWarehouseLog` | 操作日志 |
|
||||||
|
|
||||||
**项目结构**:
|
### 跨模块绑定表 (`binds.go`)
|
||||||
|
| 表 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `TabWarehouseItemWorkOrderBind` | 物品-工单 |
|
||||||
|
| `TabWarehouseItemFileBind` | 物品-图片 |
|
||||||
|
| `TabWarehouseContainerFileBind` | 容器-图片 |
|
||||||
|
| `TabPurchaseFileBind` | 采购-图片 |
|
||||||
|
| `TabWorkOrderFileBind` | 工单-图片 |
|
||||||
|
| `TabWorkOrderCommitFileBind` | 工单进度-图片 |
|
||||||
|
| `TabWorkOrderPurchaseOrderBind` | 工单-采购 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web 前端架构 (`frontend/ops_vue_js/`)
|
||||||
|
|
||||||
|
**技术栈**: Vue 3 + Vite 7 + Pinia + Vue Router (hash 模式) + Vue I18n + Tailwind CSS v4 + Tabler Icons
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── api/ # API 封装层
|
├── api/
|
||||||
│ ├── index.js # Axios 实例 + 拦截器 + 统一调用接口
|
│ ├── index.js # Axios 实例,基础 URL /api,请求拦截注入 cookie,响应拦截处理 -44
|
||||||
│ ├── auth.js # 认证 API (登录/注册/用户信息/密码修改)
|
│ ├── auth.js # 认证 + sysadmin 管理 API
|
||||||
│ ├── purchase.js # 采购订单 API
|
│ ├── purchase.js # 采购 API
|
||||||
│ ├── warehouse.js # 仓库管理 API
|
│ ├── warehouse.js # 仓库 API
|
||||||
│ ├── work_order.js # 工单管理 API
|
│ ├── work_order.js # 工单 API
|
||||||
│ ├── schedule.js # 日程管理 API
|
│ ├── schedule.js # 日程 API
|
||||||
│ └── users.js # 其他用户信息 API
|
│ └── users.js # 其他用户信息 API(按需加载头像/用户名)
|
||||||
├── components/ # 公共组件
|
├── components/
|
||||||
├── composables/ # Vue 组合式函数 (Hooks)
|
│ ├── AppHeader.vue # 导航栏(含系统管理入口,权限判断 isSysAdmin)
|
||||||
├── i18n/ # 国际化 (en.json, zh-CN.json)
|
│ ├── AppFooter.vue
|
||||||
├── layouts/ # 页面布局 (AuthLayout, DefaultLayout)
|
│ ├── AppToast.vue
|
||||||
├── router/ # 路由配置
|
│ ├── ConfirmDialog.vue
|
||||||
├── stores/ # Pinia 状态管理
|
│ ├── PurchaseOrderForm.vue
|
||||||
├── views/ # 页面视图
|
│ ├── SettingNav.vue
|
||||||
└── main.js # 应用入口
|
│ ├── tagadder.vue
|
||||||
|
│ ├── useDropzone.vue # 文件拖拽上传
|
||||||
|
│ ├── imageCropper.vue # 图片裁剪
|
||||||
|
│ ├── datePicker.vue
|
||||||
|
│ ├── dateTimePicker.vue
|
||||||
|
│ └── datatimePickerForFullCalendar.vue
|
||||||
|
├── composables/
|
||||||
|
├── i18n/
|
||||||
|
│ ├── en.json # 英文翻译
|
||||||
|
│ └── zh-CN.json # 中文翻译
|
||||||
|
├── layouts/
|
||||||
|
│ ├── DefaultLayout.vue # 需要登录的页面布局
|
||||||
|
│ └── AuthLayout.vue # 认证页面全屏布局
|
||||||
|
├── router/index.js
|
||||||
|
├── stores/
|
||||||
|
│ ├── user.js # 当前用户(isLoggedIn / isSysAdmin / cookie / avatarUrl)
|
||||||
|
│ ├── users.js # 其他用户信息缓存(按需拉取,防重复请求)
|
||||||
|
│ └── toast.js # 全局 Toast
|
||||||
|
└── views/
|
||||||
|
├── HomeView.vue
|
||||||
|
├── ScheduleView.vue # FullCalendar
|
||||||
|
├── SysAdminView.vue # 系统管理(仅 sysAdmin 可访问,meta.requireSysAdmin)
|
||||||
|
├── AdminView.vue
|
||||||
|
├── purchase/ # PurchaseList / addorder / ShowOrder / editorder
|
||||||
|
├── work_order/ # WorkOrderList / AddEditWorkOrder / ShowWorkOrder
|
||||||
|
├── warehouse/ # WarehouseOverview / ContainerList / ContainerDetail / ItemList / ItemDetail / AddItem / ItemEdit
|
||||||
|
└── settings/ # AccountView / ContactView / SecurityView
|
||||||
```
|
```
|
||||||
|
|
||||||
**API 封装** (`src/api/index.js`):
|
### 路由说明
|
||||||
- 基于 Axios,基础 URL: `/api`
|
- 公开页:`/` `/login` `/register` `/forgot_password` `/schedule` `/404`
|
||||||
- 请求拦截器自动注入 `userCookieValue`
|
- `/sysadmin` 需要 `meta.requireSysAdmin`,不满足跳回首页
|
||||||
- 响应拦截器处理 Cookie 过期 (err_code: -44)
|
- 未登录跳转 `/login?redirect=原路径`
|
||||||
- 统一返回 `{ errCode, data, raw }` 格式
|
|
||||||
- 支持文件上传 (FormData)
|
|
||||||
|
|
||||||
**路由** (`src/router/index.js`):
|
### 状态管理 (`stores/user.js`)
|
||||||
- 使用 `createWebHashHistory`(hash 模式)
|
- Cookie 持久化:`Remember=true` 存 localStorage,否则只存 sessionStorage
|
||||||
- 认证页面: `/login`, `/register`, `/forgot_password`
|
- `isSysAdmin` 由 `/users/getinfo` 返回的 `isSysAdmin` 字段驱动
|
||||||
- 需要登录的页面在白名单外
|
- `fetchUserInfo()` 在 `login()` 后自动调用
|
||||||
|
|
||||||
**页面视图** (`src/views/`):
|
### i18n 翻译节点
|
||||||
| 模块 | 页面 |
|
`week / errorpage / appname / tagadder / dropzone / cropper / purchase / work_order / warehouse / purchase_addorder / schedule / home / message / settings / button / footer / cost_type / order_status / sysadmin`
|
||||||
|------|------|
|
|
||||||
| 首页 | `HomeView.vue` |
|
|
||||||
| 日程 | `ScheduleView.vue` (FullCalendar) |
|
|
||||||
| 采购 | `PurchaseList.vue`, `addorder.vue`, `ShowOrder.vue`, `editorder.vue` |
|
|
||||||
| 工单 | `WorkOrderList.vue`, `AddEditWorkOrder.vue`, `ShowWorkOrder.vue` |
|
|
||||||
| 仓库 | `WarehouseOverview.vue`, `WarehouseContainerList.vue`, `WarehouseContainerDetail.vue`, `WarehouseItemList.vue`, `WarehouseItemDetail.vue`, `WarehouseAddItem.vue`, `WarehouseItemEdit.vue` |
|
|
||||||
| 设置 | `AccountView.vue`, `ContactView.vue`, `SecurityView.vue` |
|
|
||||||
|
|
||||||
**状态管理** (`src/stores/`):
|
### 构建配置
|
||||||
- `user.js`: 用户状态 (登录/登出/会话恢复/用户信息)
|
- 输出目录: `../../backend/my_work/dist`(后端直接 serve)
|
||||||
- `toast.js`: 全局 Toast 通知
|
|
||||||
- `users.js`: 其他用户信息缓存
|
|
||||||
|
|
||||||
**样式方案**:
|
|
||||||
- Tailwind CSS v4
|
|
||||||
- Tabler Icons
|
|
||||||
- 亮色/暗色模式支持
|
|
||||||
|
|
||||||
**国际化**: `src/i18n/en.json`, `zh-CN.json`
|
|
||||||
- 覆盖模块: week, errorpage, appname, tagadder, dropzone, cropper, purchase, work_order, warehouse, purchase_addorder, schedule, home, message, settings, button, footer, cost_type, order_status
|
|
||||||
|
|
||||||
**构建配置** (`vite.config.js`):
|
|
||||||
- 输出目录: `../../backend/my_work/dist`
|
|
||||||
- 开发代理: `/api` → `http://127.0.0.1:8080`
|
- 开发代理: `/api` → `http://127.0.0.1:8080`
|
||||||
- 路径别名: `@` → `./src`
|
- 路径别名: `@` → `./src`
|
||||||
|
|
||||||
### 移动端 (`frontend/ops2_uniapp/`)
|
---
|
||||||
|
|
||||||
**技术栈**: uni-app + Vue 3 + Pinia + HBuilderX
|
## 移动端 (`frontend/ops2_uniapp/`)
|
||||||
|
|
||||||
**项目结构**:
|
**技术栈**: uni-app + Vue 3 + Pinia
|
||||||
```
|
|
||||||
ops2_uniapp/
|
|
||||||
├── api/ # API 接口封装
|
|
||||||
│ ├── index.js # 基础请求工具(框架已有,方法待实现)
|
|
||||||
│ ├── request.js # 请求配置(待完善)
|
|
||||||
│ └── user.js # 用户接口
|
|
||||||
├── components/
|
|
||||||
│ └── my-toast/ # 自定义 Toast 组件
|
|
||||||
├── pages/ # 页面
|
|
||||||
│ ├── index/index.vue # 主页 TabBar(占位)
|
|
||||||
│ ├── order/order.vue # 订单 TabBar(占位)
|
|
||||||
│ ├── message/message.vue # 消息 TabBar(占位)
|
|
||||||
│ ├── user/user.vue # 用户 TabBar(基础框架)
|
|
||||||
│ ├── login/login.vue # 登录页(已完成 85%)
|
|
||||||
│ └── settings/settings.vue # 设置页(已完成 90%)
|
|
||||||
├── stores/
|
|
||||||
│ ├── config.js # 配置 Store(完整)
|
|
||||||
│ └── user.js # 用户 Store(基础)
|
|
||||||
├── utils/
|
|
||||||
│ └── index.js # 工具函数(isUrl)
|
|
||||||
├── pages.json # 路由配置
|
|
||||||
├── manifest.json # 应用配置
|
|
||||||
└── package.json # 依赖(pinia)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stores 状态管理**:
|
**当前完成度**: ~40%
|
||||||
- `useConfigStore`: apiBaseUrl / appName / version / theme
|
|
||||||
- `setApiBaseUrl()` / `getApiBaseUrl()` - API 地址持久化
|
|
||||||
- `useUserStore`: username / token
|
|
||||||
- `setUser()` / `logout()`
|
|
||||||
|
|
||||||
**API 封装状态**:
|
| 页面 | 完成度 |
|
||||||
- `api/index.js`: 框架有,get/post/upload 方法空实现
|
|---|---|
|
||||||
- 登录页直接使用 `uni.request()` 调用,绕过了封装层
|
| login | 85%(表单/验证/请求/Toast) |
|
||||||
- 需要完善 Cookie 认证机制(参照 Web 前端)
|
| settings | 90%(API 地址配置/连接测试) |
|
||||||
|
| index/order/message | 5%(占位) |
|
||||||
|
| user | 20%(登录入口) |
|
||||||
|
|
||||||
**页面完成度**:
|
**待完成**:
|
||||||
| 页面 | 完成度 | 说明 |
|
- 完善 `api/index.js`(Cookie 认证,参照 Web 前端)
|
||||||
|------|--------|------|
|
- 实现各功能页面(仓库/工单/采购等)
|
||||||
| index | 5% | 占位文本 |
|
|
||||||
| order | 5% | 占位文本 |
|
|
||||||
| message | 5% | 占位文本 |
|
|
||||||
| user | 20% | 登录按钮 + 设置入口 |
|
|
||||||
| login | **85%** | 表单 + 验证 + 请求 + Toast |
|
|
||||||
| settings | **90%** | API 地址编辑 + 连接测试 |
|
|
||||||
|
|
||||||
**当前总完成度**: ~35-40%
|
---
|
||||||
|
|
||||||
**移动端待开发**:
|
|
||||||
1. 完善 API 封装层(Cookie 认证)
|
|
||||||
2. 实现各功能页面(主页仪表盘、订单列表、消息列表、用户中心)
|
|
||||||
3. 添加更多组件(Loading、确认对话框、空状态)
|
|
||||||
4. 对接后端各模块(仓库、工单、采购等)
|
|
||||||
|
|
||||||
## 前后端交互协议
|
|
||||||
|
|
||||||
### 请求格式 (POST JSON)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"userCookieValue": "xxx",
|
|
||||||
...业务参数
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 响应格式
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"err_code": 0,
|
|
||||||
"return": { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 认证机制
|
|
||||||
- 登录成功后服务端返回 Cookie(存储在 `TabCookie_` 表)
|
|
||||||
- 后续请求通过 `userCookieValue` 字段传递
|
|
||||||
- Cookie 过期码: -44
|
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
1. **移动端开发时**: 需要完善 `api/index.js` 的请求封装,参照 Web 前端实现 Cookie 认证
|
1. **后端 JSON 字段命名**: 结构体字段为 PascalCase(如 `UserID / AvatarPath`),前端需对应
|
||||||
2. **仓库模块**: 是当前开发重点,支持树形容器、物品管理、工单关联
|
2. **头像**: `usersStore.getAvatarUrlFromUserID(id)` 返回 `/api/static/avatar/{path}` 或默认 `/ava.svg`
|
||||||
3. **同源部署**: 后端直接 serve `./dist` 静态文件,简化部署
|
3. **i18n 修改**: 同时修改 `en.json` 和 `zh-CN.json` 两个文件
|
||||||
|
4. **同源部署**: 后端 serve `./dist` 静态资源,前端 build 直接输出到后端目录
|
||||||
|
5. **API 封装**: `api/index.js` 统一返回 `{ errCode, data, raw }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 更新记录
|
## 更新记录
|
||||||
- 2026-04-24: 首次梳理项目运行逻辑,保存长效记忆
|
- 2026-04-24: 首次梳理
|
||||||
|
- 2026-04-28: 修复 SysAdminView 中 `<img .../>` 误删 bug;添加 message.sysadmin 英文翻译
|
||||||
|
- 2026-04-29: 全面重新分析代码,更新并精简记忆文档
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func main() {
|
|||||||
routers.ApiPurchaseInit()
|
routers.ApiPurchaseInit()
|
||||||
routers.ApiWorkOrderInit()
|
routers.ApiWorkOrderInit()
|
||||||
routers.ApiWarehouseInit()
|
routers.ApiWarehouseInit()
|
||||||
|
routers.ApiCustomerInit()
|
||||||
|
|
||||||
routers.BindsInit() //最后初始化绑定数据表
|
routers.BindsInit() //最后初始化绑定数据表
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func ApiRoot(r *gin.RouterGroup) {
|
|||||||
ApiWorkOrder(r.Group("/work_order"))
|
ApiWorkOrder(r.Group("/work_order"))
|
||||||
ApiWarehouse(r.Group("/warehouse"))
|
ApiWarehouse(r.Group("/warehouse"))
|
||||||
ApiSysAdmin(r.Group("/admin"))
|
ApiSysAdmin(r.Group("/admin"))
|
||||||
|
ApiCustomer(r.Group("/customer"))
|
||||||
r.GET("/", func(ctx *gin.Context) {
|
r.GET("/", func(ctx *gin.Context) {
|
||||||
ReturnJson(ctx, "apiOK", gin.H{
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
"isOpsApiRoot": true,
|
"isOpsApiRoot": true,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func ApiCustomerInit() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiCustomer(r *gin.RouterGroup) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: 'sysadmin',
|
path: 'sysadmin',
|
||||||
name: 'sysadmin',
|
name: 'sysadmin',
|
||||||
component: () => import('@/views/SysAdminView.vue'),
|
component: () => import('@/views/sysadmin/SysAdminView.vue'),
|
||||||
meta: { requireSysAdmin: true },
|
meta: { requireSysAdmin: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
|
||||||
|
|
||||||
usePageTitle('message.administrator')
|
|
||||||
const { t } = useI18n()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mx-auto max-w-6xl px-6 py-6">
|
|
||||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('message.administrator') }}</h2>
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white px-12 py-12 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
|
||||||
<p class="text-gray-400">{{ t('message.functionality_not_yet_developed') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onActivated } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import { IconRefresh, IconChevronLeft, IconChevronRight, IconPlus, IconX } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
|
// 用户组列表
|
||||||
|
const groups = ref([])
|
||||||
|
const groupsLoading = ref(false)
|
||||||
|
|
||||||
|
// 选中的组及其成员
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
const groupMembers = ref([])
|
||||||
|
const groupMembersLoading = ref(false)
|
||||||
|
const groupMemberPage = ref(1)
|
||||||
|
const groupMemberPageSize = ref(20)
|
||||||
|
const groupMemberTotal = ref(0)
|
||||||
|
const groupMemberTotalPages = computed(() => Math.ceil(groupMemberTotal.value / groupMemberPageSize.value))
|
||||||
|
|
||||||
|
// 添加成员弹窗
|
||||||
|
const showAddMemberDialog = ref(false)
|
||||||
|
const addMemberSearch = ref('')
|
||||||
|
const addMemberSearchResults = ref([])
|
||||||
|
const addMemberLoading = ref(false)
|
||||||
|
const addMemberSearchLoading = ref(false)
|
||||||
|
|
||||||
|
// 确认弹窗
|
||||||
|
const showConfirmDialog = ref(false)
|
||||||
|
const confirmDialogConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmText: '',
|
||||||
|
cancelText: '',
|
||||||
|
danger: false,
|
||||||
|
onConfirm: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchGroups() {
|
||||||
|
groupsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getGroups()
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
groups.value = res.data.groups || []
|
||||||
|
groups.value.forEach(g => {
|
||||||
|
g.memberIDs?.forEach(id => usersStore.fetchUser(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
groupsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGroupMembers() {
|
||||||
|
if (!selectedGroup.value) return
|
||||||
|
groupMembersLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getGroupMembers(selectedGroup.value.id, {
|
||||||
|
page: groupMemberPage.value,
|
||||||
|
page_size: groupMemberPageSize.value,
|
||||||
|
})
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
groupMembers.value = res.data.members || []
|
||||||
|
groupMemberTotal.value = res.data.total || 0
|
||||||
|
groupMemberPage.value = res.data.page || 1
|
||||||
|
groupMemberPageSize.value = res.data.page_size || 20
|
||||||
|
groupMembers.value.forEach(m => usersStore.fetchUser(m.id))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
groupMembersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(group) {
|
||||||
|
selectedGroup.value = group
|
||||||
|
groupMemberPage.value = 1
|
||||||
|
fetchGroupMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupMemberPageChange(page) {
|
||||||
|
groupMemberPage.value = page
|
||||||
|
fetchGroupMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddMemberDialog() {
|
||||||
|
showAddMemberDialog.value = true
|
||||||
|
addMemberSearch.value = ''
|
||||||
|
addMemberSearchResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddMemberDialog() {
|
||||||
|
showAddMemberDialog.value = false
|
||||||
|
addMemberSearch.value = ''
|
||||||
|
addMemberSearchResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsersToAdd() {
|
||||||
|
if (!addMemberSearch.value.trim()) {
|
||||||
|
addMemberSearchResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addMemberSearchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getUsers({
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
search: addMemberSearch.value,
|
||||||
|
})
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
const existingMemberIds = new Set(groupMembers.value.map(m => m.id))
|
||||||
|
addMemberSearchResults.value = (res.data.users || []).filter(u => !existingMemberIds.has(u.id))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
addMemberSearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroupMember(userId) {
|
||||||
|
if (!selectedGroup.value) return
|
||||||
|
addMemberLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.addGroupMember(selectedGroup.value.id, userId)
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
toast.success(t('sysadmin.add_member') + t('message.save_ok'))
|
||||||
|
fetchGroupMembers()
|
||||||
|
addMemberSearchResults.value = addMemberSearchResults.value.filter(u => u.id !== userId)
|
||||||
|
} else {
|
||||||
|
toast.error(res.raw?.err_msg || t('sysadmin.add_member') + t('message.save_ok'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
addMemberLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog(config) {
|
||||||
|
confirmDialogConfig.value = { ...confirmDialogConfig.value, ...config }
|
||||||
|
showConfirmDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (confirmDialogConfig.value.onConfirm) {
|
||||||
|
confirmDialogConfig.value.onConfirm()
|
||||||
|
}
|
||||||
|
showConfirmDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroupMember(userId) {
|
||||||
|
if (!selectedGroup.value) return
|
||||||
|
openConfirmDialog({
|
||||||
|
title: t('sysadmin.remove_member_title'),
|
||||||
|
message: t('sysadmin.remove_member_confirm'),
|
||||||
|
confirmText: t('sysadmin.remove_member'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.removeGroupMember(selectedGroup.value.id, userId)
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
toast.success(t('sysadmin.remove_member_title') + t('message.delete_ok'))
|
||||||
|
fetchGroupMembers()
|
||||||
|
} else {
|
||||||
|
toast.error(res.raw?.err_msg || t('sysadmin.remove_member') + t('message.delete_ok'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fetchGroups })
|
||||||
|
|
||||||
|
onActivated(() => fetchGroups())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.tab_groups') }}</h2>
|
||||||
|
<button
|
||||||
|
@click="fetchGroups"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
|
:disabled="groupsLoading"
|
||||||
|
>
|
||||||
|
<IconRefresh :size="18" :class="{ 'animate-spin': groupsLoading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<!-- 用户组列表 -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||||
|
<div class="bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 dark:bg-dk-base dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.group_list') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="groupsLoading" class="p-4 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.loading') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="groups.length === 0" class="p-4 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.no_groups') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="divide-y divide-gray-200 dark:divide-dk-muted">
|
||||||
|
<button
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
@click="selectGroup(group)"
|
||||||
|
:class="[
|
||||||
|
'w-full px-4 py-3 text-left transition-colors',
|
||||||
|
selectedGroup?.id === group.id
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-dk-base'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-dk-text">{{ group.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 组成员详情 -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div v-if="!selectedGroup" class="rounded-md bg-gray-50 p-8 text-center dark:bg-dk-base">
|
||||||
|
<p class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.select_group_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-dk-text">{{ selectedGroup.name }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.total_members', { count: groupMemberTotal }) }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="openAddMemberDialog"
|
||||||
|
class="flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<IconPlus :size="16" /> {{ t('sysadmin.add_member') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dk-muted">
|
||||||
|
<thead class="bg-gray-50 dark:bg-dk-base">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_user') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_email') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_type') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_action') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dk-muted dark:bg-dk-card">
|
||||||
|
<tr v-if="groupMembersLoading" class="text-center">
|
||||||
|
<td colspan="4" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.loading') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="groupMembers.length === 0" class="text-center">
|
||||||
|
<td colspan="4" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.no_members') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="member in groupMembers" :key="member.id" class="hover:bg-gray-50 dark:hover:bg-dk-base">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(member.id)"
|
||||||
|
class="h-7 w-7 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-dk-text">
|
||||||
|
{{ usersStore.getUsernameFromUserID(member.id) || member.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ member.email }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
member.type === 'admin' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ member.type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<button
|
||||||
|
@click="removeGroupMember(member.id)"
|
||||||
|
class="text-red-600 hover:text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.remove_member') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.pagination', { current: groupMemberPage, total: groupMemberTotalPages }) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="onGroupMemberPageChange(groupMemberPage - 1)"
|
||||||
|
:disabled="groupMemberPage <= 1 || groupMembersLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
<IconChevronLeft :size="16" /> {{ t('sysadmin.prev_page') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="onGroupMemberPageChange(groupMemberPage + 1)"
|
||||||
|
:disabled="groupMemberPage >= groupMemberTotalPages || groupMembersLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.next_page') }} <IconChevronRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加成员弹窗 -->
|
||||||
|
<div
|
||||||
|
v-if="showAddMemberDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
@click.self="closeAddMemberDialog"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dk-card">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.add_member_title', { name: selectedGroup?.name }) }}</h3>
|
||||||
|
<button
|
||||||
|
@click="closeAddMemberDialog"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:text-dk-subtle dark:hover:text-dk-text"
|
||||||
|
>
|
||||||
|
<IconX :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="addMemberSearch"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('sysadmin.search_user_placeholder')"
|
||||||
|
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-base dark:text-dk-text"
|
||||||
|
@keyup.enter="searchUsersToAdd"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="searchUsersToAdd"
|
||||||
|
:disabled="addMemberSearchLoading"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ addMemberSearchLoading ? t('sysadmin.searching') : t('sysadmin.search') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
<div v-if="addMemberSearchLoading" class="py-4 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.searching') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="addMemberSearchResults.length === 0 && addMemberSearch" class="py-4 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.no_search_results') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!addMemberSearch" class="py-4 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.search_hint') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="user in addMemberSearchResults"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-dk-muted"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(user.id)"
|
||||||
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-dk-text">
|
||||||
|
{{ usersStore.getUsernameFromUserID(user.id) || user.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-dk-subtle">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="addGroupMember(user.id)"
|
||||||
|
:disabled="addMemberLoading"
|
||||||
|
class="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closeAddMemberDialog"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-text dark:hover:bg-dk-base"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.close') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 确认弹窗 -->
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model="showConfirmDialog"
|
||||||
|
:title="confirmDialogConfig.title"
|
||||||
|
:message="confirmDialogConfig.message"
|
||||||
|
:confirm-text="confirmDialogConfig.confirmText"
|
||||||
|
:cancel-text="confirmDialogConfig.cancelText"
|
||||||
|
:danger="confirmDialogConfig.danger"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onActivated } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { IconSearch, IconRefresh, IconChevronLeft, IconChevronRight } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
|
const loginFailLogs = ref([])
|
||||||
|
const loginFailLogsLoading = ref(false)
|
||||||
|
const loginFailLogSearch = ref('')
|
||||||
|
const loginFailLogPage = ref(1)
|
||||||
|
const loginFailLogPageSize = ref(20)
|
||||||
|
const loginFailLogTotal = ref(0)
|
||||||
|
const loginFailLogTotalPages = computed(() => Math.ceil(loginFailLogTotal.value / loginFailLogPageSize.value))
|
||||||
|
|
||||||
|
async function fetchLoginFailLogs() {
|
||||||
|
loginFailLogsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getLoginFailLogs({
|
||||||
|
page: loginFailLogPage.value,
|
||||||
|
page_size: loginFailLogPageSize.value,
|
||||||
|
search: loginFailLogSearch.value,
|
||||||
|
})
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
loginFailLogs.value = res.data.logs || []
|
||||||
|
loginFailLogTotal.value = res.data.total || 0
|
||||||
|
loginFailLogPage.value = res.data.page || 1
|
||||||
|
loginFailLogPageSize.value = res.data.page_size || 20
|
||||||
|
loginFailLogs.value.forEach(log => {
|
||||||
|
if (log.user_id > 0) {
|
||||||
|
usersStore.fetchUser(log.user_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
loginFailLogsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoginFailLogSearch() {
|
||||||
|
loginFailLogPage.value = 1
|
||||||
|
fetchLoginFailLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoginFailLogPageChange(page) {
|
||||||
|
loginFailLogPage.value = page
|
||||||
|
fetchLoginFailLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(reason) {
|
||||||
|
const reasonMap = {
|
||||||
|
'password_error': t('sysadmin.reason_password_error'),
|
||||||
|
'user_not_found': t('sysadmin.reason_user_not_found'),
|
||||||
|
}
|
||||||
|
return reasonMap[reason] || reason
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReasonClass(reason) {
|
||||||
|
if (reason === 'password_error') {
|
||||||
|
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
if (reason === 'user_not_found') {
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fetchLoginFailLogs })
|
||||||
|
|
||||||
|
onActivated(() => fetchLoginFailLogs())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.tab_logs') }}</h2>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.total_logs', { count: loginFailLogTotal }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
v-model="loginFailLogSearch"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('sysadmin.search_log_placeholder')"
|
||||||
|
class="w-full rounded-md border border-gray-300 py-2 pl-9 pr-4 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-base dark:text-dk-text"
|
||||||
|
@keyup.enter="onLoginFailLogSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="onLoginFailLogSearch"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.search') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="fetchLoginFailLogs"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
|
:disabled="loginFailLogsLoading"
|
||||||
|
>
|
||||||
|
<IconRefresh :size="18" :class="{ 'animate-spin': loginFailLogsLoading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志列表 -->
|
||||||
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dk-muted">
|
||||||
|
<thead class="bg-gray-50 dark:bg-dk-base">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_user') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_reason') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_count') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_ip') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_updated_at') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_created') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dk-muted dark:bg-dk-card">
|
||||||
|
<tr v-if="loginFailLogsLoading" class="text-center">
|
||||||
|
<td colspan="6" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.loading') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="loginFailLogs.length === 0" class="text-center">
|
||||||
|
<td colspan="6" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.no_logs') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="log in loginFailLogs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dk-base">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="log.user_id > 0"
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(log.user_id)"
|
||||||
|
class="h-7 w-7 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<div v-else class="h-7 w-7 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-dk-text">
|
||||||
|
{{ log.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
getReasonClass(log.reason)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ formatReason(log.reason) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<span :class="[
|
||||||
|
'font-medium',
|
||||||
|
log.count >= 5 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-dk-text'
|
||||||
|
]">
|
||||||
|
{{ log.count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-500 dark:text-dk-subtle">{{ log.ip }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ new Date(log.updated_at).toLocaleString() }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ new Date(log.created_at).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.pagination', { current: loginFailLogPage, total: loginFailLogTotalPages }) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="onLoginFailLogPageChange(loginFailLogPage - 1)"
|
||||||
|
:disabled="loginFailLogPage <= 1 || loginFailLogsLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
<IconChevronLeft :size="16" /> {{ t('sysadmin.prev_page') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="onLoginFailLogPageChange(loginFailLogPage + 1)"
|
||||||
|
:disabled="loginFailLogPage >= loginFailLogTotalPages || loginFailLogsLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.next_page') }} <IconChevronRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import UsersTab from '@/views/sysadmin/UsersTab.vue'
|
||||||
|
import GroupsTab from '@/views/sysadmin/GroupsTab.vue'
|
||||||
|
import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
|
const activeTab = ref('users')
|
||||||
|
const sysAdmins = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 子组件 ref,用于主动调用其刷新方法
|
||||||
|
const usersTabRef = ref(null)
|
||||||
|
const groupsTabRef = ref(null)
|
||||||
|
const logsTabRef = ref(null)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'users', label: t('sysadmin.tab_users') },
|
||||||
|
{ id: 'groups', label: t('sysadmin.tab_groups') },
|
||||||
|
{ id: 'logs', label: t('sysadmin.tab_logs') },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function fetchSysAdmins() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.sysAdmins()
|
||||||
|
if (res.errCode === 0 && Array.isArray(res.data.sysAdmins)) {
|
||||||
|
sysAdmins.value = res.data.sysAdmins
|
||||||
|
res.data.sysAdmins.forEach(id => usersStore.fetchUser(id))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSysAdmins()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('sysadmin.title') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg bg-amber-100 px-3 py-1.5 dark:bg-amber-900/30">
|
||||||
|
<span class="text-amber-700 dark:text-amber-400">{{ t('sysadmin.admin_label') }}: {{ userStore.user?.Username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-dk-muted">
|
||||||
|
<nav class="-mb-px flex gap-6">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
:class="[
|
||||||
|
'border-b-2 px-1 pb-3 text-sm font-medium transition-colors',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-dk-subtle dark:hover:text-dk-text',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||||
|
<KeepAlive>
|
||||||
|
<UsersTab v-if="activeTab === 'users'" ref="usersTabRef" />
|
||||||
|
<GroupsTab v-else-if="activeTab === 'groups'" ref="groupsTabRef" />
|
||||||
|
<LogsTab v-else-if="activeTab === 'logs'" ref="logsTabRef" />
|
||||||
|
</KeepAlive>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SysAdmins List -->
|
||||||
|
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-4 dark:border-dk-muted dark:bg-dk-card">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('sysadmin.current_admins') }}</h3>
|
||||||
|
<button
|
||||||
|
@click="fetchSysAdmins"
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? t('sysadmin.loading') : t('sysadmin.refresh') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="adminId in sysAdmins"
|
||||||
|
:key="adminId"
|
||||||
|
class="flex items-center gap-2 rounded-full bg-amber-100 px-3 py-1 dark:bg-amber-900/30"
|
||||||
|
>
|
||||||
|
<img :src="usersStore.getAvatarUrlFromUserID(adminId)" class="w-5 h-5 rounded-full" alt="avatar" />
|
||||||
|
<span class="text-xs font-medium text-amber-800 dark:text-amber-400">
|
||||||
|
{{ usersStore.getUsernameFromUserID(adminId) || 'ID: ' + adminId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="sysAdmins.length === 0" class="text-sm text-gray-400 dark:text-dk-muted">
|
||||||
|
{{ t('sysadmin.no_admins') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onActivated } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { IconSearch, IconRefresh, IconChevronLeft, IconChevronRight } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
|
// 用户列表
|
||||||
|
const users = ref([])
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const userSearch = ref('')
|
||||||
|
const userPage = ref(1)
|
||||||
|
const userPageSize = ref(20)
|
||||||
|
const userTotal = ref(0)
|
||||||
|
const totalPages = computed(() => Math.ceil(userTotal.value / userPageSize.value))
|
||||||
|
|
||||||
|
// 用户详情弹窗
|
||||||
|
const showUserDetail = ref(false)
|
||||||
|
const userDetail = ref(null)
|
||||||
|
const userDetailInfo = ref(null)
|
||||||
|
const userDetailLoading = ref(false)
|
||||||
|
const newPassword = ref('')
|
||||||
|
const resetPasswordLoading = ref(false)
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getUsers({
|
||||||
|
page: userPage.value,
|
||||||
|
page_size: userPageSize.value,
|
||||||
|
search: userSearch.value,
|
||||||
|
})
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
users.value = res.data.users || []
|
||||||
|
userTotal.value = res.data.total || 0
|
||||||
|
userPage.value = res.data.page || 1
|
||||||
|
userPageSize.value = res.data.page_size || 20
|
||||||
|
users.value.forEach(u => usersStore.fetchUser(u.id))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
userPage.value = 1
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page) {
|
||||||
|
userPage.value = page
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUserDetail(user) {
|
||||||
|
userDetail.value = user
|
||||||
|
showUserDetail.value = true
|
||||||
|
userDetailLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getUserDetail(user.id)
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
userDetail.value = res.data.user || user
|
||||||
|
userDetailInfo.value = res.data.userinfo || null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
userDetailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserDetail() {
|
||||||
|
showUserDetail.value = false
|
||||||
|
userDetail.value = null
|
||||||
|
userDetailInfo.value = null
|
||||||
|
newPassword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetUserPassword() {
|
||||||
|
if (!newPassword.value || newPassword.value.length < 6) {
|
||||||
|
toast.warning(t('sysadmin.new_password_placeholder'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!userDetail.value) return
|
||||||
|
resetPasswordLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.resetUserPassword(userDetail.value.id, newPassword.value)
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
toast.success(t('message.change_ok'))
|
||||||
|
newPassword.value = ''
|
||||||
|
} else {
|
||||||
|
toast.error(res.raw?.err_msg || t('message.change_ok'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
resetPasswordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGender(gender) {
|
||||||
|
const map = { 'M': t('settings.male'), 'F': t('settings.female'), 'U': '-' }
|
||||||
|
return map[gender] || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fetchUsers })
|
||||||
|
|
||||||
|
onMounted(() => fetchUsers())
|
||||||
|
onActivated(() => fetchUsers())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.tab_users') }}</h2>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.total_users', { count: userTotal }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
v-model="userSearch"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('sysadmin.search_placeholder')"
|
||||||
|
class="w-full rounded-md border border-gray-300 py-2 pl-9 pr-4 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-base dark:text-dk-text"
|
||||||
|
@keyup.enter="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="onSearch"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.search') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="fetchUsers"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
|
:disabled="usersLoading"
|
||||||
|
>
|
||||||
|
<IconRefresh :size="18" :class="{ 'animate-spin': usersLoading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列表 -->
|
||||||
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dk-muted">
|
||||||
|
<thead class="bg-gray-50 dark:bg-dk-base">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_id') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_username') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_email') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_type') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_created_at') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_action') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dk-muted dark:bg-dk-card">
|
||||||
|
<tr v-if="usersLoading" class="text-center">
|
||||||
|
<td colspan="6" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.loading') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="users.length === 0" class="text-center">
|
||||||
|
<td colspan="6" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.no_users') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="user in users" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-dk-base">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-dk-text">{{ user.id }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(user.id)"
|
||||||
|
class="h-7 w-7 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-dk-text">
|
||||||
|
{{ usersStore.getUsernameFromUserID(user.id) || user.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ user.email }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
user.type === 'admin' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ user.type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ new Date(user.date).toLocaleString() }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<button @click="openUserDetail(user)" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{{ t('sysadmin.detail') }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.pagination', { current: userPage, total: totalPages }) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="onPageChange(userPage - 1)"
|
||||||
|
:disabled="userPage <= 1 || usersLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
<IconChevronLeft :size="16" /> {{ t('sysadmin.prev_page') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="onPageChange(userPage + 1)"
|
||||||
|
:disabled="userPage >= totalPages || usersLoading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.next_page') }} <IconChevronRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户详情弹窗 -->
|
||||||
|
<div
|
||||||
|
v-if="showUserDetail"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
@click.self="closeUserDetail"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-dk-card">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.user_detail') }}</h3>
|
||||||
|
<button
|
||||||
|
@click="closeUserDetail"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:text-dk-subtle dark:hover:text-dk-text"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="userDetailLoading" class="py-8 text-center text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="userDetail" class="space-y-4">
|
||||||
|
<!-- 用户头像和基本信息 -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(userDetail.id)"
|
||||||
|
class="h-16 w-16 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||||
|
{{ usersStore.getUsernameFromUserID(userDetail.id) || userDetail.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-dk-subtle">{{ userDetail.email }}</div>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
userDetail.type === 'admin' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ userDetail.type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-200 dark:border-dk-muted" />
|
||||||
|
|
||||||
|
<!-- 详细信息 -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.user_id') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetail.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.name') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetail.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.table_created_at') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ new Date(userDetail.date).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户扩展信息 -->
|
||||||
|
<template v-if="userDetailInfo">
|
||||||
|
<hr class="border-gray-200 dark:border-dk-muted" />
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.extended_info') }}</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.info_nickname') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetailInfo.username || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.info_remark') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetailInfo.firstname || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.birthday') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ formatDate(userDetailInfo.birthdate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.gender') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ formatGender(userDetailInfo.gender) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.region') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetailInfo.region || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.language') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-dk-text">{{ userDetailInfo.language || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改密码区域 -->
|
||||||
|
<div class="mt-4 space-y-3 border-t border-gray-200 pt-4 dark:border-dk-muted">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.reset_password') }}</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
:placeholder="t('sysadmin.new_password_placeholder')"
|
||||||
|
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-base dark:text-dk-text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="resetUserPassword"
|
||||||
|
:disabled="resetPasswordLoading || !newPassword"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ resetPasswordLoading ? t('sysadmin.resetting') : t('sysadmin.reset_password_btn') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closeUserDetail"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-text dark:hover:bg-dk-base"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.close') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -15,12 +15,12 @@ let packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|||||||
// 每次 build 时自动递增 patch 版本
|
// 每次 build 时自动递增 patch 版本
|
||||||
const isBuild = process.argv.includes("build");
|
const isBuild = process.argv.includes("build");
|
||||||
if (isBuild) {
|
if (isBuild) {
|
||||||
const parts = packageJson.version.split(".");
|
// const parts = packageJson.version.split(".");
|
||||||
const patch = Math.max(0, parseInt(parts[2] || "0", 10));
|
// const patch = Math.max(0, parseInt(parts[2] || "0", 10));
|
||||||
parts[2] = String(patch + 1);
|
// parts[2] = String(patch + 1);
|
||||||
packageJson.version = parts.join(".");
|
// packageJson.version = parts.join(".");
|
||||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8");
|
// writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8");
|
||||||
console.log(`[bump] version → ${packageJson.version}`);
|
// console.log(`[bump] version → ${packageJson.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
Reference in New Issue
Block a user