149 Commits
Author SHA1 Message Date
Zhou Weidong e2618735ba chore(merge): 合并 main 的样式排序到 develop 2026-10-29 09:21:32 +08:00
Zhou Weidong b68361b037 chore(merge): 合并 phase1 UI prep 后台用户系统筛选与列表展示
合并后新增收藏/点赞列表页、用户单元格组件与系统筛选,
并统一观看时间格式与标签文案。
2026-10-27 20:20:31 +08:00
Vicky bedae02946 feat: phase1 UI prep — 后台 App 用户列表支持系统筛选 + 国家中文名 + 标签语义优化
新增: 系统下拉 (ANDROID/IOS)、国家中文名映射、用户列昵称/UID 冒号标签
详情抽屉: 同步系统/国家/标签展示
2026-10-27 15:45:23 +08:00
Vicky daf681ae1a fix(admin): 观看时间统一格式化为 yyyy-MM-dd HH:mm:ss
后端改输出格式后仍保留前端兜底,同时修掉用户详情里的原始 ISO 展示。
2026-10-27 14:39:18 +08:00
Vicky 111bbdaeb7 fix(admin): 用户列标签改用半角冒号 2026-10-27 14:29:56 +08:00
Vicky 5da72d1aa2 fix(admin): 用户列补全「昵称 / UID / 设备」标签与冒号
两行都以「字段名:值」呈现,避免只看到一串随机字母。
2026-10-27 14:28:59 +08:00
Vicky e1eb58d4c4 feat(admin): 列表支持按系统筛选,用户列补语义标签
- 收藏/点赞/观看增加「全部系统 / Android / iOS」下拉
- 用户列第二行标注 UID,游客行标注设备,避免一串字母看不出含义
2026-10-27 14:27:16 +08:00
Vicky 5dbc752798 feat(admin): 观看/收藏/点赞列表单独增加系统列
用户单元格只保留身份信息,Android/iOS 抽成独立「系统」列,三页列结构一致。
2026-10-27 14:17:37 +08:00
Vicky c89c5668e1 refactor(admin): 用户单元格只保留平台标识
机型与系统版本号属于多余信息,Android/iOS 加游客标签已足够区分。
2026-10-27 14:00:07 +08:00
Vicky 9ea22d5951 chore(merge): 合并后台用户单元格统一 2026-10-27 13:53:54 +08:00
Vicky f3fbc57c14 feat(admin): 统一用户单元格,展示游客设备号与客户端系统
- 新增 AppUserCell,游客显示标签+deviceId,第二行统一展示系统与机型
- 收藏、点赞、观看记录三页复用,观看记录去掉重复的系统列
2026-10-27 13:53:53 +08:00
Zhou Weidong c45820258e chore(merge): 合并游客标签样式修正 2026-10-27 13:11:10 +08:00
Zhou Weidong 12d63e9670 fix(admin): 修正游客标签拉伸 2026-10-27 13:10:19 +08:00
Zhou Weidong 7aa1571c15 chore(merge): 合并后台互动列表展示调整 2026-10-27 13:05:14 +08:00
Zhou Weidong 8cd4569a20 fix(admin): 收紧收藏与点赞列表展示 2026-10-27 13:03:53 +08:00
Zhou Weidong 83bb87eeb3 chore(merge): 合并后台 props lint 修复 2026-10-27 12:47:46 +08:00
Zhou Weidong 3409a2b427 fix(admin): 修复组件 props 变异的 lint 基线 2026-10-27 12:47:45 +08:00
Zhou Weidong a21fe6f189 chore(merge): 合并后台收藏与点赞列表页 2026-10-27 12:38:40 +08:00
Zhou Weidong 5580f9b99b feat(admin): 新增收藏与点赞列表页
全量 lint 仍有 5 处既有 vue/no-mutating-props 基线错误,
位于 LocaleTextFields.vue 与 ClassificationDramaDialogs.vue,本次未处理。
2026-10-27 12:38:31 +08:00
Zhou Weidong d846d2d864 Merge branch fix/icons-vue-deps 2026-10-26 13:06:45 +08:00
Zhou Weidong e6d1d6997b fix(deps): 补充 icons-vue 依赖声明 2026-10-26 13:06:38 +08:00
Zhou Weidong d661b808d1 chore(merge): 合并后台多语言工作台和登录页 2026-10-24 17:32:51 +08:00
Zhou Weidong d42a09fa36 feat(admin): 后台页面显示版权标识 2026-10-24 17:32:32 +08:00
Zhou Weidong 33bf5bd3bb feat(admin): 登录页补充水果装饰 2026-10-24 17:32:12 +08:00
Zhou Weidong a266d44483 feat(admin): 增加工作台大屏和转码通知 2026-10-24 17:31:16 +08:00
Zhou Weidong 2b2c6bd708 feat(admin): 内容管理支持多语言和标签 2026-10-24 17:30:52 +08:00
Zhou Weidong eaee765239 fix(app-version): 修复版本页类型检查 2026-10-24 17:30:29 +08:00
Zhou Weidong 18234ebd5b Merge branch fix/app-version-batch 2026-10-23 16:46:29 +08:00
Zhou Weidong 93d6d8c59f fix(app-version): 修复弹窗禁用并改批量提交 2026-10-23 16:46:22 +08:00
Zhou Weidong 9fc57e5c1a chore(merge): 合并 App 版本管理页面 2026-10-23 15:27:46 +08:00
Zhou Weidong 0a28eb0237 feat(admin): 新增 App 版本管理页面 2026-10-23 15:27:45 +08:00
Zhou Weidong 73a9b078ee chore(merge): 合并后台默认主题色调整 2026-10-23 15:13:00 +08:00
Zhou Weidong bb48f14ba7 feat(admin): 头部与菜单主题默认改为白色 2026-10-23 15:12:59 +08:00
Zhou Weidong ada74efc3a chore(merge): 合并运营大盘 ECharts 图表 2026-10-23 14:58:56 +08:00
Zhou Weidong 10372524ba feat(admin): 运营大盘接入 ECharts 图表 2026-10-23 14:58:55 +08:00
Zhou Weidong fd9a9c50c4 chore(merge): 合并立即补齐转码按钮 2026-10-23 14:14:48 +08:00
Zhou Weidong e51d371e07 feat(admin): 转码设置页新增立即补齐转码按钮 2026-10-23 14:14:46 +08:00
Zhou Weidong ada3b56d14 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:08:30 +08:00
Zhou Weidong 7e8ae86d9f chore(merge): 对齐远程 main 2026-10-23 13:08:28 +08:00
Zhou Weidong 430ba59613 chore(merge): 将 develop 同步到 release/v1.0.0 2026-10-23 13:08:09 +08:00
Zhou Weidong d7d43fed44 chore(merge): 合并转码设置菜单去重修复 2026-10-23 12:55:57 +08:00
Zhou Weidong aa892fe7a8 fix(router): 修正转码设置菜单重复注入 2026-10-23 12:55:56 +08:00
Zhou Weidong 7c10e057a5 chore(merge): 合并协议设置与用户系统展示 2026-10-23 12:35:26 +08:00
Zhou Weidong 8bd6aa35cb style(admin): 修正页面格式以通过检查 2026-10-23 12:35:09 +08:00
Zhou Weidong 0578052d6e feat(admin): 用户与观看记录展示系统 2026-10-23 12:34:52 +08:00
Zhou Weidong c1fb6cc21c feat(admin): 新增三语协议设置页 2026-10-23 12:34:33 +08:00
Zhou Weidong 91f1f7d793 fix(admin): 短剧简介长度上限调整为 1000 2026-10-22 22:18:06 +08:00
Zhou Weidong a47700a980 feat(admin): 新增分类管理与剧单管理页面 2026-10-22 22:18:05 +08:00
Zhou Weidong 1d18b7b52b feat(admin): 清晰度产物支持 HLS 预览播放 2026-10-22 18:12:30 +08:00
Zhou Weidong 208b9f29d8 chore(merge): 合并转码实时进度展示 2026-10-22 15:13:57 +08:00
Zhou Weidong 9b2ab1a425 fix(admin): 转码面板展示实时进度与更新时间 2026-10-22 15:12:12 +08:00
Zhou Weidong 0d5d0d841f chore(merge): 合并转码媒资 id 精度修复 2026-10-22 14:59:54 +08:00
Zhou Weidong fecc3b4817 fix(admin): 转码媒资 id 按字符串传递并处理空态 2026-10-22 14:59:22 +08:00
Zhou Weidong a4893c31a2 chore(merge): 合并转码面板组件拆分 2026-10-22 14:48:43 +08:00
Zhou Weidong e2ca3bfa81 refactor(admin): 拆分剧集转码产物面板组件 2026-10-22 14:47:01 +08:00
Zhou Weidong 52b10d3040 chore(merge): 合并剧集转码产物面板 2026-10-22 14:26:05 +08:00
Zhou Weidong 41151a7f17 feat(admin): 剧集编辑页展示转码产物与帧图 2026-10-22 14:23:47 +08:00
Zhou Weidong 75da3ea0ba chore(merge): 合并后台转码设置页面 2026-10-22 11:30:05 +08:00
Zhou Weidong 218196ee9f feat(admin): 新增转码设置页面 2026-10-22 11:29:48 +08:00
Zhou Weidong 723d056fc7 chore(merge): 批量上传改为手动点击开始 2026-10-22 09:31:43 +08:00
Zhou Weidong f174ba46ae feat(admin): 批量上传改为手动点击开始 2026-10-22 09:31:35 +08:00
Zhou Weidong 2e89f8460c chore(merge): 剧集列表分页条始终显示 2026-10-22 09:23:10 +08:00
Zhou Weidong 632bf13990 fix(admin): 剧集列表分页条始终显示 2026-10-22 09:23:04 +08:00
Zhou Weidong becbe18d76 chore(merge): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:19:33 +08:00
Zhou Weidong 6bcabea261 fix(admin): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:18:59 +08:00
Vicky 99bb666e47 chore(merge): 剧集列表改为服务端分页 2026-10-21 23:20:56 +08:00
Zhou Weidong 092fe18ae7 feat(admin): 剧集列表改为服务端分页 2026-10-21 23:20:38 +08:00
Zhou Weidong c2a76a593a chore(merge): 剧集列表分页默认每页100条 2026-10-21 23:00:13 +08:00
Zhou Weidong a28eaa4514 feat(admin): 剧集列表分页默认每页100条 2026-10-21 23:00:08 +08:00
Zhou Weidong 189a7f689a chore(merge): 批量上传创建的分集默认已上线 2026-10-21 22:53:22 +08:00
Zhou Weidong 93e1c1b8c9 feat(admin): 批量上传创建的分集默认已上线 2026-10-21 22:53:07 +08:00
Zhou Weidong 05b977cc33 fix(admin): 批量建分集是否免费改由短剧免费集数决定 2026-10-21 22:25:50 +08:00
Zhou Weidong 5741ebbf17 fix(admin): 简化批量建分集表单并将移除操作置右 2026-10-21 22:20:10 +08:00
Zhou Weidong 836600b811 chore(build): commit-msg 拦截 AI 署名 2026-10-21 22:10:35 +08:00
Zhou Weidong 4841b0bc24 fix(admin): 移除上传提示中的并发表述
上传为异步队列,文件数量不设上限,提示只保留格式与单文件大小限制
2026-10-21 22:08:47 +08:00
Vicky e1e0055915 chore(admin): 合并剧集上传文案统一与组件拆分 2026-10-21 22:07:07 +08:00
Zhou WeidongandCursor cff864d582 refactor(admin): 拆分剧集管理页超长组件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:07:04 +08:00
Zhou WeidongandCursor c71a12f6f4 fix(admin): 统一剧集上传格式与提示文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:04:36 +08:00
Zhou WeidongandCursor 7d39e3c684 chore(admin): 合并剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:40 +08:00
Zhou WeidongandCursor 6b3620c3c4 feat(admin): 剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:28 +08:00
Zhou Weidong 8e1a02ace0 chore(web): 忽略本机启动脚本与运行日志 2026-10-21 18:55:24 +08:00
Zhou Weidong 982346045d Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:52:34 +08:00
Zhou Weidong 48b09ee8cf feat(admin): 短剧编辑支持排序权重 2026-10-21 18:52:15 +08:00
Zhou Weidong ceb7905697 Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:10:21 +08:00
Zhou Weidong 979a502373 merge: 合并 K93.1 多选完整展示 2026-10-21 17:16:54 +08:00
Zhou Weidong 3e779e4efd fix(admin): 多选类型与内容类型完整展示 2026-10-21 17:16:40 +08:00
Zhou Weidong 481c231dd9 merge: 合并 K93 短剧多类型管理 2026-10-21 17:01:37 +08:00
Zhou Weidong 9e427d485f feat(admin): 短剧类型与内容类型多选 2026-10-21 17:01:22 +08:00
Zhou Weidong 58380170b0 merge: 合并 K92 举报列表短剧与分集展示 2026-10-21 16:45:08 +08:00
Zhou Weidong e583bfed5f feat(admin): 举报列表展示短剧与分集 2026-10-21 16:44:55 +08:00
Zhou Weidong 923a350cde merge: 合并 K91 观看记录页面与治理筛选 2026-10-21 16:36:09 +08:00
Zhou Weidong 0710ea55f3 feat(admin): 观看记录独立菜单与治理状态全部筛选 2026-10-21 16:35:41 +08:00
Zhou Weidong 50f2b177b1 merge: 合并 K89 分集互动数据管理 2026-10-21 15:59:13 +08:00
Zhou Weidong 0e814840b1 feat(admin): 剧集列表展示分集互动数据 2026-10-21 15:59:08 +08:00
Zhou Weidong c1e03f94be merge: 合并 K88 短剧内容类型与互动数据管理 2026-10-21 15:44:12 +08:00
Zhou Weidong c8fe571be1 feat(admin): 短剧列表展示并编辑互动数据 2026-10-21 15:43:51 +08:00
Zhou Weidong 45507dee8d perf(admin): 降低首屏体积并修复 Windows 访问白屏
1) Editor 改 defineAsyncComponent,IEditorConfig 改 import type
2) dev server 绑定 :: 双栈,避免 Windows 下 localhost 走 ::1 超时
3) dev 脚本去掉 POSIX 环境变量前缀,Windows 可直接 pnpm dev
2026-10-21 13:35:55 +08:00
Zhou Weidong 1737f37d82 fix(login): 忘记密码链接加提示,避免点击无响应 2026-10-20 09:15:19 +08:00
Zhou Weidong d283d223c0 chore: 忽略构建产物 apk 2026-10-20 09:12:11 +08:00
Zhou Weidong e9a956f16c chore(web): 忽略运行时产物(日志/启动脚本) 2026-10-19 22:43:29 +08:00
Zhou Weidong f78960fcce chore(web): 合并 release 分片上传修复 2026-10-19 18:46:27 +08:00
Zhou Weidong 8e9435ee7d fix(web): 修复分片上传 chunkSize 字符串拼接导致大文件超限 2026-10-19 18:38:06 +08:00
Zhou Weidong 83e535d12a chore(web): 合并剧集编辑弹窗 2026-10-19 13:55:29 +08:00
Zhou Weidong 2b5dda7c71 feat(web): 重做剧集编辑弹窗 2026-10-19 13:53:57 +08:00
Zhou Weidong 685d4fd1bd chore(web): 合并轮播图编辑弹窗 2026-10-19 13:49:09 +08:00
Zhou Weidong f281dcf1b3 feat(web): 重做轮播图编辑弹窗 2026-10-19 13:47:42 +08:00
Zhou Weidong 5db565b15d chore(web): 合并列表图片预览 2026-10-19 13:42:57 +08:00
Zhou Weidong 8a84f24baf feat(web): 支持后台列表图片大图预览 2026-10-19 13:42:28 +08:00
Zhou Weidong 046400ae4a chore(web): 合并剧集管理布局修复 2026-10-19 13:32:02 +08:00
Zhou Weidong 122aa21323 fix(web): 重做剧集管理顶部布局 2026-10-19 13:30:29 +08:00
Zhou Weidong 9f92d113f2 chore(web): 合并二级菜单背景修复 2026-10-19 13:28:07 +08:00
Zhou Weidong f6f4d264ee fix(web): 调整二级菜单背景样式 2026-10-19 13:27:44 +08:00
Zhou Weidong f023c7d416 chore(web): 合并工作台菜单图标修复 2026-10-19 13:22:44 +08:00
Zhou Weidong bb03a5ad84 fix(web): 修复工作台菜单图标继承 2026-10-19 13:21:09 +08:00
Zhou Weidong 73da0bbd85 chore(web): 合并 K23.1 布局修复 2026-10-19 12:58:02 +08:00
Zhou Weidong c8bf4a8fd4 fix(web): 修复禁用按钮与弹窗布局 2026-10-19 12:56:07 +08:00
Zhou Weidong 6f522303a0 chore(web): 合并 K23.1 后台修正 2026-10-19 12:34:21 +08:00
Zhou Weidong f5b399a52f fix(web): 统一危险操作按钮样式 2026-10-19 12:32:23 +08:00
Zhou Weidong e77db70278 fix(web): 修正浅色品牌文字颜色 2026-10-19 12:32:01 +08:00
Zhou Weidong fe1ae954e3 chore(web): 合并菜单对齐修复 2026-10-19 12:04:22 +08:00
Zhou Weidong 40fbd6ec7b fix(web): 对齐父子菜单文字基线 2026-10-19 12:04:18 +08:00
Zhou Weidong ee3bb1c286 chore(web): 合并 K23 后台视觉深化 2026-10-19 12:00:41 +08:00
Zhou Weidong 898e439b84 fix(web): 优化锁屏弹窗布局与交互 2026-10-19 12:00:30 +08:00
Zhou Weidong 805d7ddc52 feat(web): 重做短剧编辑弹窗 2026-10-19 12:00:26 +08:00
Zhou Weidong ba90bf9bfb style(web): 统一辣椒红主色与菜单层级 2026-10-19 12:00:21 +08:00
Zhou Weidong 3e8312ea58 chore(web): 合并首屏 loading 文字修复 2026-10-19 11:34:19 +08:00
Zhou Weidong a97b8cc026 fix(web): 修正首屏 loading 文字颜色 2026-10-19 11:34:15 +08:00
Zhou Weidong e5c5101450 chore(web): 合并 K22.1 后台收尾 2026-10-19 11:32:09 +08:00
Zhou Weidong 87970487af chore(web): 清理 mock 与旧品牌资源 2026-10-19 11:32:00 +08:00
Zhou Weidong ada741f53d style(web): 精简登录品牌与注册入口 2026-10-19 11:31:55 +08:00
Zhou Weidong 5494190330 chore(web): 合并 K22 路由图标收尾 2026-10-19 11:18:15 +08:00
Zhou Weidong 29465ab4ed fix(web): 统一静态路由图标 2026-10-19 11:18:11 +08:00
Zhou Weidong 8f3447e528 chore(web): 合并 K22 后台视觉统一 2026-10-19 11:12:52 +08:00
Zhou Weidong 97fbeda508 style(web): 统一后台图标与全局视觉 2026-10-19 11:12:38 +08:00
Zhou Weidong 5c087f4db9 chore(admin): 合并注册限流配置项 2026-10-17 23:29:04 +08:00
Zhou Weidong 54db082ffb feat(admin): 增加注册限流配置项 2026-10-17 23:28:40 +08:00
Vicky 3ec9ea6717 fix(web): 适配后端 ID 字符串化(雪花 id 不能过 Number)
后端 J1 起所有 Long id 以字符串下发,审计出 4 处把 id 当数字用的地方:

