暂存
This commit is contained in:
parent
781416dc19
commit
746f5d85ec
185
docs/migration/781416d-backend-frontend-migration-inventory.md
Normal file
185
docs/migration/781416d-backend-frontend-migration-inventory.md
Normal file
@ -0,0 +1,185 @@
|
||||
# 迁移对照清单(merge commit `781416d`)
|
||||
|
||||
> 范围口径:仅统计 `781416d^1..781416d` 引入的变更,且只关注 `backend/` 与 `frontend/` 目录。
|
||||
> 迁移目标:在新工程 `java-backend/`(Spring Boot 3.2 + MyBatis-Plus + Flyway)与 `java-frontend/`(Vue3 + Vite + Ant Design Vue)实现等价功能。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览(按类别)
|
||||
|
||||
### 1.1 DB(Prisma → Flyway / Java 实体)
|
||||
|
||||
- **副本变更文件**
|
||||
- `backend/prisma/schema.prisma`(本次仅 1 行变更,但它是 DB 迁移的入口文件,需核对差异对应的表/字段)
|
||||
- **Java 目标**
|
||||
- `java-backend/src/main/resources/db/migration/`(新增增量脚本)
|
||||
- `java-backend/src/main/java/com/lesingle/creation/entity/`(实体字段对齐)
|
||||
- `java-backend/src/main/java/com/lesingle/creation/mapper/`(必要查询/统计 SQL、交互表读写)
|
||||
|
||||
### 1.2 API(后端接口变更/新增)
|
||||
|
||||
#### A. Analytics(租户端数据统计看板)
|
||||
|
||||
- **副本新增**
|
||||
- `backend/src/contests/analytics/analytics.controller.ts`
|
||||
- `backend/src/contests/analytics/analytics.service.ts`
|
||||
- `backend/src/contests/analytics/analytics.module.ts`
|
||||
- **接口**
|
||||
- `GET /api/analytics/overview`
|
||||
- `GET /api/analytics/review`
|
||||
- **设计依据**
|
||||
- `docs/design/org-admin/data-analytics-dashboard.md`
|
||||
- **Java 目标**
|
||||
- `java-backend/.../controller/AnalyticsController.java`
|
||||
- `java-backend/.../service/AnalyticsService.java`(+ impl)
|
||||
- `java-backend/.../mapper/AnalyticsMapper.java`(聚合统计 SQL:趋势/漏斗/对比/工作量/奖项分布)
|
||||
|
||||
#### B. Public Interaction(公众端点赞/收藏/交互状态/我的收藏)
|
||||
|
||||
- **副本新增**
|
||||
- `backend/src/public/interaction.service.ts`
|
||||
- **副本改动接入点**
|
||||
- `backend/src/public/public.controller.ts`
|
||||
- `backend/src/public/public.service.ts`
|
||||
- `backend/src/public/public.module.ts`
|
||||
- `backend/src/public/gallery.service.ts`
|
||||
- **接口(设计稿口径)**
|
||||
- `POST /api/public/works/:id/like`
|
||||
- `POST /api/public/works/:id/favorite`
|
||||
- `GET /api/public/works/:id/interaction`
|
||||
- `GET /api/public/mine/favorites`
|
||||
- **设计依据**
|
||||
- `docs/design/public/like-favorite.md`
|
||||
- **Java 目标**
|
||||
- `java-backend/.../controller/PublicInteractionController.java`(或并入现有 public controller)
|
||||
- `java-backend/.../service/PublicInteractionService.java`
|
||||
- `java-backend/.../mapper/UserWorkLikeMapper.java`、`UserWorkFavoriteMapper.java`
|
||||
- `java-backend/.../mapper/UserWorkMapper.java`(冗余计数 `like_count`/`favorite_count` 更新)
|
||||
- 事务:toggle 需要「记录表 + 冗余计数」同事务
|
||||
|
||||
#### C. Contest / Work / Review / Registration / Result / Notice 联动调整
|
||||
|
||||
- **副本改动文件**
|
||||
- `backend/src/contests/contests/contests.controller.ts`
|
||||
- `backend/src/contests/contests/contests.service.ts`
|
||||
- `backend/src/contests/registrations/registrations.controller.ts`
|
||||
- `backend/src/contests/registrations/registrations.service.ts`
|
||||
- `backend/src/contests/works/dto/query-work.dto.ts`
|
||||
- `backend/src/contests/works/works.service.ts`
|
||||
- `backend/src/contests/reviews/reviews.service.ts`
|
||||
- `backend/src/contests/results/dto/auto-set-awards.dto.ts`
|
||||
- `backend/src/contests/results/dto/set-award.dto.ts`
|
||||
- `backend/src/contests/results/results.service.ts`
|
||||
- `backend/src/contests/notices/dto/query-notice.dto.ts`
|
||||
- `backend/src/contests/notices/notices.service.ts`
|
||||
- **Java 目标**
|
||||
- 对齐到 `java-backend/.../controller/*Contest*.java`、`*Work*.java`、`*Review*.java`、`*Registration*.java`、`*Result*.java`、`*Notice*.java`
|
||||
- 重点关注:DTO 结构变化、筛选字段、批量操作、发布/撤销流程、进度统计口径
|
||||
|
||||
#### D. Content / Tag / Gallery / Content Review(超管端内容管理)
|
||||
|
||||
- **副本改动文件**
|
||||
- `backend/src/public/content-review.controller.ts`
|
||||
- `backend/src/public/content-review.service.ts`
|
||||
- `backend/src/public/tags.controller.ts`
|
||||
- `backend/src/public/tags.service.ts`
|
||||
- `backend/src/public/gallery.service.ts`(含推荐作品/广场联动)
|
||||
- **Java 目标**
|
||||
- `java-backend/.../controller/ContentReviewController.java`
|
||||
- `java-backend/.../controller/TagsController.java`
|
||||
- `java-backend/.../controller/GalleryController.java`(或 public/gallery controller)
|
||||
- 对齐:批量审核、撤销审核、推荐作品列表、标签颜色/排序/使用次数
|
||||
|
||||
#### E. Tenant / User(租户/用户联动修复)
|
||||
|
||||
- **副本改动文件**
|
||||
- `backend/src/tenants/tenants.controller.ts`
|
||||
- `backend/src/tenants/tenants.service.ts`
|
||||
- `backend/src/users/users.controller.ts`
|
||||
- `backend/src/users/users.service.ts`
|
||||
- **Java 目标**
|
||||
- `java-backend/.../controller/TenantController.java`、`UserController.java` 等
|
||||
- 重点关注:超管跨租户逻辑、403 修复、重置密码/查询条件差异
|
||||
|
||||
### 1.3 前端(页面 / API / 路由布局)
|
||||
|
||||
#### A. Analytics 页面与 API
|
||||
|
||||
- **副本新增**
|
||||
- `frontend/src/api/analytics.ts`
|
||||
- `frontend/src/views/analytics/Overview.vue`
|
||||
- `frontend/src/views/analytics/Review.vue`
|
||||
- **Java 前端目标**
|
||||
- `java-frontend/src/api/analytics.ts`
|
||||
- `java-frontend/src/views/analytics/Overview.vue`
|
||||
- `java-frontend/src/views/analytics/Review.vue`
|
||||
- `java-frontend/src/router/*` + 菜单系统接入「数据统计」
|
||||
|
||||
#### B. 公众端:点赞/收藏/我的收藏 + 布局响应式
|
||||
|
||||
- **副本新增/改动**
|
||||
- `frontend/src/views/public/mine/Favorites.vue`(新增:我的收藏)
|
||||
- `frontend/src/views/public/mine/Index.vue`(新增入口)
|
||||
- `frontend/src/views/public/Gallery.vue`(卡片支持点赞交互/推荐位联动)
|
||||
- `frontend/src/views/public/works/Detail.vue`(详情交互栏)
|
||||
- `frontend/src/layouts/PublicLayout.vue`(桌面端顶部导航等)
|
||||
- `frontend/src/api/public.ts`(新增 interaction API)
|
||||
- **Java 前端目标**
|
||||
- 同路径迁移到 `java-frontend/src/views/public/...`、`java-frontend/src/layouts/...`、`java-frontend/src/api/public.ts`
|
||||
|
||||
#### C. 机构端/超管端页面大规模优化(活动全链路 + 内容管理)
|
||||
|
||||
- **副本改动页面(高风险联动点)**
|
||||
- 内容管理:`frontend/src/views/content/TagManagement.vue`、`WorkManagement.vue`、`WorkReview.vue`
|
||||
- 活动管理:`frontend/src/views/contests/*`(Create/Index/judges/notices/registrations/results/reviews/works 等)
|
||||
- 系统:`frontend/src/views/system/tenants/Index.vue`、`system/users/Index.vue`、`system/tenant-info/Index.vue`(新增机构信息页)
|
||||
- 工作台:`frontend/src/views/workbench/TenantDashboard.vue`(新增租户端工作台首页)
|
||||
- 登录:`frontend/src/views/auth/Login.vue`(冲突修复)
|
||||
- **Java 前端目标**
|
||||
- 对应迁移到 `java-frontend/src/views/...`,并对齐与 Java 后端接口/权限点
|
||||
|
||||
### 1.4 菜单与路由(动态菜单/初始化同步)
|
||||
|
||||
- **副本后端菜单**
|
||||
- `backend/data/menus.json`(菜单注册:含「数据统计」「我的收藏」等入口变化)
|
||||
- `backend/scripts/init-menus.ts`(初始化同步逻辑变更)
|
||||
- **副本前端菜单/路由**
|
||||
- `frontend/src/router/index.ts`
|
||||
- `frontend/src/utils/menu.ts`
|
||||
- **Java 目标**
|
||||
- 如果 Java 前端菜单来自后端:对齐 `java-backend` 的菜单接口与初始化数据(或迁移 `menus.json` 到 Java 的菜单种子机制)
|
||||
- 如果 Java 前端本地生成菜单:对齐 `java-frontend` 的路由与 menu 构建逻辑
|
||||
|
||||
---
|
||||
|
||||
## 2. 迁移对照表(文件级)
|
||||
|
||||
> 说明:下面按文件列出“副本变更 → 迁移类别 → Java 目标 → 验收点”。
|
||||
> 状态:`TODO` 表示尚未迁移;完成迁移后在对应条目补充“已迁移”的 commit/说明(不要改动本文件的口径范围)。
|
||||
|
||||
| 副本变更文件 | 类别 | Java 目标(建议) | 验收点 |
|
||||
|---|---|---|---|
|
||||
| `backend/src/contests/analytics/analytics.controller.ts` | API | `java-backend/.../controller/AnalyticsController.java` | overview/review 两接口路径/参数/结构一致 |
|
||||
| `backend/src/contests/analytics/analytics.service.ts` | API/SQL | `java-backend/.../service/AnalyticsService*` + `AnalyticsMapper` | 指标口径一致,SQL 参数化且含 tenant 过滤 |
|
||||
| `backend/src/public/interaction.service.ts` | API/DB | `PublicInteractionService*` + like/favorite mapper | toggle 事务一致性、计数正确、并发安全 |
|
||||
| `backend/src/public/public.controller.ts` | API | public 端 controller(整合交互相关路由) | 路由与鉴权一致 |
|
||||
| `backend/src/public/gallery.service.ts` | API | gallery service/mapper | recommended 列表与交互状态返回一致 |
|
||||
| `backend/src/public/content-review.*` | API/DB | content review controller/service/mapper | 批量通过/拒绝/撤销,列表筛选一致 |
|
||||
| `backend/src/public/tags.*` | API/DB | tags controller/service/mapper | 标签颜色/排序/使用次数、CRUD 一致 |
|
||||
| `backend/prisma/schema.prisma` | DB | Flyway 脚本 + entity 对齐 | 表/字段/索引对齐(逻辑删除与租户字段) |
|
||||
| `backend/data/menus.json` | 菜单 | Java 菜单种子/初始化 | 菜单能驱动路由与权限展示 |
|
||||
| `frontend/src/api/analytics.ts` | 前端/API | `java-frontend/src/api/analytics.ts` | 请求路径/参数/响应适配 |
|
||||
| `frontend/src/views/analytics/*` | 前端 | `java-frontend/src/views/analytics/*` | 看板可正常加载、图表展示 |
|
||||
| `frontend/src/api/public.ts` | 前端/API | `java-frontend/src/api/public.ts` | like/favorite/interaction/favorites API 可用 |
|
||||
| `frontend/src/views/public/mine/Favorites.vue` | 前端 | `java-frontend/.../Favorites.vue` | 我的收藏可分页、跳转作品详情正常 |
|
||||
| `frontend/src/layouts/PublicLayout.vue` | 前端/布局 | `java-frontend/.../PublicLayout.vue` | 桌面端导航/响应式不回归 |
|
||||
| `frontend/src/router/index.ts`、`frontend/src/utils/menu.ts` | 路由/菜单 | `java-frontend` 对应路由与菜单构建 | 「数据统计」「我的收藏」入口可达、权限控制正确 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键注意事项(迁移时强约束)
|
||||
|
||||
- **多租户**:所有业务数据查询必须带 `tenantId`;超管可跨租户(Java 端以 `UserPrincipal` 判定)。
|
||||
- **统计 SQL 安全**:副本工程存在拼接 `IN (ids.join(','))` 的风险,Java 端必须用 MyBatis 动态 SQL 参数化。
|
||||
- **交互 toggle 事务**:点赞/收藏需要「记录表 + 冗余计数」同事务,并防止并发下计数异常。
|
||||
|
||||
40
docs/migration/default-role-matrix.md
Normal file
40
docs/migration/default-role-matrix.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 默认角色/菜单/权限 最小可用集(全租户)
|
||||
|
||||
本文件用于指导 Flyway “全租户补齐”迁移脚本的默认授权策略,目标是让任一租户创建后即可登录、看到菜单、并具备最小可用功能入口。
|
||||
|
||||
## 角色(role.code)
|
||||
- `super_admin`:平台超管(通常仅超级租户/运维使用)
|
||||
- `tenant_admin`:租户管理员(每个租户至少 1 个)
|
||||
- `school_admin`:学校管理端角色
|
||||
- `teacher`:教师端角色
|
||||
- `student`:学生端角色
|
||||
- `judge`:评委端角色
|
||||
|
||||
> 注:目前历史迁移仅对 `tenant_id=1` 初始化了 `school_admin/teacher/student/judge`,但线上实际登录已出现 `tenant_admin`,因此迁移需对全租户补齐该角色。
|
||||
|
||||
## 菜单可见性(t_auth_role_menu)
|
||||
|
||||
### tenant_admin(租户管理员)
|
||||
- **默认策略**:可见全部管理端菜单,但**不包含平台级租户管理**(避免非平台人员管理其他租户)。\n
|
||||
- ✅ 包含:除 `permission='super_admin'` 以及 `permission LIKE 'tenant:%'` 的菜单外的所有 `t_auth_menu`。\n
|
||||
- ✅ 祖先节点补齐:即使祖先菜单未直接授权,也应在接口层补齐返回。\n
|
||||
- ✅ 说明:最终可见菜单仍以 `t_auth_role_menu` 为准;如需更精细裁剪,可在系统中二次授权。
|
||||
|
||||
### school_admin / teacher / student / judge
|
||||
- **默认策略**:优先按 `tenant_id=1` 的同名角色授权模板复制到其他租户(同 role.code)。\n
|
||||
- 复制范围:`t_auth_role_menu` 中 `role_id` 对应 `tenant_id=1` 且 `role.code IN ('school_admin','teacher','student','judge')` 的记录。\n
|
||||
- 如果模板不存在,则降级为:仅授予 `workbench` + 与角色端相关的一级菜单(例如 `activities`/`homework`)。
|
||||
|
||||
## 权限(t_auth_role_permission)
|
||||
|
||||
### 权限来源
|
||||
- 以 `tenant_id=1` 为“权限码模板”:将 `t_auth_permission(tenant_id=1)` 中的权限码复制到所有租户(缺失则补齐)。\n
|
||||
- 原因:权限表按租户隔离,但权限码是全局统一的前后端契约;菜单/路由/按钮依赖这些 code。
|
||||
|
||||
### tenant_admin(建议最小集)
|
||||
- **系统管理(租户内)**:`user:*`、`role:*`、`permission:*`、`menu:*`、`dict:*`、`config:*`、`log:*`\n
|
||||
- **业务最小可用**:建议至少包含 `contest:*`、`registration:*`、`judge:*`、`notice:*`、`homework:*`、`work:*`、`activity:*`、`review:score`、以及前端提取的权限码(见 `docs/migration/permission-inventory.md`)。
|
||||
|
||||
### 其他角色
|
||||
- 默认按 `tenant_id=1` 角色模板复制 `t_auth_role_permission`;不存在模板时至少保证页面可进入(满足路由 `meta.permissions`)。\n
|
||||
|
||||
91
docs/migration/permission-inventory.md
Normal file
91
docs/migration/permission-inventory.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 前端权限码清单(自动提取)
|
||||
|
||||
来源:
|
||||
- `v-permission`(按钮/操作级)
|
||||
- 路由 `meta.permissions`(页面/路由级)
|
||||
- 逻辑校验 `hasPermission/hasAnyPermission`
|
||||
|
||||
提取脚本:`java-frontend/scripts/extract-perms.cjs`
|
||||
|
||||
## v-permission(按钮/操作)
|
||||
- `class:create`
|
||||
- `class:delete`
|
||||
- `class:update`
|
||||
- `config:create`
|
||||
- `config:delete`
|
||||
- `config:update`
|
||||
- `contest:create`
|
||||
- `contest:delete`
|
||||
- `contest:publish`
|
||||
- `contest:update`
|
||||
- `department:create`
|
||||
- `department:delete`
|
||||
- `department:update`
|
||||
- `dict:create`
|
||||
- `dict:delete`
|
||||
- `dict:update`
|
||||
- `grade:create`
|
||||
- `grade:delete`
|
||||
- `grade:update`
|
||||
- `homework:create`
|
||||
- `homework:delete`
|
||||
- `homework:read`
|
||||
- `homework:update`
|
||||
- `judge:create`
|
||||
- `judge:delete`
|
||||
- `judge:read`
|
||||
- `judge:update`
|
||||
- `log:delete`
|
||||
- `menu:create`
|
||||
- `menu:delete`
|
||||
- `menu:update`
|
||||
- `notice:create`
|
||||
- `notice:delete`
|
||||
- `notice:update`
|
||||
- `role:create`
|
||||
- `role:delete`
|
||||
- `role:update`
|
||||
- `school:create`
|
||||
- `school:update`
|
||||
- `student:create`
|
||||
- `student:delete`
|
||||
- `student:update`
|
||||
- `teacher:create`
|
||||
- `teacher:delete`
|
||||
- `teacher:update`
|
||||
- `tenant:create`
|
||||
- `tenant:delete`
|
||||
- `tenant:update`
|
||||
|
||||
## route.meta.permissions(页面/路由)
|
||||
- `activity:read`
|
||||
- `contest:create`
|
||||
- `contest:read`
|
||||
- `contest:update`
|
||||
- `homework:read`
|
||||
- `registration:read`
|
||||
- `review:score`
|
||||
- `work:read`
|
||||
|
||||
## logic checks(脚本/逻辑判断)
|
||||
- `ai-3d:create`
|
||||
- `class:create`
|
||||
- `class:delete`
|
||||
- `config:create`
|
||||
- `department:create`
|
||||
- `department:delete`
|
||||
- `dict:create`
|
||||
- `grade:create`
|
||||
- `grade:delete`
|
||||
- `menu:create`
|
||||
- `menu:read`
|
||||
- `permission:read`
|
||||
- `role:create`
|
||||
- `role:read`
|
||||
- `school:create`
|
||||
- `school:update`
|
||||
- `student:create`
|
||||
- `student:delete`
|
||||
- `teacher:create`
|
||||
- `teacher:delete`
|
||||
|
||||
456
docs/migration/permission-system.md
Normal file
456
docs/migration/permission-system.md
Normal file
@ -0,0 +1,456 @@
|
||||
# 超管端权限系统设计文档
|
||||
|
||||
> 整理日期:2026-04-01
|
||||
> 目的:为后端 Java 转写提供权限逻辑参考,涵盖机构管理、用户中心、系统设置三大模块
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
### 1.1 多租户 RBAC 模型
|
||||
|
||||
系统采用 **多租户 + 角色权限(RBAC)** 架构,核心数据模型关系如下:
|
||||
|
||||
```
|
||||
Tenant (租户)
|
||||
├── TenantMenu (M2M) ──→ Menu (菜单模板,全局共享)
|
||||
├── User (用户)
|
||||
│ └── UserRole (M2M) ──→ Role (角色,租户隔离)
|
||||
│ └── RolePermission (M2M) ──→ Permission (权限,租户隔离)
|
||||
└── Config (系统配置,租户隔离)
|
||||
```
|
||||
|
||||
### 1.2 核心模型字段
|
||||
|
||||
#### Tenant(租户)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Int (PK) | 主键 |
|
||||
| code | String (UNIQUE) | 租户编码,用于登录 URL |
|
||||
| domain | String (UNIQUE) | 子域名访问 |
|
||||
| isSuper | Int | 0=普通租户,1=超级租户 |
|
||||
| validState | Int | 1=启用,2=禁用 |
|
||||
| creator / modifier | String | 审计字段 |
|
||||
|
||||
#### User(用户)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Int (PK) | 主键 |
|
||||
| tenantId | Int (FK) | 所属租户 |
|
||||
| username | String | 租户内唯一 |
|
||||
| email / phone | String | 租户内唯一 |
|
||||
| userType | String | adult / child |
|
||||
| userSource | String | admin_created / self_registered / child_migrated |
|
||||
| validState | Int | 1=启用,2=禁用 |
|
||||
|
||||
#### Role(角色)—— 租户隔离
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Int (PK) | 主键 |
|
||||
| tenantId | Int (FK) | 所属租户 |
|
||||
| code | String | 租户内唯一,`super_admin` 为特殊角色 |
|
||||
| name | String | 角色名称 |
|
||||
|
||||
#### Permission(权限)—— 租户隔离
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Int (PK) | 主键 |
|
||||
| tenantId | Int (FK) | 所属租户 |
|
||||
| code | String | 权限码,格式:`{resource}:{action}` |
|
||||
| resource | String | 资源标识 |
|
||||
| action | String | 操作标识 |
|
||||
|
||||
唯一约束:`(tenantId, resource, action)`
|
||||
|
||||
#### Menu(菜单)—— 全局共享
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Int (PK) | 主键 |
|
||||
| parentId | Int | 父级菜单,支持多级嵌套 |
|
||||
| permission | String | 关联的权限码,控制前端可见性 |
|
||||
| validState | Int | 1=启用,2=禁用 |
|
||||
|
||||
#### TenantMenu(租户-菜单关联)—— 中间表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| tenantId | Int (FK) | 租户 ID |
|
||||
| menuId | Int (FK) | 菜单 ID |
|
||||
|
||||
---
|
||||
|
||||
## 2. 鉴权链路
|
||||
|
||||
每个请求依次经过四层守卫,任一层失败即拒绝:
|
||||
|
||||
```
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ① JwtAuthGuard │
|
||||
│ 校验 JWT Token,解出 {userId, tenantId} │
|
||||
│ 文件: auth/guards/jwt-auth.guard.ts │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ② TenantGuard │
|
||||
│ 提取租户上下文(优先级从高到低): │
|
||||
│ 1. Header: x-tenant-id(直接 ID) │
|
||||
│ 2. Header: x-tenant-code(按 code 查询) │
|
||||
│ 3. 子域名解析 │
|
||||
│ 4. JWT 中的 tenantId │
|
||||
│ 校验:租户存在 && validState = 1 │
|
||||
│ 注入:request.tenantId, request.tenant │
|
||||
│ 文件: tenants/guards/tenant.guard.ts │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ③ RolesGuard(可选,按需装饰) │
|
||||
│ @Roles('super_admin', 'tenant_admin') │
|
||||
│ OR 逻辑:用户拥有任一指定角色即通过 │
|
||||
│ 文件: auth/guards/roles.guard.ts │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ④ PermissionsGuard(可选,按需装饰) │
|
||||
│ @RequirePermission('tenant:create') │
|
||||
│ OR 逻辑:用户拥有任一指定权限即通过 │
|
||||
│ ⚠️ 重要:super_admin 角色直接放行, │
|
||||
│ 不检查具体权限 │
|
||||
│ 文件: auth/guards/permissions.guard.ts │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 权限解析路径
|
||||
|
||||
```
|
||||
User → UserRole → Role
|
||||
├── Role.code == 'super_admin' → 跳过权限检查,直接放行
|
||||
└── RolePermission → Permission.code → 与接口要求的权限码比对
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块一:机构管理
|
||||
|
||||
### 3.1 文件位置
|
||||
|
||||
| 层级 | 文件路径 |
|
||||
|------|----------|
|
||||
| Controller | `backend/src/tenants/tenants.controller.ts` |
|
||||
| Service | `backend/src/tenants/tenants.service.ts` |
|
||||
| 前端页面 | `frontend/src/views/system/tenants/Index.vue` |
|
||||
| 前端 API | `frontend/src/api/tenants.ts` |
|
||||
|
||||
### 3.2 接口权限矩阵
|
||||
|
||||
| 接口路径 | 方法 | 权限码 | 额外校验 |
|
||||
|----------|------|--------|----------|
|
||||
| `/tenants` | GET | `tenant:read` | 列表自动排除内部租户 |
|
||||
| `/tenants` | POST | `tenant:create` | `checkSuperTenant()` |
|
||||
| `/tenants/:id` | GET | `tenant:read` | — |
|
||||
| `/tenants/:id` | PATCH | `tenant:update` | `checkSuperTenant()` |
|
||||
| `/tenants/:id/status` | PATCH | `tenant:update` | `checkSuperTenant()` + 禁止禁用超级租户自身 |
|
||||
| `/tenants/:id/menus` | GET | `tenant:read` | 查看该租户已分配的菜单树 |
|
||||
| `/tenants/:id` | DELETE | `tenant:delete` | `checkSuperTenant()` + 禁止删除超级租户 |
|
||||
|
||||
### 3.3 核心逻辑
|
||||
|
||||
#### checkSuperTenant()
|
||||
|
||||
所有写操作(创建、编辑、删除、启禁用)前,必须校验当前登录用户所属租户的 `isSuper = 1`。非超级租户用户无法执行任何机构管理写操作。
|
||||
|
||||
```
|
||||
伪代码:
|
||||
function checkSuperTenant(tenantId):
|
||||
tenant = findById(tenantId)
|
||||
if tenant.isSuper != 1:
|
||||
throw ForbiddenException("仅超级租户可执行此操作")
|
||||
```
|
||||
|
||||
#### 内部租户隐藏
|
||||
|
||||
列表查询自动排除以下 code 的租户,这些是系统内部使用的特殊租户:
|
||||
- `super` — 超级租户
|
||||
- `public` — 公众端
|
||||
- `school` — 学校
|
||||
- `teacher` — 教师
|
||||
- `student` — 学生
|
||||
- `judge` — 评委
|
||||
|
||||
#### 菜单分配
|
||||
|
||||
创建或编辑机构时,可通过 `TenantMenu` 中间表选择该机构可使用的菜单。前端以 checkbox 树形组件展示所有菜单模板供勾选。
|
||||
|
||||
#### 安全兜底
|
||||
|
||||
- 禁止禁用超级租户自身(防止系统锁死)
|
||||
- 禁止删除超级租户
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块二:用户中心
|
||||
|
||||
### 4.1 文件位置
|
||||
|
||||
| 层级 | 文件路径 |
|
||||
|------|----------|
|
||||
| Controller | `backend/src/users/users.controller.ts` |
|
||||
| Service | `backend/src/users/users.service.ts` |
|
||||
| 前端页面 | `frontend/src/views/system/users/Index.vue` |
|
||||
| 前端 API | `frontend/src/api/users.ts` |
|
||||
|
||||
### 4.2 用户分类(超管跨租户视角)
|
||||
|
||||
超管端通过 `userType` 查询参数,按租户类型将用户分为四类:
|
||||
|
||||
| userType 参数值 | 租户筛选条件 | 含义 |
|
||||
|----------------|-------------|------|
|
||||
| `platform` | `Tenant.isSuper = 1` | 运营团队(超级租户下的用户) |
|
||||
| `org` | `Tenant.isSuper = 0 AND code NOT IN ('public', 'judge')` | 机构用户 |
|
||||
| `judge` | `Tenant.code = 'judge'` | 评委用户 |
|
||||
| `public` | `Tenant.code = 'public'` | 公众用户 |
|
||||
|
||||
对应实现:`users.service.ts` 的 `buildTenantConditionByUserType()` 方法。
|
||||
|
||||
### 4.3 接口权限说明
|
||||
|
||||
用户中心接口**未使用 `@RequirePermission` 装饰器**,权限校验在 Service 层隐式完成:
|
||||
|
||||
| 操作 | 权限控制方式 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 跨租户查看用户列表 | Service 层判断 `isSuper` | 仅超级租户用户可跨租户查询 |
|
||||
| 用户统计 | Service 层判断 `isSuper` | 仅超级租户用户 |
|
||||
| 创建用户 | Service 层校验 | 超管可在任意租户创建用户 |
|
||||
| 编辑用户 | Service 层校验 | 超管可编辑任意租户的用户 |
|
||||
| 启/禁用用户 | Service 层校验 | 禁止禁用租户内最后一个管理员 |
|
||||
| 删除用户 | Service 层校验 | 超管可删除任意租户用户 |
|
||||
| 分配角色 | 创建/编辑时指定 | 角色来源:目标用户所属租户的角色列表 |
|
||||
|
||||
### 4.4 核心逻辑
|
||||
|
||||
#### 跨租户查询
|
||||
|
||||
超级租户用户(`isSuper = 1`)可查看所有租户的用户,普通租户用户仅能查看和管理本租户用户。
|
||||
|
||||
#### 账号保护
|
||||
|
||||
禁止禁用某个租户内的最后一个管理员账号,防止该租户被锁死无法登录管理。
|
||||
|
||||
```
|
||||
伪代码:
|
||||
function disableUser(userId):
|
||||
user = findById(userId)
|
||||
adminCount = countAdminsInTenant(user.tenantId)
|
||||
if adminCount <= 1 && user.isAdmin:
|
||||
throw BadRequestException("不能禁用租户内最后一个管理员")
|
||||
```
|
||||
|
||||
#### 角色分配
|
||||
|
||||
创建或编辑用户时,角色选择列表来源于**目标用户所属租户**的角色,而非当前操作者所属租户。即超管在给某机构创建用户时,可分配的角色是该机构下的角色。
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块三:系统设置
|
||||
|
||||
系统设置包含多个子模块,权限策略各不相同。
|
||||
|
||||
### 5.1 菜单管理
|
||||
|
||||
| 层级 | 文件路径 |
|
||||
|------|----------|
|
||||
| Controller | `backend/src/menus/menus.controller.ts` |
|
||||
| Service | `backend/src/menus/menus.service.ts` |
|
||||
| 前端页面 | `frontend/src/views/system/menus/Index.vue` |
|
||||
|
||||
**特点:菜单是全局模板,不按租户隔离,仅超管可管理。**
|
||||
|
||||
#### 用户菜单加载逻辑(findUserMenus)
|
||||
|
||||
```
|
||||
输入:userId, tenantId
|
||||
│
|
||||
├── 用户角色包含 super_admin?
|
||||
│ ├── 是 → 返回所有有效菜单
|
||||
│ └── 否 ↓
|
||||
│
|
||||
├── 获取用户所有权限码列表
|
||||
│ User → UserRole → Role → RolePermission → Permission.code
|
||||
│
|
||||
├── 获取租户已分配的菜单 ID 列表
|
||||
│ TenantMenu WHERE tenantId = ?
|
||||
│
|
||||
└── 过滤逻辑:
|
||||
菜单在租户分配范围内
|
||||
AND (菜单无 permission 字段 OR 用户拥有该 permission)
|
||||
AND (菜单有 path OR 存在可见的子菜单)
|
||||
```
|
||||
|
||||
### 5.2 角色管理
|
||||
|
||||
| 层级 | 文件路径 |
|
||||
|------|----------|
|
||||
| Controller | `backend/src/roles/roles.controller.ts` |
|
||||
| Service | `backend/src/roles/roles.service.ts` |
|
||||
| 前端页面 | `frontend/src/views/system/roles/Index.vue` |
|
||||
|
||||
**特点:角色按租户隔离,所有操作限定在当前租户内。**
|
||||
|
||||
| 操作 | 守卫 | 说明 |
|
||||
|------|------|------|
|
||||
| 查看角色列表 | JwtAuthGuard | 仅返回当前租户的角色 |
|
||||
| 创建角色 | JwtAuthGuard | 在当前租户内创建 |
|
||||
| 编辑角色 | JwtAuthGuard | 更新基本信息 + RolePermission 关联 |
|
||||
| 删除角色 | JwtAuthGuard | 同时清理 UserRole、RolePermission 关联记录 |
|
||||
|
||||
### 5.3 权限管理
|
||||
|
||||
| 层级 | 文件路径 |
|
||||
|------|----------|
|
||||
| Controller | `backend/src/permissions/permissions.controller.ts` |
|
||||
| Service | `backend/src/permissions/permissions.service.ts` |
|
||||
| 前端页面 | `frontend/src/views/system/permissions/Index.vue` |
|
||||
|
||||
**特点:仅 `super_admin` 角色可创建/编辑/删除权限。**
|
||||
|
||||
| 操作 | 守卫 | 角色要求 |
|
||||
|------|------|---------|
|
||||
| 查看权限列表 | JwtAuthGuard | 所有登录用户可查看 |
|
||||
| 创建权限 | `@Roles('super_admin')` | 仅 super_admin |
|
||||
| 编辑权限 | `@Roles('super_admin')` | 仅 super_admin |
|
||||
| 删除权限 | `@Roles('super_admin')` | 仅 super_admin |
|
||||
|
||||
#### 权限码规范
|
||||
|
||||
格式:`{resource}:{action}`
|
||||
|
||||
| 资源 | 权限码 |
|
||||
|------|--------|
|
||||
| 租户 | `tenant:create` `tenant:read` `tenant:update` `tenant:delete` |
|
||||
| 用户 | `user:create` `user:read` `user:update` `user:delete` `user:password:update` |
|
||||
| 角色 | `role:create` `role:read` `role:update` `role:delete` `role:assign` |
|
||||
| 菜单 | `menu:create` `menu:read` `menu:update` `menu:delete` |
|
||||
| 字典 | `dict:create` `dict:read` `dict:update` `dict:delete` |
|
||||
| 配置 | `config:create` `config:read` `config:update` `config:delete` |
|
||||
| 日志 | `log:read` `log:delete` |
|
||||
| 活动 | `contest:*` |
|
||||
| 报名 | `registration:*` |
|
||||
| 作品 | `work:*` |
|
||||
| 评审 | `review:*` |
|
||||
| 成果 | `result:*` |
|
||||
| 公告 | `notice:*` |
|
||||
|
||||
### 5.4 其他超管专属子模块
|
||||
|
||||
| 子模块 | 权限码前缀 | 说明 |
|
||||
|--------|-----------|------|
|
||||
| 数据字典 | `dict:*` | 全局字典维护 |
|
||||
| 系统配置 | `config:*` | 按租户隔离的配置项 |
|
||||
| 日志记录 | `log:*` | 系统操作日志 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端权限控制
|
||||
|
||||
### 6.1 Auth Store
|
||||
|
||||
文件:`frontend/src/stores/auth.ts`
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `isSuperAdmin()` | 判断当前用户角色列表是否包含 `super_admin` |
|
||||
| `hasPermission(code)` | super_admin 直接返回 true;否则检查 `user.permissions[]` |
|
||||
| `hasAnyPermission(codes[])` | OR 逻辑,满足任一权限即可 |
|
||||
| `hasRole(role)` | 检查角色列表 |
|
||||
| `fetchUserMenus()` | 调用 `GET /menus/user-menus`,后端按权限过滤后返回菜单树 |
|
||||
|
||||
### 6.2 权限指令 v-permission
|
||||
|
||||
文件:`frontend/src/directives/permission.ts`
|
||||
|
||||
```html
|
||||
<!-- 默认模式:无权限则禁用按钮(灰显不可点击) -->
|
||||
<a-button v-permission="'tenant:create'">新建机构</a-button>
|
||||
|
||||
<!-- hide 模式:无权限则隐藏元素(display:none) -->
|
||||
<a-button v-permission.hide="'tenant:delete'">删除</a-button>
|
||||
|
||||
<!-- 多权限 OR:满足任一权限即可 -->
|
||||
<a-button v-permission="['tenant:update', 'tenant:delete']">操作</a-button>
|
||||
|
||||
<!-- 多权限 AND:必须同时满足所有权限 -->
|
||||
<a-button v-permission.all="['role:create', 'permission:assign']">高级操作</a-button>
|
||||
```
|
||||
|
||||
| 修饰符 | 行为 |
|
||||
|--------|------|
|
||||
| (无) | 禁用元素,灰显不可点击 |
|
||||
| `.hide` | 从 DOM 隐藏 |
|
||||
| `.all` | 要求满足全部权限(默认为 OR) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 菜单可见性分配
|
||||
|
||||
### 超级租户可见菜单
|
||||
|
||||
- 活动监管
|
||||
- 内容管理
|
||||
- 活动管理
|
||||
- 机构管理
|
||||
- 用户中心
|
||||
- 系统设置(全部子项)
|
||||
- 我的评审
|
||||
|
||||
### 普通租户默认可见菜单
|
||||
|
||||
- 工作台
|
||||
- 学校管理
|
||||
- 我的评审
|
||||
- 活动管理
|
||||
- 系统设置(仅:用户管理、角色管理)
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据初始化脚本
|
||||
|
||||
位于 `backend/scripts/`,Java 端需等价实现:
|
||||
|
||||
| 脚本 | 执行顺序 | 功能 |
|
||||
|------|---------|------|
|
||||
| `init-menus.ts` | 1 | 从 `data/menus.json` 创建菜单树,超级租户分配全部菜单 |
|
||||
| `init-super-tenant.ts` | 2 | 创建超级租户(code=super, isSuper=1)+ 管理员账号 |
|
||||
| `init-admin-permissions.ts` | 3 | 创建全部权限记录(~90+),赋给 super_admin 角色 |
|
||||
|
||||
### 初始超管账号
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 租户 Code | `super` |
|
||||
| 用户名 | `admin` |
|
||||
| 密码 | `admin@super`(bcrypt 加密,10 轮 salt) |
|
||||
| 角色 | `super_admin` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Java 转写注意事项
|
||||
|
||||
| 序号 | 要点 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | **super_admin 跳过权限检查** | PermissionsGuard 中判断角色包含 `super_admin` 即放行,不查权限表。Java 端的拦截器/AOP 需等价实现 |
|
||||
| 2 | **租户上下文必须注入每个请求** | TenantGuard 从 header → subdomain → JWT 多来源提取 tenantId,校验租户有效后注入请求上下文 |
|
||||
| 3 | **checkSuperTenant 是额外校验** | 机构管理的写接口除了权限码校验外,还需 Service 层验证操作者所属租户 `isSuper=1` |
|
||||
| 4 | **菜单全局 + TenantMenu 分配** | Menu 表不带 tenantId,通过 TenantMenu 中间表控制各租户的菜单范围 |
|
||||
| 5 | **权限和角色按租户隔离** | Permission 和 Role 表都有 tenantId,查询时必须带租户条件 |
|
||||
| 6 | **用户中心无装饰器权限** | 用户管理接口未使用 `@RequirePermission`,权限在 Service 层隐式判断,Java 端需在 Service 层实现相同逻辑 |
|
||||
| 7 | **账号保护** | 禁止禁用租户最后一个管理员、禁止删除/禁用超级租户自身 |
|
||||
| 8 | **密码加密** | bcrypt,10 轮 salt,Java 端使用 `BCryptPasswordEncoder` |
|
||||
| 9 | **权限码格式** | 统一使用 `resource:action` 格式,与前端指令对应 |
|
||||
| 10 | **菜单加载有 super_admin 快速路径** | super_admin 跳过 TenantMenu 和 permission 过滤,直接返回所有菜单 |
|
||||
159
docs/migration/rbac-audit.sql
Normal file
159
docs/migration/rbac-audit.sql
Normal file
@ -0,0 +1,159 @@
|
||||
-- =========================================================
|
||||
-- RBAC/菜单/权限 数据审计(只读)
|
||||
-- 目标:快速定位“已授权但菜单为空 / 权限缺失 / 绑定缺失”等问题
|
||||
-- 说明:不做任何写操作,可在生产环境安全执行
|
||||
-- =========================================================
|
||||
|
||||
-- 0) 基础:租户列表
|
||||
SELECT id, code, name, tenant_type, is_super, valid_state, deleted
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0
|
||||
ORDER BY id;
|
||||
|
||||
-- 1) 角色存在但未做菜单授权(role_menu 为空)
|
||||
-- 重点:tenant_admin / school_admin / teacher / student / judge 等关键角色
|
||||
SELECT
|
||||
r.tenant_id,
|
||||
t.code AS tenant_code,
|
||||
r.id AS role_id,
|
||||
r.code AS role_code,
|
||||
r.name AS role_name
|
||||
FROM t_auth_role r
|
||||
JOIN t_sys_tenant t ON t.id = r.tenant_id AND t.deleted = 0
|
||||
LEFT JOIN t_auth_role_menu rm ON rm.role_id = r.id
|
||||
WHERE r.deleted = 0
|
||||
AND r.valid_state = 1
|
||||
GROUP BY r.tenant_id, t.code, r.id, r.code, r.name
|
||||
HAVING COUNT(rm.id) = 0
|
||||
ORDER BY r.tenant_id, r.code;
|
||||
|
||||
-- 2) 菜单表中声明的 permission 在某些租户的 permission 表缺失
|
||||
-- 由于 t_auth_menu 无 tenant_id,permission code 应在每个租户的 t_auth_permission 中都存在(除 super_admin 这种全局码也同理)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
t.code AS tenant_code,
|
||||
m.permission AS menu_permission_code
|
||||
FROM t_sys_tenant t
|
||||
JOIN (
|
||||
SELECT DISTINCT permission
|
||||
FROM t_auth_menu
|
||||
WHERE deleted = 0
|
||||
AND valid_state = 1
|
||||
AND permission IS NOT NULL
|
||||
AND permission <> ''
|
||||
) m ON 1 = 1
|
||||
LEFT JOIN t_auth_permission p
|
||||
ON p.tenant_id = t.id
|
||||
AND p.code = m.permission
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
WHERE t.deleted = 0
|
||||
AND t.valid_state = 1
|
||||
AND p.id IS NULL
|
||||
ORDER BY t.id, m.permission;
|
||||
|
||||
-- 3) 关键前端权限码在各租户 permission 表缺失
|
||||
-- 来自 docs/migration/permission-inventory.md(前端提取)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
t.code AS tenant_code,
|
||||
req.code AS required_code
|
||||
FROM t_sys_tenant t
|
||||
JOIN (
|
||||
SELECT 'activity:read' AS code UNION ALL
|
||||
SELECT 'ai-3d:create' UNION ALL
|
||||
SELECT 'class:create' UNION ALL
|
||||
SELECT 'class:delete' UNION ALL
|
||||
SELECT 'class:update' UNION ALL
|
||||
SELECT 'config:create' UNION ALL
|
||||
SELECT 'config:delete' UNION ALL
|
||||
SELECT 'config:update' UNION ALL
|
||||
SELECT 'contest:create' UNION ALL
|
||||
SELECT 'contest:delete' UNION ALL
|
||||
SELECT 'contest:publish' UNION ALL
|
||||
SELECT 'contest:read' UNION ALL
|
||||
SELECT 'contest:update' UNION ALL
|
||||
SELECT 'department:create' UNION ALL
|
||||
SELECT 'department:delete' UNION ALL
|
||||
SELECT 'department:update' UNION ALL
|
||||
SELECT 'dict:create' UNION ALL
|
||||
SELECT 'dict:delete' UNION ALL
|
||||
SELECT 'dict:update' UNION ALL
|
||||
SELECT 'grade:create' UNION ALL
|
||||
SELECT 'grade:delete' UNION ALL
|
||||
SELECT 'grade:update' UNION ALL
|
||||
SELECT 'homework:create' UNION ALL
|
||||
SELECT 'homework:delete' UNION ALL
|
||||
SELECT 'homework:read' UNION ALL
|
||||
SELECT 'homework:update' UNION ALL
|
||||
SELECT 'judge:create' UNION ALL
|
||||
SELECT 'judge:delete' UNION ALL
|
||||
SELECT 'judge:read' UNION ALL
|
||||
SELECT 'judge:update' UNION ALL
|
||||
SELECT 'log:delete' UNION ALL
|
||||
SELECT 'menu:create' UNION ALL
|
||||
SELECT 'menu:delete' UNION ALL
|
||||
SELECT 'menu:read' UNION ALL
|
||||
SELECT 'menu:update' UNION ALL
|
||||
SELECT 'notice:create' UNION ALL
|
||||
SELECT 'notice:delete' UNION ALL
|
||||
SELECT 'notice:update' UNION ALL
|
||||
SELECT 'permission:read' UNION ALL
|
||||
SELECT 'registration:read' UNION ALL
|
||||
SELECT 'review:score' UNION ALL
|
||||
SELECT 'role:create' UNION ALL
|
||||
SELECT 'role:delete' UNION ALL
|
||||
SELECT 'role:read' UNION ALL
|
||||
SELECT 'role:update' UNION ALL
|
||||
SELECT 'school:create' UNION ALL
|
||||
SELECT 'school:update' UNION ALL
|
||||
SELECT 'student:create' UNION ALL
|
||||
SELECT 'student:delete' UNION ALL
|
||||
SELECT 'student:update' UNION ALL
|
||||
SELECT 'teacher:create' UNION ALL
|
||||
SELECT 'teacher:delete' UNION ALL
|
||||
SELECT 'teacher:update' UNION ALL
|
||||
SELECT 'tenant:create' UNION ALL
|
||||
SELECT 'tenant:delete' UNION ALL
|
||||
SELECT 'tenant:update' UNION ALL
|
||||
SELECT 'work:read'
|
||||
) req ON 1 = 1
|
||||
LEFT JOIN t_auth_permission p
|
||||
ON p.tenant_id = t.id
|
||||
AND p.code = req.code
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
WHERE t.deleted = 0
|
||||
AND t.valid_state = 1
|
||||
AND p.id IS NULL
|
||||
ORDER BY t.id, req.code;
|
||||
|
||||
-- 4) 角色拥有菜单,但缺少对应 permission(可能导致“菜单看得到但点进去 403 / 按钮缺失”)
|
||||
-- 规则:若菜单 permission 非空,则角色至少要拥有该 permission(role_permission)
|
||||
SELECT
|
||||
r.tenant_id,
|
||||
t.code AS tenant_code,
|
||||
r.id AS role_id,
|
||||
r.code AS role_code,
|
||||
m.id AS menu_id,
|
||||
m.name AS menu_name,
|
||||
m.permission AS menu_permission_code
|
||||
FROM t_auth_role r
|
||||
JOIN t_sys_tenant t ON t.id = r.tenant_id AND t.deleted = 0
|
||||
JOIN t_auth_role_menu rm ON rm.role_id = r.id
|
||||
JOIN t_auth_menu m ON m.id = rm.menu_id AND m.deleted = 0 AND m.valid_state = 1
|
||||
LEFT JOIN t_auth_permission p
|
||||
ON p.tenant_id = r.tenant_id
|
||||
AND p.code = m.permission
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
LEFT JOIN t_auth_role_permission rp
|
||||
ON rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
WHERE r.deleted = 0
|
||||
AND r.valid_state = 1
|
||||
AND m.permission IS NOT NULL
|
||||
AND m.permission <> ''
|
||||
AND (p.id IS NULL OR rp.id IS NULL)
|
||||
ORDER BY r.tenant_id, r.code, m.id;
|
||||
|
||||
54
docs/migration/verify-matrix.md
Normal file
54
docs/migration/verify-matrix.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 验收矩阵(菜单 × 按钮 × 接口)
|
||||
|
||||
目标:验证“菜单可见性(role_menu)”“按钮显示(v-permission)”“接口鉴权(@PreAuthorize)”三者一致,且多租户不串台。
|
||||
|
||||
## 预置条件
|
||||
- 执行 Flyway 迁移:`V32__rbac_align_all_tenants.sql`、`V33__rbac_permission_code_aliases_and_menu_fix.sql`
|
||||
- 确认每个租户至少存在角色:`tenant_admin`(以及按需:`school_admin/teacher/student/judge`)
|
||||
- 给测试用户绑定目标租户与角色(`t_auth_user_role`)
|
||||
|
||||
## 用例
|
||||
|
||||
### A. 菜单可见性(GET /api/menus/user-menus)
|
||||
- **tenant_admin**:登录后菜单应非空;不应出现“只剩空白页面/无菜单”。\n
|
||||
- **school_admin/teacher/student/judge**:登录后菜单与 tenant_id=1 模板一致(允许租户裁剪)。\n
|
||||
- **租户隔离**:同一 userId 若历史绑定了其他租户角色,仍只返回当前 token 的 tenantId 对应角色菜单。\n
|
||||
|
||||
### B. 路由访问(路由 meta.permissions)
|
||||
抽查以下路由(以实际菜单为准):\n
|
||||
- `contests/create`:需 `contest:create`\n
|
||||
- `contests/:id`:需 `contest:read` 或 `activity:read`\n
|
||||
- `analytics/overview`:需 `contest:read`\n
|
||||
- `activities/review/:id`:需 `review:score`(兼容 `contest:review:score`)\n
|
||||
|
||||
期望:\n
|
||||
- **有权限**:可进入页面\n
|
||||
- **无权限**:跳转到 `403`\n
|
||||
|
||||
### C. 按钮显示(v-permission)
|
||||
抽查页面:\n
|
||||
- 活动列表:`contest:create`、`contest:update`、`contest:delete`、`contest:publish`\n
|
||||
- 评委管理:`judge:create/read/update/delete`\n
|
||||
- 公告管理:`notice:create/update/delete`\n
|
||||
- 系统管理:`role:*`、`menu:*`、`dict:*`、`config:*`、`tenant:*`(平台超管)\n
|
||||
|
||||
期望:\n
|
||||
- **有权限**:按钮可见且可用\n
|
||||
- **无权限**:按钮不可见(或禁用,按业务约定)\n
|
||||
|
||||
### D. 接口鉴权(后端 @PreAuthorize)
|
||||
抽查接口:\n
|
||||
- 公告:`POST/PUT/DELETE /api/contests/notices...` 需 `notice:*`(兼容 `contest:notice:*`)\n
|
||||
- 评委:`POST/PUT/DELETE /api/contests/judges...` 需 `judge:*`(兼容 `contest:judge:*`)\n
|
||||
- 评分:`POST /api/contests/reviews/score` 需 `review:score`(兼容 `contest:review:score`)\n
|
||||
|
||||
期望:\n
|
||||
- **有权限**:200\n
|
||||
- **无权限**:403\n
|
||||
|
||||
## 常用排查
|
||||
- 执行只读审计:`docs/migration/rbac-audit.sql`\n
|
||||
- 角色无菜单授权(role_menu 为空)\n
|
||||
- 菜单 permission 在租户权限表缺失\n
|
||||
- 角色有菜单但缺少对应 permission 绑定\n
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
package com.lesingle.creation.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* AI3D 生成类型枚举
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AI3DGenerateTypeEnum {
|
||||
|
||||
NORMAL("Normal", "带纹理"),
|
||||
GEOMETRY("Geometry", "白模"),
|
||||
LOW_POLY("LowPoly", "低多边形"),
|
||||
SKETCH("Sketch", "草图");
|
||||
|
||||
private final String code;
|
||||
private final String desc;
|
||||
|
||||
public static AI3DGenerateTypeEnum getByCode(String code) {
|
||||
if (code == null) {
|
||||
return NORMAL;
|
||||
}
|
||||
for (AI3DGenerateTypeEnum type : values()) {
|
||||
if (type.getCode().equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return NORMAL;
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
package com.lesingle.creation.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* AI3D 任务状态枚举
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AI3DTaskStatusEnum {
|
||||
|
||||
PENDING("pending", "待处理"),
|
||||
PROCESSING("processing", "处理中"),
|
||||
COMPLETED("completed", "已完成"),
|
||||
FAILED("failed", "失败"),
|
||||
TIMEOUT("timeout", "超时");
|
||||
|
||||
private final String code;
|
||||
private final String desc;
|
||||
|
||||
public static AI3DTaskStatusEnum getByCode(String code) {
|
||||
if (code == null) {
|
||||
return PENDING;
|
||||
}
|
||||
for (AI3DTaskStatusEnum status : values()) {
|
||||
if (status.getCode().equals(code)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return PENDING;
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
package com.lesingle.creation.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.lesingle.creation.common.core.Result;
|
||||
import com.lesingle.creation.common.security.UserPrincipal;
|
||||
import com.lesingle.creation.dto.ai3d.AI3DTaskQueryDTO;
|
||||
import com.lesingle.creation.dto.ai3d.CreateAI3DTaskDTO;
|
||||
import com.lesingle.creation.service.AI3DTaskService;
|
||||
import com.lesingle.creation.vo.ai3d.AI3DTaskVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* AI 3D 生成控制器
|
||||
*/
|
||||
@Tag(name = "AI 3D 生成")
|
||||
@RestController
|
||||
@RequestMapping("/api/ai-3d")
|
||||
@RequiredArgsConstructor
|
||||
public class AI3DTaskController {
|
||||
|
||||
private final AI3DTaskService ai3dTaskService;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建 AI 3D 任务")
|
||||
public Result<AI3DTaskVO> create(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestBody @Validated CreateAI3DTaskDTO dto) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
Long userId = userPrincipal.getUserId();
|
||||
AI3DTaskVO result = ai3dTaskService.create(dto, tenantId, userId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取任务详情")
|
||||
public Result<AI3DTaskVO> getDetail(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long id) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
AI3DTaskVO result = ai3dTaskService.getDetail(id, tenantId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询任务列表")
|
||||
public Result<Page<AI3DTaskVO>> pageQuery(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@ModelAttribute AI3DTaskQueryDTO queryDTO) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
Long userId = userPrincipal.getUserId(); // 查询自己的任务
|
||||
Page<AI3DTaskVO> result = ai3dTaskService.pageQuery(queryDTO, tenantId, userId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/cancel")
|
||||
@Operation(summary = "取消任务")
|
||||
public Result<Void> cancel(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long id) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
ai3dTaskService.cancel(id, tenantId);
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/retry")
|
||||
@Operation(summary = "重试失败的任务")
|
||||
public Result<AI3DTaskVO> retry(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long id) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
AI3DTaskVO result = ai3dTaskService.retry(id, tenantId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除 AI 3D 任务")
|
||||
@PreAuthorize("hasAuthority('ai-3d:delete')")
|
||||
public Result<Void> delete(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long id) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
ai3dTaskService.delete(id, tenantId);
|
||||
return Result.success(null);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.lesingle.creation.controller;
|
||||
|
||||
import com.lesingle.creation.common.core.Result;
|
||||
import com.lesingle.creation.common.security.UserPrincipal;
|
||||
import com.lesingle.creation.service.AnalyticsService;
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsOverviewVO;
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsReviewVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 数据统计接口(租户端)
|
||||
*/
|
||||
@Tag(name = "数据统计")
|
||||
@RestController
|
||||
@RequestMapping("/api/analytics")
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsController {
|
||||
|
||||
private final AnalyticsService analyticsService;
|
||||
|
||||
@GetMapping("/overview")
|
||||
@Operation(summary = "运营概览")
|
||||
@PreAuthorize("hasAuthority('contest:read')")
|
||||
public Result<AnalyticsOverviewVO> getOverview(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestParam(required = false) Long contestId,
|
||||
@RequestParam(required = false) String timeRange) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
return Result.success(analyticsService.getOverview(tenantId, contestId, timeRange));
|
||||
}
|
||||
|
||||
@GetMapping("/review")
|
||||
@Operation(summary = "评审分析")
|
||||
@PreAuthorize("hasAuthority('contest:read')")
|
||||
public Result<AnalyticsReviewVO> getReview(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestParam(required = false) Long contestId) {
|
||||
Long tenantId = userPrincipal.getTenantId();
|
||||
return Result.success(analyticsService.getReviewAnalysis(tenantId, contestId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +60,7 @@ public class ContestController {
|
||||
|
||||
@GetMapping("/my-contests")
|
||||
@Operation(summary = "我参与的活动列表")
|
||||
@PreAuthorize("hasAuthority('activity:read')")
|
||||
public Result<Page<ContestListVO>> myContests(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
ContestQueryDTO queryDTO) {
|
||||
|
||||
@ -32,7 +32,7 @@ public class ContestJudgeController {
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:create')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:create','contest:judge:create')")
|
||||
public Result<JudgeVO> create(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestBody @Validated CreateJudgeDTO dto) {
|
||||
@ -54,7 +54,7 @@ public class ContestJudgeController {
|
||||
|
||||
@PutMapping("/{id:\\d+}")
|
||||
@Operation(summary = "更新评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:update')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:update','contest:judge:update')")
|
||||
public Result<JudgeVO> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Validated UpdateJudgeDTO dto) {
|
||||
@ -64,7 +64,7 @@ public class ContestJudgeController {
|
||||
|
||||
@DeleteMapping("/{id:\\d+}")
|
||||
@Operation(summary = "删除评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:delete')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:delete','contest:judge:delete')")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
judgeService.delete(id);
|
||||
return Result.success(null);
|
||||
@ -88,7 +88,7 @@ public class ContestJudgeController {
|
||||
|
||||
@PostMapping("/freeze")
|
||||
@Operation(summary = "冻结评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:update')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:update','contest:judge:update')")
|
||||
public Result<JudgeVO> freeze(@RequestBody Map<String, Long> params) {
|
||||
Long id = params.get("id");
|
||||
JudgeVO result = judgeService.freeze(id);
|
||||
@ -97,7 +97,7 @@ public class ContestJudgeController {
|
||||
|
||||
@PostMapping("/unfreeze")
|
||||
@Operation(summary = "解冻评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:update')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:update','contest:judge:update')")
|
||||
public Result<JudgeVO> unfreeze(@RequestBody Map<String, Long> params) {
|
||||
Long id = params.get("id");
|
||||
JudgeVO result = judgeService.unfreeze(id);
|
||||
@ -106,7 +106,7 @@ public class ContestJudgeController {
|
||||
|
||||
@PostMapping("/batch-delete")
|
||||
@Operation(summary = "批量删除评委")
|
||||
@PreAuthorize("hasAuthority('contest:judge:delete')")
|
||||
@PreAuthorize("hasAnyAuthority('judge:delete','contest:judge:delete')")
|
||||
public Result<Void> batchDelete(@RequestBody List<Long> ids) {
|
||||
judgeService.batchDelete(ids);
|
||||
return Result.success(null);
|
||||
|
||||
@ -31,7 +31,7 @@ public class ContestNoticeController {
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建公告")
|
||||
@PreAuthorize("hasAuthority('contest:notice:create')")
|
||||
@PreAuthorize("hasAnyAuthority('notice:create','contest:notice:create')")
|
||||
public Result<NoticeVO> create(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestBody @Validated CreateNoticeDTO dto) {
|
||||
@ -53,7 +53,7 @@ public class ContestNoticeController {
|
||||
|
||||
@PutMapping("/{id:\\d+}")
|
||||
@Operation(summary = "更新公告")
|
||||
@PreAuthorize("hasAuthority('contest:notice:update')")
|
||||
@PreAuthorize("hasAnyAuthority('notice:update','contest:notice:update')")
|
||||
public Result<NoticeVO> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Validated UpdateNoticeDTO dto) {
|
||||
@ -63,7 +63,7 @@ public class ContestNoticeController {
|
||||
|
||||
@DeleteMapping("/{id:\\d+}")
|
||||
@Operation(summary = "删除公告")
|
||||
@PreAuthorize("hasAuthority('contest:notice:delete')")
|
||||
@PreAuthorize("hasAnyAuthority('notice:delete','contest:notice:delete')")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
noticeService.delete(id);
|
||||
return Result.success(null);
|
||||
@ -71,7 +71,7 @@ public class ContestNoticeController {
|
||||
|
||||
@PostMapping("/{id:\\d+}/publish")
|
||||
@Operation(summary = "发布公告")
|
||||
@PreAuthorize("hasAuthority('contest:notice:publish')")
|
||||
@PreAuthorize("hasAnyAuthority('notice:publish','contest:notice:publish')")
|
||||
public Result<NoticeVO> publish(@PathVariable Long id) {
|
||||
NoticeVO result = noticeService.publish(id);
|
||||
return Result.success(result);
|
||||
|
||||
@ -82,6 +82,7 @@ public class ContestPresetCommentController {
|
||||
*/
|
||||
@GetMapping("/judge/contests")
|
||||
@Operation(summary = "评委参与的活动列表")
|
||||
@PreAuthorize("hasAnyAuthority('review:score','contest:review:score')")
|
||||
public Result<List<JudgeContestVO>> getJudgeContests(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
Long judgeId = userPrincipal.getUserId();
|
||||
|
||||
@ -54,7 +54,7 @@ public class ContestReviewController {
|
||||
|
||||
@PostMapping("/score")
|
||||
@Operation(summary = "评分")
|
||||
@PreAuthorize("hasAuthority('contest:review:score')")
|
||||
@PreAuthorize("hasAnyAuthority('review:score','contest:review:score')")
|
||||
public Result<WorkScoreVO> score(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestBody @Validated CreateScoreDTO dto) {
|
||||
@ -66,7 +66,7 @@ public class ContestReviewController {
|
||||
|
||||
@PutMapping("/score/{scoreId}")
|
||||
@Operation(summary = "更新评分")
|
||||
@PreAuthorize("hasAuthority('contest:review:score')")
|
||||
@PreAuthorize("hasAnyAuthority('review:score','contest:review:score')")
|
||||
public Result<WorkScoreVO> updateScore(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long scoreId,
|
||||
@ -89,7 +89,7 @@ public class ContestReviewController {
|
||||
|
||||
@GetMapping("/my-assignments")
|
||||
@Operation(summary = "获取评委待评审作品列表")
|
||||
@PreAuthorize("hasAuthority('contest:review:score')")
|
||||
@PreAuthorize("hasAnyAuthority('review:score','contest:review:score')")
|
||||
public Result<List<ReviewAssignmentVO>> getMyAssignments(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
Long judgeId = userPrincipal.getUserId();
|
||||
@ -189,6 +189,7 @@ public class ContestReviewController {
|
||||
|
||||
@GetMapping("/judge/contests")
|
||||
@Operation(summary = "获取评委参与的活动列表")
|
||||
@PreAuthorize("hasAuthority('contest:review:score')")
|
||||
public Result<java.util.List<com.lesingle.creation.vo.review.JudgeContestVO>> getJudgeContests(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
Long judgeId = userPrincipal.getUserId();
|
||||
@ -200,6 +201,7 @@ public class ContestReviewController {
|
||||
|
||||
@GetMapping("/judge/contests/{contestId}/works")
|
||||
@Operation(summary = "获取评委在某个活动下的作品列表")
|
||||
@PreAuthorize("hasAuthority('contest:review:score')")
|
||||
public Result<com.baomidou.mybatisplus.extension.plugins.pagination.Page<com.lesingle.creation.vo.review.JudgeWorkVO>>
|
||||
getJudgeContestWorks(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
|
||||
@ -132,6 +132,7 @@ public class ContestWorkController {
|
||||
|
||||
@GetMapping("/guided")
|
||||
@Operation(summary = "查询教师指导的作品列表")
|
||||
@PreAuthorize("hasAuthority('activity:read')")
|
||||
public Result<Page<WorkVO>> getGuidedWorks(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@ModelAttribute WorkQueryDTO queryDTO) {
|
||||
|
||||
@ -80,6 +80,7 @@ public class HomeworkController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取作业详情")
|
||||
@PreAuthorize("hasAuthority('homework:read')")
|
||||
public Result<HomeworkDetailVO> getDetail(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable Long id) {
|
||||
@ -90,6 +91,7 @@ public class HomeworkController {
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询作业列表")
|
||||
@PreAuthorize("hasAuthority('homework:read')")
|
||||
public Result<Page<HomeworkListVO>> pageQuery(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@ModelAttribute HomeworkQueryDTO queryDTO) {
|
||||
|
||||
@ -42,8 +42,11 @@ public class MenuController {
|
||||
@GetMapping
|
||||
@Operation(summary = "菜单树")
|
||||
@PreAuthorize("hasAuthority('menu:read')")
|
||||
public Result<List<MenuTreeVO>> tree() {
|
||||
List<MenuTreeVO> result = menuService.tree();
|
||||
public Result<List<MenuTreeVO>> tree(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestParam(required = false) String scene) {
|
||||
Long tenantId = userPrincipal != null ? userPrincipal.getTenantId() : null;
|
||||
List<MenuTreeVO> result = menuService.tree(scene, tenantId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
|
||||
@ -8,9 +8,14 @@ import com.lesingle.creation.dto.publicuser.PublicLoginDTO;
|
||||
import com.lesingle.creation.dto.publicuser.PublicRegisterDTO;
|
||||
import com.lesingle.creation.dto.publicuser.PublicUserUpdateDTO;
|
||||
import com.lesingle.creation.service.PublicService;
|
||||
import com.lesingle.creation.service.InteractionService;
|
||||
import com.lesingle.creation.vo.child.ChildVO;
|
||||
import com.lesingle.creation.vo.publicuser.PublicUserVO;
|
||||
import com.lesingle.creation.vo.publicuser.LoginResponseVO;
|
||||
import com.lesingle.creation.vo.publicwork.InteractionStatusVO;
|
||||
import com.lesingle.creation.vo.publicwork.MyFavoritesVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleFavoriteVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleLikeVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -21,6 +26,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 公共接口控制器
|
||||
@ -33,6 +39,7 @@ import java.util.List;
|
||||
public class PublicController {
|
||||
|
||||
private final PublicService publicService;
|
||||
private final InteractionService interactionService;
|
||||
|
||||
// ==================== 注册 & 登录(公开接口) ====================
|
||||
|
||||
@ -173,4 +180,73 @@ public class PublicController {
|
||||
public Result getActivityDetail(@PathVariable Long id) {
|
||||
return Result.success(publicService.getActivityDetail(id));
|
||||
}
|
||||
|
||||
// ==================== 点赞/收藏(需要登录) ====================
|
||||
|
||||
@PostMapping("/works/{id}/like")
|
||||
@Operation(summary = "点赞/取消点赞(toggle)")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public Result<ToggleLikeVO> toggleLike(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable("id") Long workId) {
|
||||
return Result.success(interactionService.toggleLike(userPrincipal.getUserId(), workId));
|
||||
}
|
||||
|
||||
@PostMapping("/works/{id}/favorite")
|
||||
@Operation(summary = "收藏/取消收藏(toggle)")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public Result<ToggleFavoriteVO> toggleFavorite(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable("id") Long workId) {
|
||||
return Result.success(interactionService.toggleFavorite(userPrincipal.getUserId(), workId));
|
||||
}
|
||||
|
||||
@GetMapping("/works/{id}/interaction")
|
||||
@Operation(summary = "查询当前用户对作品的交互状态")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public Result<InteractionStatusVO> getInteraction(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@PathVariable("id") Long workId) {
|
||||
return Result.success(interactionService.getInteractionStatus(userPrincipal.getUserId(), workId));
|
||||
}
|
||||
|
||||
@GetMapping("/mine/favorites")
|
||||
@Operation(summary = "我的收藏列表")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public Result<MyFavoritesVO> myFavorites(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestParam(required = false) Integer page,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
return Result.success(interactionService.getMyFavorites(
|
||||
userPrincipal.getTenantId(),
|
||||
userPrincipal.getUserId(),
|
||||
page,
|
||||
pageSize
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/works/interaction/batch")
|
||||
@Operation(summary = "批量查询交互状态")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public Result<Map<Long, InteractionStatusVO>> batchInteraction(
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal,
|
||||
@RequestBody BatchInteractionRequest body) {
|
||||
return Result.success(interactionService.batchGetInteractionStatus(
|
||||
userPrincipal.getTenantId(),
|
||||
userPrincipal.getUserId(),
|
||||
body.getWorkIds()
|
||||
));
|
||||
}
|
||||
|
||||
public static class BatchInteractionRequest {
|
||||
private List<Long> workIds;
|
||||
|
||||
public List<Long> getWorkIds() {
|
||||
return workIds;
|
||||
}
|
||||
|
||||
public void setWorkIds(List<Long> workIds) {
|
||||
this.workIds = workIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
package com.lesingle.creation.dto.ai3d;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 3D 任务查询 DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "AI 3D 任务查询参数")
|
||||
public class AI3DTaskQueryDTO {
|
||||
|
||||
@Schema(description = "页码", example = "1")
|
||||
private Integer pageNum = 1;
|
||||
|
||||
@Schema(description = "每页数量", example = "10")
|
||||
private Integer pageSize = 10;
|
||||
|
||||
@Schema(description = "任务状态:pending/processing/completed/failed/timeout")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "输入类型:text/image")
|
||||
private String inputType;
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
package com.lesingle.creation.dto.ai3d;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 创建 AI 3D 任务请求 DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "创建 AI 3D 任务请求")
|
||||
public class CreateAI3DTaskDTO {
|
||||
|
||||
@NotBlank(message = "输入内容不能为空")
|
||||
@Schema(description = "输入类型:text/image", example = "text")
|
||||
private String inputType;
|
||||
|
||||
@NotBlank(message = "输入内容不能为空")
|
||||
@Schema(description = "输入内容:文字描述或图片 URL", example = "一只可爱的小猫")
|
||||
private String inputContent;
|
||||
|
||||
@Schema(description = "生成类型:Normal/Geometry/LowPoly/Sketch", example = "Normal")
|
||||
private String generateType = "Normal";
|
||||
}
|
||||
@ -12,6 +12,9 @@ import jakarta.validation.constraints.NotBlank;
|
||||
@Schema(description = "创建菜单请求")
|
||||
public class CreateMenuDTO {
|
||||
|
||||
@Schema(description = "菜单场景:portal-平台端,tenant-租户端", example = "tenant")
|
||||
private String scene;
|
||||
|
||||
@NotBlank(message = "菜单名称不能为空")
|
||||
@Schema(description = "菜单名称", example = "用户管理")
|
||||
private String name;
|
||||
|
||||
@ -10,6 +10,9 @@ import lombok.Data;
|
||||
@Schema(description = "更新菜单请求")
|
||||
public class UpdateMenuDTO {
|
||||
|
||||
@Schema(description = "菜单场景:portal-平台端,tenant-租户端")
|
||||
private String scene;
|
||||
|
||||
@Schema(description = "菜单名称")
|
||||
private String name;
|
||||
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
package com.lesingle.creation.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.lesingle.creation.common.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* AI 3D 任务表实体类
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("t_biz_ai3d_task")
|
||||
public class AI3DTask extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 租户 ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private Long tenantId;
|
||||
|
||||
/**
|
||||
* 用户 ID
|
||||
*/
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 输入类型:text/image
|
||||
*/
|
||||
@TableField("input_type")
|
||||
private String inputType;
|
||||
|
||||
/**
|
||||
* 输入内容:文字描述或图片 URL
|
||||
*/
|
||||
@TableField("input_content")
|
||||
private String inputContent;
|
||||
|
||||
/**
|
||||
* 生成类型:Normal/Geometry/LowPoly/Sketch
|
||||
*/
|
||||
@TableField("generate_type")
|
||||
private String generateType;
|
||||
|
||||
/**
|
||||
* 任务状态:pending/processing/completed/failed/timeout
|
||||
*/
|
||||
@TableField("status")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 生成的 3D 模型 URL(单结果)
|
||||
*/
|
||||
@TableField("result_url")
|
||||
private String resultUrl;
|
||||
|
||||
/**
|
||||
* 预览图 URL(单结果)
|
||||
*/
|
||||
@TableField("preview_url")
|
||||
private String previewUrl;
|
||||
|
||||
/**
|
||||
* 生成的 3D 模型 URL 数组(多结果,JSON 格式)
|
||||
*/
|
||||
@TableField("result_urls")
|
||||
private String resultUrls;
|
||||
|
||||
/**
|
||||
* 预览图 URL 数组(多结果,JSON 格式)
|
||||
*/
|
||||
@TableField("preview_urls")
|
||||
private String previewUrls;
|
||||
|
||||
/**
|
||||
* 失败错误信息
|
||||
*/
|
||||
@TableField("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 外部 AI 服务任务 ID
|
||||
*/
|
||||
@TableField("external_task_id")
|
||||
private String externalTaskId;
|
||||
|
||||
/**
|
||||
* 已重试次数
|
||||
*/
|
||||
@TableField("retry_count")
|
||||
private Integer retryCount;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
@TableField("complete_time")
|
||||
private java.time.LocalDateTime completeTime;
|
||||
}
|
||||
@ -18,6 +18,12 @@ import java.util.List;
|
||||
@TableName("t_auth_menu")
|
||||
public class Menu extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 菜单场景:portal-平台端,tenant-租户端
|
||||
*/
|
||||
@TableField("scene")
|
||||
private String scene;
|
||||
|
||||
/**
|
||||
* 菜单名称
|
||||
*/
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package com.lesingle.creation.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* UGC 作品收藏明细
|
||||
*/
|
||||
@Data
|
||||
@TableName("user_work_favorites")
|
||||
public class UserWorkFavorite {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("tenant_id")
|
||||
private Long tenantId;
|
||||
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
@TableField("work_id")
|
||||
private Long workId;
|
||||
|
||||
@TableField("create_time")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package com.lesingle.creation.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* UGC 作品点赞明细
|
||||
*/
|
||||
@Data
|
||||
@TableName("user_work_likes")
|
||||
public class UserWorkLike {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("tenant_id")
|
||||
private Long tenantId;
|
||||
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
@TableField("work_id")
|
||||
private Long workId;
|
||||
|
||||
@TableField("create_time")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -0,0 +1,236 @@
|
||||
package com.lesingle.creation.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AnalyticsMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT id
|
||||
FROM t_biz_contest
|
||||
WHERE deleted = 0
|
||||
AND contest_state = 'published'
|
||||
AND (
|
||||
contest_tenants IS NULL
|
||||
OR JSON_LENGTH(contest_tenants) = 0
|
||||
OR JSON_CONTAINS(contest_tenants, JSON_ARRAY(#{tenantId}))
|
||||
)
|
||||
""")
|
||||
List<Long> selectVisibleContestIds(@Param("tenantId") Long tenantId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DATE_FORMAT(registration_time, '%Y-%m') AS month, COUNT(*) AS cnt
|
||||
FROM t_biz_contest_registration
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = #{tenantId}
|
||||
AND registration_time >= #{startTime}
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
</script>
|
||||
""")
|
||||
List<MonthCountRow> selectRegistrationsByMonth(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("contestIds") List<Long> contestIds,
|
||||
@Param("startTime") LocalDateTime startTime);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DATE_FORMAT(submit_time, '%Y-%m') AS month, COUNT(*) AS cnt
|
||||
FROM t_biz_contest_work
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = #{tenantId}
|
||||
AND is_latest = 1
|
||||
AND submit_time >= #{startTime}
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
</script>
|
||||
""")
|
||||
List<MonthCountRow> selectWorksByMonth(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("contestIds") List<Long> contestIds,
|
||||
@Param("startTime") LocalDateTime startTime);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT
|
||||
contest_id AS contestId,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN registration_state = 'passed' THEN 1 ELSE 0 END) AS passed
|
||||
FROM t_biz_contest_registration
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = #{tenantId}
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY contest_id
|
||||
</script>
|
||||
""")
|
||||
List<RegistrationAggRow> selectRegistrationAggByContest(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT
|
||||
contest_id AS contestId,
|
||||
COUNT(*) AS worksTotal,
|
||||
SUM(CASE WHEN status IN ('accepted','awarded') THEN 1 ELSE 0 END) AS reviewed,
|
||||
SUM(CASE WHEN award_name IS NOT NULL AND award_name <> '' THEN 1 ELSE 0 END) AS awarded,
|
||||
AVG(final_score) AS avgScore
|
||||
FROM t_biz_contest_work
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = #{tenantId}
|
||||
AND is_latest = 1
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY contest_id
|
||||
</script>
|
||||
""")
|
||||
List<WorkAggRow> selectWorkAggByContest(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*)
|
||||
FROM t_biz_contest_work_assignment
|
||||
WHERE deleted = 0
|
||||
AND assignment_status = 'pending'
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
Long countPendingAssignments(@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*)
|
||||
FROM t_biz_contest_work_score
|
||||
WHERE deleted = 0
|
||||
AND create_time >= #{startTime}
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
Long countRecentScores(@Param("contestIds") List<Long> contestIds, @Param("startTime") LocalDateTime startTime);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT AVG(DATEDIFF(s.create_time, w.submit_time)) AS avgDays
|
||||
FROM t_biz_contest_work_score s
|
||||
JOIN t_biz_contest_work w ON w.id = s.work_id AND w.deleted = 0 AND w.is_latest = 1
|
||||
WHERE s.deleted = 0
|
||||
AND s.contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
Double selectAvgReviewDays(@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT AVG(stddev_score) AS avgStddev
|
||||
FROM (
|
||||
SELECT work_id, STDDEV_SAMP(score) AS stddev_score
|
||||
FROM t_biz_contest_work_assignment
|
||||
WHERE deleted = 0
|
||||
AND score IS NOT NULL
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY work_id
|
||||
HAVING COUNT(score) > 1
|
||||
) t
|
||||
</script>
|
||||
""")
|
||||
Double selectAvgScoreStddev(@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT judge_id AS judgeId, MAX(judge_name) AS judgeName, COUNT(DISTINCT contest_id) AS contestCount
|
||||
FROM t_biz_contest_judge
|
||||
WHERE deleted = 0
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY judge_id
|
||||
</script>
|
||||
""")
|
||||
List<JudgeBaseRow> selectJudgeBase(@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT
|
||||
judge_id AS judgeId,
|
||||
COUNT(*) AS assignedCount,
|
||||
SUM(CASE WHEN score IS NOT NULL THEN 1 ELSE 0 END) AS scoredCount,
|
||||
AVG(score) AS avgScore,
|
||||
STDDEV_SAMP(score) AS scoreStddev
|
||||
FROM t_biz_contest_work_assignment
|
||||
WHERE deleted = 0
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY judge_id
|
||||
</script>
|
||||
""")
|
||||
List<JudgeAggRow> selectJudgeAgg(@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT award_name AS awardName, COUNT(*) AS cnt
|
||||
FROM t_biz_contest_work
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = #{tenantId}
|
||||
AND is_latest = 1
|
||||
AND award_name IS NOT NULL
|
||||
AND award_name <> ''
|
||||
AND contest_id IN
|
||||
<foreach collection="contestIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY award_name
|
||||
</script>
|
||||
""")
|
||||
List<AwardGroupRow> selectAwardDistribution(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("contestIds") List<Long> contestIds);
|
||||
|
||||
record MonthCountRow(String month, Long cnt) {}
|
||||
|
||||
record RegistrationAggRow(Long contestId, Long total, Long passed) {}
|
||||
|
||||
record WorkAggRow(Long contestId, Long worksTotal, Long reviewed, Long awarded, Double avgScore) {}
|
||||
|
||||
record JudgeBaseRow(Long judgeId, String judgeName, Long contestCount) {}
|
||||
|
||||
record JudgeAggRow(Long judgeId, Long assignedCount, Long scoredCount, Double avgScore, Double scoreStddev) {}
|
||||
|
||||
record AwardGroupRow(String awardName, Long cnt) {}
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
package com.lesingle.creation.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.lesingle.creation.entity.UserWorkFavorite;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface UserWorkFavoriteMapper extends BaseMapper<UserWorkFavorite> {
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
f.id AS favoriteId,
|
||||
f.work_id AS workId,
|
||||
f.create_time AS favoriteTime,
|
||||
w.title AS workTitle,
|
||||
w.cover_url AS coverUrl,
|
||||
w.like_count AS likeCount,
|
||||
w.view_count AS viewCount,
|
||||
w.favorite_count AS favoriteCount,
|
||||
u.id AS creatorId,
|
||||
u.nickname AS creatorNickname,
|
||||
u.avatar AS creatorAvatar
|
||||
FROM user_work_favorites f
|
||||
JOIN user_works w ON w.id = f.work_id AND w.is_deleted = 0 AND w.status = 'published'
|
||||
JOIN t_sys_user u ON u.id = w.user_id
|
||||
WHERE f.tenant_id = #{tenantId}
|
||||
AND f.user_id = #{userId}
|
||||
ORDER BY f.create_time DESC
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
""")
|
||||
List<MyFavoriteRow> selectMyFavorites(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM user_work_favorites f
|
||||
JOIN user_works w ON w.id = f.work_id AND w.is_deleted = 0 AND w.status = 'published'
|
||||
WHERE f.tenant_id = #{tenantId}
|
||||
AND f.user_id = #{userId}
|
||||
""")
|
||||
Long countMyFavorites(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
||||
|
||||
record MyFavoriteRow(
|
||||
Long favoriteId,
|
||||
Long workId,
|
||||
LocalDateTime favoriteTime,
|
||||
String workTitle,
|
||||
String coverUrl,
|
||||
Integer likeCount,
|
||||
Integer viewCount,
|
||||
Integer favoriteCount,
|
||||
Long creatorId,
|
||||
String creatorNickname,
|
||||
String creatorAvatar
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
package com.lesingle.creation.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.lesingle.creation.entity.AI3DTask;
|
||||
import com.lesingle.creation.entity.UserWorkLike;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* AI 3D 任务 Mapper 接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface AI3DTaskMapper extends BaseMapper<AI3DTask> {
|
||||
|
||||
public interface UserWorkLikeMapper extends BaseMapper<UserWorkLike> {
|
||||
}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
package com.lesingle.creation.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.lesingle.creation.dto.ai3d.AI3DTaskQueryDTO;
|
||||
import com.lesingle.creation.dto.ai3d.CreateAI3DTaskDTO;
|
||||
import com.lesingle.creation.entity.AI3DTask;
|
||||
import com.lesingle.creation.vo.ai3d.AI3DTaskVO;
|
||||
|
||||
/**
|
||||
* AI 3D 任务服务接口
|
||||
*/
|
||||
public interface AI3DTaskService extends IService<AI3DTask> {
|
||||
|
||||
/**
|
||||
* 创建 AI 3D 任务
|
||||
* @param dto 创建任务请求
|
||||
* @param tenantId 租户 ID
|
||||
* @param userId 用户 ID
|
||||
* @return 任务详情
|
||||
*/
|
||||
AI3DTaskVO create(CreateAI3DTaskDTO dto, Long tenantId, Long userId);
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
* @param id 任务 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @return 任务详情
|
||||
*/
|
||||
AI3DTaskVO getDetail(Long id, Long tenantId);
|
||||
|
||||
/**
|
||||
* 分页查询任务列表
|
||||
* @param queryDTO 查询参数
|
||||
* @param tenantId 租户 ID
|
||||
* @param userId 用户 ID(可选,查询自己的任务)
|
||||
* @return 任务列表
|
||||
*/
|
||||
Page<AI3DTaskVO> pageQuery(AI3DTaskQueryDTO queryDTO, Long tenantId, Long userId);
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* @param id 任务 ID
|
||||
* @param tenantId 租户 ID
|
||||
*/
|
||||
void cancel(Long id, Long tenantId);
|
||||
|
||||
/**
|
||||
* 重试失败的任务
|
||||
* @param id 任务 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @return 任务详情
|
||||
*/
|
||||
AI3DTaskVO retry(Long id, Long tenantId);
|
||||
|
||||
/**
|
||||
* 删除 AI 3D 任务
|
||||
* @param id 任务 ID
|
||||
* @param tenantId 租户 ID
|
||||
*/
|
||||
void delete(Long id, Long tenantId);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.lesingle.creation.service;
|
||||
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsOverviewVO;
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsReviewVO;
|
||||
|
||||
public interface AnalyticsService {
|
||||
|
||||
AnalyticsOverviewVO getOverview(Long tenantId, Long contestId, String timeRange);
|
||||
|
||||
AnalyticsReviewVO getReviewAnalysis(Long tenantId, Long contestId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package com.lesingle.creation.service;
|
||||
|
||||
import com.lesingle.creation.vo.publicwork.InteractionStatusVO;
|
||||
import com.lesingle.creation.vo.publicwork.MyFavoritesVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleFavoriteVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleLikeVO;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
public interface InteractionService {
|
||||
|
||||
ToggleLikeVO toggleLike(Long userId, Long workId);
|
||||
|
||||
ToggleFavoriteVO toggleFavorite(Long userId, Long workId);
|
||||
|
||||
InteractionStatusVO getInteractionStatus(Long userId, Long workId);
|
||||
|
||||
MyFavoritesVO getMyFavorites(Long tenantId, Long userId, Integer page, Integer pageSize);
|
||||
|
||||
Map<Long, InteractionStatusVO> batchGetInteractionStatus(Long tenantId, Long userId, List<Long> workIds);
|
||||
}
|
||||
|
||||
@ -30,6 +30,15 @@ public interface MenuService extends IService<Menu> {
|
||||
*/
|
||||
List<MenuTreeVO> tree();
|
||||
|
||||
/**
|
||||
* 获取菜单树(按场景过滤)
|
||||
*
|
||||
* @param scene 菜单场景:portal|tenant(为空则按租户类型推断)
|
||||
* @param tenantId 当前租户ID(用于默认场景推断)
|
||||
* @return 菜单树
|
||||
*/
|
||||
List<MenuTreeVO> tree(String scene, Long tenantId);
|
||||
|
||||
/**
|
||||
* 获取用户菜单(根据用户角色权限)
|
||||
*
|
||||
|
||||
@ -1,190 +0,0 @@
|
||||
package com.lesingle.creation.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.lesingle.creation.dto.ai3d.AI3DTaskQueryDTO;
|
||||
import com.lesingle.creation.dto.ai3d.CreateAI3DTaskDTO;
|
||||
import com.lesingle.creation.entity.AI3DTask;
|
||||
import com.lesingle.creation.common.exception.BusinessException;
|
||||
import com.lesingle.creation.mapper.AI3DTaskMapper;
|
||||
import com.lesingle.creation.service.AI3DTaskService;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.lesingle.creation.vo.ai3d.AI3DTaskVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 3D 任务服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AI3DTaskServiceImpl extends ServiceImpl<AI3DTaskMapper, AI3DTask>
|
||||
implements AI3DTaskService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AI3DTaskVO create(CreateAI3DTaskDTO dto, Long tenantId, Long userId) {
|
||||
AI3DTask task = new AI3DTask();
|
||||
task.setTenantId(tenantId);
|
||||
task.setUserId(userId);
|
||||
task.setInputType(dto.getInputType());
|
||||
task.setInputContent(dto.getInputContent());
|
||||
task.setGenerateType(dto.getGenerateType());
|
||||
task.setStatus("pending");
|
||||
task.setRetryCount(0);
|
||||
|
||||
this.save(task);
|
||||
log.info("创建 AI 3D 任务成功,ID={}, 输入类型={}", task.getId(), dto.getInputType());
|
||||
|
||||
// TODO: 调用腾讯混元 AI 服务进行 3D 生成
|
||||
// 这里应该异步调用外部 AI 服务,并在完成后更新任务状态
|
||||
|
||||
return convertToVO(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AI3DTaskVO getDetail(Long id, Long tenantId) {
|
||||
AI3DTask task = this.getById(id);
|
||||
if (task == null || !task.getTenantId().equals(tenantId)) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
return convertToVO(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<AI3DTaskVO> pageQuery(AI3DTaskQueryDTO queryDTO, Long tenantId, Long userId) {
|
||||
Page<AI3DTask> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
|
||||
|
||||
LambdaQueryWrapper<AI3DTask> wrapper = new LambdaQueryWrapper<AI3DTask>()
|
||||
.eq(AI3DTask::getTenantId, tenantId)
|
||||
.eq(AI3DTask::getDeleted, 0)
|
||||
.eq(queryDTO.getStatus() != null, AI3DTask::getStatus, queryDTO.getStatus())
|
||||
.eq(queryDTO.getInputType() != null, AI3DTask::getInputType, queryDTO.getInputType())
|
||||
.eq(userId != null, AI3DTask::getUserId, userId)
|
||||
.orderByDesc(AI3DTask::getCreateTime);
|
||||
|
||||
Page<AI3DTask> resultPage = this.page(page, wrapper);
|
||||
|
||||
List<AI3DTaskVO> voList = resultPage.getRecords().stream()
|
||||
.map(this::convertToVO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Page<AI3DTaskVO> voPage = new Page<>(
|
||||
resultPage.getCurrent(),
|
||||
resultPage.getSize(),
|
||||
resultPage.getTotal()
|
||||
);
|
||||
voPage.setRecords(voList);
|
||||
return voPage;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cancel(Long id, Long tenantId) {
|
||||
AI3DTask task = this.getById(id);
|
||||
if (task == null || !task.getTenantId().equals(tenantId)) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
// 只有 pending/processing 状态的任务可以取消
|
||||
if (!"pending".equals(task.getStatus()) && !"processing".equals(task.getStatus())) {
|
||||
throw new BusinessException("当前状态无法取消任务");
|
||||
}
|
||||
|
||||
task.setStatus("timeout");
|
||||
this.updateById(task);
|
||||
log.info("取消 AI 3D 任务成功,ID={}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AI3DTaskVO retry(Long id, Long tenantId) {
|
||||
AI3DTask task = this.getById(id);
|
||||
if (task == null || !task.getTenantId().equals(tenantId)) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
// 只有 failed 状态的任务可以重试
|
||||
if (!"failed".equals(task.getStatus())) {
|
||||
throw new BusinessException("只有失败的任务可以重试");
|
||||
}
|
||||
|
||||
// 限制最大重试次数
|
||||
if (task.getRetryCount() >= 3) {
|
||||
throw new BusinessException("已达到最大重试次数");
|
||||
}
|
||||
|
||||
task.setStatus("pending");
|
||||
task.setRetryCount(task.getRetryCount() + 1);
|
||||
task.setErrorMessage(null);
|
||||
this.updateById(task);
|
||||
log.info("重试 AI 3D 任务成功,ID={}, 重试次数={}", id, task.getRetryCount());
|
||||
|
||||
// TODO: 重新调用 AI 服务
|
||||
|
||||
return convertToVO(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id, Long tenantId) {
|
||||
AI3DTask task = this.getById(id);
|
||||
if (task == null || !task.getTenantId().equals(tenantId)) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
task.setDeleted(1);
|
||||
this.updateById(task);
|
||||
log.info("删除 AI 3D 任务成功,ID={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态(供异步回调使用)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateTaskStatus(Long taskId, String status, String resultUrl,
|
||||
String previewUrl, String errorMessage) {
|
||||
AI3DTask task = this.getById(taskId);
|
||||
if (task != null) {
|
||||
task.setStatus(status);
|
||||
if ("completed".equals(status)) {
|
||||
task.setResultUrl(resultUrl);
|
||||
task.setPreviewUrl(previewUrl);
|
||||
task.setCompleteTime(LocalDateTime.now());
|
||||
} else if ("failed".equals(status)) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
this.updateById(task);
|
||||
log.info("更新 AI 3D 任务状态,ID={}, 状态={}", taskId, status);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 转换方法 ==========
|
||||
|
||||
private AI3DTaskVO convertToVO(AI3DTask task) {
|
||||
AI3DTaskVO vo = new AI3DTaskVO();
|
||||
vo.setId(task.getId());
|
||||
vo.setInputType(task.getInputType());
|
||||
vo.setInputContent(task.getInputContent());
|
||||
vo.setGenerateType(task.getGenerateType());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setResultUrl(task.getResultUrl());
|
||||
vo.setPreviewUrl(task.getPreviewUrl());
|
||||
vo.setResultUrls(task.getResultUrls());
|
||||
vo.setPreviewUrls(task.getPreviewUrls());
|
||||
vo.setErrorMessage(task.getErrorMessage());
|
||||
vo.setExternalTaskId(task.getExternalTaskId());
|
||||
vo.setRetryCount(task.getRetryCount());
|
||||
vo.setCreateTime(task.getCreateTime());
|
||||
vo.setCompleteTime(task.getCompleteTime());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
package com.lesingle.creation.service.impl;
|
||||
|
||||
import com.lesingle.creation.mapper.AnalyticsMapper;
|
||||
import com.lesingle.creation.mapper.ContestMapper;
|
||||
import com.lesingle.creation.service.AnalyticsService;
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsOverviewVO;
|
||||
import com.lesingle.creation.vo.analytics.AnalyticsReviewVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsServiceImpl implements AnalyticsService {
|
||||
|
||||
private final AnalyticsMapper analyticsMapper;
|
||||
private final ContestMapper contestMapper;
|
||||
|
||||
@Override
|
||||
public AnalyticsOverviewVO getOverview(Long tenantId, Long contestId, String timeRange) {
|
||||
// timeRange 目前仅用于兼容参数(副本工程前端会传/保留),统计范围先按“最近6个月趋势 + 全量汇总”实现
|
||||
List<Long> visibleContestIds = analyticsMapper.selectVisibleContestIds(tenantId);
|
||||
if (contestId != null) {
|
||||
visibleContestIds = visibleContestIds.stream().filter(id -> Objects.equals(id, contestId)).toList();
|
||||
}
|
||||
|
||||
AnalyticsOverviewVO vo = new AnalyticsOverviewVO();
|
||||
|
||||
if (visibleContestIds.isEmpty()) {
|
||||
vo.setSummary(emptySummary());
|
||||
vo.setFunnel(emptyFunnel());
|
||||
vo.setMonthlyTrend(buildEmptyMonthsTrend());
|
||||
vo.setContestComparison(Collections.emptyList());
|
||||
return vo;
|
||||
}
|
||||
|
||||
Map<Long, AnalyticsMapper.RegistrationAggRow> regAgg = analyticsMapper
|
||||
.selectRegistrationAggByContest(tenantId, visibleContestIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.RegistrationAggRow::contestId, Function.identity()));
|
||||
|
||||
Map<Long, AnalyticsMapper.WorkAggRow> workAgg = analyticsMapper
|
||||
.selectWorkAggByContest(tenantId, visibleContestIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.WorkAggRow::contestId, Function.identity()));
|
||||
|
||||
long totalRegistrations = regAgg.values().stream().mapToLong(r -> Optional.ofNullable(r.total()).orElse(0L)).sum();
|
||||
long passedRegistrations = regAgg.values().stream().mapToLong(r -> Optional.ofNullable(r.passed()).orElse(0L)).sum();
|
||||
|
||||
long totalWorks = workAgg.values().stream().mapToLong(w -> Optional.ofNullable(w.worksTotal()).orElse(0L)).sum();
|
||||
long reviewedWorks = workAgg.values().stream().mapToLong(w -> Optional.ofNullable(w.reviewed()).orElse(0L)).sum();
|
||||
long awardedWorks = workAgg.values().stream().mapToLong(w -> Optional.ofNullable(w.awarded()).orElse(0L)).sum();
|
||||
|
||||
AnalyticsOverviewVO.Summary summary = new AnalyticsOverviewVO.Summary();
|
||||
summary.setTotalContests(visibleContestIds.size());
|
||||
summary.setTotalRegistrations(totalRegistrations);
|
||||
summary.setPassedRegistrations(passedRegistrations);
|
||||
summary.setTotalWorks(totalWorks);
|
||||
summary.setReviewedWorks(reviewedWorks);
|
||||
summary.setAwardedWorks(awardedWorks);
|
||||
vo.setSummary(summary);
|
||||
|
||||
AnalyticsOverviewVO.Funnel funnel = new AnalyticsOverviewVO.Funnel();
|
||||
funnel.setRegistered(totalRegistrations);
|
||||
funnel.setPassed(passedRegistrations);
|
||||
funnel.setSubmitted(totalWorks);
|
||||
funnel.setReviewed(reviewedWorks);
|
||||
funnel.setAwarded(awardedWorks);
|
||||
vo.setFunnel(funnel);
|
||||
|
||||
// 月度趋势(最近6个月)
|
||||
LocalDate firstDayOfSixMonthsAgo = LocalDate.now().minusMonths(5).withDayOfMonth(1);
|
||||
LocalDateTime startTime = firstDayOfSixMonthsAgo.atStartOfDay();
|
||||
|
||||
Map<String, Long> regByMonth = analyticsMapper.selectRegistrationsByMonth(tenantId, visibleContestIds, startTime)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.MonthCountRow::month, r -> Optional.ofNullable(r.cnt()).orElse(0L)));
|
||||
|
||||
Map<String, Long> worksByMonth = analyticsMapper.selectWorksByMonth(tenantId, visibleContestIds, startTime)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.MonthCountRow::month, r -> Optional.ofNullable(r.cnt()).orElse(0L)));
|
||||
|
||||
List<AnalyticsOverviewVO.MonthlyTrendItem> monthlyTrend = new ArrayList<>();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
LocalDate d = firstDayOfSixMonthsAgo.plusMonths(i);
|
||||
String m = String.format("%d-%02d", d.getYear(), d.getMonthValue());
|
||||
AnalyticsOverviewVO.MonthlyTrendItem item = new AnalyticsOverviewVO.MonthlyTrendItem();
|
||||
item.setMonth(m);
|
||||
item.setRegistrations(regByMonth.getOrDefault(m, 0L));
|
||||
item.setWorks(worksByMonth.getOrDefault(m, 0L));
|
||||
monthlyTrend.add(item);
|
||||
}
|
||||
vo.setMonthlyTrend(monthlyTrend);
|
||||
|
||||
// 活动名称(避免一条条查询)
|
||||
Map<Long, String> contestNames = contestMapper.selectBatchIds(visibleContestIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(c -> c.getId(), c -> c.getContestName(), (a, b) -> a));
|
||||
|
||||
// 活动对比
|
||||
List<AnalyticsOverviewVO.ContestComparisonItem> comparison = new ArrayList<>();
|
||||
for (Long cid : visibleContestIds) {
|
||||
AnalyticsMapper.RegistrationAggRow r = regAgg.get(cid);
|
||||
AnalyticsMapper.WorkAggRow w = workAgg.get(cid);
|
||||
|
||||
long regTotal = r != null && r.total() != null ? r.total() : 0L;
|
||||
long regPassed = r != null && r.passed() != null ? r.passed() : 0L;
|
||||
long worksTotal2 = w != null && w.worksTotal() != null ? w.worksTotal() : 0L;
|
||||
long worksReviewed2 = w != null && w.reviewed() != null ? w.reviewed() : 0L;
|
||||
long worksAwarded2 = w != null && w.awarded() != null ? w.awarded() : 0L;
|
||||
|
||||
AnalyticsOverviewVO.ContestComparisonItem row = new AnalyticsOverviewVO.ContestComparisonItem();
|
||||
row.setContestId(cid);
|
||||
row.setContestName(contestNames.getOrDefault(cid, "-"));
|
||||
row.setRegistrations(regTotal);
|
||||
row.setPassRate(regTotal > 0 ? (int) Math.round(regPassed * 100.0 / regTotal) : 0);
|
||||
row.setSubmitRate(regPassed > 0 ? (int) Math.round(worksTotal2 * 100.0 / regPassed) : 0);
|
||||
row.setReviewRate(worksTotal2 > 0 ? (int) Math.round(worksReviewed2 * 100.0 / worksTotal2) : 0);
|
||||
row.setAwardRate(worksTotal2 > 0 ? (int) Math.round(worksAwarded2 * 100.0 / worksTotal2) : 0);
|
||||
row.setAvgScore(w != null && w.avgScore() != null ? round2(w.avgScore()) : null);
|
||||
comparison.add(row);
|
||||
}
|
||||
vo.setContestComparison(comparison);
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AnalyticsReviewVO getReviewAnalysis(Long tenantId, Long contestId) {
|
||||
List<Long> visibleContestIds = analyticsMapper.selectVisibleContestIds(tenantId);
|
||||
if (contestId != null) {
|
||||
visibleContestIds = visibleContestIds.stream().filter(id -> Objects.equals(id, contestId)).toList();
|
||||
}
|
||||
|
||||
AnalyticsReviewVO vo = new AnalyticsReviewVO();
|
||||
|
||||
if (visibleContestIds.isEmpty()) {
|
||||
vo.setEfficiency(emptyEfficiency());
|
||||
vo.setJudgeWorkload(Collections.emptyList());
|
||||
vo.setAwardDistribution(Collections.emptyList());
|
||||
return vo;
|
||||
}
|
||||
|
||||
// 效率指标
|
||||
long pendingAssignments = Optional.ofNullable(analyticsMapper.countPendingAssignments(visibleContestIds)).orElse(0L);
|
||||
LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30);
|
||||
long recentScoreCount = Optional.ofNullable(analyticsMapper.countRecentScores(visibleContestIds, thirtyDaysAgo)).orElse(0L);
|
||||
|
||||
double avgReviewDays = Optional.ofNullable(analyticsMapper.selectAvgReviewDays(visibleContestIds)).orElse(0d);
|
||||
double avgScoreStddev = Optional.ofNullable(analyticsMapper.selectAvgScoreStddev(visibleContestIds)).orElse(0d);
|
||||
|
||||
AnalyticsReviewVO.Efficiency efficiency = new AnalyticsReviewVO.Efficiency();
|
||||
efficiency.setAvgReviewDays(round1(avgReviewDays));
|
||||
efficiency.setDailyReviewCount(round1(recentScoreCount / 30.0));
|
||||
efficiency.setPendingAssignments(pendingAssignments);
|
||||
efficiency.setAvgScoreStddev(round1(avgScoreStddev));
|
||||
vo.setEfficiency(efficiency);
|
||||
|
||||
// 评委工作量
|
||||
Map<Long, AnalyticsMapper.JudgeBaseRow> judgeBase = analyticsMapper.selectJudgeBase(visibleContestIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.JudgeBaseRow::judgeId, Function.identity()));
|
||||
|
||||
Map<Long, AnalyticsMapper.JudgeAggRow> judgeAgg = analyticsMapper.selectJudgeAgg(visibleContestIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(AnalyticsMapper.JudgeAggRow::judgeId, Function.identity(), (a, b) -> a));
|
||||
|
||||
List<AnalyticsReviewVO.JudgeWorkloadItem> judgeWorkload = new ArrayList<>();
|
||||
for (Map.Entry<Long, AnalyticsMapper.JudgeBaseRow> entry : judgeBase.entrySet()) {
|
||||
Long judgeId = entry.getKey();
|
||||
AnalyticsMapper.JudgeBaseRow base = entry.getValue();
|
||||
AnalyticsMapper.JudgeAggRow agg = judgeAgg.get(judgeId);
|
||||
|
||||
long assigned = agg != null && agg.assignedCount() != null ? agg.assignedCount() : 0L;
|
||||
long scored = agg != null && agg.scoredCount() != null ? agg.scoredCount() : 0L;
|
||||
|
||||
AnalyticsReviewVO.JudgeWorkloadItem item = new AnalyticsReviewVO.JudgeWorkloadItem();
|
||||
item.setJudgeId(judgeId);
|
||||
item.setJudgeName(base.judgeName() != null ? base.judgeName() : "-");
|
||||
item.setContestCount(base.contestCount() != null ? base.contestCount().intValue() : 0);
|
||||
item.setAssignedCount(assigned);
|
||||
item.setScoredCount(scored);
|
||||
item.setCompletionRate(assigned > 0 ? (int) Math.round(scored * 100.0 / assigned) : 0);
|
||||
item.setAvgScore(agg != null && agg.avgScore() != null ? round2(agg.avgScore()) : null);
|
||||
item.setScoreStddev(agg != null && agg.scoreStddev() != null ? round2(agg.scoreStddev()) : 0d);
|
||||
judgeWorkload.add(item);
|
||||
}
|
||||
judgeWorkload.sort(Comparator.comparing(AnalyticsReviewVO.JudgeWorkloadItem::getAssignedCount).reversed());
|
||||
vo.setJudgeWorkload(judgeWorkload);
|
||||
|
||||
// 奖项分布
|
||||
List<AnalyticsMapper.AwardGroupRow> groups = analyticsMapper.selectAwardDistribution(tenantId, visibleContestIds);
|
||||
long total = groups.stream().mapToLong(g -> Optional.ofNullable(g.cnt()).orElse(0L)).sum();
|
||||
|
||||
List<AnalyticsReviewVO.AwardDistributionItem> awardDistribution = new ArrayList<>();
|
||||
for (AnalyticsMapper.AwardGroupRow g : groups) {
|
||||
AnalyticsReviewVO.AwardDistributionItem item = new AnalyticsReviewVO.AwardDistributionItem();
|
||||
item.setAwardName(g.awardName());
|
||||
item.setCount(Optional.ofNullable(g.cnt()).orElse(0L));
|
||||
item.setPercentage(total > 0 ? (int) Math.round(item.getCount() * 100.0 / total) : 0);
|
||||
awardDistribution.add(item);
|
||||
}
|
||||
vo.setAwardDistribution(awardDistribution);
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static AnalyticsOverviewVO.Summary emptySummary() {
|
||||
AnalyticsOverviewVO.Summary s = new AnalyticsOverviewVO.Summary();
|
||||
s.setTotalContests(0);
|
||||
s.setTotalRegistrations(0L);
|
||||
s.setPassedRegistrations(0L);
|
||||
s.setTotalWorks(0L);
|
||||
s.setReviewedWorks(0L);
|
||||
s.setAwardedWorks(0L);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static AnalyticsOverviewVO.Funnel emptyFunnel() {
|
||||
AnalyticsOverviewVO.Funnel f = new AnalyticsOverviewVO.Funnel();
|
||||
f.setRegistered(0L);
|
||||
f.setPassed(0L);
|
||||
f.setSubmitted(0L);
|
||||
f.setReviewed(0L);
|
||||
f.setAwarded(0L);
|
||||
return f;
|
||||
}
|
||||
|
||||
private static List<AnalyticsOverviewVO.MonthlyTrendItem> buildEmptyMonthsTrend() {
|
||||
LocalDate firstDay = LocalDate.now().minusMonths(5).withDayOfMonth(1);
|
||||
List<AnalyticsOverviewVO.MonthlyTrendItem> list = new ArrayList<>();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
LocalDate d = firstDay.plusMonths(i);
|
||||
AnalyticsOverviewVO.MonthlyTrendItem item = new AnalyticsOverviewVO.MonthlyTrendItem();
|
||||
item.setMonth(String.format("%d-%02d", d.getYear(), d.getMonthValue()));
|
||||
item.setRegistrations(0L);
|
||||
item.setWorks(0L);
|
||||
list.add(item);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static AnalyticsReviewVO.Efficiency emptyEfficiency() {
|
||||
AnalyticsReviewVO.Efficiency e = new AnalyticsReviewVO.Efficiency();
|
||||
e.setAvgReviewDays(0d);
|
||||
e.setDailyReviewCount(0d);
|
||||
e.setPendingAssignments(0L);
|
||||
e.setAvgScoreStddev(0d);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static double round1(double v) {
|
||||
return BigDecimal.valueOf(v).setScale(1, BigDecimal.ROUND_HALF_UP).doubleValue();
|
||||
}
|
||||
|
||||
private static double round2(double v) {
|
||||
return BigDecimal.valueOf(v).setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,11 +206,11 @@ public class AuthServiceImpl implements AuthService {
|
||||
}
|
||||
|
||||
// 获取用户角色列表
|
||||
List<String> roles = getUserRoles(user.getId());
|
||||
List<String> roles = getUserRoles(user.getId(), tenantId);
|
||||
vo.setRoles(roles);
|
||||
|
||||
// 获取用户权限列表
|
||||
List<String> permissions = getUserPermissions(user.getId());
|
||||
List<String> permissions = getUserPermissions(user.getId(), tenantId);
|
||||
vo.setPermissions(permissions);
|
||||
|
||||
return vo;
|
||||
@ -219,7 +219,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
/**
|
||||
* 获取用户角色列表
|
||||
*/
|
||||
private List<String> getUserRoles(Long userId) {
|
||||
private List<String> getUserRoles(Long userId, Long tenantId) {
|
||||
// 查询用户关联的角色
|
||||
List<UserRole> userRoles = userRoleMapper.selectList(
|
||||
new LambdaQueryWrapper<UserRole>()
|
||||
@ -235,20 +235,23 @@ public class AuthServiceImpl implements AuthService {
|
||||
.map(UserRole::getRoleId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 查询角色详情
|
||||
List<Role> roles = roleMapper.selectBatchIds(roleIds);
|
||||
// 查询角色详情(仅当前租户有效角色,避免跨租户角色串台)
|
||||
List<Role> roles = roleMapper.selectList(
|
||||
new LambdaQueryWrapper<Role>()
|
||||
.in(Role::getId, roleIds)
|
||||
.eq(Role::getTenantId, tenantId)
|
||||
.eq(Role::getDeleted, 0)
|
||||
.eq(Role::getValidState, 1)
|
||||
);
|
||||
|
||||
// 返回角色编码列表(只返回有效的角色)
|
||||
return roles.stream()
|
||||
.filter(role -> role.getValidState() == 1)
|
||||
.map(Role::getCode)
|
||||
.collect(Collectors.toList());
|
||||
return roles.stream().map(Role::getCode).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限列表
|
||||
*/
|
||||
private List<String> getUserPermissions(Long userId) {
|
||||
private List<String> getUserPermissions(Long userId, Long tenantId) {
|
||||
// 查询用户关联的角色
|
||||
List<UserRole> userRoles = userRoleMapper.selectList(
|
||||
new LambdaQueryWrapper<UserRole>()
|
||||
@ -264,10 +267,25 @@ public class AuthServiceImpl implements AuthService {
|
||||
.map(UserRole::getRoleId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 仅保留当前租户有效角色(避免跨租户角色导致权限串台)
|
||||
List<Long> tenantRoleIds = roleMapper.selectList(
|
||||
new LambdaQueryWrapper<Role>()
|
||||
.in(Role::getId, roleIds)
|
||||
.eq(Role::getTenantId, tenantId)
|
||||
.eq(Role::getDeleted, 0)
|
||||
.eq(Role::getValidState, 1)
|
||||
).stream()
|
||||
.map(Role::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (tenantRoleIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 查询角色关联的权限
|
||||
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePermission>()
|
||||
.in(RolePermission::getRoleId, roleIds)
|
||||
.in(RolePermission::getRoleId, tenantRoleIds)
|
||||
);
|
||||
|
||||
if (rolePermissions.isEmpty()) {
|
||||
|
||||
@ -0,0 +1,218 @@
|
||||
package com.lesingle.creation.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.lesingle.creation.common.exception.BusinessException;
|
||||
import com.lesingle.creation.entity.UserWork;
|
||||
import com.lesingle.creation.entity.UserWorkFavorite;
|
||||
import com.lesingle.creation.entity.UserWorkLike;
|
||||
import com.lesingle.creation.mapper.UserWorkFavoriteMapper;
|
||||
import com.lesingle.creation.mapper.UserWorkLikeMapper;
|
||||
import com.lesingle.creation.mapper.UserWorkMapper;
|
||||
import com.lesingle.creation.service.InteractionService;
|
||||
import com.lesingle.creation.vo.publicwork.InteractionStatusVO;
|
||||
import com.lesingle.creation.vo.publicwork.MyFavoritesVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleFavoriteVO;
|
||||
import com.lesingle.creation.vo.publicwork.ToggleLikeVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InteractionServiceImpl implements InteractionService {
|
||||
|
||||
private final UserWorkMapper userWorkMapper;
|
||||
private final UserWorkLikeMapper userWorkLikeMapper;
|
||||
private final UserWorkFavoriteMapper userWorkFavoriteMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ToggleLikeVO toggleLike(Long userId, Long workId) {
|
||||
UserWork work = ensureWorkExists(workId);
|
||||
Long tenantId = work.getTenantId();
|
||||
|
||||
UserWorkLike existing = userWorkLikeMapper.selectOne(new LambdaQueryWrapper<UserWorkLike>()
|
||||
.eq(UserWorkLike::getTenantId, tenantId)
|
||||
.eq(UserWorkLike::getUserId, userId)
|
||||
.eq(UserWorkLike::getWorkId, workId)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
ToggleLikeVO vo = new ToggleLikeVO();
|
||||
if (existing != null) {
|
||||
userWorkLikeMapper.deleteById(existing.getId());
|
||||
userWorkMapper.update(null, new LambdaUpdateWrapper<UserWork>()
|
||||
.eq(UserWork::getId, workId)
|
||||
.setSql("like_count = IF(like_count > 0, like_count - 1, 0)"));
|
||||
vo.setLiked(false);
|
||||
} else {
|
||||
UserWorkLike like = new UserWorkLike();
|
||||
like.setTenantId(tenantId);
|
||||
like.setUserId(userId);
|
||||
like.setWorkId(workId);
|
||||
userWorkLikeMapper.insert(like);
|
||||
|
||||
userWorkMapper.update(null, new LambdaUpdateWrapper<UserWork>()
|
||||
.eq(UserWork::getId, workId)
|
||||
.setSql("like_count = like_count + 1"));
|
||||
vo.setLiked(true);
|
||||
}
|
||||
|
||||
UserWork updated = userWorkMapper.selectById(workId);
|
||||
vo.setLikeCount(Optional.ofNullable(updated.getLikeCount()).orElse(0));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ToggleFavoriteVO toggleFavorite(Long userId, Long workId) {
|
||||
UserWork work = ensureWorkExists(workId);
|
||||
Long tenantId = work.getTenantId();
|
||||
|
||||
UserWorkFavorite existing = userWorkFavoriteMapper.selectOne(new LambdaQueryWrapper<UserWorkFavorite>()
|
||||
.eq(UserWorkFavorite::getTenantId, tenantId)
|
||||
.eq(UserWorkFavorite::getUserId, userId)
|
||||
.eq(UserWorkFavorite::getWorkId, workId)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
ToggleFavoriteVO vo = new ToggleFavoriteVO();
|
||||
if (existing != null) {
|
||||
userWorkFavoriteMapper.deleteById(existing.getId());
|
||||
userWorkMapper.update(null, new LambdaUpdateWrapper<UserWork>()
|
||||
.eq(UserWork::getId, workId)
|
||||
.setSql("favorite_count = IF(favorite_count > 0, favorite_count - 1, 0)"));
|
||||
vo.setFavorited(false);
|
||||
} else {
|
||||
UserWorkFavorite fav = new UserWorkFavorite();
|
||||
fav.setTenantId(tenantId);
|
||||
fav.setUserId(userId);
|
||||
fav.setWorkId(workId);
|
||||
userWorkFavoriteMapper.insert(fav);
|
||||
|
||||
userWorkMapper.update(null, new LambdaUpdateWrapper<UserWork>()
|
||||
.eq(UserWork::getId, workId)
|
||||
.setSql("favorite_count = favorite_count + 1"));
|
||||
vo.setFavorited(true);
|
||||
}
|
||||
|
||||
UserWork updated = userWorkMapper.selectById(workId);
|
||||
vo.setFavoriteCount(Optional.ofNullable(updated.getFavoriteCount()).orElse(0));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InteractionStatusVO getInteractionStatus(Long userId, Long workId) {
|
||||
UserWork work = ensureWorkExists(workId);
|
||||
Long tenantId = work.getTenantId();
|
||||
|
||||
boolean liked = userWorkLikeMapper.selectCount(new LambdaQueryWrapper<UserWorkLike>()
|
||||
.eq(UserWorkLike::getTenantId, tenantId)
|
||||
.eq(UserWorkLike::getUserId, userId)
|
||||
.eq(UserWorkLike::getWorkId, workId)) > 0;
|
||||
|
||||
boolean favorited = userWorkFavoriteMapper.selectCount(new LambdaQueryWrapper<UserWorkFavorite>()
|
||||
.eq(UserWorkFavorite::getTenantId, tenantId)
|
||||
.eq(UserWorkFavorite::getUserId, userId)
|
||||
.eq(UserWorkFavorite::getWorkId, workId)) > 0;
|
||||
|
||||
InteractionStatusVO vo = new InteractionStatusVO();
|
||||
vo.setLiked(liked);
|
||||
vo.setFavorited(favorited);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MyFavoritesVO getMyFavorites(Long tenantId, Long userId, Integer page, Integer pageSize) {
|
||||
int p = page == null || page < 1 ? 1 : page;
|
||||
int ps = pageSize == null || pageSize < 1 ? 12 : pageSize;
|
||||
|
||||
long offset = (long) (p - 1) * ps;
|
||||
List<UserWorkFavoriteMapper.MyFavoriteRow> rows = userWorkFavoriteMapper.selectMyFavorites(tenantId, userId, offset, ps);
|
||||
long total = Optional.ofNullable(userWorkFavoriteMapper.countMyFavorites(tenantId, userId)).orElse(0L);
|
||||
|
||||
List<MyFavoritesVO.Item> list = new ArrayList<>();
|
||||
for (UserWorkFavoriteMapper.MyFavoriteRow r : rows) {
|
||||
MyFavoritesVO.Creator creator = new MyFavoritesVO.Creator();
|
||||
creator.setId(r.creatorId());
|
||||
creator.setNickname(r.creatorNickname());
|
||||
creator.setAvatar(r.creatorAvatar());
|
||||
|
||||
MyFavoritesVO.Work work = new MyFavoritesVO.Work();
|
||||
work.setId(r.workId());
|
||||
work.setTitle(r.workTitle());
|
||||
work.setCoverUrl(r.coverUrl());
|
||||
work.setLikeCount(Optional.ofNullable(r.likeCount()).orElse(0));
|
||||
work.setViewCount(Optional.ofNullable(r.viewCount()).orElse(0));
|
||||
work.setFavoriteCount(Optional.ofNullable(r.favoriteCount()).orElse(0));
|
||||
work.setCreator(creator);
|
||||
|
||||
MyFavoritesVO.Item item = new MyFavoritesVO.Item();
|
||||
item.setId(r.favoriteId());
|
||||
item.setWorkId(r.workId());
|
||||
item.setCreateTime(r.favoriteTime());
|
||||
item.setWork(work);
|
||||
|
||||
list.add(item);
|
||||
}
|
||||
|
||||
MyFavoritesVO vo = new MyFavoritesVO();
|
||||
vo.setList(list);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(p);
|
||||
vo.setPageSize(ps);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, InteractionStatusVO> batchGetInteractionStatus(Long tenantId, Long userId, List<Long> workIds) {
|
||||
if (workIds == null || workIds.isEmpty()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
List<UserWorkLike> likes = userWorkLikeMapper.selectList(new LambdaQueryWrapper<UserWorkLike>()
|
||||
.eq(UserWorkLike::getTenantId, tenantId)
|
||||
.eq(UserWorkLike::getUserId, userId)
|
||||
.in(UserWorkLike::getWorkId, workIds)
|
||||
);
|
||||
List<UserWorkFavorite> favorites = userWorkFavoriteMapper.selectList(new LambdaQueryWrapper<UserWorkFavorite>()
|
||||
.eq(UserWorkFavorite::getTenantId, tenantId)
|
||||
.eq(UserWorkFavorite::getUserId, userId)
|
||||
.in(UserWorkFavorite::getWorkId, workIds)
|
||||
);
|
||||
|
||||
Set<Long> likedSet = new HashSet<>();
|
||||
for (UserWorkLike l : likes) likedSet.add(l.getWorkId());
|
||||
|
||||
Set<Long> favoritedSet = new HashSet<>();
|
||||
for (UserWorkFavorite f : favorites) favoritedSet.add(f.getWorkId());
|
||||
|
||||
Map<Long, InteractionStatusVO> result = new HashMap<>();
|
||||
for (Long id : workIds) {
|
||||
InteractionStatusVO vo = new InteractionStatusVO();
|
||||
vo.setLiked(likedSet.contains(id));
|
||||
vo.setFavorited(favoritedSet.contains(id));
|
||||
result.put(id, vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private UserWork ensureWorkExists(Long workId) {
|
||||
UserWork work = userWorkMapper.selectById(workId);
|
||||
if (work == null || work.getIsDeleted() != null && work.getIsDeleted() == 1) {
|
||||
throw new BusinessException("作品不存在或未发布");
|
||||
}
|
||||
if (!"published".equals(work.getStatus())) {
|
||||
throw new BusinessException("作品不存在或未发布");
|
||||
}
|
||||
return work;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,8 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
private final PermissionMapper permissionMapper;
|
||||
private final RoleMenuMapper roleMenuMapper;
|
||||
private final RoleMapper roleMapper;
|
||||
private final TenantMenuMapper tenantMenuMapper;
|
||||
private final TenantMapper tenantMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -47,6 +49,7 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
|
||||
// 创建菜单
|
||||
Menu menu = new Menu();
|
||||
menu.setScene(StringUtils.hasText(dto.getScene()) ? dto.getScene() : "tenant");
|
||||
menu.setName(dto.getName());
|
||||
menu.setPath(dto.getPath());
|
||||
menu.setIcon(dto.getIcon());
|
||||
@ -65,11 +68,18 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
|
||||
@Override
|
||||
public List<MenuTreeVO> tree() {
|
||||
log.info("查询菜单树");
|
||||
return tree(null, null);
|
||||
}
|
||||
|
||||
// 查询所有菜单
|
||||
@Override
|
||||
public List<MenuTreeVO> tree(String scene, Long tenantId) {
|
||||
String resolvedScene = resolveScene(scene, tenantId);
|
||||
log.info("查询菜单树,scene: {}, tenantId: {}", resolvedScene, tenantId);
|
||||
|
||||
// 查询指定场景下的菜单
|
||||
LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Menu::getValidState, 1)
|
||||
.eq(Menu::getScene, resolvedScene)
|
||||
.orderByAsc(Menu::getSort, Menu::getId);
|
||||
|
||||
List<Menu> allMenus = menuMapper.selectList(wrapper);
|
||||
@ -82,6 +92,11 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
public List<MenuTreeVO> getUserMenus(Long userId, Long tenantId) {
|
||||
log.info("查询用户菜单,用户 ID: {}, 租户 ID: {}", userId, tenantId);
|
||||
|
||||
// 租户端:菜单仅由机构开通(TenantMenu)决定,不受角色权限二次裁剪
|
||||
if (!isSuperTenant(tenantId)) {
|
||||
return getTenantEnabledMenusOnly(tenantId);
|
||||
}
|
||||
|
||||
// 1. 查询用户关联的角色
|
||||
List<UserRole> userRoles = userRoleMapper.selectList(
|
||||
new LambdaQueryWrapper<UserRole>()
|
||||
@ -99,18 +114,31 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
.map(UserRole::getRoleId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 2.1 仅保留当前租户下的有效角色(避免跨租户角色串台)
|
||||
List<Role> tenantRoles = roleMapper.selectList(
|
||||
new LambdaQueryWrapper<Role>()
|
||||
.in(Role::getId, roleIds)
|
||||
.eq(Role::getTenantId, tenantId)
|
||||
.eq(Role::getDeleted, 0)
|
||||
.eq(Role::getValidState, 1)
|
||||
);
|
||||
|
||||
if (tenantRoles.isEmpty()) {
|
||||
log.warn("用户在当前租户下没有有效角色,用户 ID: {}, 租户 ID: {}", userId, tenantId);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<Long> tenantRoleIds = tenantRoles.stream().map(Role::getId).collect(Collectors.toList());
|
||||
|
||||
// 3. 判断是否超级管理员(角色 code=super_admin 或权限 code=super_admin 兜底)
|
||||
boolean hasSuperAdmin = false;
|
||||
List<Role> roles = roleMapper.selectBatchIds(roleIds);
|
||||
if (roles != null) {
|
||||
hasSuperAdmin = roles.stream().anyMatch(r -> "super_admin".equals(r.getCode()));
|
||||
}
|
||||
hasSuperAdmin = tenantRoles.stream().anyMatch(r -> "super_admin".equals(r.getCode()));
|
||||
|
||||
if (!hasSuperAdmin) {
|
||||
// 兜底:如果权限中包含 super_admin,也视为超管
|
||||
List<RolePermission> rolePermissionsForSuper = rolePermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePermission>()
|
||||
.in(RolePermission::getRoleId, roleIds)
|
||||
.in(RolePermission::getRoleId, tenantRoleIds)
|
||||
);
|
||||
if (!rolePermissionsForSuper.isEmpty()) {
|
||||
List<Long> permissionIds = rolePermissionsForSuper.stream()
|
||||
@ -123,38 +151,77 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询菜单(超管返回所有;非超管按角色-菜单关联取并集 + 补齐祖先节点)
|
||||
LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Menu::getValidState, 1)
|
||||
// 4) super_admin 快速路径:跳过 TenantMenu 与 permission 过滤,直接返回全部有效菜单
|
||||
LambdaQueryWrapper<Menu> allValidWrapper = new LambdaQueryWrapper<>();
|
||||
allValidWrapper.eq(Menu::getValidState, 1)
|
||||
.orderByAsc(Menu::getSort, Menu::getId);
|
||||
List<Menu> allValidMenus = menuMapper.selectList(allValidWrapper);
|
||||
|
||||
List<Menu> allMenus;
|
||||
if (hasSuperAdmin) {
|
||||
// 超管:查询所有菜单
|
||||
log.info("用户是超级管理员,返回所有菜单");
|
||||
allMenus = menuMapper.selectList(wrapper);
|
||||
} else {
|
||||
// 非超管:按角色-菜单关联表控制可见性
|
||||
List<RoleMenu> roleMenus = roleMenuMapper.selectList(
|
||||
new LambdaQueryWrapper<RoleMenu>()
|
||||
.in(RoleMenu::getRoleId, roleIds)
|
||||
);
|
||||
if (roleMenus.isEmpty()) {
|
||||
log.warn("用户角色未配置任何菜单可见性,用户 ID: {}", userId);
|
||||
return new ArrayList<>();
|
||||
log.info("用户是 super_admin,返回全部有效菜单(跳过 TenantMenu 与 permission 过滤)");
|
||||
return buildTree(allValidMenus, 0L);
|
||||
}
|
||||
|
||||
Set<Long> visibleMenuIds = roleMenus.stream()
|
||||
.map(RoleMenu::getMenuId)
|
||||
// 5) 平台端非 super_admin:按文档 findUserMenus 逻辑过滤(TenantMenu 范围 + permission 可见性)
|
||||
// 5.1 获取用户权限码列表
|
||||
Set<String> userPermissionCodes = new HashSet<>();
|
||||
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePermission>()
|
||||
.in(RolePermission::getRoleId, tenantRoleIds)
|
||||
);
|
||||
if (rolePermissions != null && !rolePermissions.isEmpty()) {
|
||||
List<Long> permissionIds = rolePermissions.stream()
|
||||
.map(RolePermission::getPermissionId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
|
||||
if (permissions != null) {
|
||||
userPermissionCodes.addAll(permissions.stream()
|
||||
.filter(p -> p != null && p.getValidState() != null && p.getValidState() == 1)
|
||||
.map(Permission::getCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
// 5.2 获取租户已分配菜单 ID 列表
|
||||
List<TenantMenu> tenantMenus = tenantMenuMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantMenu>()
|
||||
.eq(TenantMenu::getTenantId, tenantId)
|
||||
);
|
||||
if (tenantMenus == null || tenantMenus.isEmpty()) {
|
||||
log.warn("租户未开通任何菜单(tenant_menu 为空),tenantId: {}", tenantId);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<Long> tenantMenuIds = tenantMenus.stream()
|
||||
.map(TenantMenu::getMenuId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 拉取全量菜单用于补齐祖先节点,然后再裁剪
|
||||
List<Menu> all = menuMapper.selectList(wrapper);
|
||||
// 5.3 过滤:在租户菜单范围内 AND(无 permission 或用户拥有 permission)
|
||||
Map<Long, Menu> menuMap = new HashMap<>();
|
||||
for (Menu m : all) {
|
||||
for (Menu m : allValidMenus) {
|
||||
menuMap.put(m.getId(), m);
|
||||
}
|
||||
|
||||
Set<Long> visibleMenuIds = new HashSet<>();
|
||||
for (Menu m : allValidMenus) {
|
||||
if (m == null) {
|
||||
continue;
|
||||
}
|
||||
if (!tenantMenuIds.contains(m.getId())) {
|
||||
continue;
|
||||
}
|
||||
String perm = m.getPermission();
|
||||
if (!StringUtils.hasText(perm) || userPermissionCodes.contains(perm)) {
|
||||
visibleMenuIds.add(m.getId());
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleMenuIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 5.4 补齐祖先节点(用于构建树)
|
||||
Set<Long> withAncestors = new HashSet<>(visibleMenuIds);
|
||||
for (Long mid : visibleMenuIds) {
|
||||
Menu cur = menuMap.get(mid);
|
||||
@ -163,22 +230,81 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
if (pid == null || pid == 0L) {
|
||||
break;
|
||||
}
|
||||
if (withAncestors.add(pid)) {
|
||||
withAncestors.add(pid);
|
||||
cur = menuMap.get(pid);
|
||||
} else {
|
||||
// 已存在,继续向上也可能已处理过
|
||||
cur = menuMap.get(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allMenus = all.stream()
|
||||
List<Menu> visibleMenus = allValidMenus.stream()
|
||||
.filter(m -> withAncestors.contains(m.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return buildTree(visibleMenus, 0L);
|
||||
}
|
||||
|
||||
// 5. 构建树形结构
|
||||
return buildTree(allMenus, 0L);
|
||||
private boolean isSuperTenant(Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return false;
|
||||
}
|
||||
Tenant tenant = tenantMapper.selectById(tenantId);
|
||||
if (tenant == null) {
|
||||
return false;
|
||||
}
|
||||
boolean isSuper = tenant.getIsSuper() != null && tenant.getIsSuper() == 1;
|
||||
boolean isPlatformType = "platform".equals(tenant.getTenantType());
|
||||
boolean isSuperCode = "super".equals(tenant.getCode());
|
||||
return isSuper || isPlatformType || isSuperCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户端菜单:只按 TenantMenu 开通范围返回(不做 permission 裁剪)
|
||||
*/
|
||||
private List<MenuTreeVO> getTenantEnabledMenusOnly(Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<TenantMenu> tenantMenus = tenantMenuMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantMenu>()
|
||||
.eq(TenantMenu::getTenantId, tenantId)
|
||||
);
|
||||
if (tenantMenus == null || tenantMenus.isEmpty()) {
|
||||
log.warn("租户未开通任何菜单(tenant_menu 为空),tenantId: {}", tenantId);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Set<Long> enabledMenuIds = tenantMenus.stream()
|
||||
.map(TenantMenu::getMenuId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 拉取全部有效菜单(全局模板),再按 enabledMenuIds 裁剪并补齐祖先节点
|
||||
List<Menu> allValidMenus = menuMapper.selectList(new LambdaQueryWrapper<Menu>()
|
||||
.eq(Menu::getValidState, 1)
|
||||
.orderByAsc(Menu::getSort, Menu::getId));
|
||||
|
||||
Map<Long, Menu> menuMap = new HashMap<>();
|
||||
for (Menu m : allValidMenus) {
|
||||
menuMap.put(m.getId(), m);
|
||||
}
|
||||
|
||||
Set<Long> withAncestors = new HashSet<>(enabledMenuIds);
|
||||
for (Long mid : enabledMenuIds) {
|
||||
Menu cur = menuMap.get(mid);
|
||||
while (cur != null) {
|
||||
Long pid = cur.getParentId();
|
||||
if (pid == null || pid == 0L) {
|
||||
break;
|
||||
}
|
||||
withAncestors.add(pid);
|
||||
cur = menuMap.get(pid);
|
||||
}
|
||||
}
|
||||
|
||||
List<Menu> visibleMenus = allValidMenus.stream()
|
||||
.filter(m -> withAncestors.contains(m.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return buildTree(visibleMenus, 0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -206,6 +332,9 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
// 更新菜单信息
|
||||
Menu menu = new Menu();
|
||||
menu.setId(id);
|
||||
if (StringUtils.hasText(dto.getScene())) {
|
||||
menu.setScene(dto.getScene());
|
||||
}
|
||||
menu.setName(dto.getName());
|
||||
menu.setPath(dto.getPath());
|
||||
menu.setIcon(dto.getIcon());
|
||||
@ -266,6 +395,7 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
*/
|
||||
private MenuDetailVO convertToDetailVO(Menu menu) {
|
||||
MenuDetailVO vo = new MenuDetailVO();
|
||||
vo.setScene(menu.getScene());
|
||||
vo.setId(menu.getId());
|
||||
vo.setName(menu.getName());
|
||||
vo.setPath(menu.getPath());
|
||||
@ -287,6 +417,7 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
*/
|
||||
private MenuTreeVO convertToTreeVO(Menu menu) {
|
||||
MenuTreeVO vo = new MenuTreeVO();
|
||||
vo.setScene(menu.getScene());
|
||||
vo.setId(menu.getId());
|
||||
vo.setName(menu.getName());
|
||||
vo.setPath(menu.getPath());
|
||||
@ -297,4 +428,76 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
vo.setSort(menu.getSort());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String resolveScene(String scene, Long tenantId) {
|
||||
if (StringUtils.hasText(scene)) {
|
||||
return scene;
|
||||
}
|
||||
if (tenantId == null) {
|
||||
return "tenant";
|
||||
}
|
||||
Tenant tenant = tenantMapper.selectById(tenantId);
|
||||
if (tenant != null) {
|
||||
// 兜底:即使历史数据未回填 is_super,只要 tenant_type=platform 也视为平台租户
|
||||
boolean isSuper = tenant.getIsSuper() != null && tenant.getIsSuper() == 1;
|
||||
boolean isPlatformType = "platform".equals(tenant.getTenantType());
|
||||
// 文档约定:超管租户 code=super
|
||||
boolean isSuperCode = "super".equals(tenant.getCode());
|
||||
if (isSuper || isPlatformType || isSuperCode) {
|
||||
return "portal";
|
||||
}
|
||||
}
|
||||
return "tenant";
|
||||
}
|
||||
|
||||
private List<MenuTreeVO> getTenantUserMenus(Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 1) 查询租户已开通菜单
|
||||
List<TenantMenu> tenantMenus = tenantMenuMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantMenu>()
|
||||
.eq(TenantMenu::getTenantId, tenantId)
|
||||
);
|
||||
if (tenantMenus == null || tenantMenus.isEmpty()) {
|
||||
log.warn("租户未开通任何菜单(tenant_menu 为空),tenantId: {}", tenantId);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Set<Long> enabledMenuIds = tenantMenus.stream()
|
||||
.map(TenantMenu::getMenuId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 2) 拉取租户端全量菜单,用于补齐祖先节点,再按 enabledMenuIds 裁剪
|
||||
LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Menu::getValidState, 1)
|
||||
.eq(Menu::getScene, "tenant")
|
||||
.orderByAsc(Menu::getSort, Menu::getId);
|
||||
List<Menu> allTenantMenus = menuMapper.selectList(wrapper);
|
||||
|
||||
Map<Long, Menu> menuMap = new HashMap<>();
|
||||
for (Menu m : allTenantMenus) {
|
||||
menuMap.put(m.getId(), m);
|
||||
}
|
||||
|
||||
Set<Long> withAncestors = new HashSet<>(enabledMenuIds);
|
||||
for (Long mid : enabledMenuIds) {
|
||||
Menu cur = menuMap.get(mid);
|
||||
while (cur != null) {
|
||||
Long pid = cur.getParentId();
|
||||
if (pid == null || pid == 0L) {
|
||||
break;
|
||||
}
|
||||
withAncestors.add(pid);
|
||||
cur = menuMap.get(pid);
|
||||
}
|
||||
}
|
||||
|
||||
List<Menu> visible = allTenantMenus.stream()
|
||||
.filter(m -> withAncestors.contains(m.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return buildTree(visible, 0L);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import com.lesingle.creation.mapper.PermissionMapper;
|
||||
import com.lesingle.creation.mapper.RoleMenuMapper;
|
||||
import com.lesingle.creation.mapper.RoleMapper;
|
||||
import com.lesingle.creation.mapper.RolePermissionMapper;
|
||||
import com.lesingle.creation.mapper.TenantMapper;
|
||||
import com.lesingle.creation.service.RoleService;
|
||||
import com.lesingle.creation.vo.role.RoleDetailVO;
|
||||
import com.lesingle.creation.vo.role.RoleListVO;
|
||||
@ -39,6 +40,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
private final RolePermissionMapper rolePermissionMapper;
|
||||
private final PermissionMapper permissionMapper;
|
||||
private final RoleMenuMapper roleMenuMapper;
|
||||
private final TenantMapper tenantMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -79,6 +81,9 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
|
||||
// 如果提供了菜单 ID,创建菜单关联(用于菜单可见性授权)
|
||||
if (!CollectionUtils.isEmpty(dto.getMenuIds())) {
|
||||
if (!isPortalTenant(tenantId)) {
|
||||
throw new BusinessException("当前租户不支持角色菜单授权,请在机构管理中配置可用菜单");
|
||||
}
|
||||
List<RoleMenu> roleMenus = dto.getMenuIds().stream()
|
||||
.distinct()
|
||||
.map(menuId -> {
|
||||
@ -190,6 +195,9 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
|
||||
// 如果提供了 menuIds,更新菜单关联(菜单可见性授权)
|
||||
if (dto.getMenuIds() != null) {
|
||||
if (!isPortalTenant(tenantId)) {
|
||||
throw new BusinessException("当前租户不支持角色菜单授权,请在机构管理中配置可用菜单");
|
||||
}
|
||||
LambdaQueryWrapper<RoleMenu> rmWrapper = new LambdaQueryWrapper<>();
|
||||
rmWrapper.eq(RoleMenu::getRoleId, id);
|
||||
roleMenuMapper.delete(rmWrapper);
|
||||
@ -209,6 +217,13 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
}
|
||||
}
|
||||
|
||||
// 普通租户:清理历史 role_menu,避免遗留数据造成误解(租户端菜单不依赖 role_menu)
|
||||
if (!isPortalTenant(tenantId)) {
|
||||
LambdaQueryWrapper<RoleMenu> rmWrapper = new LambdaQueryWrapper<>();
|
||||
rmWrapper.eq(RoleMenu::getRoleId, id);
|
||||
roleMenuMapper.delete(rmWrapper);
|
||||
}
|
||||
|
||||
return convertToDetailVO(roleMapper.selectById(id));
|
||||
}
|
||||
|
||||
@ -291,7 +306,8 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
vo.setPermissionNames(names);
|
||||
}
|
||||
|
||||
// 获取角色菜单(用于菜单授权回显)
|
||||
// 获取角色菜单(仅平台端用于菜单授权回显;租户端角色不参与菜单可见性)
|
||||
if (isPortalTenant(role.getTenantId())) {
|
||||
LambdaQueryWrapper<RoleMenu> rmWrapper = new LambdaQueryWrapper<>();
|
||||
rmWrapper.eq(RoleMenu::getRoleId, role.getId());
|
||||
List<RoleMenu> roleMenus = roleMenuMapper.selectList(rmWrapper);
|
||||
@ -302,10 +318,27 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
vo.setMenuIds(menuIds);
|
||||
} else {
|
||||
vo.setMenuIds(new ArrayList<>());
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
private boolean isPortalTenant(Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return false;
|
||||
}
|
||||
var tenant = tenantMapper.selectById(tenantId);
|
||||
if (tenant == null) {
|
||||
return false;
|
||||
}
|
||||
boolean isSuper = tenant.getIsSuper() != null && tenant.getIsSuper() == 1;
|
||||
boolean isPlatformType = "platform".equals(tenant.getTenantType());
|
||||
boolean isSuperCode = "super".equals(tenant.getCode());
|
||||
return isSuper || isPlatformType || isSuperCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为列表 VO
|
||||
*/
|
||||
|
||||
@ -6,13 +6,16 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.lesingle.creation.common.exception.BusinessException;
|
||||
import com.lesingle.creation.dto.tenant.CreateTenantDTO;
|
||||
import com.lesingle.creation.dto.tenant.UpdateTenantDTO;
|
||||
import com.lesingle.creation.entity.Menu;
|
||||
import com.lesingle.creation.entity.Tenant;
|
||||
import com.lesingle.creation.entity.TenantMenu;
|
||||
import com.lesingle.creation.entity.User;
|
||||
import com.lesingle.creation.mapper.MenuMapper;
|
||||
import com.lesingle.creation.mapper.TenantMapper;
|
||||
import com.lesingle.creation.mapper.UserMapper;
|
||||
import com.lesingle.creation.service.TenantMenuService;
|
||||
import com.lesingle.creation.service.TenantService;
|
||||
import com.lesingle.creation.vo.menu.MenuTreeVO;
|
||||
import com.lesingle.creation.vo.tenant.TenantDetailVO;
|
||||
import com.lesingle.creation.vo.tenant.TenantListVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -22,7 +25,11 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -36,6 +43,7 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
||||
private final TenantMapper tenantMapper;
|
||||
private final TenantMenuService tenantMenuService;
|
||||
private final UserMapper userMapper;
|
||||
private final MenuMapper menuMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -240,10 +248,78 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
||||
throw new BusinessException("租户不存在");
|
||||
}
|
||||
|
||||
// TODO: 实现获取租户菜单树逻辑
|
||||
// 1) 读取租户已开通菜单
|
||||
List<TenantMenu> tenantMenus = tenantMenuService.list(
|
||||
new LambdaQueryWrapper<TenantMenu>()
|
||||
.eq(TenantMenu::getTenantId, tenantId)
|
||||
);
|
||||
if (tenantMenus == null || tenantMenus.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Set<Long> enabledMenuIds = tenantMenus.stream()
|
||||
.map(TenantMenu::getMenuId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 2) 拉取租户端全量菜单(scene=tenant),用于补齐祖先节点再裁剪
|
||||
List<Menu> allTenantMenus = menuMapper.selectList(
|
||||
new LambdaQueryWrapper<Menu>()
|
||||
.eq(Menu::getValidState, 1)
|
||||
.eq(Menu::getScene, "tenant")
|
||||
.orderByAsc(Menu::getSort, Menu::getId)
|
||||
);
|
||||
|
||||
Map<Long, Menu> menuMap = new HashMap<>();
|
||||
for (Menu m : allTenantMenus) {
|
||||
menuMap.put(m.getId(), m);
|
||||
}
|
||||
|
||||
Set<Long> withAncestors = new HashSet<>(enabledMenuIds);
|
||||
for (Long mid : enabledMenuIds) {
|
||||
Menu cur = menuMap.get(mid);
|
||||
while (cur != null) {
|
||||
Long pid = cur.getParentId();
|
||||
if (pid == null || pid == 0L) {
|
||||
break;
|
||||
}
|
||||
withAncestors.add(pid);
|
||||
cur = menuMap.get(pid);
|
||||
}
|
||||
}
|
||||
|
||||
List<Menu> visible = allTenantMenus.stream()
|
||||
.filter(m -> withAncestors.contains(m.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return buildMenuTreeVO(visible, 0L);
|
||||
}
|
||||
|
||||
private List<MenuTreeVO> buildMenuTreeVO(List<Menu> menus, Long parentId) {
|
||||
return menus.stream()
|
||||
.filter(menu -> {
|
||||
Long pid = menu.getParentId();
|
||||
if (pid == null) {
|
||||
pid = 0L;
|
||||
}
|
||||
return pid.equals(parentId);
|
||||
})
|
||||
.map(menu -> {
|
||||
MenuTreeVO vo = new MenuTreeVO();
|
||||
vo.setScene(menu.getScene());
|
||||
vo.setId(menu.getId());
|
||||
vo.setName(menu.getName());
|
||||
vo.setPath(menu.getPath());
|
||||
vo.setIcon(menu.getIcon());
|
||||
vo.setComponent(menu.getComponent());
|
||||
vo.setParentId(menu.getParentId());
|
||||
vo.setPermission(menu.getPermission());
|
||||
vo.setSort(menu.getSort());
|
||||
vo.setChildren(buildMenuTreeVO(menus, menu.getId()));
|
||||
return vo;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为详情 VO
|
||||
*/
|
||||
|
||||
@ -88,6 +88,7 @@ public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
// 查询租户信息
|
||||
Tenant tenant = tenantMapper.selectById(user.getTenantId());
|
||||
String tenantCode = tenant != null ? tenant.getCode() : "default";
|
||||
boolean isSuperTenant = tenant != null && tenant.getIsSuper() != null && tenant.getIsSuper() == 1;
|
||||
|
||||
// 查询用户角色
|
||||
List<UserRole> userRoles = userRoleMapper.selectList(new LambdaQueryWrapper<UserRole>()
|
||||
@ -101,16 +102,37 @@ public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
List<Role> roles = roleMapper.selectList(
|
||||
new LambdaQueryWrapper<Role>()
|
||||
.in(Role::getId, roleIds)
|
||||
.eq(Role::getDeleted, 0));
|
||||
.eq(Role::getTenantId, user.getTenantId())
|
||||
.eq(Role::getDeleted, 0)
|
||||
.eq(Role::getValidState, 1));
|
||||
for (Role role : roles) {
|
||||
if (StringUtils.hasText(role.getCode())) {
|
||||
authorityStrings.add("ROLE_" + role.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
// 只使用当前租户有效角色ID,避免跨租户角色串台
|
||||
List<Long> tenantRoleIds = roles.stream().map(Role::getId).distinct().collect(Collectors.toList());
|
||||
if (tenantRoleIds.isEmpty()) {
|
||||
authorityStrings.addAll(permissionCodes);
|
||||
List<SimpleGrantedAuthority> authorities = authorityStrings.stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
UserPrincipal userPrincipal = new UserPrincipal(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getPassword(),
|
||||
authorities,
|
||||
user.getTenantId(),
|
||||
tenantCode,
|
||||
isSuperTenant
|
||||
);
|
||||
return userPrincipal;
|
||||
}
|
||||
|
||||
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePermission>()
|
||||
.in(RolePermission::getRoleId, roleIds)
|
||||
.in(RolePermission::getRoleId, tenantRoleIds)
|
||||
);
|
||||
|
||||
if (!rolePermissions.isEmpty()) {
|
||||
@ -144,7 +166,7 @@ public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
authorities,
|
||||
user.getTenantId(),
|
||||
tenantCode,
|
||||
"platform".equals(tenantCode) // platform 租户为超级租户
|
||||
isSuperTenant
|
||||
);
|
||||
|
||||
log.debug("用户加载成功:{}, 租户 ID: {}", user.getUsername(), user.getTenantId());
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
package com.lesingle.creation.vo.ai3d;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI 3D 任务 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "AI 3D 任务响应")
|
||||
public class AI3DTaskVO {
|
||||
|
||||
@Schema(description = "任务 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "输入类型:text/image")
|
||||
private String inputType;
|
||||
|
||||
@Schema(description = "输入内容")
|
||||
private String inputContent;
|
||||
|
||||
@Schema(description = "生成类型:Normal/Geometry/LowPoly/Sketch")
|
||||
private String generateType;
|
||||
|
||||
@Schema(description = "任务状态:pending/processing/completed/failed/timeout")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "生成的 3D 模型 URL")
|
||||
private String resultUrl;
|
||||
|
||||
@Schema(description = "预览图 URL")
|
||||
private String previewUrl;
|
||||
|
||||
@Schema(description = "生成的 3D 模型 URL 数组(JSON)")
|
||||
private String resultUrls;
|
||||
|
||||
@Schema(description = "预览图 URL 数组(JSON)")
|
||||
private String previewUrls;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
@Schema(description = "外部 AI 服务任务 ID")
|
||||
private String externalTaskId;
|
||||
|
||||
@Schema(description = "已重试次数")
|
||||
private Integer retryCount;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "完成时间")
|
||||
private LocalDateTime completeTime;
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.lesingle.creation.vo.analytics;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "运营概览")
|
||||
public class AnalyticsOverviewVO {
|
||||
|
||||
@Schema(description = "汇总指标")
|
||||
private Summary summary;
|
||||
|
||||
@Schema(description = "漏斗数据")
|
||||
private Funnel funnel;
|
||||
|
||||
@Schema(description = "月度趋势")
|
||||
private List<MonthlyTrendItem> monthlyTrend;
|
||||
|
||||
@Schema(description = "活动对比")
|
||||
private List<ContestComparisonItem> contestComparison;
|
||||
|
||||
@Data
|
||||
public static class Summary {
|
||||
private Integer totalContests;
|
||||
private Long totalRegistrations;
|
||||
private Long passedRegistrations;
|
||||
private Long totalWorks;
|
||||
private Long reviewedWorks;
|
||||
private Long awardedWorks;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Funnel {
|
||||
private Long registered;
|
||||
private Long passed;
|
||||
private Long submitted;
|
||||
private Long reviewed;
|
||||
private Long awarded;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MonthlyTrendItem {
|
||||
private String month;
|
||||
private Long registrations;
|
||||
private Long works;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ContestComparisonItem {
|
||||
private Long contestId;
|
||||
private String contestName;
|
||||
private Long registrations;
|
||||
private Integer passRate;
|
||||
private Integer submitRate;
|
||||
private Integer reviewRate;
|
||||
private Integer awardRate;
|
||||
private Double avgScore;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
package com.lesingle.creation.vo.analytics;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "评审分析")
|
||||
public class AnalyticsReviewVO {
|
||||
|
||||
@Schema(description = "评审效率")
|
||||
private Efficiency efficiency;
|
||||
|
||||
@Schema(description = "评委工作量")
|
||||
private List<JudgeWorkloadItem> judgeWorkload;
|
||||
|
||||
@Schema(description = "奖项分布")
|
||||
private List<AwardDistributionItem> awardDistribution;
|
||||
|
||||
@Data
|
||||
public static class Efficiency {
|
||||
private Double avgReviewDays;
|
||||
private Double dailyReviewCount;
|
||||
private Long pendingAssignments;
|
||||
private Double avgScoreStddev;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class JudgeWorkloadItem {
|
||||
private Long judgeId;
|
||||
private String judgeName;
|
||||
private Integer contestCount;
|
||||
private Long assignedCount;
|
||||
private Long scoredCount;
|
||||
private Integer completionRate;
|
||||
private Double avgScore;
|
||||
private Double scoreStddev;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AwardDistributionItem {
|
||||
private String awardName;
|
||||
private Long count;
|
||||
private Integer percentage;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@ import java.util.List;
|
||||
@Schema(description = "菜单详情响应")
|
||||
public class MenuDetailVO {
|
||||
|
||||
@Schema(description = "菜单场景:portal-平台端,tenant-租户端")
|
||||
private String scene;
|
||||
|
||||
@Schema(description = "菜单 ID")
|
||||
private Long id;
|
||||
|
||||
|
||||
@ -13,6 +13,9 @@ import java.util.List;
|
||||
@Schema(description = "菜单树响应")
|
||||
public class MenuTreeVO {
|
||||
|
||||
@Schema(description = "菜单场景:portal-平台端,tenant-租户端")
|
||||
private String scene;
|
||||
|
||||
@Schema(description = "菜单 ID")
|
||||
private Long id;
|
||||
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.lesingle.creation.vo.publicwork;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "作品交互状态")
|
||||
public class InteractionStatusVO {
|
||||
|
||||
@Schema(description = "是否已点赞")
|
||||
private Boolean liked;
|
||||
|
||||
@Schema(description = "是否已收藏")
|
||||
private Boolean favorited;
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package com.lesingle.creation.vo.publicwork;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "我的收藏列表")
|
||||
public class MyFavoritesVO {
|
||||
|
||||
private List<Item> list;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
private Integer pageSize;
|
||||
|
||||
@Data
|
||||
public static class Item {
|
||||
private Long id;
|
||||
private Long workId;
|
||||
private LocalDateTime createTime;
|
||||
private Work work;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Work {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String coverUrl;
|
||||
private Integer likeCount;
|
||||
private Integer viewCount;
|
||||
private Integer favoriteCount;
|
||||
private Creator creator;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Creator {
|
||||
private Long id;
|
||||
private String nickname;
|
||||
private String avatar;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.lesingle.creation.vo.publicwork;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "收藏/取消收藏响应")
|
||||
public class ToggleFavoriteVO {
|
||||
|
||||
@Schema(description = "是否已收藏")
|
||||
private Boolean favorited;
|
||||
|
||||
@Schema(description = "最新收藏数")
|
||||
private Integer favoriteCount;
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.lesingle.creation.vo.publicwork;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "点赞/取消点赞响应")
|
||||
public class ToggleLikeVO {
|
||||
|
||||
@Schema(description = "是否已点赞")
|
||||
private Boolean liked;
|
||||
|
||||
@Schema(description = "最新点赞数")
|
||||
private Integer likeCount;
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
-- UGC 作品点赞/收藏明细表(对齐 Nest/Prisma 的 interaction 能力)
|
||||
-- 说明:
|
||||
-- 1) 计数冗余字段已在 user_works.like_count / favorite_count 中存在,本迁移只补齐明细记录表
|
||||
-- 2) 明细表增加 tenant_id,便于多租户隔离与统计
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_work_likes` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` BIGINT NOT NULL COMMENT '租户 ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '点赞用户 ID(t_sys_user.id)',
|
||||
`work_id` BIGINT NOT NULL COMMENT '作品 ID(user_works.id)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_user_work` (`tenant_id`, `user_id`, `work_id`),
|
||||
KEY `idx_tenant_work` (`tenant_id`, `work_id`),
|
||||
KEY `idx_tenant_user` (`tenant_id`, `user_id`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='UGC 作品点赞明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_work_favorites` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` BIGINT NOT NULL COMMENT '租户 ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '收藏用户 ID(t_sys_user.id)',
|
||||
`work_id` BIGINT NOT NULL COMMENT '作品 ID(user_works.id)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_user_work` (`tenant_id`, `user_id`, `work_id`),
|
||||
KEY `idx_tenant_work` (`tenant_id`, `work_id`),
|
||||
KEY `idx_tenant_user` (`tenant_id`, `user_id`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='UGC 作品收藏明细';
|
||||
|
||||
@ -0,0 +1,209 @@
|
||||
-- ============================================
|
||||
-- 初始化多端角色(学校管理端/教师端/学生端/评委端)& 补齐菜单/路由使用的权限码(幂等)
|
||||
-- 说明:
|
||||
-- 1) 前端菜单转换为路由时,会把 menu.permission 写入 route.meta.permissions
|
||||
-- 因此必须保证这些 permission code 在 t_auth_permission 中存在,并绑定到角色
|
||||
-- 2) 本脚本尽量保持幂等:INSERT ... ON DUPLICATE KEY UPDATE
|
||||
-- ============================================
|
||||
|
||||
-- 1. 补齐前端路由/菜单用到但历史脚本未覆盖的权限码(tenant_id=1)
|
||||
INSERT INTO `t_auth_permission`
|
||||
(`tenant_id`, `name`, `code`, `resource`, `action`, `description`, `valid_state`)
|
||||
VALUES
|
||||
-- 菜单层(用于菜单/路由 meta 权限校验)
|
||||
(1, '报名管理', 'contest:registration', 'contest', 'registration', '报名管理菜单权限', 1),
|
||||
(1, '作品管理', 'contest:work', 'contest', 'work', '作品管理菜单权限', 1),
|
||||
(1, '评审管理', 'contest:review', 'contest', 'review', '评审管理菜单权限', 1),
|
||||
(1, '评委管理', 'contest:judge', 'contest', 'judge', '评委管理菜单权限', 1),
|
||||
(1, '评审结果', 'contest:result', 'contest', 'result', '评审结果菜单权限', 1),
|
||||
(1, '评审规则', 'contest:rule', 'contest', 'rule', '评审规则菜单权限', 1),
|
||||
(1, '公告管理', 'contest:notice', 'contest', 'notice', '公告管理菜单权限', 1),
|
||||
|
||||
-- 路由层(baseRoutes 中使用)
|
||||
(1, '活动访问', 'activity:read', 'activity', 'read', '访问活动相关页面权限', 1),
|
||||
(1, '报名查询', 'registration:read', 'registration', 'read', '查询报名相关数据权限', 1),
|
||||
(1, '作品查询', 'work:read', 'work', 'read', '查询作品相关数据权限', 1),
|
||||
(1, '作业查询', 'homework:read', 'homework', 'read', '查询作业相关数据权限', 1),
|
||||
(1, '评审评分', 'review:score', 'review', 'score', '评审评分页面权限(兼容前端路由)', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`resource` = VALUES(`resource`),
|
||||
`action` = VALUES(`action`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 2. 初始化四端角色(tenant_id=1,幂等)
|
||||
INSERT INTO `t_auth_role` (`tenant_id`, `name`, `code`, `description`, `valid_state`)
|
||||
VALUES
|
||||
(1, '学校管理员', 'school_admin', '学校管理端角色', 1),
|
||||
(1, '教师', 'teacher', '教师端角色', 1),
|
||||
(1, '学生', 'student', '学生端角色', 1),
|
||||
(1, '评委', 'judge', '评委端角色', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 3. 增量补齐多端菜单(tenant_id=1)
|
||||
-- 新增一级菜单:活动中心(id=30)
|
||||
INSERT INTO t_auth_menu (id, name, path, icon, component, parent_id, permission, sort, valid_state)
|
||||
VALUES (30, '活动中心', 'activities', 'Calendar', NULL, 0, NULL, 5, 1)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
-- 活动中心子菜单
|
||||
INSERT INTO t_auth_menu (name, path, icon, component, parent_id, permission, sort, valid_state)
|
||||
VALUES
|
||||
('我的指导', 'guidance', 'Bulb', 'activities/Guidance', 30, 'activity:read', 1, 1),
|
||||
('评审任务', 'review', 'Audit', 'activities/Review', 30, 'contest:review:score', 2, 1),
|
||||
('预设评语', 'preset-comments', 'Message', 'activities/PresetComments', 30, 'contest:preset-comment:create', 3, 1)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
-- 新增一级菜单:作业中心(id=31)
|
||||
INSERT INTO t_auth_menu (id, name, path, icon, component, parent_id, permission, sort, valid_state)
|
||||
VALUES (31, '作业中心', 'homework', 'Book', 'homework/Index', 0, 'homework:read', 6, 1)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
-- 作业中心子菜单
|
||||
INSERT INTO t_auth_menu (name, path, icon, component, parent_id, permission, sort, valid_state)
|
||||
VALUES
|
||||
('学生作业', 'students', 'Team', 'homework/StudentList', 31, 'homework:read', 1, 1),
|
||||
('提交记录', 'submissions', 'FileText', 'homework/Submissions', 31, 'homework:read', 2, 1),
|
||||
('评审规则', 'review-rules', 'Setting', 'homework/ReviewRules', 31, 'homework:review-rule:create', 3, 1)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
-- 4. 角色 - 权限绑定(tenant_id=1,幂等)
|
||||
-- 约定:按角色 code + 权限 code 做绑定,避免硬编码 id
|
||||
|
||||
-- 4.1 学校管理员:学校/组织/人员全量 + 活动管理常用 + 作业查询
|
||||
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'school_admin'
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code IN (
|
||||
'school:create','school:read','school:update','school:delete',
|
||||
'department:create','department:read','department:update','department:delete',
|
||||
'grade:create','grade:read','grade:update','grade:delete',
|
||||
'class:create','class:read','class:update','class:delete',
|
||||
'teacher:create','teacher:read','teacher:update','teacher:delete',
|
||||
'student:create','student:read','student:update','student:delete',
|
||||
'contest:read','contest:create','contest:update','contest:publish',
|
||||
'contest:registration','contest:work','contest:review','contest:judge','contest:result','contest:rule','contest:notice',
|
||||
'registration:read','work:read','homework:read','activity:read',
|
||||
'contest:notice:create','contest:notice:update','contest:notice:delete','contest:notice:publish',
|
||||
'contest:judge:create','contest:judge:update','contest:judge:delete',
|
||||
'contest:review-rule:create','contest:review-rule:update','contest:review-rule:delete',
|
||||
'contest:review:assign'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 4.2 教师:活动访问 + 作业访问 + 指导作品查询
|
||||
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'teacher'
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code IN (
|
||||
'activity:read','contest:read',
|
||||
'homework:read','homework:review','homework:review-rule:create','homework:review-rule:update','homework:review-rule:delete',
|
||||
'work:read'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 4.3 学生:活动访问 + 报名/作品提交 + 作业访问
|
||||
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'student'
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code IN (
|
||||
'activity:read','contest:read',
|
||||
'contest:register',
|
||||
'contest:work:submit','contest:work:update',
|
||||
'homework:read',
|
||||
'registration:read','work:read'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 4.4 评委:评审评分 + 预设评语 + 活动访问
|
||||
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'judge'
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code IN (
|
||||
'activity:read','contest:read',
|
||||
'contest:review:score',
|
||||
'contest:preset-comment:create','contest:preset-comment:delete',
|
||||
'review:score'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 5. 角色 - 菜单可见性绑定(tenant_id=1,幂等)
|
||||
|
||||
-- 5.1 学校管理员:工作台 + 活动管理 + 学校管理 + 活动中心 + 作业中心
|
||||
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_menu` m ON m.deleted = 0
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'school_admin'
|
||||
AND (
|
||||
m.id IN (1,2,12,30,31)
|
||||
OR m.parent_id IN (1,2,12,30,31)
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 5.2 教师:工作台 + 活动中心 + 作业中心
|
||||
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_menu` m ON m.deleted = 0
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'teacher'
|
||||
AND (
|
||||
m.id IN (1,30,31)
|
||||
OR m.parent_id IN (1,30,31)
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 5.3 学生:工作台 + 作业中心
|
||||
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_menu` m ON m.deleted = 0
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'student'
|
||||
AND (
|
||||
m.id IN (1,31)
|
||||
OR m.parent_id IN (1,31)
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 5.4 评委:工作台 + 活动中心
|
||||
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id
|
||||
FROM `t_auth_role` r
|
||||
JOIN `t_auth_menu` m ON m.deleted = 0
|
||||
WHERE r.tenant_id = 1
|
||||
AND r.code = 'judge'
|
||||
AND (
|
||||
m.id IN (1,30)
|
||||
OR m.parent_id IN (1,30)
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
@ -0,0 +1,203 @@
|
||||
-- =========================================================
|
||||
-- 全租户 RBAC/菜单可见性 补齐与对齐(幂等)
|
||||
-- 目标:
|
||||
-- 1) 以 tenant_id=1 作为“权限/角色模板”,将权限码补齐到所有租户
|
||||
-- 2) 补齐关键角色(school_admin/teacher/student/judge/tenant_admin)到所有租户
|
||||
-- 3) 复制模板角色的 role_permission / role_menu 到所有租户(同 role.code)
|
||||
-- 4) 为 tenant_admin 生成默认:role_menu(显式菜单可见性)与 role_permission(接口/按钮权限)
|
||||
-- 说明:
|
||||
-- - t_auth_menu 为全局菜单表(无 tenant_id),role_menu 仅记录 menu_id
|
||||
-- - t_auth_permission 按租户隔离(tenant_id),必须保证所有租户都具备前后端用到的 code
|
||||
-- - 本迁移只做“增量补齐”,不做删除;支持重复执行
|
||||
-- =========================================================
|
||||
|
||||
-- 1) 权限码补齐:将 tenant_id=1 的权限复制到所有租户(按 code 对齐)
|
||||
INSERT INTO t_auth_permission
|
||||
(`tenant_id`, `name`, `code`, `resource`, `action`, `description`, `valid_state`, `create_by`, `deleted`)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
p.name,
|
||||
p.code,
|
||||
p.resource,
|
||||
p.action,
|
||||
p.description,
|
||||
1 AS valid_state,
|
||||
'system' AS create_by,
|
||||
0 AS deleted
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_permission p
|
||||
ON p.tenant_id = 1
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`resource` = VALUES(`resource`),
|
||||
`action` = VALUES(`action`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 2) 关键角色补齐:复制 tenant_id=1 的 school_admin/teacher/student/judge 到所有租户
|
||||
INSERT INTO t_auth_role
|
||||
(`tenant_id`, `name`, `code`, `description`, `valid_state`, `create_by`, `deleted`)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
r.name,
|
||||
r.code,
|
||||
r.description,
|
||||
1 AS valid_state,
|
||||
'system' AS create_by,
|
||||
0 AS deleted
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_role r
|
||||
ON r.tenant_id = 1
|
||||
AND r.deleted = 0
|
||||
AND r.valid_state = 1
|
||||
AND r.code IN ('school_admin','teacher','student','judge')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 3) tenant_admin 角色补齐:每个租户至少一个
|
||||
INSERT INTO t_auth_role
|
||||
(`tenant_id`, `name`, `code`, `description`, `valid_state`, `create_by`, `deleted`)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
'租户管理员' AS name,
|
||||
'tenant_admin' AS code,
|
||||
'租户管理端角色' AS description,
|
||||
1 AS valid_state,
|
||||
'system' AS create_by,
|
||||
0 AS deleted
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 4) 复制模板角色-权限(role_permission):按 role.code + permission.code 对齐
|
||||
-- 4.1 school_admin/teacher/student/judge
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
rt.id AS role_id,
|
||||
pt.id AS permission_id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_role rt
|
||||
ON rt.tenant_id = t.id
|
||||
AND rt.deleted = 0
|
||||
AND rt.valid_state = 1
|
||||
JOIN t_auth_role r1
|
||||
ON r1.tenant_id = 1
|
||||
AND r1.code = rt.code
|
||||
AND r1.deleted = 0
|
||||
AND r1.valid_state = 1
|
||||
AND r1.code IN ('school_admin','teacher','student','judge')
|
||||
JOIN t_auth_role_permission rp1
|
||||
ON rp1.role_id = r1.id
|
||||
JOIN t_auth_permission p1
|
||||
ON p1.id = rp1.permission_id
|
||||
AND p1.deleted = 0
|
||||
AND p1.valid_state = 1
|
||||
JOIN t_auth_permission pt
|
||||
ON pt.tenant_id = t.id
|
||||
AND pt.code = p1.code
|
||||
AND pt.deleted = 0
|
||||
AND pt.valid_state = 1
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 4.2 tenant_admin:默认授予“除 super_admin / tenant:* 外”的全部权限(租户内管理+业务入口)
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
rta.id AS role_id,
|
||||
p.id AS permission_id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_role rta
|
||||
ON rta.tenant_id = t.id
|
||||
AND rta.code = 'tenant_admin'
|
||||
AND rta.deleted = 0
|
||||
AND rta.valid_state = 1
|
||||
JOIN t_auth_permission p
|
||||
ON p.tenant_id = t.id
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code <> 'super_admin'
|
||||
AND p.code NOT LIKE 'tenant:%'
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 5) 复制模板角色-菜单(role_menu):按 role.code 复制 tenant_id=1 的授权
|
||||
INSERT INTO t_auth_role_menu (`role_id`, `menu_id`)
|
||||
SELECT
|
||||
rt.id AS role_id,
|
||||
rm1.menu_id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_role rt
|
||||
ON rt.tenant_id = t.id
|
||||
AND rt.deleted = 0
|
||||
AND rt.valid_state = 1
|
||||
JOIN t_auth_role r1
|
||||
ON r1.tenant_id = 1
|
||||
AND r1.code = rt.code
|
||||
AND r1.deleted = 0
|
||||
AND r1.valid_state = 1
|
||||
AND r1.code IN ('school_admin','teacher','student','judge')
|
||||
JOIN t_auth_role_menu rm1
|
||||
ON rm1.role_id = r1.id
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 6) tenant_admin 默认菜单可见性:全量管理端菜单(显式授权),排除平台级租户管理与 super_admin 专属菜单
|
||||
INSERT INTO t_auth_role_menu (`role_id`, `menu_id`)
|
||||
SELECT
|
||||
rta.id AS role_id,
|
||||
m.id AS menu_id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN t_auth_role rta
|
||||
ON rta.tenant_id = t.id
|
||||
AND rta.code = 'tenant_admin'
|
||||
AND rta.deleted = 0
|
||||
AND rta.valid_state = 1
|
||||
JOIN t_auth_menu m
|
||||
ON m.deleted = 0
|
||||
AND m.valid_state = 1
|
||||
WHERE
|
||||
-- 目录/公共菜单允许为空
|
||||
(
|
||||
m.permission IS NULL
|
||||
OR m.permission = ''
|
||||
OR (
|
||||
m.permission <> 'super_admin'
|
||||
AND m.permission NOT LIKE 'tenant:%'
|
||||
)
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
-- =========================================================
|
||||
-- 权限码别名补齐 + 菜单 permission 修正(全租户,幂等)
|
||||
-- 场景:
|
||||
-- - 历史存在 contest:judge:* / contest:notice:* / contest:review:score 等权限码
|
||||
-- - 前端与部分登录返回权限码使用 judge:* / notice:* / review:score
|
||||
-- 目标:
|
||||
-- 1) 为所有租户补齐 judge:* / notice:* / review:score 等权限码
|
||||
-- 2) 若角色已绑定旧码,则自动补绑新码(保证按钮可见 + 路由校验通过)
|
||||
-- 3) 修正菜单表中旧的 menu.permission(全局菜单表,无 tenant_id)
|
||||
-- =========================================================
|
||||
|
||||
-- 0) 只处理有效租户
|
||||
-- (注意:t_auth_menu 为全局表,不受此过滤影响)
|
||||
|
||||
-- 1) 补齐新权限码到所有租户(缺失则插入)
|
||||
INSERT INTO t_auth_permission
|
||||
(`tenant_id`, `name`, `code`, `resource`, `action`, `description`, `valid_state`, `create_by`, `deleted`)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
x.name,
|
||||
x.code,
|
||||
x.resource,
|
||||
x.action,
|
||||
x.description,
|
||||
1 AS valid_state,
|
||||
'system' AS create_by,
|
||||
0 AS deleted
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM t_sys_tenant
|
||||
WHERE deleted = 0 AND valid_state = 1
|
||||
) t
|
||||
JOIN (
|
||||
SELECT '评委查询' AS name, 'judge:read' AS code, 'judge' AS resource, 'read' AS action, '查询评委权限' AS description
|
||||
UNION ALL SELECT '评委创建','judge:create','judge','create','创建评委权限'
|
||||
UNION ALL SELECT '评委更新','judge:update','judge','update','更新评委权限'
|
||||
UNION ALL SELECT '评委删除','judge:delete','judge','delete','删除评委权限'
|
||||
UNION ALL SELECT '公告查询','notice:read','notice','read','查询公告权限'
|
||||
UNION ALL SELECT '公告创建','notice:create','notice','create','创建公告权限'
|
||||
UNION ALL SELECT '公告更新','notice:update','notice','update','更新公告权限'
|
||||
UNION ALL SELECT '公告删除','notice:delete','notice','delete','删除公告权限'
|
||||
UNION ALL SELECT '公告发布','notice:publish','notice','publish','发布公告权限'
|
||||
UNION ALL SELECT '评审评分','review:score','review','score','评审评分权限'
|
||||
) x ON 1 = 1
|
||||
LEFT JOIN t_auth_permission p
|
||||
ON p.tenant_id = t.id
|
||||
AND p.code = x.code
|
||||
AND p.deleted = 0
|
||||
WHERE p.id IS NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`resource` = VALUES(`resource`),
|
||||
`action` = VALUES(`action`),
|
||||
`description` = VALUES(`description`),
|
||||
`valid_state` = 1,
|
||||
`deleted` = 0;
|
||||
|
||||
-- 2) 角色权限“旧码 -> 新码”自动补绑(保证前端按钮/路由用的新码能命中)
|
||||
-- 2.1 contest:judge:* -> judge:*
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
rp.role_id,
|
||||
p_new.id AS permission_id
|
||||
FROM t_auth_role_permission rp
|
||||
JOIN t_auth_permission p_old ON p_old.id = rp.permission_id AND p_old.deleted = 0
|
||||
JOIN t_auth_permission p_new
|
||||
ON p_new.tenant_id = p_old.tenant_id
|
||||
AND p_new.deleted = 0
|
||||
AND (
|
||||
(p_old.code = 'contest:judge:create' AND p_new.code = 'judge:create')
|
||||
OR (p_old.code = 'contest:judge:update' AND p_new.code = 'judge:update')
|
||||
OR (p_old.code = 'contest:judge:delete' AND p_new.code = 'judge:delete')
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 2.2 contest:notice:* -> notice:*
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
rp.role_id,
|
||||
p_new.id AS permission_id
|
||||
FROM t_auth_role_permission rp
|
||||
JOIN t_auth_permission p_old ON p_old.id = rp.permission_id AND p_old.deleted = 0
|
||||
JOIN t_auth_permission p_new
|
||||
ON p_new.tenant_id = p_old.tenant_id
|
||||
AND p_new.deleted = 0
|
||||
AND (
|
||||
(p_old.code = 'contest:notice:create' AND p_new.code = 'notice:create')
|
||||
OR (p_old.code = 'contest:notice:update' AND p_new.code = 'notice:update')
|
||||
OR (p_old.code = 'contest:notice:delete' AND p_new.code = 'notice:delete')
|
||||
OR (p_old.code = 'contest:notice:publish' AND p_new.code = 'notice:publish')
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 2.3 contest:review:score -> review:score
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
rp.role_id,
|
||||
p_new.id AS permission_id
|
||||
FROM t_auth_role_permission rp
|
||||
JOIN t_auth_permission p_old ON p_old.id = rp.permission_id AND p_old.deleted = 0
|
||||
JOIN t_auth_permission p_new
|
||||
ON p_new.tenant_id = p_old.tenant_id
|
||||
AND p_new.deleted = 0
|
||||
AND p_old.code = 'contest:review:score'
|
||||
AND p_new.code = 'review:score'
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 2.4 tenant_admin 兜底补绑(若 tenant_admin 已存在,但早于新码插入)
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
r.id AS role_id,
|
||||
p.id AS permission_id
|
||||
FROM t_auth_role r
|
||||
JOIN t_sys_tenant t ON t.id = r.tenant_id AND t.deleted = 0 AND t.valid_state = 1
|
||||
JOIN t_auth_permission p
|
||||
ON p.tenant_id = r.tenant_id
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
AND p.code IN (
|
||||
'judge:read','judge:create','judge:update','judge:delete',
|
||||
'notice:read','notice:create','notice:update','notice:delete','notice:publish',
|
||||
'review:score'
|
||||
)
|
||||
WHERE r.deleted = 0 AND r.valid_state = 1 AND r.code = 'tenant_admin'
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 3) 修正菜单 permission(全局菜单表)
|
||||
UPDATE t_auth_menu
|
||||
SET permission = 'judge:read'
|
||||
WHERE deleted = 0 AND permission = 'contest:judge';
|
||||
|
||||
UPDATE t_auth_menu
|
||||
SET permission = 'notice:read'
|
||||
WHERE deleted = 0 AND permission = 'contest:notice';
|
||||
|
||||
UPDATE t_auth_menu
|
||||
SET permission = 'review:score'
|
||||
WHERE deleted = 0 AND permission = 'contest:review:score';
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
-- =========================================================
|
||||
-- 菜单场景拆分:平台端(portal) vs 租户端(tenant)
|
||||
-- 目标:
|
||||
-- 1) t_auth_menu 增加 scene 字段,避免平台端/租户端菜单混用导致授权错乱
|
||||
-- 2) 将 system 菜单树标记为 portal,其余默认 tenant
|
||||
-- 3) 清理租户菜单关联表中误选的 portal 菜单(避免租户端看到平台端菜单)
|
||||
-- 说明:
|
||||
-- - t_auth_menu 为全局表(无 tenant_id)
|
||||
-- - t_auth_tenant_menu 为租户开通菜单表
|
||||
-- =========================================================
|
||||
|
||||
-- 1) 增加 scene 字段(若已存在则跳过:MySQL 8 不支持 IF NOT EXISTS for ADD COLUMN,保持一次性迁移即可)
|
||||
ALTER TABLE t_auth_menu
|
||||
ADD COLUMN scene VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT '菜单场景:portal-平台端,tenant-租户端';
|
||||
|
||||
-- 2) 回填:system 菜单树标记为 portal(id=19 为 system 根菜单)
|
||||
UPDATE t_auth_menu
|
||||
SET scene = 'portal'
|
||||
WHERE deleted = 0
|
||||
AND (id = 19 OR parent_id = 19);
|
||||
|
||||
-- 3) 清理:租户菜单关联表移除 portal 菜单(只清理已存在的错误数据)
|
||||
DELETE tm
|
||||
FROM t_auth_tenant_menu tm
|
||||
JOIN t_auth_menu m ON m.id = tm.menu_id
|
||||
WHERE m.deleted = 0
|
||||
AND m.scene = 'portal';
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
-- =========================================================
|
||||
-- 超管端(平台租户)super_admin 兜底授权(全量按钮/接口 + portal 菜单)
|
||||
-- 目标:
|
||||
-- 1) 对所有 is_super=1 的租户下 code='super_admin' 角色,补齐全部有效权限
|
||||
-- 2) 对上述角色,补齐全部 portal 菜单可见性(t_auth_menu.scene='portal')
|
||||
-- 说明:
|
||||
-- - 幂等:使用 ON DUPLICATE KEY UPDATE
|
||||
-- - 若某些租户未初始化对应权限码,本脚本不会创建权限,只做“已有权限”的全量绑定
|
||||
-- =========================================================
|
||||
|
||||
-- 1) super_admin 绑定全部有效权限
|
||||
INSERT INTO t_auth_role_permission (`role_id`, `permission_id`)
|
||||
SELECT
|
||||
r.id AS role_id,
|
||||
p.id AS permission_id
|
||||
FROM t_auth_role r
|
||||
JOIN t_sys_tenant t
|
||||
ON t.id = r.tenant_id
|
||||
AND t.deleted = 0
|
||||
AND t.valid_state = 1
|
||||
AND t.is_super = 1
|
||||
JOIN t_auth_permission p
|
||||
ON p.tenant_id = r.tenant_id
|
||||
AND p.deleted = 0
|
||||
AND p.valid_state = 1
|
||||
WHERE r.deleted = 0
|
||||
AND r.valid_state = 1
|
||||
AND r.code = 'super_admin'
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
-- 2) super_admin 绑定全部 portal 菜单可见性
|
||||
INSERT INTO t_auth_role_menu (`role_id`, `menu_id`)
|
||||
SELECT
|
||||
r.id AS role_id,
|
||||
m.id AS menu_id
|
||||
FROM t_auth_role r
|
||||
JOIN t_sys_tenant t
|
||||
ON t.id = r.tenant_id
|
||||
AND t.deleted = 0
|
||||
AND t.valid_state = 1
|
||||
AND t.is_super = 1
|
||||
JOIN t_auth_menu m
|
||||
ON m.deleted = 0
|
||||
AND m.scene = 'portal'
|
||||
WHERE r.deleted = 0
|
||||
AND r.valid_state = 1
|
||||
AND r.code = 'super_admin'
|
||||
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
-- =========================================================
|
||||
-- 对齐超级租户标识:tenant_type='platform' 兜底设置 is_super=1
|
||||
-- 背景:历史环境可能出现平台租户编码不为 'platform'(如 'super'),
|
||||
-- 但 tenant_type 仍为 platform,导致 is_super 未正确标记。
|
||||
-- =========================================================
|
||||
|
||||
UPDATE t_sys_tenant
|
||||
SET is_super = 1
|
||||
WHERE deleted = 0
|
||||
AND valid_state = 1
|
||||
AND tenant_type = 'platform'
|
||||
AND (is_super IS NULL OR is_super = 0);
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
-- =========================================================
|
||||
-- 对齐超管租户(文档约定 code=super)
|
||||
-- - 确保 code='super' 的租户 is_super=1
|
||||
-- - tenant_type 兜底设置为 platform(便于统一判断)
|
||||
-- =========================================================
|
||||
|
||||
UPDATE t_sys_tenant
|
||||
SET
|
||||
is_super = 1,
|
||||
tenant_type = 'platform'
|
||||
WHERE deleted = 0
|
||||
AND valid_state = 1
|
||||
AND code = 'super';
|
||||
|
||||
@ -20,10 +20,12 @@
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.6.0",
|
||||
"pinia": "^2.1.7",
|
||||
"three": "^0.182.0",
|
||||
"vee-validate": "^4.12.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^6.7.3",
|
||||
"vue-router": "^4.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
60
java-frontend/scripts/extract-perms.cjs
Normal file
60
java-frontend/scripts/extract-perms.cjs
Normal file
@ -0,0 +1,60 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "src");
|
||||
const exts = new Set([".ts", ".vue"]);
|
||||
|
||||
const codes = {
|
||||
v_permission: new Set(),
|
||||
route_meta_permissions: new Set(),
|
||||
logic_checks: new Set(),
|
||||
};
|
||||
|
||||
const reVPermission = /v-permission(?:\.[a-zA-Z]+)?\s*=\s*(?:"([^"]+)"|'([^']+)')/g;
|
||||
const rePermissionCode = /['"`]([a-zA-Z0-9_-]+:[a-zA-Z0-9_:-]+|super_admin)['"`]/g;
|
||||
const reMetaPermissions = /permissions\s*:\s*\[([^\]]+)\]/g;
|
||||
const reHasPermission = /has(?:Any)?Permission\(\s*['"`]([a-zA-Z0-9_-]+:[a-zA-Z0-9_:-]+|super_admin)['"`]/g;
|
||||
|
||||
function walk(dir) {
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
walk(p);
|
||||
continue;
|
||||
}
|
||||
if (!exts.has(path.extname(ent.name))) continue;
|
||||
|
||||
const s = fs.readFileSync(p, "utf8");
|
||||
|
||||
for (const m of s.matchAll(reVPermission)) {
|
||||
const val = m[1] || m[2] || "";
|
||||
for (const c of val.matchAll(rePermissionCode)) {
|
||||
codes.v_permission.add(c[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of s.matchAll(reMetaPermissions)) {
|
||||
const inner = m[1] || "";
|
||||
for (const c of inner.matchAll(rePermissionCode)) {
|
||||
codes.route_meta_permissions.add(c[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of s.matchAll(reHasPermission)) {
|
||||
codes.logic_checks.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
|
||||
const sort = (set) => Array.from(set).sort();
|
||||
|
||||
const result = {
|
||||
v_permission: sort(codes.v_permission),
|
||||
route_meta_permissions: sort(codes.route_meta_permissions),
|
||||
logic_checks: sort(codes.logic_checks),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
import type { PaginationParams } from "@/types/api";
|
||||
|
||||
// ==================== AI 3D 任务相关类型 ====================
|
||||
|
||||
/**
|
||||
* AI 3D 任务状态
|
||||
*/
|
||||
export type AI3DTaskStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "timeout";
|
||||
|
||||
/**
|
||||
* AI 3D 任务输入类型
|
||||
*/
|
||||
export type AI3DInputType = "text" | "image";
|
||||
|
||||
/**
|
||||
* AI 3D 任务
|
||||
*/
|
||||
export interface AI3DTask {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
userId: number;
|
||||
inputType: AI3DInputType;
|
||||
inputContent: string;
|
||||
status: AI3DTaskStatus;
|
||||
resultUrl?: string;
|
||||
previewUrl?: string;
|
||||
// 多结果支持(文生3D会生成4个不同角度的模型)
|
||||
resultUrls?: string[];
|
||||
previewUrls?: string[];
|
||||
errorMessage?: string;
|
||||
externalTaskId?: string;
|
||||
retryCount: number;
|
||||
createTime: string;
|
||||
completeTime?: string;
|
||||
// 队列位置(仅 pending 状态时返回)
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型生成类型
|
||||
*/
|
||||
export type AI3DGenerateType = "Normal" | "LowPoly" | "Geometry" | "Sketch";
|
||||
|
||||
/**
|
||||
* 创建任务参数
|
||||
*/
|
||||
export interface CreateAI3DTaskParams {
|
||||
inputType: AI3DInputType;
|
||||
inputContent: string;
|
||||
/** 模型生成类型:Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
|
||||
generateType?: AI3DGenerateType;
|
||||
/** 模型面数:10000-1500000,默认500000 */
|
||||
faceCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务参数
|
||||
*/
|
||||
export interface QueryAI3DTaskParams extends PaginationParams {
|
||||
status?: AI3DTaskStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务列表响应
|
||||
*/
|
||||
export interface AI3DTaskListResponse {
|
||||
list: AI3DTask[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// ==================== API 接口 ====================
|
||||
|
||||
/**
|
||||
* 创建生成任务
|
||||
* POST /api/ai-3d/generate
|
||||
*/
|
||||
export function createAI3DTask(data: CreateAI3DTaskParams) {
|
||||
return request.post<AI3DTask>("/api/ai-3d/generate", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
* GET /api/ai-3d/tasks
|
||||
*/
|
||||
export function getAI3DTasks(params?: QueryAI3DTaskParams) {
|
||||
return request.get<AI3DTaskListResponse>("/api/ai-3d/page", { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
* GET /api/ai-3d/:id
|
||||
*/
|
||||
export function getAI3DTask(id: number) {
|
||||
return request.get<AI3DTask>(`/api/ai-3d/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试任务
|
||||
* POST /api/ai-3d/:id/retry
|
||||
*/
|
||||
export function retryAI3DTask(id: number) {
|
||||
return request.put<AI3DTask>(`/api/ai-3d/${id}/retry`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
* DELETE /api/ai-3d/:id
|
||||
*/
|
||||
export function deleteAI3DTask(id: number) {
|
||||
return request.delete(`/api/ai-3d/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* PUT /api/ai-3d/:id/cancel
|
||||
*/
|
||||
export function cancelAI3DTask(id: number) {
|
||||
return request.put<AI3DTask>(`/api/ai-3d/${id}/cancel`);
|
||||
}
|
||||
63
java-frontend/src/api/analytics.ts
Normal file
63
java-frontend/src/api/analytics.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface OverviewData {
|
||||
summary: {
|
||||
totalContests: number
|
||||
totalRegistrations: number
|
||||
passedRegistrations: number
|
||||
totalWorks: number
|
||||
reviewedWorks: number
|
||||
awardedWorks: number
|
||||
}
|
||||
funnel: {
|
||||
registered: number
|
||||
passed: number
|
||||
submitted: number
|
||||
reviewed: number
|
||||
awarded: number
|
||||
}
|
||||
monthlyTrend: Array<{ month: string; registrations: number; works: number }>
|
||||
contestComparison: Array<{
|
||||
contestId: number
|
||||
contestName: string
|
||||
registrations: number
|
||||
passRate: number
|
||||
submitRate: number
|
||||
reviewRate: number
|
||||
awardRate: number
|
||||
avgScore: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
efficiency: {
|
||||
avgReviewDays: number
|
||||
dailyReviewCount: number
|
||||
pendingAssignments: number
|
||||
avgScoreStddev: number
|
||||
}
|
||||
judgeWorkload: Array<{
|
||||
judgeId: number
|
||||
judgeName: string
|
||||
contestCount: number
|
||||
assignedCount: number
|
||||
scoredCount: number
|
||||
completionRate: number
|
||||
avgScore: number | null
|
||||
scoreStddev: number
|
||||
}>
|
||||
awardDistribution: Array<{
|
||||
awardName: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> =>
|
||||
request.get('/analytics/overview', { params }),
|
||||
|
||||
getReview: (params?: { contestId?: number }): Promise<ReviewData> =>
|
||||
request.get('/analytics/review', { params }),
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export interface CreateMenuForm {
|
||||
component?: string;
|
||||
parentId?: number;
|
||||
permission?: string;
|
||||
scene?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
@ -35,12 +36,17 @@ export interface UpdateMenuForm {
|
||||
component?: string;
|
||||
parentId?: number;
|
||||
permission?: string;
|
||||
scene?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
export type MenuScene = "portal" | "tenant";
|
||||
|
||||
// 获取菜单列表(树形结构)
|
||||
export async function getMenusList(): Promise<Menu[]> {
|
||||
const response = await request.get<any, Menu[]>("/api/menus");
|
||||
export async function getMenusList(scene?: MenuScene): Promise<Menu[]> {
|
||||
const response = await request.get<any, Menu[]>("/api/menus", {
|
||||
params: scene ? { scene } : undefined,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import axios from "axios";
|
||||
|
||||
// 公众端专用 axios 实例
|
||||
const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || "",
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@ -260,6 +260,18 @@ export const publicMineApi = {
|
||||
publicApi.get("/api/public/mine/works", { params }),
|
||||
};
|
||||
|
||||
// ==================== 点赞 & 收藏 ====================
|
||||
|
||||
export const publicInteractionApi = {
|
||||
like: (workId: number) => publicApi.post(`/api/public/works/${workId}/like`),
|
||||
favorite: (workId: number) =>
|
||||
publicApi.post(`/api/public/works/${workId}/favorite`),
|
||||
getInteraction: (workId: number) =>
|
||||
publicApi.get(`/api/public/works/${workId}/interaction`),
|
||||
myFavorites: (params?: { page?: number; pageSize?: number }) =>
|
||||
publicApi.get("/api/public/mine/favorites", { params }),
|
||||
};
|
||||
|
||||
// ==================== 用户作品库 ====================
|
||||
|
||||
export interface UserWork {
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<!-- 这个组件作为跳转器使用,实际查看器在 model-viewer 页面 -->
|
||||
<span></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
modelUrl: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:open", value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 监听打开状态,跳转到全屏页面
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
console.log("ModelViewer watch triggered:", { open: newOpen, url: props.modelUrl })
|
||||
if (newOpen && props.modelUrl) {
|
||||
console.log("正在跳转到模型查看页面:", props.modelUrl)
|
||||
// 先关闭状态
|
||||
emit("update:open", false)
|
||||
// 跳转到模型查看页面
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: props.modelUrl }
|
||||
})
|
||||
} else if (newOpen && !props.modelUrl) {
|
||||
console.error("模型URL为空,无法跳转")
|
||||
emit("update:open", false)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-layout-sider
|
||||
v-if="!hideSidebar"
|
||||
v-model:collapsed="collapsed"
|
||||
:width="210"
|
||||
class="custom-sider"
|
||||
>
|
||||
<a-layout-sider v-if="!hideSidebar" v-model:collapsed="collapsed" :width="210" class="custom-sider">
|
||||
<div class="sider-content">
|
||||
<div class="sider-top">
|
||||
<div class="logo" :class="{ 'logo-collapsed': collapsed }">
|
||||
@ -15,42 +10,12 @@
|
||||
<span class="logo-title-sub">创想活动乐园</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 3D建模实验室快捷入口(仅学生和教师可见) -->
|
||||
<div
|
||||
v-if="canAccess3DLab"
|
||||
class="lab-entry"
|
||||
:class="{ 'lab-entry-collapsed': collapsed }"
|
||||
@click="open3DLab"
|
||||
>
|
||||
<div class="lab-entry-icon">
|
||||
<experiment-outlined />
|
||||
<a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" class="custom-menu"
|
||||
:items="menuItems" @click="handleMenuClick" />
|
||||
</div>
|
||||
<div v-if="!collapsed" class="lab-entry-content">
|
||||
<span class="lab-entry-title">3D建模实验室</span>
|
||||
<span class="lab-entry-desc">AI智能建模</span>
|
||||
</div>
|
||||
<div v-if="!collapsed" class="lab-entry-arrow">
|
||||
<right-outlined />
|
||||
</div>
|
||||
</div>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
class="custom-menu"
|
||||
:items="filteredMenuItems"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="sider-bottom"
|
||||
:class="{ 'sider-bottom-collapsed': collapsed }"
|
||||
>
|
||||
<div class="sider-bottom" :class="{ 'sider-bottom-collapsed': collapsed }">
|
||||
<a-dropdown placement="topRight">
|
||||
<div
|
||||
class="user-info"
|
||||
:class="{ 'user-info-collapsed': collapsed }"
|
||||
>
|
||||
<div class="user-info" :class="{ 'user-info-collapsed': collapsed }">
|
||||
<a-avatar :size="32" :src="userAvatar" />
|
||||
<span v-if="!collapsed" class="username">{{
|
||||
authStore.user?.nickname
|
||||
@ -73,10 +38,7 @@
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-content
|
||||
class="content"
|
||||
:class="{ 'content-fullscreen': hideSidebar }"
|
||||
>
|
||||
<a-layout-content class="content" :class="{ 'content-fullscreen': hideSidebar }">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
@ -90,8 +52,6 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
ExperimentOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import { convertMenusToMenuItems } from "@/utils/menu"
|
||||
@ -114,11 +74,6 @@ const hideSidebar = computed(() => {
|
||||
return route.meta?.hideSidebar === true
|
||||
})
|
||||
|
||||
// 判断是否可以访问3D建模实验室(仅学生和教师角色可见)
|
||||
const canAccess3DLab = computed(() => {
|
||||
return authStore.hasAnyRole(["student", "teacher"])
|
||||
})
|
||||
|
||||
// 使用动态菜单
|
||||
const menuItems = computed<MenuProps["items"]>(() => {
|
||||
if (authStore.menus && authStore.menus.length > 0) {
|
||||
@ -128,30 +83,6 @@ const menuItems = computed<MenuProps["items"]>(() => {
|
||||
return []
|
||||
})
|
||||
|
||||
// 过滤掉3D建模实验室的菜单项(仅当用户能看到快捷入口时才过滤,避免重复显示)
|
||||
const filteredMenuItems = computed<MenuProps["items"]>(() => {
|
||||
const items = menuItems.value || []
|
||||
// 如果用户能看到快捷入口,则过滤掉菜单中的3D实验室
|
||||
if (canAccess3DLab.value) {
|
||||
return items.filter((item: any) => {
|
||||
// 检查是否是3D建模实验室菜单
|
||||
const is3DLab =
|
||||
item?.key?.toLowerCase().includes("3dlab") ||
|
||||
item?.key?.toLowerCase().includes("3d-lab") ||
|
||||
item?.label?.includes("3D建模") ||
|
||||
item?.title?.includes("3D建模")
|
||||
return !is3DLab
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// 打开3D建模实验室(当前窗口跳转,更快)
|
||||
const open3DLab = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}/workbench/3d-lab`)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(routeName) => {
|
||||
@ -192,63 +123,57 @@ watch(
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
console.log("tenantCode:", tenantCode);
|
||||
// 点击菜单
|
||||
|
||||
// 调试日志
|
||||
console.log("点击菜单,key:", key)
|
||||
|
||||
// 检查是否是3D建模实验室菜单
|
||||
// 方法1: 检查key是否包含3D相关字符(考虑到路由名称的生成规则)
|
||||
// 路径 /workbench/3d-lab 会生成类似 Workbench3dLab 的key
|
||||
const is3DLab =
|
||||
key.toLowerCase().includes("3dlab") ||
|
||||
key.toLowerCase().includes("3d-lab") ||
|
||||
(key.toLowerCase().includes("workbench") &&
|
||||
key.toLowerCase().includes("3d"))
|
||||
|
||||
// 方法2: 从菜单数据中查找对应的菜单项,检查path
|
||||
const findMenuByKey = (menus: any[], targetKey: string): any => {
|
||||
for (const menu of menus) {
|
||||
if (menu.key === targetKey) {
|
||||
return menu
|
||||
// 路由名称生成规则需与 src/utils/menu.ts 一致
|
||||
const getRouteNameFromPath = (path: string | null | undefined, menuId: number): string => {
|
||||
if (path) {
|
||||
const baseName = path
|
||||
.split("/")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("")
|
||||
return `${baseName}${menuId}`
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByKey(menu.children, targetKey)
|
||||
return `Menu${menuId}`
|
||||
}
|
||||
|
||||
const findMenuByRouteName = (menus: any[], targetName: string): any | null => {
|
||||
for (const m of menus) {
|
||||
const rn = getRouteNameFromPath(m.path, m.id)
|
||||
if (rn === targetName) return m
|
||||
if (m.children?.length) {
|
||||
const found = findMenuByRouteName(m.children, targetName)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const menuItem = findMenuByKey(menuItems.value || [], key)
|
||||
const is3DLabByPath =
|
||||
menuItem?.label?.includes("3D建模") || menuItem?.title?.includes("3D建模")
|
||||
const safePushByName = async () => {
|
||||
if (tenantCode) {
|
||||
await router.push({ name: key, params: { tenantCode } })
|
||||
} else {
|
||||
await router.push({ name: key })
|
||||
}
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
console.log(
|
||||
"is3DLab:",
|
||||
is3DLab,
|
||||
"is3DLabByPath:",
|
||||
is3DLabByPath,
|
||||
"menuItem:",
|
||||
menuItem,
|
||||
)
|
||||
|
||||
if (is3DLab || is3DLabByPath) {
|
||||
// 打开3D建模实验室页面(新窗口,路由配置了 hideSidebar 会自动隐藏侧边栏)
|
||||
console.log("检测到3D建模实验室,打开新窗口")
|
||||
const base = import.meta.env.BASE_URL || "/"
|
||||
const basePath = base.endsWith("/") ? base.slice(0, -1) : base
|
||||
const fullUrl = `${window.location.origin}${basePath}/${tenantCode}/workbench/3d-lab`
|
||||
window.open(fullUrl, "_blank")
|
||||
const safePushByPath = async () => {
|
||||
const rawMenus: any[] = (authStore.menus as any[]) || []
|
||||
console.log("rawMenus:", rawMenus);
|
||||
const matched = findMenuByRouteName(rawMenus, key)
|
||||
const menuPath: string | undefined = matched?.path
|
||||
if (!menuPath) {
|
||||
await safePushByName()
|
||||
return
|
||||
}
|
||||
|
||||
// 其他菜单项正常跳转
|
||||
if (tenantCode) {
|
||||
router.push({ name: key, params: { tenantCode } })
|
||||
} else {
|
||||
router.push({ name: key })
|
||||
const clean = menuPath.startsWith("/") ? menuPath.slice(1) : menuPath
|
||||
const fullPath = tenantCode ? `/${tenantCode}/${clean}` : `/${clean}`
|
||||
await router.push({ path: fullPath })
|
||||
}
|
||||
|
||||
// 优先按 name 跳转(适配动态路由),失败则回退按 path 跳转
|
||||
safePushByName().catch(() => safePushByPath())
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
@ -303,6 +228,7 @@ $rose: #ec4899;
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-radius: 3px;
|
||||
@ -364,91 +290,6 @@ $rose: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 3D建模实验室快捷入口 ==========
|
||||
.lab-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 4px 0 14px 0;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($primary, 0.06) 0%,
|
||||
rgba($rose, 0.06) 100%
|
||||
);
|
||||
border: 1px solid rgba($primary, 0.12);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($primary, 0.10) 0%,
|
||||
rgba($rose, 0.10) 100%
|
||||
);
|
||||
border-color: rgba($primary, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba($primary, 0.12);
|
||||
}
|
||||
|
||||
.lab-entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, $primary 0%, $rose 100%);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lab-entry-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.lab-entry-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.lab-entry-desc {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.lab-entry-arrow {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
&:hover .lab-entry-arrow {
|
||||
color: $primary;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.lab-entry-collapsed {
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
margin: 4px 4px 14px 4px;
|
||||
|
||||
.lab-entry-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 底部用户区域 ==========
|
||||
.sider-bottom {
|
||||
@ -565,11 +406,9 @@ $rose: #ec4899;
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
color: $primary !important;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
background: linear-gradient(135deg,
|
||||
rgba($primary, 0.10) 0%,
|
||||
rgba($rose, 0.05) 100%
|
||||
) !important;
|
||||
rgba($rose, 0.05) 100%) !important;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba($primary, 0.08);
|
||||
|
||||
@ -609,7 +448,7 @@ $rose: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open > .ant-menu-submenu-title {
|
||||
&.ant-menu-submenu-open>.ant-menu-submenu-title {
|
||||
color: $primary;
|
||||
background: rgba($primary, 0.06) !important;
|
||||
|
||||
@ -618,7 +457,7 @@ $rose: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
&.ant-menu-submenu-selected>.ant-menu-submenu-title {
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -650,12 +489,8 @@ $rose: #ec4899;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
:deep(
|
||||
.ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-submenu-arrow
|
||||
),
|
||||
:deep(
|
||||
.ant-menu-submenu:hover > .ant-menu-submenu-title > .ant-menu-submenu-arrow
|
||||
) {
|
||||
:deep(.ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-submenu-arrow),
|
||||
:deep(.ant-menu-submenu:hover > .ant-menu-submenu-title > .ant-menu-submenu-arrow) {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,18 +66,18 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/public/mine/Registrations.vue"),
|
||||
meta: { title: "我的报名" },
|
||||
},
|
||||
{
|
||||
path: "mine/works",
|
||||
name: "PublicMyWorks",
|
||||
component: () => import("@/views/public/mine/Works.vue"),
|
||||
meta: { title: "我的作品" },
|
||||
},
|
||||
{
|
||||
path: "mine/children",
|
||||
name: "PublicMyChildren",
|
||||
component: () => import("@/views/public/mine/Children.vue"),
|
||||
meta: { title: "子女账号" },
|
||||
},
|
||||
{
|
||||
path: "mine/favorites",
|
||||
name: "PublicMyFavorites",
|
||||
component: () => import("@/views/public/mine/Favorites.vue"),
|
||||
meta: { title: "我的收藏" },
|
||||
},
|
||||
// ========== 创作与作品库 ==========
|
||||
{
|
||||
path: "create",
|
||||
@ -283,7 +283,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "student-activities/guidance",
|
||||
name: "TeacherGuidance",
|
||||
component: () => import("@/views/contests/Guidance.vue"),
|
||||
component: () => import("@/views/activities/Guidance.vue"),
|
||||
meta: {
|
||||
title: "我的指导",
|
||||
requiresAuth: true,
|
||||
@ -310,48 +310,25 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
// 3D建模实验室路由(工作台模块下)
|
||||
// ========== 数据统计 ==========
|
||||
{
|
||||
path: "workbench/3d-lab",
|
||||
name: "3DModelingLab",
|
||||
component: () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||
path: "analytics/overview",
|
||||
name: "AnalyticsOverview",
|
||||
component: () => import("@/views/analytics/Overview.vue"),
|
||||
meta: {
|
||||
title: "3D建模实验室",
|
||||
title: "运营概览",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
permissions: ["contest:read"],
|
||||
},
|
||||
},
|
||||
// 3D模型生成页面
|
||||
{
|
||||
path: "workbench/3d-lab/generate/:taskId",
|
||||
name: "AI3DGenerate",
|
||||
component: () => import("@/views/workbench/ai-3d/Generate.vue"),
|
||||
path: "analytics/review",
|
||||
name: "AnalyticsReview",
|
||||
component: () => import("@/views/analytics/Review.vue"),
|
||||
meta: {
|
||||
title: "3D模型生成",
|
||||
title: "评审分析",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 3D创作历史页面
|
||||
{
|
||||
path: "workbench/3d-lab/history",
|
||||
name: "AI3DHistory",
|
||||
component: () => import("@/views/workbench/ai-3d/History.vue"),
|
||||
meta: {
|
||||
title: "创作历史",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 3D模型预览页面
|
||||
{
|
||||
path: "workbench/model-viewer",
|
||||
name: "ModelViewer",
|
||||
component: () => import("@/views/model/ModelViewer.vue"),
|
||||
meta: {
|
||||
title: "3D模型预览",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
permissions: ["contest:read"],
|
||||
},
|
||||
},
|
||||
// 动态路由将在这里添加
|
||||
|
||||
@ -126,127 +126,8 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
try {
|
||||
const userMenus = await menusApi.getUserMenus();
|
||||
// 前端兜底:当数据库菜单缺失时,补齐关键入口,避免页面不可达
|
||||
// 场景:系统管理下的「角色管理」「权限管理」在数据库未配置或被误删
|
||||
const normalizedMenus = (userMenus || []) as Menu[];
|
||||
|
||||
const ensureSystemRolePermissionMenus = (list: Menu[]): Menu[] => {
|
||||
// 没有用户信息时无法判断权限,直接返回原菜单
|
||||
if (!user.value) return list;
|
||||
|
||||
const has = (
|
||||
nodes: Menu[],
|
||||
predicate: (m: Menu) => boolean,
|
||||
): boolean => {
|
||||
for (const n of nodes) {
|
||||
if (predicate(n)) return true;
|
||||
if (n.children && n.children.length && has(n.children, predicate))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const findSystem = (nodes: Menu[]): Menu | null => {
|
||||
for (const n of nodes) {
|
||||
// 优先按 path 匹配 system,其次按名称兜底
|
||||
if (
|
||||
n.path === "system" ||
|
||||
n.path === "/system" ||
|
||||
n.name === "系统管理"
|
||||
)
|
||||
return n;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const systemMenu = findSystem(list);
|
||||
|
||||
// 只有具备读取权限时才补齐入口(避免给无权限用户制造“点了进403”的假入口)
|
||||
const canReadRole = hasPermission("role:read");
|
||||
const canReadPermission = hasPermission("permission:read");
|
||||
const canReadMenu = hasPermission("menu:read");
|
||||
|
||||
// 如果用户完全没有相关权限,就不补
|
||||
if (!canReadRole && !canReadPermission && !canReadMenu) return list;
|
||||
|
||||
// 构造需要补齐的两个菜单(使用负数ID,避免与数据库ID冲突;key 生成依赖 id)
|
||||
const roleMenu: Menu = {
|
||||
id: -91001,
|
||||
name: "角色管理",
|
||||
path: "system/roles",
|
||||
icon: "Team",
|
||||
component: "system/roles/Index",
|
||||
parentId: systemMenu?.id ?? -91000,
|
||||
permission: "role:read",
|
||||
sort: 2,
|
||||
};
|
||||
|
||||
const permMenu: Menu = {
|
||||
id: -91002,
|
||||
name: "权限管理",
|
||||
path: "system/permissions",
|
||||
icon: "Lock",
|
||||
component: "system/permissions/Index",
|
||||
parentId: systemMenu?.id ?? -91000,
|
||||
permission: "permission:read",
|
||||
sort: 3,
|
||||
};
|
||||
|
||||
const menuMgmtMenu: Menu = {
|
||||
id: -91003,
|
||||
name: "菜单管理",
|
||||
path: "system/menus",
|
||||
icon: "Menu",
|
||||
component: "system/menus/Index",
|
||||
parentId: systemMenu?.id ?? -91000,
|
||||
permission: "menu:read",
|
||||
sort: 4,
|
||||
};
|
||||
|
||||
// 若系统管理本身缺失,则创建一个父级系统管理菜单承载
|
||||
const ensuredList = [...list];
|
||||
const ensuredSystem: Menu =
|
||||
systemMenu ??
|
||||
({
|
||||
id: -91000,
|
||||
name: "系统管理",
|
||||
path: "system",
|
||||
icon: "Setting",
|
||||
component: undefined,
|
||||
parentId: 0,
|
||||
permission: "super_admin",
|
||||
sort: 999,
|
||||
children: [],
|
||||
} as Menu);
|
||||
|
||||
if (!systemMenu) {
|
||||
ensuredList.push(ensuredSystem);
|
||||
} else if (!ensuredSystem.children) {
|
||||
ensuredSystem.children = [];
|
||||
}
|
||||
|
||||
const children = ensuredSystem.children || [];
|
||||
|
||||
// // 仅当缺失对应 path 时才补齐
|
||||
// if (canReadRole && !has(children, (m) => m.path === roleMenu.path)) {
|
||||
// children.push(roleMenu);
|
||||
// }
|
||||
// if (canReadPermission && !has(children, (m) => m.path === permMenu.path)) {
|
||||
// children.push(permMenu);
|
||||
// }
|
||||
// if (canReadMenu && !has(children, (m) => m.path === menuMgmtMenu.path)) {
|
||||
// children.push(menuMgmtMenu);
|
||||
// }
|
||||
|
||||
// 按 sort 排序,保持菜单稳定
|
||||
ensuredSystem.children = children.sort(
|
||||
(a, b) => (a.sort ?? 0) - (b.sort ?? 0),
|
||||
);
|
||||
|
||||
return ensuredList;
|
||||
};
|
||||
|
||||
menus.value = ensureSystemRolePermissionMenus(normalizedMenus);
|
||||
menus.value = normalizedMenus;
|
||||
return userMenus;
|
||||
} catch (error) {
|
||||
console.error("获取用户菜单失败:", error);
|
||||
|
||||
@ -15,7 +15,6 @@ const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
|
||||
const componentMap: Record<string, () => Promise<any>> = {
|
||||
// 工作台模块
|
||||
"workbench/Index": () => import("@/views/workbench/Index.vue"),
|
||||
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||
// 学校管理模块
|
||||
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
|
||||
"school/departments/Index": () =>
|
||||
@ -48,6 +47,9 @@ const componentMap: Record<string, () => Promise<any>> = {
|
||||
"content/WorkReview": () => import("@/views/content/WorkReview.vue"),
|
||||
"content/WorkManagement": () => import("@/views/content/WorkManagement.vue"),
|
||||
"content/TagManagement": () => import("@/views/content/TagManagement.vue"),
|
||||
// 数据统计模块
|
||||
"analytics/Overview": () => import("@/views/analytics/Overview.vue"),
|
||||
"analytics/Review": () => import("@/views/analytics/Review.vue"),
|
||||
// 作业管理模块
|
||||
"homework/Index": () => import("@/views/homework/Index.vue"),
|
||||
"homework/Submissions": () => import("@/views/homework/Submissions.vue"),
|
||||
@ -268,11 +270,28 @@ export function convertMenusToRoutes(
|
||||
normalizedPath = `${normalizedPath}.vue`
|
||||
}
|
||||
|
||||
const fallbackLoader = viewComponentModules[normalizedPath]
|
||||
const candidatePaths = [
|
||||
normalizedPath,
|
||||
// 兼容部分构建环境下 glob key 可能为 /src/xxx 形式
|
||||
normalizedPath.startsWith("@/views/")
|
||||
? normalizedPath.replace("@/views/", "/src/views/")
|
||||
: normalizedPath.startsWith("@/")
|
||||
? normalizedPath.replace("@/", "/src/")
|
||||
: normalizedPath,
|
||||
]
|
||||
|
||||
const fallbackLoader =
|
||||
candidatePaths.map((p) => viewComponentModules[p]).find(Boolean) ||
|
||||
undefined
|
||||
|
||||
if (fallbackLoader) {
|
||||
componentLoader = fallbackLoader
|
||||
} else {
|
||||
console.warn(`组件路径 "${normalizedPath}" 未找到对应的视图文件,已跳过渲染该菜单`)
|
||||
console.warn(
|
||||
`组件路径 "${normalizedPath}" 未找到对应的视图文件(尝试:${candidatePaths.join(
|
||||
", "
|
||||
)}),已跳过渲染该菜单`
|
||||
)
|
||||
componentLoader = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,14 +61,8 @@
|
||||
<FileImageOutlined />
|
||||
<span>暂无预览</span>
|
||||
</div>
|
||||
<!-- 悬浮预览按钮 -->
|
||||
<transition name="fade">
|
||||
<div v-show="isHovering && currentModelUrl" class="preview-overlay">
|
||||
<a-button type="primary" @click="handlePreview3DModel">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
3D预览
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-show="false" class="preview-overlay"></div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
@ -393,12 +387,6 @@ const getFileUrl = (fileUrl: string): string => {
|
||||
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`;
|
||||
};
|
||||
|
||||
// 当前3D模型URL
|
||||
const currentModelUrl = computed(() => {
|
||||
const item = modelItems.value[currentFileIndex.value];
|
||||
return item?.fileUrl || null;
|
||||
});
|
||||
|
||||
// 当前预览图
|
||||
const currentPreviewImage = computed(() => {
|
||||
const item = modelItems.value[currentFileIndex.value];
|
||||
@ -418,12 +406,8 @@ const totalScore = computed(() => {
|
||||
return simpleScore.value || 0;
|
||||
});
|
||||
|
||||
// 判断是否是3D模型文件
|
||||
const is3DModel = (fileName: string) => {
|
||||
if (!fileName) return false;
|
||||
const ext = fileName.toLowerCase();
|
||||
return ['.glb', '.gltf', '.obj', '.fbx', '.stl'].some(e => ext.endsWith(e));
|
||||
};
|
||||
// 判断是否是模型文件(3D 功能已下线,保留文件展示逻辑仅用于图片/附件)
|
||||
const is3DModel = (_fileName: string) => false;
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
@ -647,35 +631,6 @@ const handleViolation = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 3D模型预览
|
||||
const handlePreview3DModel = () => {
|
||||
const fileUrl = currentModelUrl.value;
|
||||
if (!fileUrl) {
|
||||
message.error("文件路径无效");
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有模型URL用于多模型切换
|
||||
const allModelUrls = modelItems.value
|
||||
.filter((m) => m.fileUrl)
|
||||
.map((m) => m.fileUrl);
|
||||
|
||||
// 存储到 sessionStorage(避免URL过长)
|
||||
if (allModelUrls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls));
|
||||
sessionStorage.setItem("model-viewer-index", String(currentFileIndex.value));
|
||||
sessionStorage.removeItem("model-viewer-url");
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", fileUrl);
|
||||
sessionStorage.removeItem("model-viewer-urls");
|
||||
sessionStorage.removeItem("model-viewer-index");
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
emit("update:open", false);
|
||||
|
||||
239
java-frontend/src/views/analytics/Overview.vue
Normal file
239
java-frontend/src/views/analytics/Overview.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="analytics-overview">
|
||||
<a-card class="title-card">
|
||||
<template #title>运营概览</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="contestFilter"
|
||||
style="width: 200px"
|
||||
placeholder="全部活动"
|
||||
allow-clear
|
||||
@change="fetchData"
|
||||
>
|
||||
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="stats-row">
|
||||
<div v-for="item in statsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card-section">
|
||||
<h3 class="section-title">报名转化漏斗</h3>
|
||||
<div class="funnel-list">
|
||||
<div v-for="(item, idx) in funnelItems" :key="item.label" class="funnel-item">
|
||||
<div class="funnel-header">
|
||||
<span class="funnel-label">{{ item.label }}</span>
|
||||
<div class="funnel-values">
|
||||
<span
|
||||
v-if="idx > 0"
|
||||
class="funnel-rate"
|
||||
:style="{ background: item.rateBg, color: item.rateColor }"
|
||||
>
|
||||
{{ item.rate }}
|
||||
</span>
|
||||
<span class="funnel-count">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="funnel-bar-bg">
|
||||
<div class="funnel-bar" :style="{ width: item.width + '%', background: item.gradient }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<h3 class="section-title">月度趋势</h3>
|
||||
<a-table
|
||||
:columns="trendColumns"
|
||||
:data-source="data?.monthlyTrend || []"
|
||||
:pagination="false"
|
||||
row-key="month"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-section" style="margin-top: 16px">
|
||||
<h3 class="section-title">活动对比</h3>
|
||||
<a-table
|
||||
:columns="comparisonColumns"
|
||||
:data-source="data?.contestComparison || []"
|
||||
:pagination="false"
|
||||
row-key="contestId"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template
|
||||
v-if="column.key === 'passRate' || column.key === 'submitRate' || column.key === 'reviewRate' || column.key === 'awardRate'"
|
||||
>
|
||||
<span class="rate-pill" :class="getRateClass(record[column.key])">
|
||||
{{ record[column.key] }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'avgScore'">
|
||||
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
TrophyOutlined,
|
||||
TeamOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileTextOutlined,
|
||||
AuditOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { analyticsApi, type OverviewData } from '@/api/analytics'
|
||||
import { contestsApi } from '@/api/contests'
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<OverviewData | null>(null)
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const statsItems = computed(() => {
|
||||
const s = data.value?.summary
|
||||
if (!s) return []
|
||||
return [
|
||||
{ key: 'contests', label: '活动总数', value: s.totalContests, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'reg', label: '累计报名', value: s.totalRegistrations, icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ key: 'passed', label: '报名通过', value: s.passedRegistrations, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ key: 'works', label: '作品总数', value: s.totalWorks, icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'reviewed', label: '已完成评审', value: s.reviewedWorks, icon: AuditOutlined, color: '#14b8a6', bgColor: 'rgba(20,184,166,0.1)' },
|
||||
{ key: 'awarded', label: '获奖作品', value: s.awardedWorks, icon: StarOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
|
||||
]
|
||||
})
|
||||
|
||||
const funnelItems = computed(() => {
|
||||
const f = data.value?.funnel
|
||||
if (!f) return []
|
||||
const max = f.registered || 1
|
||||
const calcRate = (cur: number, prev: number) => (prev > 0 ? (cur / prev * 100).toFixed(1) + '%' : '0%')
|
||||
return [
|
||||
{ label: '报名', value: f.registered, width: 100, gradient: 'linear-gradient(90deg,#6366f1,#818cf8)', rate: '', rateBg: '', rateColor: '' },
|
||||
{ label: '通过审核', value: f.passed, width: (f.passed / max) * 100, gradient: 'linear-gradient(90deg,#10b981,#34d399)', rate: calcRate(f.passed, f.registered), rateBg: '#ecfdf5', rateColor: '#10b981' },
|
||||
{ label: '提交作品', value: f.submitted, width: (f.submitted / max) * 100, gradient: 'linear-gradient(90deg,#3b82f6,#60a5fa)', rate: calcRate(f.submitted, f.passed), rateBg: '#eff6ff', rateColor: '#3b82f6' },
|
||||
{ label: '评审完成', value: f.reviewed, width: (f.reviewed / max) * 100, gradient: 'linear-gradient(90deg,#f59e0b,#fbbf24)', rate: calcRate(f.reviewed, f.submitted), rateBg: '#fffbeb', rateColor: '#f59e0b' },
|
||||
{ label: '获奖', value: f.awarded, width: (f.awarded / max) * 100, gradient: 'linear-gradient(90deg,#ef4444,#f87171)', rate: calcRate(f.awarded, f.reviewed), rateBg: '#fef2f2', rateColor: '#ef4444' },
|
||||
]
|
||||
})
|
||||
|
||||
const trendColumns = [
|
||||
{ title: '月份', dataIndex: 'month', key: 'month', width: 110 },
|
||||
{ title: '报名量', dataIndex: 'registrations', key: 'registrations', align: 'center' as const },
|
||||
{ title: '作品量', dataIndex: 'works', key: 'works', align: 'center' as const },
|
||||
]
|
||||
|
||||
const comparisonColumns = [
|
||||
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
|
||||
{ title: '报名数', dataIndex: 'registrations', key: 'registrations', width: 80, align: 'center' as const },
|
||||
{ title: '通过率', key: 'passRate', width: 90, align: 'center' as const },
|
||||
{ title: '提交率', key: 'submitRate', width: 90, align: 'center' as const },
|
||||
{ title: '评审完成率', key: 'reviewRate', width: 100, align: 'center' as const },
|
||||
{ title: '获奖率', key: 'awardRate', width: 90, align: 'center' as const },
|
||||
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
|
||||
]
|
||||
|
||||
const getRateClass = (rate: number) => {
|
||||
if (rate >= 80) return 'rate-high'
|
||||
if (rate >= 50) return 'rate-mid'
|
||||
if (rate > 0) return 'rate-low'
|
||||
return 'rate-zero'
|
||||
}
|
||||
|
||||
const fetchContestOptions = async () => {
|
||||
try {
|
||||
const res: any = await contestsApi.getList({ page: 1, pageSize: 100 })
|
||||
contestOptions.value = (res?.list || []).map((c: any) => ({ id: c.id, name: c.contestName }))
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getOverview({ contestId: contestFilter.value })
|
||||
} catch {
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContestOptions()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
||||
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
|
||||
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card-section {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
|
||||
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
|
||||
}
|
||||
|
||||
.funnel-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.funnel-item {
|
||||
.funnel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.funnel-label { font-size: 13px; font-weight: 500; color: #374151; }
|
||||
.funnel-values { display: flex; align-items: center; gap: 8px; }
|
||||
.funnel-count { font-size: 14px; font-weight: 700; color: #1e1b4b; }
|
||||
.funnel-rate { display: inline-flex; padding: 1px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||
.funnel-bar-bg { height: 28px; background: #f3f4f6; border-radius: 8px; overflow: hidden; }
|
||||
.funnel-bar { height: 100%; border-radius: 8px; transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
|
||||
}
|
||||
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.rate-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
.rate-zero { background: #f3f4f6; color: #d1d5db; }
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
</style>
|
||||
|
||||
220
java-frontend/src/views/analytics/Review.vue
Normal file
220
java-frontend/src/views/analytics/Review.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="analytics-review">
|
||||
<a-card class="title-card">
|
||||
<template #title>评审分析</template>
|
||||
<template #extra>
|
||||
<a-select
|
||||
v-model:value="contestFilter"
|
||||
style="width: 200px"
|
||||
placeholder="全部活动"
|
||||
allow-clear
|
||||
@change="fetchData"
|
||||
>
|
||||
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="stats-row">
|
||||
<div v-for="item in efficiencyItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}<span class="stat-unit">{{ item.unit }}</span></span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<span v-if="item.hint" class="stat-hint">{{ item.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-5-2">
|
||||
<div class="card-section col-span-3">
|
||||
<h3 class="section-title">评委工作量</h3>
|
||||
<a-table
|
||||
:columns="judgeColumns"
|
||||
:data-source="data?.judgeWorkload || []"
|
||||
:pagination="false"
|
||||
row-key="judgeId"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'judgeName'">
|
||||
<div class="judge-cell">
|
||||
<div class="judge-avatar" :style="{ background: getAvatarColor(record.judgeName) }">
|
||||
{{ record.judgeName?.charAt(0) }}
|
||||
</div>
|
||||
<span class="judge-name">{{ record.judgeName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'completionRate'">
|
||||
<span class="rate-pill" :class="getRateClass(record.completionRate)">{{ record.completionRate }}%</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'avgScore'">
|
||||
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'scoreStddev'">
|
||||
<span :class="getStddevClass(record.scoreStddev)">{{ record.scoreStddev }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<div class="card-section col-span-2">
|
||||
<h3 class="section-title">奖项分布</h3>
|
||||
<div v-if="data?.awardDistribution?.length" class="award-list">
|
||||
<div v-for="a in data.awardDistribution" :key="a.awardName" class="award-item">
|
||||
<div class="award-title">
|
||||
<span class="award-name">{{ a.awardName }}</span>
|
||||
<span class="award-count">{{ a.count }}({{ a.percentage }}%)</span>
|
||||
</div>
|
||||
<a-progress :percent="a.percentage" :show-info="false" stroke-color="#6366f1" />
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无奖项数据" style="padding: 60px 0" />
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ClockCircleOutlined, ThunderboltOutlined, WarningOutlined, BarChartOutlined } from '@ant-design/icons-vue'
|
||||
import { analyticsApi, type ReviewData } from '@/api/analytics'
|
||||
import { contestsApi } from '@/api/contests'
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<ReviewData | null>(null)
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const avatarColors = ['#6366f1', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
|
||||
const getAvatarColor = (name: string) => {
|
||||
const idx = name ? name.charCodeAt(0) % avatarColors.length : 0
|
||||
return `linear-gradient(135deg, ${avatarColors[idx]}, ${avatarColors[(idx + 1) % avatarColors.length]})`
|
||||
}
|
||||
|
||||
const efficiencyItems = computed(() => {
|
||||
const e = data.value?.efficiency
|
||||
if (!e) return []
|
||||
return [
|
||||
{ key: 'days', label: '平均评审周期', value: e.avgReviewDays, unit: '天', icon: ClockCircleOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', hint: '' },
|
||||
{ key: 'daily', label: '日均评审量', value: e.dailyReviewCount, unit: '个/日', icon: ThunderboltOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', hint: '' },
|
||||
{ key: 'pending', label: '待评审积压', value: e.pendingAssignments, unit: '个', icon: WarningOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', hint: '' },
|
||||
{ key: 'stddev', label: '评分一致性', value: e.avgScoreStddev, unit: '分', icon: BarChartOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', hint: '标准差越小越好' },
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
const judgeColumns = [
|
||||
{ title: '评委姓名', key: 'judgeName', width: 140 },
|
||||
{ title: '关联活动', dataIndex: 'contestCount', key: 'contestCount', width: 80, align: 'center' as const },
|
||||
{ title: '已分配', dataIndex: 'assignedCount', key: 'assignedCount', width: 70, align: 'center' as const },
|
||||
{ title: '已评分', dataIndex: 'scoredCount', key: 'scoredCount', width: 70, align: 'center' as const },
|
||||
{ title: '完成率', key: 'completionRate', width: 80, align: 'center' as const },
|
||||
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
|
||||
{ title: '标准差', key: 'scoreStddev', width: 80, align: 'center' as const },
|
||||
]
|
||||
|
||||
const getRateClass = (rate: number) => {
|
||||
if (rate >= 80) return 'rate-high'
|
||||
if (rate >= 50) return 'rate-mid'
|
||||
return 'rate-low'
|
||||
}
|
||||
|
||||
const getStddevClass = (stddev: number) => {
|
||||
if (stddev <= 3) return 'stddev-good'
|
||||
if (stddev <= 6) return 'stddev-ok'
|
||||
return 'stddev-bad'
|
||||
}
|
||||
|
||||
const fetchContestOptions = async () => {
|
||||
try {
|
||||
const res: any = await contestsApi.getList({ page: 1, pageSize: 100 })
|
||||
contestOptions.value = (res?.list || []).map((c: any) => ({ id: c.id, name: c.contestName }))
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getReview({ contestId: contestFilter.value })
|
||||
} catch {
|
||||
message.error('获取评审分析数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContestOptions()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 18px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
||||
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
|
||||
.stat-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 24px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-unit { font-size: 13px; font-weight: 400; color: #9ca3af; margin-left: 2px; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
|
||||
.stat-hint { font-size: 10px; color: #d1d5db; }
|
||||
}
|
||||
}
|
||||
|
||||
.grid-5-2 { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; }
|
||||
.col-span-3 { grid-column: 1; }
|
||||
.col-span-2 { grid-column: 2; }
|
||||
|
||||
.card-section {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
|
||||
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
|
||||
}
|
||||
|
||||
.award-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.award-item {
|
||||
.award-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.award-name { font-weight: 600; color: #1e1b4b; }
|
||||
.award-count { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
|
||||
.judge-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.judge-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.judge-name { font-weight: 500; color: #1e1b4b; }
|
||||
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.rate-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.stddev-good { font-weight: 600; color: #10b981; }
|
||||
.stddev-ok { font-weight: 600; color: #f59e0b; }
|
||||
.stddev-bad { font-weight: 600; color: #ef4444; }
|
||||
|
||||
:deep(.ant-table-wrapper) { background: transparent;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -134,19 +134,6 @@
|
||||
class="work-image"
|
||||
@click="handlePreviewImage(workFile)"
|
||||
/>
|
||||
<div v-else-if="is3DModelFile(workFile)" class="file-placeholder model-file">
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="preview-btn"
|
||||
@click.stop="handlePreview3DModel(workFile)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
预览3D模型
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-else class="file-placeholder">
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||||
@ -244,7 +231,6 @@ import {
|
||||
ReloadOutlined,
|
||||
FileOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
worksApi,
|
||||
@ -334,14 +320,6 @@ const isImageFile = (fileUrl: string): boolean => {
|
||||
return imageExtensions.some((ext) => lowerUrl.includes(ext))
|
||||
}
|
||||
|
||||
// 判断是否为3D模型文件
|
||||
const is3DModelFile = (fileUrl: string): boolean => {
|
||||
if (!fileUrl) return false
|
||||
const modelExtensions = [".glb", ".gltf", ".obj", ".fbx", ".3ds", ".dae", ".stl", ".ply"]
|
||||
const lowerUrl = fileUrl.toLowerCase()
|
||||
return modelExtensions.some((ext) => lowerUrl.includes(ext))
|
||||
}
|
||||
|
||||
// 获取文件URL
|
||||
const getFileUrl = (fileUrl: string): string => {
|
||||
if (!fileUrl) return ""
|
||||
@ -400,23 +378,6 @@ const handleDownloadAttachment = (attachment: any) => {
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview3DModel = (fileUrl: string) => {
|
||||
console.log("handlePreview3DModel called with:", fileUrl)
|
||||
if (!fileUrl) {
|
||||
message.error("文件路径无效")
|
||||
return
|
||||
}
|
||||
const fullUrl = getFileUrl(fileUrl)
|
||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||
// 跳转到模型查看器页面
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: fullUrl },
|
||||
})
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
|
||||
@ -40,82 +40,11 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选择方式" required>
|
||||
<a-radio-group v-model:value="uploadMode" @change="handleModeChange">
|
||||
<a-radio value="history">从创作历史选择</a-radio>
|
||||
<a-radio-group v-model:value="uploadMode" disabled>
|
||||
<a-radio value="local">本地上传</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 从创作历史选择模式 -->
|
||||
<template v-if="uploadMode === 'history'">
|
||||
<a-form-item label="选择作品" name="selectedWork" required>
|
||||
<div class="work-selection">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="worksLoading" class="works-loading">
|
||||
<a-spin />
|
||||
<span>加载创作历史...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="completedWorks.length === 0" class="works-empty">
|
||||
<a-empty description="暂无已完成的3D作品">
|
||||
<template #image>
|
||||
<FileImageOutlined style="font-size: 48px; color: #d9d9d9" />
|
||||
</template>
|
||||
</a-empty>
|
||||
<a-button type="link" @click="goTo3DLab">
|
||||
前往3D建模实验室创作
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 作品网格 -->
|
||||
<div v-else class="works-grid">
|
||||
<div
|
||||
v-for="work in completedWorks"
|
||||
:key="work.id"
|
||||
class="work-card"
|
||||
:class="{ 'is-selected': form.selectedWorkId === work.id }"
|
||||
@click="handleSelectWork(work)"
|
||||
>
|
||||
<div class="work-preview">
|
||||
<img
|
||||
v-if="work.previewUrl"
|
||||
:src="getPreviewUrl(work.previewUrl)"
|
||||
alt="预览"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
<div v-if="form.selectedWorkId === work.id" class="selected-badge">
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-desc" :title="work.inputContent">
|
||||
{{ work.inputContent }}
|
||||
</div>
|
||||
<div class="work-time">
|
||||
{{ formatTime(work.createTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="worksPagination.total > worksPagination.pageSize" class="works-pagination">
|
||||
<a-pagination
|
||||
v-model:current="worksPagination.current"
|
||||
:total="worksPagination.total"
|
||||
:page-size="worksPagination.pageSize"
|
||||
size="small"
|
||||
@change="handleWorksPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 本地上传模式 -->
|
||||
<template v-else>
|
||||
<a-form-item label="上传作品" name="localWorkFile" required>
|
||||
@ -128,11 +57,11 @@
|
||||
>
|
||||
<a-button>
|
||||
<template #icon><UploadOutlined /></template>
|
||||
选择3D文件
|
||||
选择作品文件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
支持格式:.obj, .fbx, .3ds, .dae, .blend, .stl, .ply, .gltf, .glb,最大500MB
|
||||
支持格式:.zip, .pdf, .jpg, .jpeg, .png, .doc, .docx,最大100MB
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
@ -204,7 +133,6 @@ import {
|
||||
} from "@ant-design/icons-vue"
|
||||
import type { FormInstance, UploadFile } from "ant-design-vue"
|
||||
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
|
||||
import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import request from "@/utils/request"
|
||||
import dayjs from "dayjs"
|
||||
@ -229,20 +157,16 @@ const visible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 上传模式
|
||||
const uploadMode = ref<"history" | "local">("history")
|
||||
// 上传模式(已下线创作历史模式)
|
||||
const uploadMode = ref<"local">("local")
|
||||
|
||||
// 3D文件格式
|
||||
const workAcceptTypes =
|
||||
".obj,.fbx,.3ds,.dae,.blend,.max,.c4d,.stl,.ply,.gltf,.glb"
|
||||
// 作品文件格式(3D建模相关已下线)
|
||||
const workAcceptTypes = ".zip,.pdf,.jpg,.jpeg,.png,.doc,.docx"
|
||||
|
||||
// 表单数据
|
||||
const form = reactive<{
|
||||
title: string
|
||||
description: string
|
||||
// 历史记录模式
|
||||
selectedWorkId: number | null
|
||||
selectedWork: AI3DTask | null
|
||||
// 本地上传模式
|
||||
localWorkFile: File | null
|
||||
localPreviewFile: File | null
|
||||
@ -251,8 +175,6 @@ const form = reactive<{
|
||||
}>({
|
||||
title: "",
|
||||
description: "",
|
||||
selectedWorkId: null,
|
||||
selectedWork: null,
|
||||
localWorkFile: null,
|
||||
localPreviewFile: null,
|
||||
attachmentFiles: [],
|
||||
@ -263,42 +185,16 @@ const localWorkFileList = ref<UploadFile[]>([])
|
||||
const localPreviewFileList = ref<UploadFile[]>([])
|
||||
const attachmentFileList = ref<UploadFile[]>([])
|
||||
|
||||
// 3D作品列表
|
||||
const worksLoading = ref(false)
|
||||
const worksList = ref<AI3DTask[]>([])
|
||||
const worksPagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 8,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 只显示已完成的作品
|
||||
const completedWorks = computed(() => {
|
||||
return worksList.value.filter((work) => work.status === "completed")
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = computed(() => ({
|
||||
title: [{ required: true, message: "请输入作品名称", trigger: "blur" }],
|
||||
description: [{ required: true, message: "请输入作品介绍", trigger: "blur" }],
|
||||
selectedWork: [
|
||||
{
|
||||
required: uploadMode.value === "history",
|
||||
validator: () => {
|
||||
if (uploadMode.value === "history" && !form.selectedWorkId) {
|
||||
return Promise.reject(new Error("请选择一个3D作品"))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
localWorkFile: [
|
||||
{
|
||||
required: uploadMode.value === "local",
|
||||
validator: () => {
|
||||
if (uploadMode.value === "local" && !form.localWorkFile) {
|
||||
return Promise.reject(new Error("请上传3D文件"))
|
||||
return Promise.reject(new Error("请上传作品文件"))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
@ -326,7 +222,6 @@ watch(
|
||||
if (newVal) {
|
||||
resetForm()
|
||||
fetchRegistrationId()
|
||||
fetchWorks()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@ -336,20 +231,6 @@ watch(visible, (newVal) => {
|
||||
emit("update:open", newVal)
|
||||
})
|
||||
|
||||
// 模式切换时清空对应的数据
|
||||
const handleModeChange = () => {
|
||||
if (uploadMode.value === "history") {
|
||||
form.localWorkFile = null
|
||||
form.localPreviewFile = null
|
||||
localWorkFileList.value = []
|
||||
localPreviewFileList.value = []
|
||||
} else {
|
||||
form.selectedWorkId = null
|
||||
form.selectedWork = null
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 获取报名记录ID
|
||||
const registrationIdRef = ref<number | null>(null)
|
||||
|
||||
@ -375,90 +256,22 @@ const fetchRegistrationId = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取3D创作历史
|
||||
const fetchWorks = async () => {
|
||||
worksLoading.value = true
|
||||
try {
|
||||
const res = await getAI3DTasks({
|
||||
page: worksPagination.current,
|
||||
pageSize: worksPagination.pageSize,
|
||||
status: "completed",
|
||||
})
|
||||
const data = res.data || res
|
||||
worksList.value = data.list || []
|
||||
worksPagination.total = data.total || 0
|
||||
} catch (error) {
|
||||
console.error("获取创作历史失败:", error)
|
||||
message.error("获取创作历史失败")
|
||||
} finally {
|
||||
worksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handleWorksPageChange = (page: number) => {
|
||||
worksPagination.current = page
|
||||
fetchWorks()
|
||||
}
|
||||
|
||||
// 选择作品
|
||||
const handleSelectWork = (work: AI3DTask) => {
|
||||
if (form.selectedWorkId === work.id) {
|
||||
form.selectedWorkId = null
|
||||
form.selectedWork = null
|
||||
} else {
|
||||
form.selectedWorkId = work.id
|
||||
form.selectedWork = work
|
||||
if (!form.title && work.inputContent) {
|
||||
form.title = work.inputContent.substring(0, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (url: string) => {
|
||||
if (!url) return ""
|
||||
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
const getPreviewUrl = (url: string) => url || ""
|
||||
|
||||
// 图片加载错误处理
|
||||
const handleImageError = (e: Event) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.style.display = "none"
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format("MM-DD HH:mm")
|
||||
}
|
||||
|
||||
// 跳转到3D实验室
|
||||
const goTo3DLab = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}/workbench/3d-lab`)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 上传3D文件前验证
|
||||
// 上传作品文件前验证
|
||||
const handleBeforeUploadWork = (file: File): boolean => {
|
||||
const fileName = file.name.toLowerCase()
|
||||
const validExtensions = [
|
||||
".obj", ".fbx", ".3ds", ".dae", ".blend",
|
||||
".max", ".c4d", ".stl", ".ply", ".gltf", ".glb",
|
||||
]
|
||||
|
||||
const validExtensions = [".zip", ".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"]
|
||||
const isValid = validExtensions.some((ext) => fileName.endsWith(ext))
|
||||
if (!isValid) {
|
||||
message.error("请上传支持的3D文件格式")
|
||||
message.error("请上传支持的作品文件格式")
|
||||
return false
|
||||
}
|
||||
|
||||
const maxSize = 500 * 1024 * 1024
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
message.error("文件大小不能超过500MB")
|
||||
message.error("文件大小不能超过100MB")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -466,7 +279,7 @@ const handleBeforeUploadWork = (file: File): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// 移除3D文件
|
||||
// 移除作品文件
|
||||
const handleRemoveWork = () => {
|
||||
form.localWorkFile = null
|
||||
return true
|
||||
@ -521,18 +334,15 @@ const handleRemoveAttachment = (file: UploadFile) => {
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
uploadMode.value = "history"
|
||||
uploadMode.value = "local"
|
||||
form.title = ""
|
||||
form.description = ""
|
||||
form.selectedWorkId = null
|
||||
form.selectedWork = null
|
||||
form.localWorkFile = null
|
||||
form.localPreviewFile = null
|
||||
form.attachmentFiles = []
|
||||
localWorkFileList.value = []
|
||||
localPreviewFileList.value = []
|
||||
attachmentFileList.value = []
|
||||
worksPagination.current = 1
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
@ -550,38 +360,12 @@ const handleSubmit = async () => {
|
||||
|
||||
submitLoading.value = true
|
||||
|
||||
let modelFiles: string[] = []
|
||||
let workFiles: string[] = []
|
||||
let previewUrl = ""
|
||||
let previewUrlsList: string[] = []
|
||||
|
||||
if (uploadMode.value === "history") {
|
||||
// 从创作历史选择模式
|
||||
if (!form.selectedWork) {
|
||||
message.error("请选择一个3D作品")
|
||||
return
|
||||
}
|
||||
// 优先使用数组形式的 resultUrls 和 previewUrls
|
||||
const resultUrls = form.selectedWork.resultUrls || []
|
||||
previewUrlsList = form.selectedWork.previewUrls || []
|
||||
|
||||
// 如果有多个模型URL,全部放入 files 数组
|
||||
if (resultUrls.length > 0) {
|
||||
modelFiles = [...resultUrls]
|
||||
} else if (form.selectedWork.resultUrl) {
|
||||
// 兼容旧数据,只有单个 resultUrl
|
||||
modelFiles = [form.selectedWork.resultUrl]
|
||||
}
|
||||
|
||||
// 预览图:优先使用数组的第一个作为主预览图
|
||||
if (previewUrlsList.length > 0) {
|
||||
previewUrl = previewUrlsList[0]
|
||||
} else {
|
||||
previewUrl = form.selectedWork.previewUrl || ""
|
||||
}
|
||||
} else {
|
||||
// 本地上传模式
|
||||
if (!form.localWorkFile) {
|
||||
message.error("请上传3D文件")
|
||||
message.error("请上传作品文件")
|
||||
return
|
||||
}
|
||||
if (!form.localPreviewFile) {
|
||||
@ -589,12 +373,12 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 上传3D文件
|
||||
// 上传作品文件
|
||||
try {
|
||||
const uploadedUrl = await uploadFile(form.localWorkFile)
|
||||
modelFiles = [uploadedUrl]
|
||||
workFiles = [uploadedUrl]
|
||||
} catch (error: any) {
|
||||
message.error("3D文件上传失败:" + (error?.message || "未知错误"))
|
||||
message.error("作品文件上传失败:" + (error?.message || "未知错误"))
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
@ -608,7 +392,6 @@ const handleSubmit = async () => {
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 上传附件并收集附件信息
|
||||
const attachments: Array<{
|
||||
@ -635,7 +418,7 @@ const handleSubmit = async () => {
|
||||
registrationId: registrationIdRef.value,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
files: modelFiles, // 只包含模型文件,不包含附件
|
||||
files: workFiles, // 只包含作品文件,不包含附件
|
||||
previewUrl: previewUrl,
|
||||
previewUrls: previewUrlsList.length > 0 ? previewUrlsList : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
|
||||
@ -41,19 +41,8 @@
|
||||
<span>模型 {{ index + 1 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮操作按钮 -->
|
||||
<transition name="fade">
|
||||
<div v-show="hoveredIndex === index" class="actions-overlay">
|
||||
<a-button
|
||||
v-if="model.fileUrl && is3DModelFile(model.fileUrl)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handlePreview3DModel(model.fileUrl, index)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
3D预览
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-show="hoveredIndex === index" class="actions-overlay"></div>
|
||||
</transition>
|
||||
|
||||
<!-- 模型序号 -->
|
||||
@ -146,7 +135,6 @@ import {
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { worksApi, registrationsApi, type ContestWork } from "@/api/contests"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
@ -268,14 +256,8 @@ const modelItems = computed<ModelItem[]>(() => {
|
||||
previewUrls = [work.value.previewUrl]
|
||||
}
|
||||
|
||||
// 过滤出3D模型文件(排除附件等)
|
||||
const modelFiles = files.filter((f: any) => {
|
||||
const url = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
|
||||
return url && is3DModelFile(url)
|
||||
})
|
||||
|
||||
// 构建模型项目列表
|
||||
return modelFiles.map((f: any, index: number) => {
|
||||
// 构建文件项目列表(3D 预览已下线,这里展示全部文件)
|
||||
return files.map((f: any, index: number) => {
|
||||
const fileUrl = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
|
||||
const previewUrl = previewUrls[index] || previewUrls[0] || ""
|
||||
return {
|
||||
@ -305,23 +287,6 @@ const workFiles = computed(() => {
|
||||
}).filter(Boolean)
|
||||
})
|
||||
|
||||
// 判断是否为3D模型文件
|
||||
const is3DModelFile = (fileUrl: string): boolean => {
|
||||
const modelExtensions = [
|
||||
".glb",
|
||||
".gltf",
|
||||
".obj",
|
||||
".fbx",
|
||||
".3ds",
|
||||
".dae",
|
||||
".stl",
|
||||
".ply",
|
||||
".zip",
|
||||
]
|
||||
const lowerUrl = fileUrl.toLowerCase()
|
||||
return modelExtensions.some((ext) => lowerUrl.includes(ext))
|
||||
}
|
||||
|
||||
// 获取文件URL(处理相对路径)
|
||||
const getFileUrl = (fileUrl: string): string => {
|
||||
if (!fileUrl) return ""
|
||||
@ -343,36 +308,6 @@ const handlePreviewError = (_e: Event, index: number) => {
|
||||
previewErrors.value[index] = true
|
||||
}
|
||||
|
||||
// 预览3D模型 - 支持多模型预览
|
||||
const handlePreview3DModel = (fileUrl: string, index: number) => {
|
||||
if (!fileUrl) {
|
||||
message.error("文件路径无效")
|
||||
return
|
||||
}
|
||||
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
|
||||
// 收集所有模型URL用于多模型切换
|
||||
const allModelUrls = modelItems.value.map((m) => m.fileUrl)
|
||||
|
||||
// 存储到 sessionStorage(避免URL过长)
|
||||
if (allModelUrls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
|
||||
sessionStorage.setItem("model-viewer-index", String(index))
|
||||
// 清除单URL存储
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", fileUrl)
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
// 不在URL上携带参数
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
})
|
||||
}
|
||||
|
||||
// 下载作品 - 下载所有模型文件
|
||||
const handleDownloadWork = () => {
|
||||
if (workFiles.value.length === 0) {
|
||||
@ -380,9 +315,8 @@ const handleDownloadWork = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 下载所有3D模型文件
|
||||
// 下载所有文件
|
||||
workFiles.value.forEach((file, index) => {
|
||||
if (is3DModelFile(file)) {
|
||||
setTimeout(() => {
|
||||
const fileUrl = getFileUrl(file)
|
||||
const link = document.createElement("a")
|
||||
@ -393,7 +327,6 @@ const handleDownloadWork = () => {
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}, index * 500) // 错开下载时间
|
||||
}
|
||||
})
|
||||
message.success("开始下载作品")
|
||||
}
|
||||
|
||||
@ -40,15 +40,6 @@
|
||||
<FileImageOutlined />
|
||||
<span>暂无预览图</span>
|
||||
</div>
|
||||
<!-- 3D预览按钮 -->
|
||||
<transition name="fade">
|
||||
<div v-if="showPreviewBtn && hasModelFile" class="preview-btn-overlay" @click="handleView3DModel">
|
||||
<a-button type="primary">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
3D模型预览
|
||||
</a-button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -136,7 +127,6 @@ import { useRoute, useRouter } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileImageOutlined,
|
||||
EyeOutlined,
|
||||
PaperClipOutlined,
|
||||
DownloadOutlined
|
||||
} from "@ant-design/icons-vue"
|
||||
@ -208,38 +198,6 @@ const previewImageUrl = computed(() => {
|
||||
return imageAttachment?.fileUrl || ""
|
||||
})
|
||||
|
||||
// 检查URL或文件名是否是3D模型文件(支持带查询参数的URL)
|
||||
const isModelFile = (urlOrFileName: string): boolean => {
|
||||
// 移除查询参数后检查扩展名
|
||||
const pathWithoutQuery = urlOrFileName.split("?")[0]
|
||||
return /\.(glb|gltf|obj|fbx|stl|zip)$/i.test(pathWithoutQuery)
|
||||
}
|
||||
|
||||
// 是否有3D模型文件
|
||||
const hasModelFile = computed(() => {
|
||||
if (!workDetail.value) return false
|
||||
// 检查 files 数组
|
||||
const hasInFiles = parsedFiles.value.some((url: string) => isModelFile(url))
|
||||
if (hasInFiles) return true
|
||||
// 检查 attachments 数组
|
||||
const hasInAttachments = workDetail.value.attachments?.some(
|
||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
||||
)
|
||||
return hasInAttachments || false
|
||||
})
|
||||
|
||||
// 获取3D模型文件URL
|
||||
const modelFileUrl = computed(() => {
|
||||
if (!workDetail.value) return ""
|
||||
// 优先从 files 数组中查找
|
||||
const modelFromFiles = parsedFiles.value.find((url: string) => isModelFile(url))
|
||||
if (modelFromFiles) return modelFromFiles
|
||||
// 其次从 attachments 中查找
|
||||
const modelAtt = workDetail.value.attachments?.find(
|
||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
||||
)
|
||||
return modelAtt?.fileUrl || ""
|
||||
})
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr?: string) => {
|
||||
@ -283,36 +241,6 @@ const handleImageError = (e: Event) => {
|
||||
target.style.display = "none"
|
||||
}
|
||||
|
||||
// 跳转3D模型预览
|
||||
const handleView3DModel = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value)
|
||||
console.log("3D模型预览 - parsedFiles:", parsedFiles.value)
|
||||
console.log("3D模型预览 - attachments:", workDetail.value?.attachments)
|
||||
if (modelFileUrl.value) {
|
||||
// 收集所有3D模型URL
|
||||
const allModelUrls = parsedFiles.value.filter((url: string) => isModelFile(url))
|
||||
|
||||
// 使用 sessionStorage 存储模型URL(与学生端保持一致)
|
||||
if (allModelUrls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
|
||||
sessionStorage.setItem("model-viewer-index", "0")
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", modelFileUrl.value)
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
// 使用 router.push 跳转
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
})
|
||||
} else {
|
||||
message.warning("未找到3D模型文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 下载附件
|
||||
const handleDownload = (attachment: any) => {
|
||||
if (attachment.fileUrl) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -67,7 +67,14 @@
|
||||
<span>{{ work.creator?.nickname }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span><heart-outlined /> {{ work.likeCount || 0 }}</span>
|
||||
<span
|
||||
:class="['like-btn', { liked: likedSet.has(work.id) }]"
|
||||
@click.stop="handleLike(work)"
|
||||
>
|
||||
<heart-filled v-if="likedSet.has(work.id)" />
|
||||
<heart-outlined v-else />
|
||||
{{ work.likeCount || 0 }}
|
||||
</span>
|
||||
<span><eye-outlined /> {{ work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,10 +89,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { SearchOutlined, PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicGalleryApi, publicTagsApi, type UserWork, type WorkTag } from '@/api/public'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
||||
|
||||
const router = useRouter()
|
||||
const works = ref<UserWork[]>([])
|
||||
const hotTags = ref<WorkTag[]>([])
|
||||
const loading = ref(false)
|
||||
@ -95,7 +105,9 @@ const sortBy = ref('latest')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = 12
|
||||
const likedSet = reactive(new Set<number>())
|
||||
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||
const hasMore = computed(() => works.value.length < total.value)
|
||||
|
||||
const fetchTags = async () => {
|
||||
@ -103,7 +115,7 @@ const fetchTags = async () => {
|
||||
}
|
||||
|
||||
const fetchWorks = async (reset = false) => {
|
||||
if (reset) { page.value = 1; works.value = [] }
|
||||
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicGalleryApi.list({
|
||||
@ -123,6 +135,22 @@ const fetchWorks = async (reset = false) => {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleLike = async (work: any) => {
|
||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||
const wasLiked = likedSet.has(work.id)
|
||||
if (wasLiked) { likedSet.delete(work.id) } else { likedSet.add(work.id) }
|
||||
work.likeCount = (work.likeCount || 0) + (wasLiked ? -1 : 1)
|
||||
try {
|
||||
const res = await publicInteractionApi.like(work.id)
|
||||
if (res.liked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
|
||||
work.likeCount = res.likeCount
|
||||
} catch {
|
||||
if (wasLiked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
|
||||
work.likeCount = (work.likeCount || 0) + (wasLiked ? 1 : -1)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => fetchWorks(true)
|
||||
const selectTag = (tagId: number) => {
|
||||
selectedTagId.value = selectedTagId.value === tagId ? null : tagId
|
||||
@ -245,6 +273,11 @@ $primary: #6366f1;
|
||||
.card-stats {
|
||||
display: flex; gap: 12px;
|
||||
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
|
||||
.like-btn {
|
||||
transition: all 0.2s;
|
||||
&.liked { color: $primary; }
|
||||
&:active { transform: scale(0.96); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
139
java-frontend/src/views/public/mine/Favorites.vue
Normal file
139
java-frontend/src/views/public/mine/Favorites.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<div class="page-header">
|
||||
<h2>我的收藏</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||
|
||||
<div v-else-if="list.length === 0" class="empty-wrap">
|
||||
<a-empty description="还没有收藏任何作品">
|
||||
<a-button type="primary" shape="round" @click="$router.push('/p/gallery')">
|
||||
去发现作品
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="works-grid">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="work-card"
|
||||
@click="$router.push(`/p/works/${item.work.id}`)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="item.work.coverUrl" :src="item.work.coverUrl" :alt="item.work.title" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<picture-outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>{{ item.work.title }}</h3>
|
||||
<div class="card-author">
|
||||
<a-avatar :size="20" :src="item.work.creator?.avatar">
|
||||
{{ item.work.creator?.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
<span>{{ item.work.creator?.nickname }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span><heart-outlined /> {{ item.work.likeCount || 0 }}</span>
|
||||
<span><eye-outlined /> {{ item.work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination-wrap">
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
simple
|
||||
@change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicInteractionApi } from '@/api/public'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = 12
|
||||
const total = ref(0)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicInteractionApi.myFavorites({ page: page.value, pageSize })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
message.error('获取收藏列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.favorites-page { max-width: 700px; margin: 0 auto; }
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
||||
}
|
||||
|
||||
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (min-width: 640px) { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.work-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba($primary, 0.04);
|
||||
|
||||
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
||||
|
||||
.card-cover {
|
||||
aspect-ratio: 3/4;
|
||||
background: #f5f3ff;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-author {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
span { font-size: 11px; color: #6b7280; }
|
||||
}
|
||||
.card-stats {
|
||||
display: flex; gap: 12px;
|
||||
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap { display: flex; justify-content: center; padding: 24px 0; }
|
||||
</style>
|
||||
|
||||
@ -33,13 +33,13 @@
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
|
||||
<div class="menu-item" @click="$router.push('/p/mine/works')">
|
||||
<div class="menu-icon" style="background: #fdf2f8; color: #ec4899">
|
||||
<picture-outlined />
|
||||
<div class="menu-item" @click="$router.push('/p/mine/favorites')">
|
||||
<div class="menu-icon" style="background: #fef3c7; color: #f59e0b">
|
||||
<star-outlined />
|
||||
</div>
|
||||
<div class="menu-content">
|
||||
<span class="menu-label">我的作品</span>
|
||||
<span class="menu-desc">{{ workCount > 0 ? `${workCount} 个作品` : '管理提交的作品' }}</span>
|
||||
<span class="menu-label">我的收藏</span>
|
||||
<span class="menu-desc">收藏的绘本作品</span>
|
||||
</div>
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
@ -98,7 +98,7 @@ import { useRouter } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileTextOutlined,
|
||||
PictureOutlined,
|
||||
StarOutlined,
|
||||
TeamOutlined,
|
||||
RightOutlined,
|
||||
LogoutOutlined,
|
||||
@ -111,7 +111,6 @@ const user = ref<any>(null)
|
||||
const showEditModal = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const regCount = ref(0)
|
||||
const workCount = ref(0)
|
||||
|
||||
// 判断当前是否子女身份
|
||||
const isChildMode = computed(() => {
|
||||
@ -161,12 +160,8 @@ const fetchProfile = async () => {
|
||||
|
||||
const fetchCounts = async () => {
|
||||
try {
|
||||
const [regs, works] = await Promise.all([
|
||||
publicMineApi.registrations({ page: 1, pageSize: 1 }),
|
||||
publicMineApi.works({ page: 1, pageSize: 1 }),
|
||||
])
|
||||
const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 })
|
||||
regCount.value = regs?.total || 0
|
||||
workCount.value = works?.total || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<div class="works-page">
|
||||
<div class="page-header">
|
||||
<h2>我的作品</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||
|
||||
<div v-else-if="list.length === 0" class="empty-wrap">
|
||||
<a-empty description="还没有提交过作品" />
|
||||
</div>
|
||||
|
||||
<div v-else class="works-grid">
|
||||
<div v-for="item in list" :key="item.id" class="work-card">
|
||||
<div class="work-cover">
|
||||
<img v-if="item.coverUrl" :src="item.coverUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<picture-outlined style="font-size: 32px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-body">
|
||||
<h3>{{ item.title || '未命名作品' }}</h3>
|
||||
<p class="work-contest">{{ item.contest?.contestName }}</p>
|
||||
<div class="work-meta">
|
||||
<span v-if="item.registration?.participantType === 'child'">
|
||||
创作者:{{ item.registration?.child?.name }}
|
||||
</span>
|
||||
<span class="work-time">{{ formatDate(item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination-wrap">
|
||||
<a-pagination v-model:current="page" :total="total" :page-size="pageSize" size="small" @change="fetchList" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { PictureOutlined } from '@ant-design/icons-vue'
|
||||
import { publicMineApi } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const total = ref(0)
|
||||
|
||||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicMineApi.works({ page: page.value, pageSize: pageSize.value })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch { /* */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.works-page { max-width: 800px; margin: 0 auto; }
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
||||
}
|
||||
|
||||
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-2px); }
|
||||
|
||||
.work-cover {
|
||||
height: 140px; overflow: hidden;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-placeholder {
|
||||
width: 100%; height: 100%;
|
||||
background: #f5f3ff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.work-body {
|
||||
padding: 14px 16px;
|
||||
h3 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.work-contest { font-size: 12px; color: #9ca3af; margin: 0 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.work-meta {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
span { font-size: 11px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap { display: flex; justify-content: center; margin-top: 24px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.works-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.work-card .work-cover { height: 110px; }
|
||||
}
|
||||
</style>
|
||||
@ -54,10 +54,29 @@
|
||||
<div v-if="work.tags?.length" class="tags-row">
|
||||
<a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>{{ work.viewCount || 0 }} 浏览</span>
|
||||
<span>{{ work.likeCount || 0 }} 点赞</span>
|
||||
<span>{{ work.favoriteCount || 0 }} 收藏</span>
|
||||
</div>
|
||||
|
||||
<!-- 互动栏 -->
|
||||
<div class="interaction-bar">
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.liked }]"
|
||||
@click="handleLike"
|
||||
>
|
||||
<heart-filled v-if="interaction.liked" />
|
||||
<heart-outlined v-else />
|
||||
<span>{{ displayLikeCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.favorited }]"
|
||||
@click="handleFavorite"
|
||||
>
|
||||
<star-filled v-if="interaction.favorited" />
|
||||
<star-outlined v-else />
|
||||
<span>{{ displayFavoriteCount }}</span>
|
||||
</div>
|
||||
<div class="action-btn">
|
||||
<eye-outlined />
|
||||
<span>{{ work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -69,20 +88,27 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||
import {
|
||||
ArrowLeftOutlined, LeftOutlined, RightOutlined,
|
||||
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = Number(route.params.id)
|
||||
|
||||
const work = ref<UserWork | null>(null)
|
||||
const loading = ref(true)
|
||||
const currentPageIndex = ref(0)
|
||||
const interaction = ref({ liked: false, favorited: false })
|
||||
const actionLoading = ref(false)
|
||||
|
||||
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||
|
||||
const isOwner = computed(() => {
|
||||
const u = localStorage.getItem('public_user')
|
||||
@ -102,10 +128,60 @@ const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
||||
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
||||
|
||||
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
||||
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||
if (actionLoading.value) return
|
||||
actionLoading.value = true
|
||||
const wasLiked = interaction.value.liked
|
||||
interaction.value.liked = !wasLiked
|
||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
|
||||
try {
|
||||
const res = await publicInteractionApi.like(workId)
|
||||
interaction.value.liked = res.liked
|
||||
if (work.value) work.value.likeCount = res.likeCount
|
||||
} catch {
|
||||
interaction.value.liked = wasLiked
|
||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||
if (actionLoading.value) return
|
||||
actionLoading.value = true
|
||||
const wasFavorited = interaction.value.favorited
|
||||
interaction.value.favorited = !wasFavorited
|
||||
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? -1 : 1)
|
||||
try {
|
||||
const res = await publicInteractionApi.favorite(workId)
|
||||
interaction.value.favorited = res.favorited
|
||||
if (work.value) work.value.favoriteCount = res.favoriteCount
|
||||
} catch {
|
||||
interaction.value.favorited = wasFavorited
|
||||
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? 1 : -1)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWork = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
try {
|
||||
work.value = await publicGalleryApi.detail(workId)
|
||||
} catch {
|
||||
work.value = await publicUserWorksApi.detail(workId)
|
||||
}
|
||||
if (isLoggedIn.value) {
|
||||
try { interaction.value = await publicInteractionApi.getInteraction(workId) } catch { /* */ }
|
||||
}
|
||||
} catch {
|
||||
message.error('获取作品详情失败')
|
||||
} finally {
|
||||
@ -188,10 +264,31 @@ $primary: #6366f1;
|
||||
|
||||
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
|
||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.stats-row {
|
||||
}
|
||||
|
||||
.interaction-bar {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
overflow: hidden;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
span { font-size: 12px; color: #9ca3af; }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:not(:last-child) { border-right: 1px solid #f3f4f6; }
|
||||
&:hover { background: rgba($primary, 0.03); }
|
||||
&.active { color: $primary; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -26,15 +26,8 @@
|
||||
</a-table>
|
||||
|
||||
<!-- 新增/编辑角色弹窗 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<a-modal :open="modalVisible" :title="modalTitle" :confirm-loading="submitLoading" width="800px" @ok="handleSubmit"
|
||||
@cancel="handleCancel" @update:open="handleOpenChange">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="basic" tab="基本信息">
|
||||
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
@ -78,18 +71,13 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="menus" tab="菜单授权">
|
||||
<a-tab-pane v-if="isPortalTenant" key="menus" tab="菜单授权">
|
||||
<div v-if="menusLoading" style="text-align: center; padding: 40px;">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<div v-else class="menu-tree-wrapper">
|
||||
<a-tree
|
||||
checkable
|
||||
block-node
|
||||
default-expand-all
|
||||
v-model:checkedKeys="form.menuIds"
|
||||
:tree-data="menuTreeData"
|
||||
>
|
||||
<a-tree checkable block-node default-expand-all v-model:checkedKeys="form.menuIds"
|
||||
:tree-data="menuTreeData">
|
||||
<template #title="{ title }">
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
@ -112,6 +100,7 @@ import { useListRequest } from '@/composables/useListRequest'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isPortalTenant = computed(() => authStore.hasRole('super_admin'))
|
||||
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@ -127,7 +116,7 @@ const activeTab = ref('basic')
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
const permissionGroups = computed(() => {
|
||||
const groups: Record<string, Permission[]> = {}
|
||||
;(allPermissions.value || []).forEach((permission) => {
|
||||
; (allPermissions.value || []).forEach((permission) => {
|
||||
if (!groups[permission.resource]) {
|
||||
groups[permission.resource] = []
|
||||
}
|
||||
@ -410,7 +399,9 @@ const handleCancel = () => {
|
||||
// 初始化加载权限列表
|
||||
onMounted(() => {
|
||||
fetchAllPermissions()
|
||||
if (isPortalTenant.value) {
|
||||
fetchAllMenus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -171,9 +171,6 @@ import {
|
||||
} from '@/api/tenants'
|
||||
import { menusApi, type Menu } from '@/api/menus'
|
||||
import { useListRequest } from '@/composables/useListRequest'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@ -291,7 +288,7 @@ const handleResetSearch = () => {
|
||||
const fetchAllMenus = async () => {
|
||||
menusLoading.value = true
|
||||
try {
|
||||
allMenus.value = await menusApi.getList()
|
||||
allMenus.value = await menusApi.getList('tenant')
|
||||
} catch {
|
||||
message.error('获取菜单列表失败')
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,961 +0,0 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<!-- Animated Background -->
|
||||
<div class="bg-animation">
|
||||
<div class="bg-gradient bg-gradient-1"></div>
|
||||
<div class="bg-gradient bg-gradient-2"></div>
|
||||
<div class="bg-gradient bg-gradient-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<span class="title">创作历史</span>
|
||||
<span class="count-badge">{{ total }} 个作品</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
placeholder="全部状态"
|
||||
style="width: 120px"
|
||||
allowClear
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select-option value="completed">已完成</a-select-option>
|
||||
<a-select-option value="processing">生成中</a-select-option>
|
||||
<a-select-option value="pending">排队中</a-select-option>
|
||||
<a-select-option value="failed">失败</a-select-option>
|
||||
<a-select-option value="timeout">超时</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="page-content">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && list.length === 0" class="loading-state">
|
||||
<div class="loader">
|
||||
<div class="loader-ring"></div>
|
||||
<div class="loader-ring"></div>
|
||||
<div class="loader-ring"></div>
|
||||
</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="list.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
<p class="empty-title">暂无创作记录</p>
|
||||
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
|
||||
<a-button type="primary" class="primary-btn" @click="goCreate">
|
||||
<ThunderboltOutlined />
|
||||
开始创作
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="history-grid">
|
||||
<div
|
||||
v-for="task in list"
|
||||
:key="task.id"
|
||||
class="history-card"
|
||||
@click="handleViewTask(task)"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<img
|
||||
v-if="task.status === 'completed' && task.previewUrl"
|
||||
:src="getPreviewUrl(task)"
|
||||
alt="预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div
|
||||
v-else-if="
|
||||
task.status === 'processing' || task.status === 'pending'
|
||||
"
|
||||
class="preview-loading"
|
||||
>
|
||||
<div class="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span class="loading-text">{{ task.status === 'pending' ? '排队中' : '生成中' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="task.status === 'failed' || task.status === 'timeout'"
|
||||
class="preview-failed"
|
||||
>
|
||||
<div class="failed-icon">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="status-badge" :class="`status-${task.status}`">
|
||||
{{ getStatusText(task.status) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬停显示的底部信息 -->
|
||||
<div class="card-hover-info" @click.stop>
|
||||
<div class="hover-left">
|
||||
<img
|
||||
v-if="task.inputType === 'image'"
|
||||
:src="task.inputContent"
|
||||
alt=""
|
||||
class="input-thumb"
|
||||
/>
|
||||
<span v-else class="input-desc">{{ task.inputContent }}</span>
|
||||
</div>
|
||||
<span class="hover-date">{{ formatDate(task.createTime) }}</span>
|
||||
<div class="hover-actions">
|
||||
<a-tooltip v-if="['failed', 'timeout'].includes(task.status)" title="重试" placement="top">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="task.retryCount >= 3"
|
||||
@click="handleRetry(task)"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除" placement="top">
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="handleDelete(task)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination-wrapper">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="false"
|
||||
show-quick-jumper
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileImageOutlined,
|
||||
CloseOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
getAI3DTasks,
|
||||
retryAI3DTask,
|
||||
deleteAI3DTask,
|
||||
type AI3DTask,
|
||||
type AI3DTaskStatus,
|
||||
} from "@/api/ai-3d"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const list = ref<AI3DTask[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const statusFilter = ref<AI3DTaskStatus | "">("")
|
||||
|
||||
// 轮询定时器
|
||||
let pollingTimer: number | null = null
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 去创建页
|
||||
const goCreate = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}/workbench/3d-lab`)
|
||||
}
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
if (statusFilter.value) {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
const res = await getAI3DTasks(params)
|
||||
const data = res.data || res
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
} catch (error) {
|
||||
console.error("获取历史记录失败:", error)
|
||||
message.error("获取历史记录失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (task: AI3DTask) => {
|
||||
if (task.previewUrl) {
|
||||
// 自己的COS桶直接访问(已配置公有读)
|
||||
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
|
||||
return task.previewUrl
|
||||
}
|
||||
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||
if (
|
||||
task.previewUrl.includes("tencentcos.cn") ||
|
||||
task.previewUrl.includes("qcloud.com")
|
||||
) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||
}
|
||||
return task.previewUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: "排队中",
|
||||
processing: "生成中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
timeout: "超时",
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format("YYYY-MM-DD HH:mm")
|
||||
}
|
||||
|
||||
// 格式化日期(年月日)
|
||||
const formatDate = (time: string) => {
|
||||
return dayjs(time).format("YYYY.MM.DD")
|
||||
}
|
||||
|
||||
// 查看任务详情
|
||||
const handleViewTask = (task: AI3DTask) => {
|
||||
router.push({
|
||||
name: "AI3DGenerate",
|
||||
params: { taskId: task.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview = (task: AI3DTask) => {
|
||||
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const urls = task.resultUrls || [task.resultUrl]
|
||||
|
||||
// 存储到 sessionStorage(支持多模型)
|
||||
if (urls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls))
|
||||
sessionStorage.setItem("model-viewer-index", "0")
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", urls[0] || "")
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = async (task: AI3DTask) => {
|
||||
if (task.retryCount >= 3) {
|
||||
message.warning("已达到最大重试次数,请创建新任务")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await retryAI3DTask(task.id)
|
||||
message.success("重试已提交")
|
||||
fetchList()
|
||||
startPolling()
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || "重试失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (task: AI3DTask) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这条创作记录吗?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteAI3DTask(task.id)
|
||||
message.success("删除成功")
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
message.error("删除失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
if (pollingTimer) return
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
)
|
||||
if (!hasProcessing) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
await fetchList()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
pollingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchList()
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
)
|
||||
if (hasProcessing) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// ==========================================
|
||||
// 项目统一设计变量 - 统一色系
|
||||
// ==========================================
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$primary-light: #40a9ff;
|
||||
$secondary: #4096ff;
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
$background: #f5f5f5;
|
||||
$surface: #ffffff;
|
||||
$surface-light: #fafafa;
|
||||
|
||||
$text: rgba(0, 0, 0, 0.85);
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
|
||||
$border: #d9d9d9;
|
||||
$border-light: #e8e8e8;
|
||||
|
||||
// 渐变 - 与导航栏选中按钮一致
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: $background;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Animated Background
|
||||
// ==========================================
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
animation: float 30s ease-in-out infinite;
|
||||
|
||||
&.bg-gradient-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: $primary;
|
||||
top: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
&.bg-gradient-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $primary-light;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
&.bg-gradient-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $secondary;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -20s;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.05);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Header
|
||||
// ==========================================
|
||||
.page-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.back-btn {
|
||||
color: $text !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.2) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
background: $gradient-primary;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba($primary, 0.1);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 20px;
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Content
|
||||
// ==========================================
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
padding-top: 88px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Loading State
|
||||
// ==========================================
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 24px;
|
||||
color: $text-muted;
|
||||
|
||||
.loader {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.loader-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.5s linear infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
border-top-color: $primary;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
top: 7.5px;
|
||||
left: 7.5px;
|
||||
border-right-color: $primary-light;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-bottom-color: $secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Empty State
|
||||
// ==========================================
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba($primary, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: $primary-light;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: $gradient-primary !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500 !important;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba($primary, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// History Grid
|
||||
// ==========================================
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: $surface;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-4px);
|
||||
|
||||
.card-preview .preview-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-hover-info {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
height: 240px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($surface-light, 0.9) 0%,
|
||||
rgba($primary, 0.05) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.preview-loading,
|
||||
.preview-failed,
|
||||
.preview-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
font-size: 32px;
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-failed {
|
||||
.failed-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($error, 0.15) 0%,
|
||||
rgba($error, 0.25) 100%
|
||||
);
|
||||
border: 2px solid rgba($error, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: $error;
|
||||
animation: pulse-error 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px 5px rgba($error, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $primary-light;
|
||||
border-radius: 50%;
|
||||
animation: dotPulse 1.4s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.status-completed {
|
||||
background: rgba($success, 0.2);
|
||||
color: $success;
|
||||
border-color: rgba($success, 0.3);
|
||||
}
|
||||
|
||||
&.status-processing,
|
||||
&.status-pending {
|
||||
background: rgba($primary-light, 0.2);
|
||||
color: $primary-light;
|
||||
border-color: rgba($primary-light, 0.3);
|
||||
}
|
||||
|
||||
&.status-failed,
|
||||
&.status-timeout {
|
||||
background: rgba($error, 0.2);
|
||||
color: $error;
|
||||
border-color: rgba($error, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// 悬停显示的底部信息
|
||||
.card-hover-info {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.hover-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.input-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Pagination
|
||||
// ==========================================
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Responsive
|
||||
// ==========================================
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 0 16px;
|
||||
|
||||
.header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/ai-3d.ts","./src/api/auth.ts","./src/api/classes.ts","./src/api/config.ts","./src/api/contests.ts","./src/api/departments.ts","./src/api/dict.ts","./src/api/grades.ts","./src/api/homework.ts","./src/api/judges-management.ts","./src/api/logs.ts","./src/api/menus.ts","./src/api/permissions.ts","./src/api/roles.ts","./src/api/schools.ts","./src/api/students.ts","./src/api/teachers.ts","./src/api/tenants.ts","./src/api/upload.ts","./src/api/users.ts","./src/composables/uselistrequest.ts","./src/composables/usesimplelistrequest.ts","./src/directives/permission.ts","./src/router/index.ts","./src/stores/auth.ts","./src/types/api.ts","./src/types/auth.ts","./src/types/router.ts","./src/utils/auth.ts","./src/utils/avatar.ts","./src/utils/menu.ts","./src/utils/request.ts","./src/app.vue","./src/components/modelviewer.vue","./src/components/richtexteditor.vue","./src/layouts/basiclayout.vue","./src/layouts/emptylayout.vue","./src/views/activities/comments.vue","./src/views/activities/guidance.vue","./src/views/activities/review.vue","./src/views/activities/reviewdetail.vue","./src/views/activities/components/reviewworkmodal.vue","./src/views/auth/login.vue","./src/views/contests/activities.vue","./src/views/contests/create.vue","./src/views/contests/detail.vue","./src/views/contests/guidance.vue","./src/views/contests/index.vue","./src/views/contests/registerindividual.vue","./src/views/contests/registerteam.vue","./src/views/contests/components/addjudgedrawer.vue","./src/views/contests/components/addparticipantdrawer.vue","./src/views/contests/components/addteacherdrawer.vue","./src/views/contests/components/submitworkdrawer.vue","./src/views/contests/components/viewworkdrawer.vue","./src/views/contests/components/workdetailmodal.vue","./src/views/contests/judges/index.vue","./src/views/contests/notices/index.vue","./src/views/contests/registrations/index.vue","./src/views/contests/registrations/records.vue","./src/views/contests/results/detail.vue","./src/views/contests/results/index.vue","./src/views/contests/reviews/index.vue","./src/views/contests/reviews/progress.vue","./src/views/contests/reviews/progressdetail.vue","./src/views/contests/reviews/tasks.vue","./src/views/contests/works/index.vue","./src/views/contests/works/worksdetail.vue","./src/views/error/403.vue","./src/views/error/404.vue","./src/views/homework/index.vue","./src/views/homework/reviewrules.vue","./src/views/homework/studentdetail.vue","./src/views/homework/studentlist.vue","./src/views/homework/submissions.vue","./src/views/model/modelviewer.vue","./src/views/school/classes/index.vue","./src/views/school/departments/index.vue","./src/views/school/grades/index.vue","./src/views/school/schools/index.vue","./src/views/school/students/index.vue","./src/views/school/teachers/index.vue","./src/views/system/config/index.vue","./src/views/system/dict/index.vue","./src/views/system/logs/index.vue","./src/views/system/menus/index.vue","./src/views/system/permissions/index.vue","./src/views/system/roles/index.vue","./src/views/system/tenants/index.vue","./src/views/system/users/index.vue","./src/views/workbench/index.vue","./src/views/workbench/ai-3d/generate.vue","./src/views/workbench/ai-3d/history.vue","./src/views/workbench/ai-3d/index.vue"],"errors":true,"version":"5.9.3"}
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/analytics.ts","./src/api/auth.ts","./src/api/classes.ts","./src/api/config.ts","./src/api/contests.ts","./src/api/departments.ts","./src/api/dict.ts","./src/api/grades.ts","./src/api/homework.ts","./src/api/judges-management.ts","./src/api/logs.ts","./src/api/menus.ts","./src/api/permissions.ts","./src/api/preset-comments.ts","./src/api/public.ts","./src/api/roles.ts","./src/api/schools.ts","./src/api/students.ts","./src/api/teachers.ts","./src/api/tenants.ts","./src/api/upload.ts","./src/api/users.ts","./src/composables/uselistrequest.ts","./src/composables/usesimplelistrequest.ts","./src/directives/permission.ts","./src/router/index.ts","./src/stores/auth.ts","./src/types/api.ts","./src/types/auth.ts","./src/types/router.ts","./src/utils/auth.ts","./src/utils/avatar.ts","./src/utils/menu.ts","./src/utils/request.ts","./src/app.vue","./src/components/richtexteditor.vue","./src/layouts/basiclayout.vue","./src/layouts/emptylayout.vue","./src/layouts/publiclayout.vue","./src/views/activities/comments.vue","./src/views/activities/guidance.vue","./src/views/activities/presetcomments.vue","./src/views/activities/review.vue","./src/views/activities/reviewdetail.vue","./src/views/activities/components/reviewworkmodal.vue","./src/views/analytics/overview.vue","./src/views/analytics/review.vue","./src/views/auth/login.vue","./src/views/content/tagmanagement.vue","./src/views/content/workmanagement.vue","./src/views/content/workreview.vue","./src/views/contests/activities.vue","./src/views/contests/create.vue","./src/views/contests/detail.vue","./src/views/contests/guidance.vue","./src/views/contests/index.vue","./src/views/contests/registerindividual.vue","./src/views/contests/registerteam.vue","./src/views/contests/superdetail.vue","./src/views/contests/components/addjudgedrawer.vue","./src/views/contests/components/addparticipantdrawer.vue","./src/views/contests/components/addteacherdrawer.vue","./src/views/contests/components/submitworkdrawer.vue","./src/views/contests/components/viewworkdrawer.vue","./src/views/contests/components/workdetailmodal.vue","./src/views/contests/judges/index.vue","./src/views/contests/notices/index.vue","./src/views/contests/registrations/index.vue","./src/views/contests/registrations/records.vue","./src/views/contests/results/detail.vue","./src/views/contests/results/index.vue","./src/views/contests/reviews/index.vue","./src/views/contests/reviews/progress.vue","./src/views/contests/reviews/progressdetail.vue","./src/views/contests/reviews/tasks.vue","./src/views/contests/works/index.vue","./src/views/contests/works/worksdetail.vue","./src/views/error/403.vue","./src/views/error/404.vue","./src/views/homework/index.vue","./src/views/homework/reviewrules.vue","./src/views/homework/studentdetail.vue","./src/views/homework/studentlist.vue","./src/views/homework/submissions.vue","./src/views/public/activities.vue","./src/views/public/activitydetail.vue","./src/views/public/gallery.vue","./src/views/public/login.vue","./src/views/public/components/workselector.vue","./src/views/public/create/generating.vue","./src/views/public/create/index.vue","./src/views/public/mine/children.vue","./src/views/public/mine/favorites.vue","./src/views/public/mine/index.vue","./src/views/public/mine/registrations.vue","./src/views/public/works/detail.vue","./src/views/public/works/index.vue","./src/views/public/works/publish.vue","./src/views/school/classes/index.vue","./src/views/school/departments/index.vue","./src/views/school/grades/index.vue","./src/views/school/schools/index.vue","./src/views/school/students/index.vue","./src/views/school/teachers/index.vue","./src/views/system/config/index.vue","./src/views/system/dict/index.vue","./src/views/system/logs/index.vue","./src/views/system/menus/index.vue","./src/views/system/permissions/index.vue","./src/views/system/public-users/index.vue","./src/views/system/roles/index.vue","./src/views/system/tenants/index.vue","./src/views/system/users/index.vue","./src/views/workbench/index.vue"],"errors":true,"version":"5.9.3"}
|
||||
38
java-frontend/vite.config.js
Normal file
38
java-frontend/vite.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
// 根据环境设置 base 路径
|
||||
var getBase = function (mode) {
|
||||
switch (mode) {
|
||||
case "test":
|
||||
return "/web-test/";
|
||||
case "production":
|
||||
return "/web/";
|
||||
default:
|
||||
return "/";
|
||||
}
|
||||
};
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(function (_a) {
|
||||
var mode = _a.mode;
|
||||
return {
|
||||
base: getBase(mode),
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8580",
|
||||
changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user