This commit is contained in:
zhonghua 2026-04-01 19:30:33 +08:00
parent 781416dc19
commit 746f5d85ec
91 changed files with 4485 additions and 9184 deletions

View 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 DBPrisma → 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 事务**:点赞/收藏需要「记录表 + 冗余计数」同事务,并防止并发下计数异常。

View 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

View 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`

View 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 | **密码加密** | bcrypt10 轮 saltJava 端使用 `BCryptPasswordEncoder` |
| 9 | **权限码格式** | 统一使用 `resource:action` 格式,与前端指令对应 |
| 10 | **菜单加载有 super_admin 快速路径** | super_admin 跳过 TenantMenu 和 permission 过滤,直接返回所有菜单 |

View 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_idpermission 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 非空,则角色至少要拥有该 permissionrole_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;

View 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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View File

@ -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();

View File

@ -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,

View File

@ -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) {

View File

@ -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) {

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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;

View File

@ -10,6 +10,9 @@ import lombok.Data;
@Schema(description = "更新菜单请求")
public class UpdateMenuDTO {
@Schema(description = "菜单场景portal-平台端tenant-租户端")
private String scene;
@Schema(description = "菜单名称")
private String name;

View File

@ -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;
}

View File

@ -18,6 +18,12 @@ import java.util.List;
@TableName("t_auth_menu")
public class Menu extends BaseEntity {
/**
* 菜单场景portal-平台端tenant-租户端
*/
@TableField("scene")
private String scene;
/**
* 菜单名称
*/

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 &gt;= #{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 &gt;= #{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 &lt;&gt; '' 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 &gt;= #{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) &gt; 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 &lt;&gt; ''
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) {}
}

View File

@ -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
) {}
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
/**
* 获取用户菜单根据用户角色权限
*

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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()) {

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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());

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 '点赞用户 IDt_sys_user.id',
`work_id` BIGINT NOT NULL COMMENT '作品 IDuser_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 '收藏用户 IDt_sys_user.id',
`work_id` BIGINT NOT NULL COMMENT '作品 IDuser_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 作品收藏明细';

View File

@ -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`);

View File

@ -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_idrole_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`);

View File

@ -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';

View File

@ -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 菜单树标记为 portalid=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';

View File

@ -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`);

View File

@ -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);

View File

@ -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';

View File

@ -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"
},

View 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));

View File

@ -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`);
}

View 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 }),
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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>

View File

@ -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: key3D
// /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;
}
}

View File

@ -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"],
},
},
// 动态路由将在这里添加

View File

@ -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);

View File

@ -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
}
}

View File

@ -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}`;
};
// 3DURL
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);
// sessionStorageURL
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);

View 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>

View 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>

View File

@ -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 = [
{

View File

@ -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,

View File

@ -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)
// sessionStorageURL
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("开始下载作品")
}

View File

@ -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 || ""
})
// URL3DURL
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
})
// 3DURL
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) {
// 3DURL
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

View File

@ -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); }
}
}
}
}

View 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>

View File

@ -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 */ }
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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"}

View 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/, ''),
},
},
},
};
});