- utils/creatorImpersonation.ts:代看创作者 id 曾 Number(raw) 存 localStorage,
  19 位 id 会被四舍五入导致 X-Creator-Id 头带错 id;改为全程字符串
- contentManagement/EpisodeManagementView.vue:Number(route.query.drama) 与 item.id 比较,
  从短剧管理带 ?drama= 跳过来会选不中;改为字符串比较
- creator-center/composables/useDramaEdit.js:Number(route.params.id) 同上,作品编辑打不开
- permission/composables/useMenuManagement.js:顶级菜单哨兵 0 与接口的 "0" 类型不一致,
  下拉回显会变裸 0;统一成字符串 '0'

核过不需要改:分页 total 后端保持数字;role.id === selectedRoleId 等两侧都来自接口;
formatCount 内部 Number() 吃字符串;全局无 id 算术/下标/a.id-b.id。
2026-10-16 16:44:19 +08:00
Vicky db313f4aa6 feat(admin): 新增商品配置与系统公告两个后台页面
- src/api/admin/product.ts、src/api/admin/notice.ts 对齐后端新接口
- ProductManagementView:列表/筛选/新建编辑/上下架/删除,新建默认下架有提示
- NoticeManagementView:列表/筛选/新建编辑/发布下架/删除
- 状态列一律用接口返回的 statusText,不在前端写死中文
- id 按字符串处理(雪花 id 超过 JS 安全整数范围,数字会被四舍五入)
2026-10-16 16:33:22 +08:00
Vicky 1148458ca1 chore(merge): 合入远端 main(全量作者标识)到 develop
为什么:远端 main 领先一个提交(1fd0b8b 全量补充作者标识),本地 main 领先一个(4564aa8
el-radio 修复);按条目要求先把远端合进来,再在 develop 上做 I1,main 本身不动(合并走 PR)。
2026-10-16 16:14:15 +08:00
Vicky 4564aa894d chore(web): 修掉 eslint 全量检查的 2 个 error,门禁口径改跑 eslint + pnpm build:pro
为什么:红线第 8 条要求 Web 门禁是 `pnpm build` + eslint 零错误,而此前只跑过 ts:check,
全量 eslint 存量有 2 个 error。Web 已冻结,本次只做"去噪"式修复,不改任何行为。

- BannerManagementView: 表格 slot 解构里去掉未被使用的 row($index 仍在用)
- CreatorCenterPage: 自闭合 <slot /> 改为 <slot></slot>(等价写法)
- 实测:eslint 0 error / 0 warning;pnpm build:pro = Build successful
2026-10-16 09:41:19 +08:00
Zhou Weidong 1fd0b8b100 style: 全量补充作者标识 // Author: fruit.com.cn 2026-10-16 00:02:03 +08:00
Vicky 2fa1416c3e fix(web): el-radio 改用 2.5.5 的 label API —— 全项目单选组失效(点一个全亮)
为什么:项目锁 element-plus 2.5.5,而 2.5.5 的 el-radio 只有 `label` 属性(`value` 是 2.6.0+ 才引入的替代 API)。
源码可证:node_modules/.pnpm/element-plus@2.5.5_vue@3.4.15/.../radio/src/radio.mjs 的 radioPropsBase 只有
label,没有 value。所以 `<el-radio value="X">` 的 value 是无效 attribute,radio 的 label 全是默认空串:
点任意一个 → 模型被设成 "" → 所有 radio 的 label 都是 "" → 全部显示选中(用户看到"点个人、公司也选上了")。
初始值非空时没有匹配项,所以一开始一个都不选中,一点就全亮。

- 全项目 14 处 el-radio(6 个文件)统一把 value= 改成 :label=(值与显示文本不同时用 slot 承载文本)
  覆盖:创作者类型 / 创作者审核 / 内容审核 / 权益类型 / App 用户处置 / 举报处理 / 菜单类型
- 不升级 element-plus(会影响整个后台组件行为,需单独评估)

实测:7 个单选组逐个点选,均"只有被点的那个选中";创作者类型额外验落库值
(选个人 → INDIVIDUAL,选机构 → COMPANY)。
2026-10-15 16:44:22 +08:00
Vicky 4509323ba8 fix(web): 切换账号残留上一账号的标签/菜单;审核按钮不再对已通过的行显示
问题 1(创作者登录后还能看到「工作台」)根因:tagsView.delAllVisitedViews() 会「保留 affix 标签」,
它判断的是 userInfo 是否还在,而 userStore.reset() 是先清标签、后清 userInfo —— 顺序反了,
写死路由表里 affix 的「工作台」标签就跨账号活了下来。整页刷新时不复现,所以只有"退出后不刷新直接登录"
才看得到(PM 复现不到就是这个原因)。修法与加固:

- userStore.reset():先清账号信息再清标签,并调 permissionStore.clearRoutes() 一并清菜单状态
- permissionStore 增加 routersOwner(当前菜单属于哪个账号)+ clearRoutes();守卫发现账号变了会先 resetRouter()
  摘掉上一个账号动态加的路由,再重建(避免两套菜单并存、也避免创作者还能直接进 /dashboard)
- 守卫里"菜单拉取失败沿用上次缓存"这条:换账号后不再沿用(宁可先给空菜单)

问题 2(创作者入驻/审核):真实 UI 全流程(新增→审核→开通→凭据→登录落地)本身能跑通,
但复现出「审核」按钮对**所有状态**都显示 —— 后端状态机是 通过可从 pending/rejected/frozen 发起、
驳回只允许 pending、冻结只允许 approved,对「已通过」的行点审核提交必然 200003 红错。修法:
- 已通过的行不再显示「审核」(保留 冻结/重置密码/开通账号)
- 审核弹窗里的「驳回」只对 pending 显示,其余状态只能通过

顺带修:views/common/DashboardView 取列表时 res 可能为 undefined(拦截器把业务错误吞成 undefined),
账号切换瞬间会抛 Cannot read properties of undefined,已加可选链。
2026-10-15 16:30:29 +08:00
Vicky 8ce43ccd4c feat(web): 创作者审核页支持开号/重置密码;登录落地按角色分流 + 首登强制改密
为什么:后台能开号了,但界面还没有入口;而且创作者登录后会落到 /dashboard/index(他没有运营后台权限)
直接掉 404。这批把开号入口、一次性凭据展示、落地分流与首登改密一次补齐。

- 创作者入驻页:「新增创作者」按钮(名称/类型/联系人/邮箱/国家)、「账号」列(已开通/未开通)、
  行内「开通账号 / 重置密码」;密码一次性弹窗展示(等宽可选中 + 一键复制 + 提示只显示这一次)
- 落地分流:新增 landingPath()(取账号能进的第一个菜单),登录与访问根路径都走它 ——
  创作者落 /creator-center/workspace,超管第一条仍是工作台(行为不变)
- 首登强制改密:Layout 挂 MustChangePasswordDialog,登录或刷新后若 profile 带 mustChangePassword 就弹不可关闭的
  改密框,改完清标记;只能改密或退出登录
2026-10-15 16:01:13 +08:00
Vicky bac070b60b fix(web): 代看目标失效时清掉本地选择
为什么:实测发现目标创作者被删掉后,顶部的「当前创作者」还显示着那个已经进不去的 id(下拉里也没有对应选项,
标签却还挂着"只读代看"),和页面提示的"请先选择代看创作者"互相打架。现在探测到身份不通过且有本地目标时,
一并清掉选择与缓存,让下拉回到占位符。
2026-10-15 15:30:35 +08:00
Vicky 685c912313 feat(web): 创作者中心「当前创作者」代看下拉 + 未绑定引导页
为什么:超管点进创作者中心原本是一片"未绑定创作者主体"的报错,既没说明原因也没有出路。
这批把两件事做出来:超管能在顶部选一个创作者只读代看;没有创作者身份的账号看到明确的引导页而不是白屏。

- 新增 useCreatorAccess(身份三态:granted / need-target / unbound)+ ImpersonationBar + CreatorAccessGuide,
  四个页面用 CreatorCenterPage 统一套壳;身份不满足时不打业务接口,避免一串无意义的报错提示
- 代看目标存 localStorage,由 axios.request 统一给 /admin/creator-center 的请求加 X-Creator-Id 头
  (不做查询参数;后端只对具备 impersonate 权限的账号认这个头)
- axios 支持 silent 请求:业务码非 0 时不弹全局提示、把 Result 交给调用方判断(引导页据此分流)
- 代看只读体验:代看态下「新建作品 / 编辑 / 提交审核 / 撤回」不渲染,编辑页整页禁用并提示原因
- 顶部下拉只在有 impersonate 权限时显示;无权限账号看不到、也不会请求 targets 接口
2026-10-15 15:25:53 +08:00
Vicky 0a2e3e0b81 feat(web): 07 创作者中心四个页面(我的作品 / 作品编辑 / 收费配置 / 数据看板)
为什么:07 的接口五批都做完了,但创作者在界面上一个都点不到 —— 只能靠 curl。
这批把创作者中心做进后台管理(沿用 AppUsersView 那套 api 封装 + composable + 视图 + 样式与 console.css),
创作者用自己的后台账号登录就能完成"建草稿 → 编辑分集传片 → 提交审核 → 配置收费 → 看数据"的闭环。

- WorkspaceView:主体状态 / 我的作品(含最近一次提交的审核进度)/ 通知列表;新建草稿、按状态给出可提交的动作、撤回
- DramaEditView:作品信息(封面、预告片)、分集增删与上移下移、分片上传(复用 /admin/media)+ 断点续传
  (会话存 localStorage,中断后「继续上传」接着传)、产物挂载、提交记录与撤回;非草稿/已下线状态整页转只读
- PriceConfigView:整剧免费集数与解锁金币 + 逐集免费/收费/试看秒数;已上线只读(需先申请下线)
- DashboardView:作品 / 播放 / 互动 / 解锁收入四组指标,取不到的显示「暂无数据源」+ notes 口径说明
- 时间列统一 yyyy-MM-dd HH:mm:ss;表格列与后端字段一一对应,不在前端编字段
- media.ts 补上传进度接口;uploader 补 resumeUpload(先查服务端进度再续传)
2026-10-15 14:47:28 +08:00
Vicky f037d243cd feat(web): 08 后台四个页面落地(内容治理 / 订单与权益 / 创作者审核 / 数据看板)
为什么:08 运营后台的接口在前几轮已经实现,但运营在界面上点不到——
评论审核、举报工单、敏感词维护、订单补发/退款、权益回收、金币补发、对账、
创作者入驻与内容提交审核、运营大盘,都只能靠 curl。本轮把这四个页面补齐,
08 才算真正闭环。

- 内容治理:评论审核(通过/驳回/删除)、举报工单处理(可联动封禁)、敏感词库增删改查
- 订单与权益:订单检索与流水、补发、退款、权益检索/人工补发/回收、金币流水与人工补币、对账运行与历史
- 创作者审核:入驻审核(通过/驳回原因码/冻结)+ 资质抽屉;内容提交审核(通过/驳回)
- 数据看板:overview 五组指标 + transcode 队列与失败任务,只读;取不到的指标显示「暂无数据源」而不是 0
- 新增 console.css(工具条/抽屉/看板卡片,含深色模式),四个页面共用;时间列统一 yyyy-MM-dd HH:mm:ss
- 按钮按权限点显隐(没有 *:update / *:audit 的角色看到的是只读态),表格列与后端字段一一对应
2026-10-15 13:28:46 +08:00
Vicky 44c2c46a68 chore(web): 收口前几轮后台改动(品牌 / 媒资上传 / 轮播图 / 类型管理 / 操作日志 / App 用户)
为什么:这些改动在前几轮就已完成并在 :3005 验证过,但一直留在工作区没有入库。
下一步 3-4 的四个页面依赖其中的公共样式入口(src/styles/business/index.css)与
api/admin 的公共类型(PageResult 等),如果不先收口,新页面的提交就会夹带旧改动,
既说不清 diff 边界,也让 index.css 这类多轮共用的文件长期处于半提交状态。

本提交只做归档,不改行为:
- 品牌与接口基址改成 FruitShorts / 短剧服务(.env.*、README、vite 代理)
- 去掉前端 mock 数据目录(src/data、src/permissions、stores/auth),改为走真实接口
- 新增 api/admin 下的接口封装与 src/utils/uploader.ts(媒资分片直传)
- 新增/改造:轮播图管理、类型管理、操作日志、App 用户管理、后台布局与公共样式
2026-10-15 13:28:01 +08:00
322 changed files with 23137 additions and 2464 deletions

No files matched your search

+3 -3
View File
@@ -8,13 +8,13 @@ VITE_API_BASE_PATH=
VITE_BASE_PATH=/ VITE_BASE_PATH=/
# 标题 # 标题
VITE_APP_TITLE=ReelShort VITE_APP_TITLE=FruitShorts
# 是否全量引入element-plus样式 # 是否全量引入element-plus样式
VITE_USE_ALL_ELEMENT_PLUS_STYLE=true VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+3 -3
View File
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
VITE_OUT_DIR=dist-dev VITE_OUT_DIR=dist-dev
# 标题 # 标题
VITE_APP_TITLE=ElementAdmin VITE_APP_TITLE=FruitShorts
# 是否包分析 # 是否包分析
VITE_USE_BUNDLE_ANALYZER=false VITE_USE_BUNDLE_ANALYZER=false
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+3 -3
View File
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=false
VITE_OUT_DIR=dist-pro VITE_OUT_DIR=dist-pro
# 标题 # 标题
VITE_APP_TITLE=ElementAdmin VITE_APP_TITLE=FruitShorts
# 是否包分析 # 是否包分析
VITE_USE_BUNDLE_ANALYZER=true VITE_USE_BUNDLE_ANALYZER=true
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=true
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+3 -3
View File
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
VITE_OUT_DIR=dist-test VITE_OUT_DIR=dist-test
# 标题 # 标题
VITE_APP_TITLE=ElementAdmin VITE_APP_TITLE=FruitShorts
# 是否包分析 # 是否包分析
VITE_USE_BUNDLE_ANALYZER=false VITE_USE_BUNDLE_ANALYZER=false
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=false VITE_USE_CSS_SPLIT=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
// @ts-check // @ts-check
const { defineConfig } = require('eslint-define-config') const { defineConfig } = require('eslint-define-config')
module.exports = defineConfig({ module.exports = defineConfig({
+6
View File
@@ -7,3 +7,9 @@ dist-ssr
*-lock.* *-lock.*
pnpm-debug pnpm-debug
stats.html stats.html
*.apk
# 本机运行时产物(start-dev.bat / vite 日志)
start-dev.bat
dev.out.log
dev.err.log
+5
View File
@@ -1,4 +1,9 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then
echo "commit message contains AI attribution, remove it before committing" >&2
exit 1
fi
pnpm commitlint --edit "$1" pnpm commitlint --edit "$1"
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
module.exports = { module.exports = {
'*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'], '*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --write--parser json'], '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --write--parser json'],
+30 -17
View File
@@ -1,29 +1,38 @@
# ReelShort 内容管理后台 # FruitShorts 内容管理后台
短剧内容管理后台。界面和工程结构使用 [vue-element-plus-admin](https://element-plus-admin-doc.cn/) 的 `main` 分支,在上面接入了短剧、剧集、角色、菜单和管理员页面。 短剧内容管理后台。界面与工程结构基于 [vue-element-plus-admin](https://element-plus-admin-doc.cn/),在其上接入了短剧、剧集、App 用户、角色、菜单和管理员页面,数据全部来自 Java 后端服务。
界面名称由 `VITE_APP_TITLE` 控制(`.env.base` 及 dev / test / pro 三个环境文件),当前值为 `FruitShorts`,同时作用于浏览器标题、侧边栏品牌与登录页。
后端工程在同级目录 `../FruitServer`,接口与启动方式见 [../FruitServer/README.md](../FruitServer/README.md)。
## 环境 ## 环境
- Node.js 18 或更高版本 - Node.js 18 或更高版本
- pnpm 8 或更高版本 - pnpm 8 或更高版本
- 已启动的后端服务(默认 `http://127.0.0.1:8080`)
## 开始 ## 开始
先启动后端(见 `../FruitServer/README.md`),再启动前端:
```sh ```sh
pnpm install pnpm install
pnpm dev pnpm dev
``` ```
开发服务地址是 [http://localhost:3005](http://localhost:3005)。同一局域网的设备可以使用终端里的 Network 地址打开。 开发服务地址是 http://localhost:3005 。`vite.config.mts` 已把 `/admin` 代理到 `http://127.0.0.1:8080`、`/files` 代理到后端本地媒资目录,因此前端不需要额外配置跨域。
演示账号: 演示账号(密码均为 `admin123`,由后端 `sql/data.sql` 初始化):
| 账号 | 密码 | | 账号 | 角色 | 说明 |
| ------- | ---------- | | -------------------- | ---------- | ------------------------------ |
| `admin` | `admin123` | | `admin` | 超级管理员 | 拥有全部菜单与操作权限 |
| `test` | `test` | | `content.editor01` | 内容运营 | 只能看到内容管理,且无审核权限 |
| `content.reviewer01` | 审核专员 | 可审核发布 |
| `data.analyst01` | 数据分析师 | 只读内容数据 |
`admin` 拥有全部菜单。`test` 只用于对照框架自带的权限示例。 用不同账号登录可以直观看到菜单与按钮随权限变化。
## 命令 ## 命令
@@ -39,22 +48,26 @@ pnpm dev
登录后菜单包括: 登录后菜单包括:
- 工作台 - 工作台:短剧总数、累计播放量、待审核数、已上线数与最近更新
- 内容管理:短剧管理、剧集管理 - 内容管理:短剧管理(含封面分片上传、提交审核/通过/驳回/上线/下线)、剧集管理(含视频分片上传、自动读取时长、播放顺序调整)、轮播图管理(含图片上传、跳转配置、排序与上下线)、类型管理(短剧类型的增删改)
- 用户管理 → App 用户:按 uid / 昵称 / 手机号 / 邮箱检索 C 端用户,查看资料、观看流水与处置记录,支持封禁 / 限制 / 解封(处置后该用户全部会话立即失效)
- 后台管理:角色权限配置、管理员用户列表、菜单管理、角色分类、系统设置 - 后台管理:角色权限配置、管理员用户列表、菜单管理、角色分类、系统设置
- 后台管理 → 操作日志:查看后台写操作的请求与响应、操作人、耗时
这些页面的数据仍是前端演示数据,没有接后端。登录和菜单由框架的 mock 接口提供。 左侧菜单由后端 `GET /admin/auth/menus` 下发,属于服务端动态路由;在菜单管理页改动菜单后,重新登录即可看到新的菜单结构。
## 目录 ## 目录
框架原有代码保持 [vue-element-plus-admin](https://github.com/kailong321200875/vue-element-plus-admin/tree/mini) 的结构。本项目新增的业务代码在: 框架原有代码保持 vue-element-plus-admin 的结构。本项目新增的业务代码:
- `src/api/admin/`:管理端接口封装(短剧、剧集、角色、菜单、管理员、配置)
- `src/views/common/DashboardView.vue` - `src/views/common/DashboardView.vue`
- `src/views/contentManagement/` - `src/views/contentManagement/`
- `src/views/permission/` - `src/views/permission/`、`src/views/permission/composables/`
- `src/views/userManagement/` - `src/views/userManagement/`、`src/views/userManagement/composables/`
- `src/views/systemManagement/` - `src/views/systemManagement/`
- `src/data/`、`src/permissions/`、`src/stores/` - `src/stores/rbac.js`:基于登录下发权限码的按钮级权限判断
- `src/utils/status.js`、`src/utils/format.js`
- `src/styles/business/` - `src/styles/business/`
路由写在 `src/router/index.ts` 的 `asyncRouterMap`。 路由写在 `src/router/index.ts` 的 `asyncRouterMap`(服务端动态路由不可用时的兜底)。
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
rules: { rules: {
@@ -0,0 +1,18 @@
# 转码媒资 ID 前端精度修复
## 根因
聚合接口把 Snowflake 媒资 ID 以字符串返回,但面板使用
`Number(detail.mediaAssetId)`,超过 `2^53` 后发生精度丢失,手动转码请求了错误 ID。
## 修复
- 聚合响应中的 episodeId/mediaAssetId/taskId 类型改为字符串。
- 父面板不再 Number 归一化,媒体 ID 校验改为字符串 `/\d+/`。
- 手动转码、帧图分页 URL 原样传递字符串。
- mediaAssetId 为空时禁用重新转码并展示明确空态。
## 验证
- 超 2^53 ID 字符串保持逐位一致。
- `pnpm lint:eslint`、`pnpm build:dev`。
@@ -0,0 +1,26 @@
# 剧集转码面板组件拆分
## 目标
- 将 `EpisodeTranscodePanel.vue` 从 816 行降到 500 行以内。
- 状态轮询、分页请求与切集重置继续由父面板统一管理。
- UI 按职责拆为状态徽标、状态操作区、清晰度表格、帧图预览。
## 组件
1. `EpisodeTranscodeStatusBadge.vue`:折叠标题中的状态与进度。
2. `EpisodeTranscodeStatusPanel.vue`:状态、进度、重试次数、错误与重转按钮。
3. `EpisodeTranscodeRenditions.vue`:清晰度表格、复制地址、新窗口打开。
4. `EpisodeTranscodeFrames.vue`:逐帧网格、雪碧图、分页与放大预览。
公共状态映射与格式化放入 `episodeTranscodeUi.ts`。
## 风险
- 父组件仍负责全部接口、轮询、请求序号和分页状态,子组件只通过 props/emits 交互。
- 不新增依赖、不改接口和 locales 文案。
## 验证
- `wc -l` 确认所有相关文件不超过 500 行。
- `pnpm lint:eslint`、`pnpm build:dev`。
@@ -0,0 +1,39 @@
# 后台转码设置页面
## 目标
1. 在内容管理下新增「转码设置」页面,权限沿用 `episode:upload`。
2. 页面字段与 FruitServer `MediaTranscodeProfile` 完全一致。
3. 对接 `GET/PUT /admin/media/transcode-profile`,保存后回填服务端响应。
4. 前端校验规则与后端一致,并在失败时定位到首个错误字段。
## 方案
- API:在 `src/api/admin/media.ts` 增加读取、保存函数,类型定义放
`src/api/admin/types.ts`;响应继续遵循现有 `Result {code,message,data}`。
- 路由:静态路由表增加 `/content/media-transcode`;默认服务端动态路由模式下,
根据 `episode:upload` 权限把同一条本地路由补充进服务端 `/content` 菜单,
已存在同名菜单时不重复添加,仅统一 i18n 标题。
- 页面:`views/systemManagement/TranscodeSettingsView.vue`,使用 Element Plus 表单、
表格、数字输入和开关;保存按钮受 `episode:upload` 控制。
- 校验:
- quality 非空且忽略首尾空格后唯一;
- width/height 为正偶数;
- videoBitrateKbps/audioBitrateKbps 为正整数;
- segmentSeconds 2–10;
- sprite.intervalSeconds 1–10、columns 5–20、thumbWidth 120–240;
- ladder 至少一档 enabled。
- 校验失败由 Element Plus 定位到首个错误 prop,动态列表错误会定位到对应行。
- 所有可见文案、提示和校验信息进入 `zh-CN.ts` / `en.ts`。
## 非目标
- 不修改 FruitServer、ReelShort 或接口契约。
- 不新增权限码、不新增依赖、不启动服务。
## 测试与门禁
- 当前工程没有单元/组件测试框架,不引入新依赖;使用 TS 类型检查和构建门禁。
- `pnpm lint:eslint`
- `pnpm ts:check`
- `pnpm build:dev`
@@ -0,0 +1,12 @@
# 转码面板实时进度与更新时间
## 目标
- 展示后端 task progress、状态文案与 updatedAt。
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
- FAILED 保持错误信息与重新转码入口。
- 保持现有轮询停止、关闭清理与字符串 ID 逻辑。
## 验证
- `pnpm lint:eslint`、`pnpm build:dev`。
+189
View File
@@ -0,0 +1,189 @@
## 2026-10-22 后台转码设置页面
### 开工确认与计划
- 仓库:ReelShortWeb
- 基线:`develop` / `origin/develop` = `723d056`
- 工作区:开工前干净
- 分支:`feature/transcode-profile-settings`
- 计划:`docs/plans/2026-10-22-transcode-profile-settings.md`
```text
$ git status --short --branch && git rev-parse --short HEAD
## develop...origin/develop
723d056
```
解读:基于指定 develop 基线创建隔离工作树。后端契约已确认字段为
`enabled / segmentSeconds / ladder / sprite`,权限为 `episode:upload`。
默认服务端动态菜单只读取 `sys_menu`,本单按“只改 Web”约束在不改后端的前提下,
对具备 `episode:upload` 的账号补充本地受控菜单,后端接口仍执行最终鉴权。
### 实现与门禁
- 新增 `/content/media-transcode` 静态路由和服务端动态菜单补充;
菜单标题走 `router.transcodeSettings`,只在账号具备 `episode:upload` 时加入。
- 新增转码设置页面,字段严格为 `enabled / segmentSeconds / ladder / sprite`;
支持档位增删、动态行校验、首个错误字段定位及服务端返回值回填。
- API 使用现有 axios Result 包装,GET 初始化、PUT 保存;成功/失败/校验提示全部走中英 i18n。
- 当前工程没有单元/组件测试框架,未新增依赖;使用 ESLint、TypeScript 与构建门禁。
```text
$ pnpm lint:eslint
> fruitshorts-admin@1.0.0 lint:eslint /private/tmp/reelshortweb-transcode-settings
> eslint --fix --ext .js,.ts,.vue ./src
$ pnpm ts:check
> fruitshorts-admin@1.0.0 ts:check /private/tmp/reelshortweb-transcode-settings
> pnpm vue-tsc --noEmit --skipLibCheck
```
解读:ESLint 零告警,TypeScript 类型检查通过。
```text
$ pnpm build:dev
> fruitshorts-admin@1.0.0 build:dev /private/tmp/reelshortweb-transcode-settings
> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus. Please adjust the manual chunk logic for these chunks.
Build successful. Please see dist-dev directory
```
解读:开发构建成功;circular chunk 为仓库既有 Element Plus 分包警告,本次未改构建配置。
## 2026-10-22 剧集转码面板组件拆分
- 分支:`feature/transcode-panel-split`
- 基线:`develop` / `origin/develop` = `52b10d3`
- 计划:`docs/plans/2026-10-22-transcode-panel-split.md`
已读取 `AGENTS.md` 与 `CODING_STANDARDS.md`。当前
`EpisodeTranscodePanel.vue` 为 816 行,超过单文件 500 行红线。
拆分将保持父组件的数据获取、轮询清理、分页请求与切集重置逻辑不变。
### 拆分结果
```text
$ wc -l src/views/contentManagement/upload/EpisodeTranscodePanel.vue \
src/views/contentManagement/upload/EpisodeTranscodeStatusBadge.vue \
src/views/contentManagement/upload/EpisodeTranscodeStatusPanel.vue \
src/views/contentManagement/upload/EpisodeTranscodeRenditions.vue \
src/views/contentManagement/upload/EpisodeTranscodeFrames.vue \
src/views/contentManagement/upload/episodeTranscodeUi.ts
266 EpisodeTranscodePanel.vue
45 EpisodeTranscodeStatusBadge.vue
155 EpisodeTranscodeStatusPanel.vue
119 EpisodeTranscodeRenditions.vue
315 EpisodeTranscodeFrames.vue
93 episodeTranscodeUi.ts
```
解读:父面板只保留接口调用、轮询、请求序号、分页状态与组件组合;
状态、清晰度、帧图分别独立,全部文件均低于 500 行。
### 门禁
```text
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:组件 props/emits、类型检查、ESLint 和开发构建全部通过;
循环 chunk 为项目既有警告。轮询启停、接口路径、分页参数和 locales 文案未改变。
## 2026-10-22 转码媒资 ID 前端精度修复
- 分支:`feature/transcode-media-id-fix-ui`
- 基线:`develop` / `origin/develop` = `a4893c3`
已复现 `Number('2113150398670966786')` 变为 `2113150398670966800`。
本次将媒资、任务 id 全链路按字符串处理,禁止 Number 转换;没有媒资时
禁用重新转码并显示空态。
```text
$ node -e "const id='2113150398670966786'; console.log(String(Number(id)))"
2113150398670966800
```
解读:Snowflake id 超过 JS 安全整数,字符串化后的 Number 已失真。
### 实现
- `EpisodeTranscodeItem` 的 episodeId/mediaAssetId/taskId,以及 rendition/thumbnail
相关 id 类型改为字符串。
- 父面板使用 `detail.mediaAssetId` 原始字符串和正则校验,不再 Number 转换。
- 手动转码与帧图分页接口原样传递字符串 id。
- `mediaAssetId` 为空时按钮保持禁用,空态优先展示后端 errorMsg 原因。
静态检查确认:
```text
$ rg -n "Number\([^\n]*mediaAssetId|mediaAssetId[^\n]*Number\(" \
src/views/contentManagement/upload src/api/admin
(无匹配)
```
### 门禁
```text
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:字符串 id 链路、类型检查、ESLint 与开发构建均通过;
循环 chunk 为项目既有警告。
## 2026-10-22 转码面板实时进度与更新时间
- 分支:`feature/transcode-progress-ui`
- 基线:`develop` / `origin/develop` = `0d5d0d8`
- 计划:`docs/plans/2026-10-22-transcode-progress-ui.md`
后端将补充 task.updatedAt;前端在现有轮询面板中展示进度、状态、
最近更新时间,并在 RUNNING/QUEUED progress=0 时显示等待进度上报提示。
### 实现
- `EpisodeTranscodeItem` 增加 `updatedAt`。
- 状态区展示“最近更新”,格式沿用 `formatDateTime`。
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
- SUCCESS/FAILED 继续停止轮询;关闭面板继续清理定时器。
- 所有 id 保持字符串传递,不引入 Number 转换。
### 门禁
```text
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:进度与更新时间类型、ESLint、TypeScript 和开发构建全部通过;
循环 chunk 为项目既有警告。
+8 -25
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/brand-mark.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title> <title><%= title %></title>
</head> </head>
@@ -16,7 +16,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
background: #f0f2f5; color: #f8f1ea;
background: #100c0a;
} }
.app-loading .app-loading-wrap { .app-loading .app-loading-wrap {
@@ -38,8 +39,9 @@
} }
.app-loading .app-loading-logo { .app-loading .app-loading-logo {
width: 100px; width: 72px;
margin: 0 auto 15px; margin: 0 auto 15px;
border-radius: 12px;
} }
.app-loading .app-loading-item { .app-loading .app-loading-item {
@@ -55,7 +57,7 @@
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 4px solid #2d8cf0; border: 4px solid #c4322a;
border-bottom: 0; border-bottom: 0;
border-left-color: transparent; border-left-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -68,7 +70,7 @@
left: calc(50% - 20px); left: calc(50% - 20px);
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid #87bdff; border: 4px solid #ff8a72;
border-right: 0; border-right: 0;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -85,16 +87,6 @@
} }
} }
@keyframes loader-outter {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes loader-inner { @keyframes loader-inner {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@@ -105,20 +97,11 @@
} }
} }
@keyframes loader-inner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
</style> </style>
<div class="app-loading"> <div class="app-loading">
<div class="app-loading-wrap"> <div class="app-loading-wrap">
<div class="app-loading-title"> <div class="app-loading-title">
<img src="/logo.png" class="app-loading-logo" alt="Logo" /> <img src="/brand-mark.png" class="app-loading-logo" alt="Logo" />
<div class="app-loading-title"><%= title %></div> <div class="app-loading-title"><%= title %></div>
</div> </div>
<div class="app-loading-item"> <div class="app-loading-item">
-18
View File
@@ -1,18 +0,0 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const modules = import.meta.glob('./**/*.mock.ts', {
import: 'default',
eager: true
})
const mockModules: any[] = []
Object.keys(modules).forEach(async (key) => {
if (key.includes('_')) {
return
}
mockModules.push(...(modules[key] as any))
})
export function setupProdMockServer() {
createProdMockServer(mockModules)
}
-129
View File
@@ -1,129 +0,0 @@
import { MockMethod } from 'vite-plugin-mock'
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const adminList = [
{
path: '/dashboard',
component: '#',
redirect: '/dashboard/index',
name: 'Dashboard',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled'
},
children: [
{
path: 'index',
name: 'dashboard',
component: 'views/common/DashboardView',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled',
affix: true
}
}
]
},
{
path: '/content',
component: '#',
redirect: '/content/dramas',
name: 'Content',
meta: {
title: '内容管理',
icon: 'ep:video-camera',
alwaysShow: true
},
children: [
{
path: 'dramas',
name: 'dramas',
component: 'views/contentManagement/DramaManagementView',
meta: {
title: '短剧管理'
}
},
{
path: 'episodes',
name: 'episodes',
component: 'views/contentManagement/EpisodeManagementView',
meta: {
title: '剧集管理'
}
}
]
},
{
path: '/system',
component: '#',
redirect: '/system/access',
name: 'System',
meta: {
title: '后台管理',
icon: 'ep:setting',
alwaysShow: true
},
children: [
{
path: 'access',
name: 'access',
component: 'views/permission/RoleManagementView',
meta: {
title: '角色权限配置'
}
},
{
path: 'admin-users',
name: 'admin-users',
component: 'views/userManagement/AdminUsersView',
meta: {
title: '管理员用户列表'
}
},
{
path: 'menus',
name: 'menus',
component: 'views/permission/MenuManagementView',
meta: {
title: '菜单管理'
}
},
{
path: 'role-categories',
name: 'role-categories',
component: 'views/permission/RoleCategoriesView',
meta: {
title: '角色分类'
}
},
{
path: 'settings',
name: 'settings',
component: 'views/systemManagement/SettingsView',
meta: {
title: '系统设置'
}
}
]
}
]
const testList = adminList.filter((route) => route.path !== '/system')
export default [
// 列表接口
{
url: '/mock/role/list',
method: 'get',
timeout,
response: ({ query }) => {
const { roleName } = query
return {
code: SUCCESS_CODE,
data: roleName === 'admin' ? adminList : testList
}
}
}
] as MockMethod[]
-90
View File
@@ -1,90 +0,0 @@
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const List: {
username: string
password: string
role: string
roleId: string
permissions: string | string[]
}[] = [
{
username: 'admin',
password: 'admin123',
role: 'admin',
roleId: '1',
permissions: ['*.*.*']
},
{
username: 'test',
password: 'test',
role: 'test',
roleId: '2',
permissions: ['example:dialog:create', 'example:dialog:delete']
}
]
export default [
// 列表接口
{
url: '/mock/user/list',
method: 'get',
response: ({ query }) => {
const { username, pageIndex, pageSize } = query
const mockList = List.filter((item) => {
if (username && item.username.indexOf(username) < 0) return false
return true
})
const pageList = mockList.filter(
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
)
return {
code: SUCCESS_CODE,
data: {
total: mockList.length,
list: pageList
}
}
}
},
// 登录接口
{
url: '/mock/user/login',
method: 'post',
timeout,
response: ({ body }) => {
const data = body
let hasUser = false
for (const user of List) {
if (user.username === data.username && user.password === data.password) {
hasUser = true
return {
code: SUCCESS_CODE,
data: user
}
}
}
if (!hasUser) {
return {
code: 500,
message: '账号或密码错误'
}
}
}
},
// 退出接口
{
url: '/mock/user/loginOut',
method: 'get',
timeout,
response: () => {
return {
code: SUCCESS_CODE,
data: null
}
}
}
]
+5 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "reelshort-admin", "name": "fruitshorts-admin",
"version": "1.0.0", "version": "1.0.0",
"description": "ReelShort 内容管理后台,基于 vue-element-plus-admin。", "description": "FruitShorts 内容管理后台,基于 vue-element-plus-admin。",
"author": "Archer <502431556@qq.com>", "author": "Archer <502431556@qq.com>",
"private": true, "private": true,
"scripts": { "scripts": {
"i": "pnpm install", "i": "pnpm install",
"dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base", "dev": "vite --mode base",
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck", "ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
"build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro", "build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro",
"build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev", "build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev",
@@ -24,8 +24,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "2.3.2",
"@faker-js/faker": "^8.4.0",
"@iconify/iconify": "^3.1.1", "@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2", "@vueuse/core": "^10.7.2",
@@ -40,6 +39,7 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "2.5.5", "element-plus": "2.5.5",
"hls.js": "^1.7.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -103,7 +103,6 @@
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-mock": "2.9.6",
"vite-plugin-progress": "^0.0.7", "vite-plugin-progress": "^0.0.7",
"vite-plugin-purge-icons": "^0.10.0", "vite-plugin-purge-icons": "^0.10.0",
"vite-plugin-style-import": "2.0.0", "vite-plugin-style-import": "2.0.0",
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
module.exports = { module.exports = {
plugins: { plugins: {
autoprefixer: {} autoprefixer: {}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
module.exports = { module.exports = {
printWidth: 100, printWidth: 100,
tabWidth: 2, tabWidth: 2,
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+14
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
@@ -20,6 +22,7 @@ appStore.initTheme()
<template> <template>
<ConfigGlobal :size="currentSize"> <ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" /> <RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<footer class="app-copyright">© FruitShorts</footer>
</ConfigGlobal> </ConfigGlobal>
</template> </template>
@@ -46,4 +49,15 @@ body {
.@{prefix-cls}-grey-mode { .@{prefix-cls}-grey-mode {
filter: grayscale(100%); filter: grayscale(100%);
} }
.app-copyright {
position: fixed;
right: 20px;
bottom: 10px;
z-index: 5;
font-size: 12px;
line-height: 1;
color: var(--el-text-color-secondary, #909399);
pointer-events: none;
}
</style> </style>
+66
View File
@@ -0,0 +1,66 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { AppUserBanRecord, AppUserItem, PageResult, WatchHistoryItem } from './types'
export interface AppUserPageParams {
pageIndex?: number
pageSize?: number
/** uid / 昵称 / 手机号 / 邮箱 */
keyword?: string
status?: string
countryCode?: string
registerSource?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
}
export interface WatchHistoryPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: number
dramaTitle?: string
dramaId?: number
episodeId?: number
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
source?: string
finished?: boolean
startTime?: string
endTime?: string
}
export const pageAppUsersApi = (params: AppUserPageParams) => {
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
}
export const getAppUserApi = (id: number) => {
return request.get<AppUserItem>({ url: `/admin/app-users/${id}` })
}
export const listAppUserBanRecordsApi = (id: number) => {
return request.get<AppUserBanRecord[]>({ url: `/admin/app-users/${id}/ban-records` })
}
export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
return request.get<WatchHistoryItem[]>({
url: `/admin/app-users/${id}/watch-history`,
params: { limit }
})
}
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
return request.get<PageResult<WatchHistoryItem>>({
url: '/admin/watch-history/page',
params
})
}
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
return request.put<void>({
url: `/admin/app-users/${id}/status`,
params: { status, reason }
})
}
+69
View File
@@ -0,0 +1,69 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface AppVersionItem {
id: string
platform: 'ANDROID' | 'IOS'
platformText: string
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate: number
minSupportedCode: number
status: number
statusText: string
releasedAt?: string
createdAt?: string
}
export interface AppVersionSaveData {
platform: 'ANDROID' | 'IOS'
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate?: 0 | 1
minSupportedCode?: number
}
export const listAppVersionsApi = (params: {
pageIndex?: number
pageSize?: number
platform?: string
status?: number
keyword?: string
}) => {
return request.get<{ total: number; list: AppVersionItem[] }>({
url: '/admin/app-versions/page',
params
})
}
export const getAppVersionApi = (id: string | number) => {
return request.get<AppVersionItem>({ url: `/admin/app-versions/${id}` })
}
export const createAppVersionApi = (data: AppVersionSaveData) => {
return request.post<string>({ url: '/admin/app-versions', data })
}
export const batchCreateAppVersionsApi = (data: AppVersionSaveData[]) => {
return request.post<string[]>({ url: '/admin/app-versions/batch', data })
}
export const updateAppVersionApi = (id: string | number, data: AppVersionSaveData) => {
return request.post<void>({ url: `/admin/app-versions/${id}`, data })
}
export const updateAppVersionStatusApi = (id: string | number, status: 0 | 1) => {
return request.post<void>({
url: `/admin/app-versions/${id}/status`,
params: { status }
})
}
export const deleteAppVersionApi = (id: string | number) => {
return request.delete<void>({ url: `/admin/app-versions/${id}` })
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { BannerItem, BannerSaveData, PageResult } from './types'
export interface BannerPageParams {
pageIndex?: number
pageSize?: number
position?: string
status?: number
}
export const pageBannersApi = (params: BannerPageParams) => {
return request.get<PageResult<BannerItem>>({ url: '/admin/banners/page', params })
}
export const getBannerApi = (id: number) => {
return request.get<BannerItem>({ url: `/admin/banners/${id}` })
}
export const createBannerApi = (data: BannerSaveData) => {
return request.post<number>({ url: '/admin/banners', data })
}
export const updateBannerApi = (id: number, data: BannerSaveData) => {
return request.put<void>({ url: `/admin/banners/${id}`, data })
}
export const deleteBannerApi = (id: number) => {
return request.delete<void>({ url: `/admin/banners/${id}` })
}
export const updateBannerStatusApi = (id: number, status: number) => {
return request.put<void>({ url: `/admin/banners/${id}/status`, params: { status } })
}
export const sortBannersApi = (ids: number[]) => {
return request.put<void>({ url: '/admin/banners/sort', data: { ids } })
}
+124
View File
@@ -0,0 +1,124 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface OrderItem {
id: number
orderNo: string
userId: number
productCode: string
productName: string
type: string
platform: string
amount: number
currency: string
coinAmount: number
/** CREATED / PAID / DELIVERED / REFUNDED / CLOSED */
status: string
paidAt?: string
deliveredAt?: string
refundReason: string
failReason: string
createdAt?: string
}
export interface OrderEventItem {
id: number
orderNo: string
event: string
fromStatus: string
toStatus: string
source: string
payload: string
operatorId: number
createdAt?: string
}
export interface EntitlementItem {
id: number
userId: number
dramaId: number
episodeId: number
/** EPISODE / DRAMA */
type: string
/** ORDER / ADMIN / ACTIVITY */
source: string
/** ACTIVE / REVOKED / EXPIRED */
status: string
orderNo: string
startAt?: string
revokeReason: string
}
export interface LedgerItem {
id: number
userId: number
changeAmount: number
balanceAfter: number
bizType: string
bizId: string
remark: string
operatorId: number
createdAt?: string
}
export interface ReconcileItem {
id: number
bizDate: string
platform: string
totalCount: number
matchedCount: number
diffCount: number
amountSum: number
status: string
detail: string
finishedAt?: string
}
export const pageOrdersApi = (params: Record<string, unknown>) => {
return request.get<PageResult<OrderItem>>({ url: '/admin/orders/page', params })
}
export const listOrderEventsApi = (orderNo: string) => {
return request.get<OrderEventItem[]>({ url: `/admin/orders/${orderNo}/events` })
}
export const compensateOrderApi = (orderNo: string) => {
return request.post<OrderItem>({ url: `/admin/orders/${orderNo}/compensate` })
}
export const refundOrderApi = (orderNo: string, reason: string) => {
return request.post<OrderItem>({ url: `/admin/orders/${orderNo}/refund`, data: { reason } })
}
export const pageEntitlementsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<EntitlementItem>>({ url: '/admin/entitlements/page', params })
}
export const grantEntitlementApi = (data: Record<string, unknown>) => {
return request.post<EntitlementItem>({ url: '/admin/entitlements', data })
}
export const revokeEntitlementApi = (id: number, reason: string) => {
return request.put<void>({ url: `/admin/entitlements/${id}/revoke`, params: { reason } })
}
export const listWalletLedgerApi = (userId: number, limit = 20) => {
return request.get<LedgerItem[]>({
url: `/admin/wallets/${userId}/ledger`,
params: { limit }
})
}
export const adjustCoinsApi = (data: Record<string, unknown>) => {
return request.post<LedgerItem>({ url: '/admin/wallets/coins', data })
}
export const pageReconcileApi = (params: Record<string, unknown>) => {
return request.get<PageResult<ReconcileItem>>({ url: '/admin/reconcile/page', params })
}
export const runReconcileApi = (params: Record<string, unknown>) => {
return request.post<ReconcileItem>({ url: '/admin/reconcile/run', params })
}
+12
View File
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { ConfigItem } from './types'
export const listConfigsApi = () => {
return request.get<ConfigItem[]>({ url: '/admin/configs' })
}
export const updateConfigsApi = (values: Record<string, string>) => {
return request.put<void>({ url: '/admin/configs', data: values })
}
+41
View File
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface ContentTagItem {
id: number
name: string
nameEn: string
nameId: string
locales?: Record<string, string>
bgColor?: string
sort: number
status: number
}
export interface ContentTagSaveData {
name: string
locales: Record<string, string>
bgColor?: string
sort?: number
status?: number
}
export const listContentTagsApi = (enabledOnly = false) => {
return request.get<ContentTagItem[]>({
url: '/admin/content-tags',
params: enabledOnly ? { enabledOnly: true } : undefined
})
}
export const createContentTagApi = (data: ContentTagSaveData) => {
return request.post<number>({ url: '/admin/content-tags', data })
}
export const updateContentTagApi = (id: number, data: ContentTagSaveData) => {
return request.put<void>({ url: `/admin/content-tags/${id}`, data })
}
export const deleteContentTagApi = (id: number) => {
return request.delete<void>({ url: `/admin/content-tags/${id}` })
}
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface CreatorItem {
id: number
userId: number
code: string
name: string
/** INDIVIDUAL / COMPANY */
type: string
contactName: string
contactMobile: string
contactEmail: string
countryCode: string
/** pending / approved / rejected / frozen */
status: string
auditNote: string
auditBy: number
auditAt?: string
settlementCurrency: string
createdAt?: string
}
export interface CreatorDocumentItem {
id: number
creatorId: number
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */
type: string
fileUrl: string
status: string
auditNote: string
createdAt?: string
}
export interface SubmissionItem {
id: number
creatorId: number
dramaId: number
/** CREATE / UPDATE / PUBLISH / OFFLINE */
type: string
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
status: string
version: number
submitAt?: string
reviewerId: number
reviewAt?: string
reviewNote: string
rejectCode: string
createdAt?: string
}
export const pageCreatorsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CreatorItem>>({ url: '/admin/creators/page', params })
}
export const listCreatorDocumentsApi = (id: number) => {
return request.get<CreatorDocumentItem[]>({ url: `/admin/creators/${id}/documents` })
}
/** action: APPROVE / REJECT / FREEZE */
export const auditCreatorApi = (id: number, data: Record<string, unknown>) => {
return request.put<CreatorItem>({ url: `/admin/creators/${id}/audit`, data })
}
/** 手工录入创作者主体(C 端申请入驻未实现前的入口) */
export const createCreatorApi = (data: Record<string, unknown>) => {
return request.post<number>({ url: '/admin/creators', data })
}
export interface CreatorAccountResult {
creatorId: number
adminUserId: number
username: string
/** 一次性明文密码:只在开通 / 重置的这次响应里出现,之后拿不回来 */
password: string | null
created: boolean
message: string
}
/** 开通创作者账号(仅审核通过可开;已开通则幂等返回) */
export const openCreatorAccountApi = (id: number, username?: string) => {
return request.post<CreatorAccountResult>({
url: `/admin/creators/${id}/account`,
data: { username }
})
}
/** 重置创作者账号密码(一次性明文返回) */
export const resetCreatorPasswordApi = (id: number) => {
return request.post<CreatorAccountResult>({
url: `/admin/creators/${id}/account/reset-password`
})
}
export const pageSubmissionsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
}
/** action: APPROVE / REJECT / WITHDRAW */
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
}
+248
View File
@@ -0,0 +1,248 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 创作者主体:登录账号绑定的创作者信息 */
export interface CreatorProfile {
id: number
code: string
name: string
/** INDIVIDUAL / COMPANY */
type: string
/** pending / approved / rejected / frozen */
status: string
statusText: string
auditNote: string
auditAt?: string
contactName: string
contactEmail: string
countryCode: string
dramaTotal: number
}
/** 代看目标下拉项(后端只给 id / 名称 / 状态) */
export interface CreatorCenterTarget {
id: number
code: string
name: string
status: string
statusText: string
}
/** 我的作品 + 最近一次提交的审核进度 */
export interface CreatorDrama {
id: number
code: string
title: string
subtitle?: string
genre?: string
description?: string
cover: string
trailerUrl?: string
language?: string
status: string
statusText: string
episodes: number
views: number
updatedAt?: string
submissionId?: number | null
submissionType?: string | null
submissionStatus?: string | null
submissionStatusText?: string | null
reviewNote?: string | null
rejectCode?: string | null
submitAt?: string | null
reviewAt?: string | null
}
export interface CreatorNotice {
id: number
/** CREATOR(入驻审核)/ SUBMISSION(内容提交审核) */
bizType: string
title: string
content: string
result: string
time?: string
}
export interface CreatorSubmission {
id: number
creatorId: number
dramaId: number
/** CREATE / UPDATE / PUBLISH / OFFLINE */
type: string
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
status: string
version: number
submitAt?: string
reviewAt?: string
reviewNote: string
rejectCode: string
}
export interface CreatorEpisode {
id: number
dramaId: number
number: number
title: string
duration: string
durationSec: number
cover: string
videoUrl: string
isFree: boolean
unlockCoin: number
status: string
statusText: string
views: number
sort: number
}
export interface CreatorDramaEditData {
title: string
subtitle?: string
categoryId?: number
genre?: string
description?: string
cover?: string
trailerUrl?: string
language?: string
}
export interface PriceEpisodeConfig {
episodeId: number
number: number
title: string
free: boolean
unlockCoin: number
previewSeconds: number
}
export interface PriceConfig {
dramaId: number
freeEpisodes: number
unlockCoin: number
episodeCount: number
changedCount: number
episodes: PriceEpisodeConfig[]
}
export interface CreatorDashboard {
generatedAt: string
drama: Record<string, number>
playback: Record<string, number>
interaction: Record<string, number>
/** paidOrderAmount 取不到时为 null */
revenue: Record<string, number | null>
/** 按天趋势:creator_stat_daily 无产出,恒为 null */
daily: null
notes: string[]
}
export const getCreatorProfileApi = () => {
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile' })
}
/**
* 静默探测当前账号的创作者身份:不弹全局提示,直接把 Result 交给调用方判断。
* 用于「已绑定 / 可代看但没选目标 / 未绑定」三态分流,渲染引导页。
*/
export const probeCreatorAccessApi = () => {
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile', silent: true })
}
/** 代看目标下拉的数据源(仅 impersonate 权限可调) */
export const listImpersonationTargetsApi = () => {
return request.get<CreatorCenterTarget[]>({ url: '/admin/creator-center/impersonation/targets' })
}
export const pageMyDramasApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CreatorDrama>>({ url: '/admin/creator-center/dramas/page', params })
}
export const getMyDramaApi = (id: number) => {
return request.get<CreatorDrama>({ url: `/admin/creator-center/dramas/${id}` })
}
export const listCreatorNoticesApi = () => {
return request.get<CreatorNotice[]>({ url: '/admin/creator-center/notices' })
}
export const getCreatorDashboardApi = () => {
return request.get<CreatorDashboard>({ url: '/admin/creator-center/dashboard' })
}
export const createDramaApi = (data: CreatorDramaEditData) => {
return request.post<number>({ url: '/admin/creator-center/dramas', data })
}
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
}
export const listEpisodesApi = (dramaId: number) => {
return request.get<CreatorEpisode[]>({ url: `/admin/creator-center/dramas/${dramaId}/episodes` })
}
export const addEpisodeApi = (dramaId: number, data: Record<string, unknown>) => {
return request.post<number>({ url: `/admin/creator-center/dramas/${dramaId}/episodes`, data })
}
export const updateEpisodeApi = (
dramaId: number,
episodeId: number,
data: Record<string, unknown>
) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`,
data
})
}
export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
return request.delete<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`
})
}
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
data: { ids }
})
}
/** 把上传好的媒资挂到分集(产物必须是本人上传的) */
export const attachAssetApi = (dramaId: number, episodeId: number, assetId: number) => {
return request.post<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}/attach`,
data: { assetId }
})
}
export const getPricesApi = (dramaId: number) => {
return request.get<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices` })
}
export const savePricesApi = (dramaId: number, data: Record<string, unknown>) => {
return request.put<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices`, data })
}
export const submitDramaApi = (dramaId: number, type: string) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/dramas/${dramaId}/submit`,
data: { type }
})
}
export const withdrawSubmissionApi = (submissionId: number) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/submissions/${submissionId}/withdraw`
})
}
export const listSubmissionsApi = (dramaId: number) => {
return request.get<CreatorSubmission[]>({
url: `/admin/creator-center/dramas/${dramaId}/submissions`
})
}
+66
View File
@@ -0,0 +1,66 @@
// Author: fruit.com.cn
import request from '@/axios'
/** 指标口径见后端 DashboardService 注释与 docs/api-phase1.md「2.8 数据看板」 */
export interface DashboardOverview {
generatedAt: string
user: Record<string, number>
content: Record<string, number>
playback: Record<string, number>
revenue: Record<string, number>
cost: Record<string, number | null>
notes: string[]
}
export interface TranscodeBoard {
generatedAt: string
queue: Record<string, number>
failedTasks: {
id: number
episodeId: number
status: string
retryCount: number
errorMsg: string
createdAt?: string
}[]
notes: string[]
}
/** 近 7 天每日趋势(给折线图用)。 */
export interface DashboardTrend {
dates: string[]
newUsers: number[]
playEvents: number[]
newTranscodes: number[]
transcodeSuccess: number[]
}
/** 分布项(给饼图用)。 */
export interface DistributionItem {
label: string
value: number
}
export interface DashboardDistribution {
dramaStatus: Record<string, number>
transcodeStatus: Record<string, number>
episodeMedia: Record<string, number>
pendingModeration: DistributionItem[]
}
export const getDashboardOverviewApi = () => {
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
}
export const getTranscodeBoardApi = () => {
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' })
}
export const getDashboardTrendApi = () => {
return request.get<DashboardTrend>({ url: '/admin/dashboard/trend' })
}
export const getDashboardDistributionApi = () => {
return request.get<DashboardDistribution>({ url: '/admin/dashboard/distribution' })
}
+75
View File
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
import request from '@/axios'
import type {
DramaCategory,
DramaItem,
DramaReviewAction,
DramaSaveData,
PageResult
} from './types'
export interface DramaPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
status?: string
categoryId?: number
categoryIds?: number[]
isHot?: boolean
dramaTypes?: Array<'ANIME' | 'LIVE'>
}
export const pageDramasApi = (params: DramaPageParams) => {
return request.get<PageResult<DramaItem>>({ url: '/admin/dramas/page', params })
}
export const getDramaApi = (id: number) => {
return request.get<DramaItem>({ url: `/admin/dramas/${id}` })
}
export const createDramaApi = (data: DramaSaveData) => {
return request.post<number>({ url: '/admin/dramas', data })
}
export const updateDramaApi = (id: number, data: DramaSaveData) => {
return request.put<void>({ url: `/admin/dramas/${id}`, data })
}
export const deleteDramaApi = (id: number) => {
return request.delete<void>({ url: `/admin/dramas/${id}` })
}
/** 审核与发布:submit / approve / reject / publish / offline */
export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => {
return request.post<void>({ url: `/admin/dramas/${id}/${action}`, data: { reason } })
}
/** 下拉选项(短剧表单用),只返回 id/name */
export const listDramaCategoryOptionsApi = () => {
return request.get<DramaCategory[]>({ url: '/admin/dicts/drama-category' })
}
export interface DramaCategorySaveData {
name: string
locales: Record<string, string>
sort?: number
status?: number
}
/** 类型管理列表,带短剧数量 */
export const listDramaCategoriesApi = () => {
return request.get<DramaCategory[]>({ url: '/admin/drama-categories' })
}
export const createDramaCategoryApi = (data: DramaCategorySaveData) => {
return request.post<number>({ url: '/admin/drama-categories', data })
}
export const updateDramaCategoryApi = (id: number, data: DramaCategorySaveData) => {
return request.put<void>({ url: `/admin/drama-categories/${id}`, data })
}
export const deleteDramaCategoryApi = (id: number) => {
return request.delete<void>({ url: `/admin/drama-categories/${id}` })
}
+89
View File
@@ -0,0 +1,89 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface DramaClassificationItem {
id: number
name: string
nameEn: string
nameId: string
locales?: Record<string, string>
sort: number
status: number
dramaCount: number
updatedAt?: string
}
export interface DramaClassificationSaveData {
name: string
locales: Record<string, string>
sort?: number
status?: number
}
export interface ClassificationDramaItem {
id: number
title: string
cover: string
status: string
totalEpisodes: number
sort: number
updatedAt: string
}
export interface ClassificationDramaPageParams {
keyword?: string
pageIndex?: number
pageSize?: number
}
export const listClassificationsApi = () => {
return request.get<DramaClassificationItem[]>({ url: '/admin/drama-classifications' })
}
export const createClassificationApi = (data: DramaClassificationSaveData) => {
return request.post<number>({ url: '/admin/drama-classifications', data })
}
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
}
export const deleteClassificationApi = (id: number) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
}
/** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`,
params
})
}
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: number,
params: ClassificationDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas/candidates`,
params
})
}
/** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds }
})
}
/** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
})
}
+87
View File
@@ -0,0 +1,87 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
import type { ClassificationDramaItem } from './dramaClassification'
export interface DramaPlaylistCategoryItem {
id: number
name: string
nameEn: string
nameId: string
description: string
descriptionEn: string
descriptionId: string
locales?: Record<string, string>
descriptionLocales?: Record<string, string>
sort: number
status: number
dramaCount: number
}
export interface DramaPlaylistCategorySaveData {
name: string
locales: Record<string, string>
descriptionLocales?: Record<string, string>
sort?: number
status?: number
}
export interface PlaylistCategoryDramaPageParams {
keyword?: string
pageIndex?: number
pageSize?: number
}
export const listPlaylistCategoriesApi = () => {
return request.get<DramaPlaylistCategoryItem[]>({ url: '/admin/drama-playlist-categories' })
}
export const createPlaylistCategoryApi = (data: DramaPlaylistCategorySaveData) => {
return request.post<number>({ url: '/admin/drama-playlist-categories', data })
}
export const updatePlaylistCategoryApi = (id: number, data: DramaPlaylistCategorySaveData) => {
return request.put<void>({ url: `/admin/drama-playlist-categories/${id}`, data })
}
export const deletePlaylistCategoryApi = (id: number) => {
return request.delete<void>({ url: `/admin/drama-playlist-categories/${id}` })
}
/** 分类下已关联的短剧 */
export const listPlaylistCategoryDramasApi = (
id: number,
params: PlaylistCategoryDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-playlist-categories/${id}/dramas`,
params
})
}
/** 尚未关联到该分类的短剧(选择弹窗用) */
export const listPlaylistCategoryDramaCandidatesApi = (
id: number,
params: PlaylistCategoryDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-playlist-categories/${id}/dramas/candidates`,
params
})
}
/** 批量关联短剧,返回实际新增数量 */
export const bindPlaylistCategoryDramasApi = (id: number, dramaIds: number[]) => {
return request.post<number>({
url: `/admin/drama-playlist-categories/${id}/dramas`,
data: { dramaIds }
})
}
/** 从分类移除短剧 */
export const unbindPlaylistCategoryDramaApi = (id: number, dramaId: number) => {
return request.delete<void>({
url: `/admin/drama-playlist-categories/${id}/dramas/${dramaId}`
})
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 收藏与点赞只读分页筛选条件。 */
export interface EngagementPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: string | number
deviceId?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
dramaId?: string | number
dramaTitle?: string
targetType?: string
targetId?: string | number
episodeId?: string | number
startTime?: string
endTime?: string
}
/** 后台互动列表行。 */
export interface EngagementItem {
id: string
userId: string | null
uid: string | null
nickname: string | null
deviceId: string | null
/** 客户端平台 ANDROID / IOS / WEB;老客户端未上报时为空。 */
platform: string | null
anonymous: boolean
dramaId: string | null
dramaTitle: string | null
dramaCover: string | null
dramaStatus: string | null
dramaPublished: boolean | null
targetType: string
targetId: string
episodeId: string | null
episodeNo: number | null
episodeTitle: string | null
commentSummary: string | null
createdAt: string
}
/** 查询收藏分页。 */
export const pageFavoritesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/favorites/page', params })
/** 查询点赞分页。 */
export const pageLikesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/likes/page', params })
+49
View File
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { EpisodeItem, EpisodeSaveData, PageResult } from './types'
export interface EpisodePageParams {
dramaId: number | string
status?: string
pageIndex?: number
pageSize?: number
}
/**
* 解包剧集列表分页。
* 契约(OpenAPI PageResultEpisodeRespVO):data = { total, list }。
* 兼容误把 data 当数组的旧调用,避免列表空白。
*/
export function parseEpisodePage(payload: unknown): PageResult<EpisodeItem> {
if (Array.isArray(payload)) {
return { total: payload.length, list: payload as EpisodeItem[] }
}
if (payload && typeof payload === 'object') {
const page = payload as { total?: unknown; list?: unknown }
const list = Array.isArray(page.list) ? (page.list as EpisodeItem[]) : []
const total = Number(page.total)
return { total: Number.isFinite(total) ? total : list.length, list }
}
return { total: 0, list: [] }
}
export const listEpisodesApi = (params: EpisodePageParams) => {
return request.get<PageResult<EpisodeItem>>({ url: '/admin/episodes', params })
}
export const createEpisodeApi = (data: EpisodeSaveData) => {
return request.post<number>({ url: '/admin/episodes', data })
}
export const updateEpisodeApi = (id: number, data: EpisodeSaveData) => {
return request.put<void>({ url: `/admin/episodes/${id}`, data })
}
export const deleteEpisodeApi = (id: number) => {
return request.delete<void>({ url: `/admin/episodes/${id}` })
}
export const sortEpisodesApi = (ids: number[]) => {
return request.put<void>({ url: '/admin/episodes/sort', data: { ids } })
}
+51
View File
@@ -0,0 +1,51 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult, CommentItem, ReportItem, SensitiveWordItem } from './types'
export const pageCommentsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CommentItem>>({ url: '/admin/comments/page', params })
}
export const listCommentAuditRecordsApi = (id: number) => {
return request.get<Record<string, unknown>[]>({
url: `/admin/comments/${id}/audit-records`
})
}
/** result: PASS / REJECT */
export const auditCommentApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/comments/${id}/audit`, data })
}
export const deleteCommentApi = (id: number) => {
return request.delete<void>({ url: `/admin/comments/${id}` })
}
export const pageReportsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<ReportItem>>({ url: '/admin/reports/page', params })
}
/** status: handled / rejected;banTarget=true 时联动封禁被举报用户 */
export const handleReportApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/reports/${id}/handle`, data })
}
export const pageSensitiveWordsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SensitiveWordItem>>({
url: '/admin/sensitive-words/page',
params
})
}
export const createSensitiveWordApi = (data: Record<string, unknown>) => {
return request.post<number>({ url: '/admin/sensitive-words', data })
}
export const updateSensitiveWordApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/sensitive-words/${id}`, data })
}
export const deleteSensitiveWordApi = (id: number) => {
return request.delete<void>({ url: `/admin/sensitive-words/${id}` })
}
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface LegalDocument {
docCode: 'terms' | 'privacy' | string
lang: 'zh' | 'en' | 'id' | string
title: string
content: string
}
export const listLegalDocumentsApi = () => {
return request.get<LegalDocument[]>({ url: '/admin/legal-documents' })
}
export const saveLegalDocumentsApi = (items: LegalDocument[]) => {
return request.put<void>({ url: '/admin/legal-documents', data: items })
}
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { OperationLogItem, PageResult } from './types'
export interface OperationLogPageParams {
pageIndex?: number
pageSize?: number
username?: string
module?: string
action?: string
status?: string
startTime?: string
endTime?: string
}
export const pageOperationLogsApi = (params: OperationLogPageParams) => {
return request.get<PageResult<OperationLogItem>>({ url: '/admin/operation-logs/page', params })
}
export const getOperationLogApi = (id: number) => {
return request.get<OperationLogItem>({ url: `/admin/operation-logs/${id}` })
}
+80
View File
@@ -0,0 +1,80 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { MediaAsset, MediaTranscodeProfile } from './types'
export interface UploadInitData {
fileName: string
fileSize: number
/** DRAMA_COVER / EPISODE / AVATAR */
bizType?: string
chunkSize?: number
}
export interface UploadInitResult {
uploadId: string
chunkSize: number
totalChunks: number
objectKey: string
}
export const initUploadApi = (data: UploadInitData) => {
return request.post<UploadInitResult>({ url: '/admin/media/upload/init', data })
}
/** 不设置 Content-Type,交给浏览器带 boundary */
export const uploadChunkApi = (uploadId: string, index: number, chunk: Blob, fileName: string) => {
const form = new FormData()
form.append('uploadId', uploadId)
form.append('index', String(index))
form.append('file', chunk, fileName)
return request.post<number>({ url: '/admin/media/upload/chunk', data: form })
}
export const completeUploadApi = (uploadId: string, durationSec?: number) => {
return request.post<MediaAsset>({
url: '/admin/media/upload/complete',
data: { uploadId, durationSec }
})
}
export interface UploadProgress {
uploadId: string
fileName: string
bizType: string
fileSize: number
chunkSize: number
totalChunks: number
receivedChunks: number
percent: number
/** uploading / completed */
status: string
assetId: number
url: string
}
/** 断点续传回显:传到第几片、还剩多少(服务端说了算,客户端不自己记账) */
export const getUploadProgressApi = (uploadId: string) => {
return request.get<UploadProgress>({ url: `/admin/media/upload/${uploadId}` })
}
export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
}
/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */
export const getMediaTranscodeProfileApi = () => {
return request.get<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
silent: true
})
}
/** 保存转码档位配置,并返回服务端最终生效值。 */
export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => {
return request.put<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
data,
silent: true
})
}
+66
View File
@@ -0,0 +1,66 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 系统公告:字段与 /admin/notices 的响应一一对应 */
export interface NoticeItem {
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
id: string
title: string
content: string
/** ALL / USER / CREATOR */
target: string
targetText: string
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY */
type: string
typeText: string
/** 1 已发布 / 0 已下架 */
status: number
statusText: string
publishAt?: string
expireAt?: string
createdAt?: string
}
export interface NoticePageParams {
pageIndex?: number
pageSize?: number
status?: number
target?: string
type?: string
keyword?: string
}
export interface NoticeSaveData {
title: string
content: string
target: string
type: string
publishAt?: string | null
expireAt?: string | null
}
export const pageNoticesApi = (params: NoticePageParams) => {
return request.get<PageResult<NoticeItem>>({ url: '/admin/notices/page', params })
}
export const getNoticeApi = (id: string | number) => {
return request.get<NoticeItem>({ url: `/admin/notices/${id}` })
}
export const createNoticeApi = (data: NoticeSaveData) => {
return request.post<string>({ url: '/admin/notices', data })
}
export const updateNoticeApi = (id: string | number, data: NoticeSaveData) => {
return request.put<void>({ url: `/admin/notices/${id}`, data })
}
export const updateNoticeStatusApi = (id: string | number, status: number) => {
return request.put<void>({ url: `/admin/notices/${id}/status`, params: { status } })
}
export const deleteNoticeApi = (id: string | number) => {
return request.delete<void>({ url: `/admin/notices/${id}` })
}
+85
View File
@@ -0,0 +1,85 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 商品(金币包):字段与 /admin/products 的响应一一对应 */
export interface ProductItem {
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
id: string
code: string
name: string
/** COIN_PACK / VIP */
type: string
typeText: string
/** ALL / GOOGLE / APPLE */
platform: string
storeProductId: string
coinAmount: number
bonusCoin: number
/** coinAmount + bonusCoin,服务端算好,前端不再自己加 */
totalCoin: number
priceAmount: number
currency: string
displayPrice: string
regionCodes: string
vipDays: number
sort: number
/** 1 已上架 / 0 已下架 */
status: number
statusText: string
effectiveAt?: string
expireAt?: string
createdAt?: string
}
export interface ProductPageParams {
pageIndex?: number
pageSize?: number
type?: string
status?: number
platform?: string
keyword?: string
}
export interface ProductSaveData {
code: string
name: string
type: string
platform: string
storeProductId: string
coinAmount: number
bonusCoin: number
priceAmount: number
currency: string
displayPrice: string
regionCodes: string
vipDays: number
sort: number
effectiveAt?: string | null
expireAt?: string | null
}
export const pageProductsApi = (params: ProductPageParams) => {
return request.get<PageResult<ProductItem>>({ url: '/admin/products/page', params })
}
export const getProductApi = (id: string | number) => {
return request.get<ProductItem>({ url: `/admin/products/${id}` })
}
export const createProductApi = (data: ProductSaveData) => {
return request.post<string>({ url: '/admin/products', data })
}
export const updateProductApi = (id: string | number, data: ProductSaveData) => {
return request.put<void>({ url: `/admin/products/${id}`, data })
}
export const updateProductStatusApi = (id: string | number, status: number) => {
return request.put<void>({ url: `/admin/products/${id}/status`, params: { status } })
}
export const deleteProductApi = (id: string | number) => {
return request.delete<void>({ url: `/admin/products/${id}` })
}
+87
View File
@@ -0,0 +1,87 @@
// Author: fruit.com.cn
import request from '@/axios'
import type {
MenuNode,
MenuSaveData,
PageResult,
PermissionGroup,
RoleCategoryItem,
RoleItem,
RoleSaveData
} from './types'
export interface RolePageParams {
keyword?: string
pageIndex?: number
pageSize?: number
}
export const pageRolesApi = (params: RolePageParams) => {
return request.get<PageResult<RoleItem>>({ url: '/admin/roles/page', params })
}
export const listRolesApi = () => {
return request.get<RoleItem[]>({ url: '/admin/roles/list' })
}
export const createRoleApi = (data: RoleSaveData) => {
return request.post<number>({ url: '/admin/roles', data })
}
export const updateRoleApi = (id: number, data: RoleSaveData) => {
return request.put<void>({ url: `/admin/roles/${id}`, data })
}
export const deleteRoleApi = (id: number) => {
return request.delete<void>({ url: `/admin/roles/${id}` })
}
export const getRolePermissionsApi = (id: number) => {
return request.get<string[]>({ url: `/admin/roles/${id}/permissions` })
}
export const updateRolePermissionsApi = (id: number, permissions: string[]) => {
return request.put<void>({ url: `/admin/roles/${id}/permissions`, data: { permissions } })
}
export const listPermissionGroupsApi = () => {
return request.get<PermissionGroup[]>({ url: '/admin/permissions' })
}
export interface RoleCategorySaveData {
name: string
description?: string
}
export const listRoleCategoriesApi = () => {
return request.get<RoleCategoryItem[]>({ url: '/admin/role-categories' })
}
export const createRoleCategoryApi = (data: RoleCategorySaveData) => {
return request.post<number>({ url: '/admin/role-categories', data })
}
export const updateRoleCategoryApi = (id: number, data: RoleCategorySaveData) => {
return request.put<void>({ url: `/admin/role-categories/${id}`, data })
}
export const deleteRoleCategoryApi = (id: number) => {
return request.delete<void>({ url: `/admin/role-categories/${id}` })
}
export const getMenuTreeApi = () => {
return request.get<MenuNode[]>({ url: '/admin/menus/tree' })
}
export const createMenuApi = (data: MenuSaveData) => {
return request.post<number>({ url: '/admin/menus', data })
}
export const updateMenuApi = (id: number, data: MenuSaveData) => {
return request.put<void>({ url: `/admin/menus/${id}`, data })
}
export const deleteMenuApi = (id: number) => {
return request.delete<void>({ url: `/admin/menus/${id}` })
}
+43
View File
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import request from '@/axios'
import type {
EpisodeTranscodeItem,
MediaThumbnailResult,
TranscodeBackfillResult,
TranscodeTriggerResult
} from './types'
/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */
export const getEpisodeTranscodeApi = (episodeId: number | string) => {
return request.get<EpisodeTranscodeItem>({
url: `/admin/episodes/${episodeId}/transcode`
})
}
/** 按媒体分页读取帧图;页面切换时复用该接口。 */
export const getMediaThumbnailsApi = (
mediaAssetId: string,
pageIndex: number,
pageSize: number
) => {
return request.get<MediaThumbnailResult>({
url: `/admin/media/${mediaAssetId}/thumbnails`,
params: { pageIndex, pageSize }
})
}
/** 手动触发或重转当前分集绑定的媒体。 */
export const triggerMediaTranscodeApi = (mediaAssetId: string) => {
return request.post<TranscodeTriggerResult>({
url: `/admin/media/${mediaAssetId}/transcode`
})
}
/** 手动补齐转码:扫描缺码率产物/帧图的分集并入队。 */
export const runTranscodeBackfillApi = (batchSize?: number) => {
return request.post<TranscodeBackfillResult>({
url: '/admin/media/transcode/backfill',
params: batchSize ? { batchSize } : undefined
})
}
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface TranscodeNoticeItem {
id: number
status: string
dramaTitle: string
episodeNo?: number
episodeTitle?: string
progress?: number
errorMsg?: string
updatedAt?: string
}
export interface TranscodeNotice {
running: number
success: number
failed: number
runningList: TranscodeNoticeItem[]
successList: TranscodeNoticeItem[]
failedList: TranscodeNoticeItem[]
}
export const getTranscodeNoticesApi = () => {
return request.get<TranscodeNotice>({ url: '/admin/notifications/transcode' })
}
+509
View File
@@ -0,0 +1,509 @@
// Author: fruit.com.cn
export interface PageResult<T> {
total: number
list: T[]
}
export interface DramaCategory {
id: number
name: string
nameEn?: string
nameId?: string
locales?: Record<string, string>
sort?: number
status?: number
/** 该类型下的短剧数量,管理列表才有 */
dramaCount?: number
}
export interface DramaItem {
id: number
code?: string
title: string
subtitle?: string
categoryId?: number
categoryIds?: number[]
categoryNames?: string[]
category?: string
genre?: string
description?: string
cover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
episodes?: number
freeEpisodes?: number
unlockCoin?: number
/** 是否热门 */
isHot?: boolean
status: string
statusText: string
views?: number
favoriteCount?: number
likeCount?: number
commentCount?: number
sort?: number
publishAt?: string
createdAt?: string
updatedAt?: string
}
export interface DramaSaveData {
title: string
subtitle?: string
categoryId?: number
categoryIds?: number[]
genre?: string
description?: string
cover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
freeEpisodes?: number
unlockCoin?: number
favoriteCount?: number
likeCount?: number
heat?: number
isHot?: boolean
sort?: number
}
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
export interface EpisodeItem {
id: number
dramaId: number
number: number
title: string
duration: string
durationSec: number
cover?: string
videoUrl?: string
isFree: boolean
unlockCoin: number
status: string
statusText: string
views?: number
likeCount?: number
commentCount?: number
favoriteCount?: number
heat?: number
sort?: number
}
export interface EpisodeSaveData {
dramaId: number
number: number
title?: string
durationSec?: number
cover?: string
videoUrl?: string
isFree?: boolean
unlockCoin?: number
likeCount?: number
status?: string
sort?: number
}
export interface AdminUserItem {
id: number
username: string
name: string
roleId: number | null
role: string
ipWhitelist: string
status: number
statusText: string
twoFactor: number
twoFactorText: string
createdAt?: string
lastLogin?: string
}
export interface AdminUserSaveData {
username?: string
password?: string
name: string
roleId: number
ipWhitelist?: string
status?: number
twoFactor?: number
}
export interface RoleItem {
id: number
name: string
code: string
description: string
categoryId: number
category: string
level: number
status: number
statusText: string
builtin: boolean
memberCount: number
permissions: string[]
}
export interface RoleSaveData {
name: string
code?: string
description?: string
categoryId?: number
level?: number
status?: number
permissions?: string[]
}
export interface RoleCategoryItem {
id: number
name: string
description: string
roleCount: number
}
export interface PermissionItem {
code: string
name: string
}
export interface PermissionGroup {
name: string
items: PermissionItem[]
}
export interface MenuNode {
id: number
parentId?: number
path?: string
name?: string
component?: string
redirect?: string
icon?: string
type?: string
permissionCode?: string
sort?: number
visible?: number
status?: number
meta?: { title?: string; icon?: string; alwaysShow?: boolean; affix?: boolean }
children?: MenuNode[]
}
export interface MenuSaveData {
parentId?: number
title: string
routePath?: string
routeName?: string
component?: string
redirect?: string
icon?: string
permissionCode?: string
type?: string
sort?: number
visible?: number
status?: number
}
export interface ConfigItem {
key: string
value: string
remark?: string
}
export interface MediaAsset {
id: number
bizType: string
fileName: string
objectKey: string
url: string
sizeBytes: number
md5: string
durationSec?: number
/** uploading / uploaded / transcoding / ready / failed */
status: string
createdAt?: string
}
/** 单档清晰度配置,字段与 FruitServer MediaTranscodeProfile.Rung 一致。 */
export interface MediaTranscodeLadderRung {
quality: string
width: number
height: number
videoBitrateKbps: number
audioBitrateKbps: number
enabled: boolean
}
/** 雪碧图采样配置,字段与 FruitServer MediaTranscodeProfile.Sprite 一致。 */
export interface MediaTranscodeSprite {
enabled: boolean
intervalSeconds: number
columns: number
thumbWidth: number
}
/** 转码档位配置,字段与 FruitServer MediaTranscodeProfile 一致。 */
export interface MediaTranscodeProfile {
enabled: boolean
segmentSeconds: number
ladder: MediaTranscodeLadderRung[]
sprite: MediaTranscodeSprite
}
export interface MediaRenditionItem {
id: string
quality: string
qualityName: string
width: number
height: number
resolution: string
videoBitrateKbps: number
audioBitrateKbps: number
videoCodec: string
audioCodec?: string
container: string
hlsUrl: string
sizeBytes: number
durationSec: number
segmentCount: number
status: string
}
export interface MediaThumbnailFrameItem {
frameIndex: number
timeMs: number
endTimeMs?: number
imageUrl: string
spriteUrl: string
x: number
y: number
width: number
height: number
}
export interface MediaThumbnailSpriteInfo {
spriteUrl?: string
spriteUrls: string[]
vttUrl?: string
columns: number
rows: number
totalFrames: number
thumbWidth: number
thumbHeight: number
intervalSeconds: number
}
export interface MediaThumbnailResult {
mediaAssetId?: string | null
episodeId?: string | null
/** READY / FAILED / EMPTY / INVALID / NO_EPISODE */
status: string
errorMsg?: string
pageIndex: number
pageSize: number
frames: PageResult<MediaThumbnailFrameItem>
sprite?: MediaThumbnailSpriteInfo | null
}
export interface EpisodeTranscodeItem {
episodeId: string
mediaAssetId?: string | null
taskId?: string | null
status: string
progress?: number
retryCount?: number
errorMsg?: string
updatedAt?: string
renditions: MediaRenditionItem[]
thumbnails?: MediaThumbnailResult | null
}
export interface TranscodeTriggerResult {
taskId: string
status: string
}
export interface TranscodeBackfillItem {
episodeId: string
taskId: string
}
export interface TranscodeBackfillResult {
triggered: number
items: TranscodeBackfillItem[]
}
export interface BannerItem {
id: number
imageUrl: string
/** HOME_TOP / DISCOVER */
position: string
positionText: string
/** NONE / DRAMA / URL */
linkType: string
linkTypeText: string
linkValue: string
/** 跳转目标的展示文案 */
linkText: string
sort: number
status: number
statusText: string
updatedAt?: string
}
export interface BannerSaveData {
imageUrl: string
position: string
linkType: string
linkValue?: string
sort?: number
status?: number
}
export interface OperationLogItem {
id: number
userId: number
username: string
module: string
action: string
requestMethod: string
requestUrl: string
requestParams?: string
requestBody?: string
responseBody?: string
/** SUCCESS / FAIL */
status: string
statusText: string
errorMsg?: string
durationMs: number
ip: string
createdAt?: string
}
/** C 端用户(后台视图,比客户端多一个自增 id,用于后台按 id 寻址) */
export interface AppUserItem {
id: number
uid: string
nickname: string
avatarUrl: string
gender: string
countryCode: string
regionCode: string
language: string
timezone: string
currency: string
/** normal / limited / banned / deactivated */
status: string
/** 脱敏后的手机号或邮箱 */
maskedTarget: string
createdAt?: string
lastLoginAt?: string
/** ANDROID / IOS / WEB */
platform?: string
/** 系统版本,例如 Android 16 */
osVersion?: string
/** 预留:会员等级与到期(第二阶段) */
vipLevel?: number
vipExpireAt?: string
}
export interface AppUserBanRecord {
id: number
userId: number
/** BAN / LIMIT / MUTE */
type: string
reasonCode: string
reason: string
refType: string
refId: number
startAt?: string
endAt?: string
operatorId: number
/** none / pending / approved / rejected */
appealStatus: string
appealContent: string
handleNote: string
}
export interface WatchHistoryItem {
id: number
userId?: number
uid?: string
nickname?: string
dramaId: number
dramaTitle?: string
dramaPublished?: boolean
episodeId: number
episodeNo?: number
episodeTitle?: string
positionMs: number
durationMs: number
finished?: boolean
source: string
deviceId: string
platform?: string
osVersion?: string
watchedAt?: string
}
/** 评论(后台视图,字段与 /admin/comments/page 一一对应) */
export interface CommentItem {
id: number
dramaId: number
dramaTitle?: string
episodeId: number
episodeNo?: number
episodeTitle?: string
parentId: number
rootId: number
userId: number
uid: string
nickname: string
content: string
/** pending / published / rejected / deleted */
status: string
auditReason: string
likeCount: number
replyCount: number
reportCount: number
createdAt?: string
}
export interface ReportItem {
id: number
reporterId: number
reporterNickname?: string
reporterUid?: string
/** COMMENT / USER / DRAMA */
targetType: string
targetId: number
targetSummary?: string
dramaId?: number
dramaTitle?: string
episodeId?: number
episodeNo?: number
episodeTitle?: string
commentContent?: string
targetNickname?: string
targetUid?: string
reasonCode: string
description: string
/** pending / handled / rejected */
status: string
handleBy: number
handleAt?: string
handleNote: string
createdAt?: string
}
export interface SensitiveWordItem {
id: number
word: string
category: string
/** BLOCK / REVIEW / REPLACE */
level: string
replaceText: string
language: string
status: number
}
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { AdminUserItem, AdminUserSaveData, PageResult } from './types'
export interface AdminUserPageParams {
pageIndex?: number
pageSize?: number
username?: string
roleId?: number
status?: number
}
export const pageAdminUsersApi = (params: AdminUserPageParams) => {
return request.get<PageResult<AdminUserItem>>({ url: '/admin/admin-users/page', params })
}
export const createAdminUserApi = (data: AdminUserSaveData) => {
return request.post<number>({ url: '/admin/admin-users', data })
}
export const updateAdminUserApi = (id: number, data: AdminUserSaveData) => {
return request.put<void>({ url: `/admin/admin-users/${id}`, data })
}
export const deleteAdminUserApi = (id: number) => {
return request.delete<void>({ url: `/admin/admin-users/${id}` })
}
export const updateAdminUserStatusApi = (id: number, status: number) => {
return request.put<void>({ url: `/admin/admin-users/${id}/status`, params: { status } })
}
export const resetAdminUserPasswordApi = (id: number, password: string) => {
return request.put<void>({ url: `/admin/admin-users/${id}/password`, data: { password } })
}
+13 -5
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import request from '@/axios' import request from '@/axios'
import type { UserType } from './types' import type { UserType } from './types'
@@ -6,19 +8,25 @@ interface RoleParams {
} }
export const loginApi = (data: UserType): Promise<IResponse<UserType>> => { export const loginApi = (data: UserType): Promise<IResponse<UserType>> => {
return request.post({ url: '/mock/user/login', data }) return request.post({ url: '/admin/auth/login', data })
} }
export const loginOutApi = (): Promise<IResponse> => { export const loginOutApi = (): Promise<IResponse> => {
return request.get({ url: '/mock/user/loginOut' }) return request.post({ url: '/admin/auth/logout' })
}
/** 当前登录人信息(含实时权限) */
export const getProfileApi = (): Promise<IResponse<UserType>> => {
return request.get({ url: '/admin/auth/profile' })
} }
export const getAdminRoleApi = ( export const getAdminRoleApi = (
params: RoleParams params: RoleParams
): Promise<IResponse<AppCustomRouteRecordRaw[]>> => { ): Promise<IResponse<AppCustomRouteRecordRaw[]>> => {
return request.get({ url: '/mock/role/list', params }) return request.get({ url: '/admin/auth/menus', params })
} }
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => { /** 自助改密(首登强制改密也走这个接口) */
return request.get({ url: '/mock/role/list2', params }) export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => {
return request.post<void>({ url: '/admin/auth/password', data })
} }
+8 -1
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
export type UserLoginType = { export type UserLoginType = {
username: string username: string
password: string
} }
export type UserType = { export type UserType = {
@@ -9,4 +10,10 @@ export type UserType = {
role: string role: string
roleId: string roleId: string
permissions: string | string[] permissions: string | string[]
/** 登录成功后由后端下发 */
token?: string
userId?: number
name?: string
avatar?: string
roleName?: string
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+6
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import { AxiosResponse, InternalAxiosRequestConfig } from './types' import { AxiosResponse, InternalAxiosRequestConfig } from './types'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import qs from 'qs' import qs from 'qs'
@@ -41,6 +43,10 @@ const defaultResponseInterceptors = (response: AxiosResponse) => {
} else if (response.data.code === SUCCESS_CODE) { } else if (response.data.code === SUCCESS_CODE) {
return response.data return response.data
} else { } else {
// 静默请求:调用方要自己看业务码(例如创作者中心据此渲染引导页),不弹全局提示
if ((response.config as { silent?: boolean })?.silent) {
return response.data
}
ElMessage.error(response?.data?.message) ElMessage.error(response?.data?.message)
if (response?.data?.code === 401) { if (response?.data?.code === 401) {
const userStore = useUserStoreWithOut() const userStore = useUserStoreWithOut()
+16 -3
View File
@@ -1,19 +1,32 @@
// Author: fruit.com.cn
import service from './service' import service from './service'
import { CONTENT_TYPE } from '@/constants' import { CONTENT_TYPE } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user' import { useUserStoreWithOut } from '@/store/modules/user'
import { creatorCenterHeaders } from '@/utils/creatorImpersonation'
const request = (option: AxiosConfig) => { const request = (option: AxiosConfig) => {
const { url, method, params, data, headers, responseType } = option const { url, method, params, data, headers, responseType, silent } = option
const userStore = useUserStoreWithOut() const userStore = useUserStoreWithOut()
// FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData
// 创作者中心的请求统一带上代看目标头(超管选了代看创作者时才有值;
// 后端只对具备 impersonate 权限的账号认这个头,创作者带了也不生效)
const impersonationHeaders =
typeof url === 'string' && url.startsWith('/admin/creator-center') ? creatorCenterHeaders() : {}
return service.request({ return service.request({
url: url, url: url,
method, method,
params, params,
data, data,
responseType: responseType, responseType: responseType,
silent,
headers: { headers: {
'Content-Type': CONTENT_TYPE, ...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }),
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken ?? '', [userStore.getTokenKey ?? 'Authorization']: userStore.getToken
? `Bearer ${userStore.getToken}`
: '',
...impersonationHeaders,
...headers ...headers
} }
}) })
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import axios, { AxiosError } from 'axios' import axios, { AxiosError } from 'axios'
import { defaultRequestInterceptors, defaultResponseInterceptors } from './config' import { defaultRequestInterceptors, defaultResponseInterceptors } from './config'
+4
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import type { import type {
InternalAxiosRequestConfig, InternalAxiosRequestConfig,
AxiosResponse, AxiosResponse,
@@ -18,6 +20,8 @@ interface RequestInterceptors<T> {
interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig { interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
interceptors?: RequestInterceptors<T> interceptors?: RequestInterceptors<T>
/** 静默请求:业务码非 0 时由调用方处理,不弹全局提示(见 axios/config.ts) */
silent?: boolean
} }
export { export {
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
import AppIcon from './src/AppIcon.vue'
export { AppIcon }
+40
View File
@@ -0,0 +1,40 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import { Icon } from '@/components/Icon'
import { normalizeAppIcon } from '@/constants/icons'
import { propTypes } from '@/utils/propTypes'
/**
* 后台统一图标入口:默认使用 Material Symbols Rounded。
*/
const props = defineProps({
icon: propTypes.string,
color: propTypes.string,
size: propTypes.number.def(18),
hoverColor: propTypes.string
})
const resolvedIcon = computed(() => normalizeAppIcon(props.icon))
</script>
<template>
<Icon
:icon="resolvedIcon"
:color="color"
:size="size"
:hover-color="hoverColor"
class="app-icon"
/>
</template>
<style scoped>
.app-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
import AppImage from './src/AppImage.vue'
export { AppImage }
+94
View File
@@ -0,0 +1,94 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { ElImage } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
defineOptions({ inheritAttrs: false })
const props = defineProps({
src: {
type: String,
required: true
},
previewList: {
type: Array as () => string[],
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
fit: {
type: String as PropType<'fill' | 'contain' | 'cover' | 'none' | 'scale-down'>,
default: 'cover'
},
class: {
type: [String, Array, Object],
default: ''
},
style: {
type: [String, Array, Object],
default: undefined
},
alt: {
type: String,
default: ''
}
})
const resolvedPreviewList = computed(() => {
const images = (props.previewList || []).filter(Boolean)
return images.length ? images : [props.src]
})
const resolvedInitialIndex = computed(() =>
Math.max(0, Math.min(props.initialIndex, resolvedPreviewList.value.length - 1))
)
</script>
<template>
<ElImage
v-bind="$attrs"
:src="src"
:alt="alt"
:fit="fit"
:preview-src-list="resolvedPreviewList"
:initial-index="resolvedInitialIndex"
:preview-teleported="true"
:hide-on-click-modal="true"
:class="['app-image', props.class]"
:style="style"
>
<template #error>
<div class="app-image-error">
<AppIcon :icon="AppIcons.error" :size="20" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.app-image {
cursor: zoom-in;
}
.app-image :deep(.el-image__inner) {
display: block;
width: 100%;
height: 100%;
}
.app-image-error {
display: flex;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-lighter);
align-items: center;
justify-content: center;
}
</style>
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Backtop from './src/Backtop.vue' import Backtop from './src/Backtop.vue'
export { Backtop } export { Backtop }
+2
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElBacktop } from 'element-plus' import { ElBacktop } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Breadcrumb from './src/Breadcrumb.vue' import Breadcrumb from './src/Breadcrumb.vue'
export { Breadcrumb } export { Breadcrumb }
+6 -4
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script lang="tsx"> <script lang="tsx">
import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus' import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus'
import { ref, watch, computed, unref, defineComponent, TransitionGroup } from 'vue' import { ref, watch, computed, unref, defineComponent, TransitionGroup } from 'vue'
@@ -7,7 +9,7 @@ import { filterBreadcrumb } from './helper'
import { filter, treeToList } from '@/utils/tree' import { filter, treeToList } from '@/utils/tree'
import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router' import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
@@ -52,7 +54,7 @@ export default defineComponent({
<ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}> <ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
{meta?.icon && breadcrumbIcon.value ? ( {meta?.icon && breadcrumbIcon.value ? (
<> <>
<Icon icon={meta.icon} class="mr-[5px]"></Icon> {t(v?.meta?.title)} <AppIcon icon={meta.icon} class="mr-[5px]"></AppIcon> {t(v?.meta?.title)}
</> </>
) : ( ) : (
t(v?.meta?.title) t(v?.meta?.title)
@@ -115,10 +117,10 @@ export default defineComponent({
:deep(&__item):last-child { :deep(&__item):last-child {
.@{prefix-cls}__inner { .@{prefix-cls}__inner {
color: var(--el-text-color-placeholder); color: var(--el-text-color-regular);
&:hover { &:hover {
color: var(--el-text-color-placeholder); color: var(--el-color-primary);
} }
} }
} }
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import { pathResolve } from '@/utils/routerHelper' import { pathResolve } from '@/utils/routerHelper'
import type { RouteMeta } from 'vue-router' import type { RouteMeta } from 'vue-router'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import BaseButton from './src/Button.vue' import BaseButton from './src/Button.vue'
export { BaseButton } export { BaseButton }
+2
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { ElButton, ComponentSize, ButtonType } from 'element-plus' import { ElButton, ComponentSize, ButtonType } from 'element-plus'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Collapse from './src/Collapse.vue' import Collapse from './src/Collapse.vue'
export { Collapse } export { Collapse }
+5 -2
View File
@@ -1,8 +1,11 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, unref } from 'vue' import { computed, unref } from 'vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -24,9 +27,9 @@ const toggleCollapse = () => {
<template> <template>
<div :class="prefixCls" @click="toggleCollapse"> <div :class="prefixCls" @click="toggleCollapse">
<Icon <AppIcon
:size="18" :size="18"
:icon="collapse ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'" :icon="collapse ? AppIcons.menuOpen : AppIcons.menu"
:color="color" :color="color"
class="cursor-pointer" class="cursor-pointer"
/> />
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import ConfigGlobal from './src/ConfigGlobal.vue' import ConfigGlobal from './src/ConfigGlobal.vue'
export type { ConfigGlobalTypes } from './src/types' export type { ConfigGlobalTypes } from './src/types'
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { provide, computed, watch, onMounted } from 'vue' import { provide, computed, watch, onMounted } from 'vue'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import { ComponentSize } from 'element-plus' import { ComponentSize } from 'element-plus'
export interface ConfigGlobalTypes { export interface ConfigGlobalTypes {
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import ContentDetailWrap from './src/ContentDetailWrap.vue' import ContentDetailWrap from './src/ContentDetailWrap.vue'
export { ContentDetailWrap } export { ContentDetailWrap }
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElCard } from 'element-plus' import { ElCard } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import ContentWrap from './src/ContentWrap.vue' import ContentWrap from './src/ContentWrap.vue'
export { ContentWrap } export { ContentWrap }
@@ -1,7 +1,10 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElCard, ElTooltip } from 'element-plus' import { ElCard, ElTooltip } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -22,7 +25,7 @@ defineProps({
<template #content> <template #content>
<div class="max-w-200px">{{ message }}</div> <div class="max-w-200px">{{ message }}</div>
</template> </template>
<Icon class="ml-5px" icon="bi:question-circle-fill" :size="14" /> <AppIcon class="ml-5px" :icon="AppIcons.help" :size="14" />
</ElTooltip> </ElTooltip>
</div> </div>
</template> </template>
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import ContextMenu from './src/ContextMenu.vue' import ContextMenu from './src/ContextMenu.vue'
import { ElDropdown } from 'element-plus' import { ElDropdown } from 'element-plus'
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus' import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { PropType, ref } from 'vue' import { PropType, ref } from 'vue'
@@ -64,7 +66,7 @@ defineExpose({
:disabled="item.disabled" :disabled="item.disabled"
:command="item" :command="item"
> >
<Icon :icon="item.icon" /> {{ t(item.label) }} <AppIcon :icon="item.icon" /> {{ t(item.label) }}
</ElDropdownItem> </ElDropdownItem>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
export interface ContextMenuSchema { export interface ContextMenuSchema {
disabled?: boolean disabled?: boolean
divided?: boolean divided?: boolean
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Descriptions from './src/Descriptions.vue' import Descriptions from './src/Descriptions.vue'
export type { DescriptionsSchema } from './src/types' export type { DescriptionsSchema } from './src/types'
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script lang="tsx"> <script lang="tsx">
import { ElCollapseTransition, ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus' import { ElCollapseTransition, ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
@@ -5,8 +7,9 @@ import { propTypes } from '@/utils/propTypes'
import { ref, unref, PropType, computed, defineComponent } from 'vue' import { ref, unref, PropType, computed, defineComponent } from 'vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { DescriptionsSchema } from './types' import { DescriptionsSchema } from './types'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import { AppIcons } from '@/constants/icons'
const appStore = useAppStore() const appStore = useAppStore()
@@ -92,12 +95,14 @@ export default defineComponent({
{props.title} {props.title}
{props.message ? ( {props.message ? (
<ElTooltip content={props.message} placement="right"> <ElTooltip content={props.message} placement="right">
<Icon icon="bi:question-circle-fill" class="ml-5px" size={14} /> <AppIcon icon={AppIcons.help} class="ml-5px" size={14} />
</ElTooltip> </ElTooltip>
) : null} ) : null}
</div> </div>
</div> </div>
{props.collapse ? <Icon icon={show.value ? 'ep:arrow-down' : 'ep:arrow-up'} /> : null} {props.collapse ? (
<AppIcon icon={show.value ? AppIcons.chevronDown : AppIcons.chevronUp} />
) : null}
</div> </div>
) : null} ) : null}
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
export interface DescriptionsSchema { export interface DescriptionsSchema {
span?: number // 占多少分 span?: number // 占多少分
field: string // 字段名 field: string // 字段名
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Dialog from './src/Dialog.vue' import Dialog from './src/Dialog.vue'
export { Dialog } export { Dialog }
+7 -4
View File
@@ -1,8 +1,11 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElDialog, ElScrollbar } from 'element-plus' import { ElDialog, ElScrollbar } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue' import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue'
import { isNumber } from '@/utils/is' import { isNumber } from '@/utils/is'
import { AppIcons } from '@/constants/icons'
const slots = useSlots() const slots = useSlots()
@@ -75,17 +78,17 @@ const dialogStyle = computed(() => {
<div <div
class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]" class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]"
> >
<Icon <AppIcon
v-if="fullscreen" v-if="fullscreen"
class="cursor-pointer is-hover !h-54px mr-10px" class="cursor-pointer is-hover !h-54px mr-10px"
:icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'" :icon="isFullscreen ? AppIcons.fullscreenExit : AppIcons.fullscreen"
color="var(--el-color-info)" color="var(--el-color-info)"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
@click="toggleFull" @click="toggleFull"
/> />
<Icon <AppIcon
class="cursor-pointer is-hover !h-54px" class="cursor-pointer is-hover !h-54px"
icon="ep:close" :icon="AppIcons.close"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
color="var(--el-color-info)" color="var(--el-color-info)"
@click="close" @click="close"
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Editor from './src/Editor.vue' import Editor from './src/Editor.vue'
import { IDomEditor } from '@wangeditor/editor' import { IDomEditor } from '@wangeditor/editor'
+2
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, computed, PropType, unref, nextTick, ref, watch, shallowRef } from 'vue' import { onBeforeUnmount, computed, PropType, unref, nextTick, ref, watch, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Error from './src/Error.vue' import Error from './src/Error.vue'
export { Error } export { Error }
+2
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import pageError from '@/assets/svgs/404.svg' import pageError from '@/assets/svgs/404.svg'
import networkError from '@/assets/svgs/500.svg' import networkError from '@/assets/svgs/500.svg'
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Form from './src/Form.vue' import Form from './src/Form.vue'
import type { FormSchema, FormSetProps } from './src/types' import type { FormSchema, FormSetProps } from './src/types'
export type { export type {
+2
View File
@@ -1,3 +1,5 @@
<!-- Author: fruit.com.cn -->
<script lang="tsx"> <script lang="tsx">
import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue' import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue'
import { import {
@@ -1,3 +1,6 @@
// Author: fruit.com.cn
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ElCascader, ElCascader,
@@ -21,9 +24,12 @@ import {
ElUpload ElUpload
} from 'element-plus' } from 'element-plus'
import { InputPassword } from '@/components/InputPassword' import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { ComponentName } from '../types' import { ComponentName } from '../types'
// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载,
// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。
const Editor = defineAsyncComponent(() => import('@/components/Editor'))
const componentMap: Recordable<Component, ComponentName> = { const componentMap: Recordable<Component, ComponentName> = {
RadioGroup: ElRadioGroup, RadioGroup: ElRadioGroup,
RadioButton: ElRadioGroup, RadioButton: ElRadioGroup,
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { PlaceholderModel, FormSchema, ComponentNameEnum, ColProps } from '../types' import { PlaceholderModel, FormSchema, ComponentNameEnum, ColProps } from '../types'
import { isFunction } from '@/utils/is' import { isFunction } from '@/utils/is'
+4 -1
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import { import {
AutocompleteProps, AutocompleteProps,
InputNumberProps, InputNumberProps,
@@ -20,7 +22,8 @@ import {
ISelectProps, ISelectProps,
UploadProps UploadProps
} from 'element-plus' } from 'element-plus'
import { IEditorConfig } from '@wangeditor/editor' // 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链
import type { IEditorConfig } from '@wangeditor/editor'
import { CSSProperties } from 'vue' import { CSSProperties } from 'vue'
export interface PlaceholderModel { export interface PlaceholderModel {
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import Icon from './src/Icon.vue' import Icon from './src/Icon.vue'
export type { IconTypes } from './src/types' export type { IconTypes } from './src/types'
+7 -3
View File
@@ -1,9 +1,12 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, unref } from 'vue' import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus' import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -20,9 +23,10 @@ const props = defineProps({
}) })
const isLocal = computed(() => props.icon.startsWith('svg-icon:')) const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
const normalizedIcon = computed(() => normalizeAppIcon(props.icon))
const symbolId = computed(() => { const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
}) })
// 是否使用在线图标 // 是否使用在线图标
@@ -46,8 +50,8 @@ const getIconifyStyle = computed(() => {
</svg> </svg>
<template v-else> <template v-else>
<Icon v-if="isUseOnline" :icon="icon" :style="getIconifyStyle" /> <Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${icon} iconify`" :style="getIconifyStyle"></div> <div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template> </template>
</ElIcon> </ElIcon>
</template> </template>
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
export interface IconTypes { export interface IconTypes {
size?: number size?: number
color?: string color?: string
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more