Compare commits

..

155 Commits

Author SHA1 Message Date
yys 97a91a39e2 fix: 独立出积分变更权限 2026-04-27 10:49:11 +08:00
yys 413a3b118a feat: 管理端团队名称统一改成团队下拉框 2026-04-23 17:52:50 +08:00
yys 24f518acd1 feat: 团队订单与管理端订单代码重构、合并 2026-04-23 17:16:57 +08:00
yys e1733e3f47 feat: 团队订单与管理端订单代码重构、合并 2026-04-23 16:40:02 +08:00
yys f7d40414c0 feat: 团队订单与管理端订单代码重构、合并 2026-04-23 14:43:03 +08:00
yys 9eb8514fdf feat: 团队后台 - 团队用户,增加下放、回收积分 2026-04-23 12:31:37 +08:00
yys ba675ce0e0 feat: 按策划需求改进管理后台 2026-04-23 11:11:08 +08:00
yys 63f251de66 fix:团队统计修改 2026-04-22 17:57:55 +08:00
yys d578280f82 Merge branch 'seedance_balance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance_balance 2026-04-22 17:42:50 +08:00
yys 0a714b1322 fix:团队统计修改 2026-04-22 17:40:59 +08:00
old burden 24c6936dd0 Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance 2026-04-22 14:50:59 +08:00
old burden 742310844d fix:样式修改 2026-04-22 14:50:39 +08:00
yys b1707c8d20 fix:团队统计修改 2026-04-22 13:01:36 +08:00
yys abbd589b82 fix: 解决、改进管理后台-管理端-订单记录 相关功能 2026-04-21 18:01:44 +08:00
yys fb38439543 fix: 解决、改进管理后台-管理端-团队管理 相关的功能 2026-04-21 17:33:19 +08:00
old burden 28e51137c5 fix:团队统计修改 2026-04-21 16:33:53 +08:00
old burden 7b4395761c fix:增加时间搜索 2026-04-21 16:26:45 +08:00
old burden e1a574d99e fix:充值订单和业务订单bug修复 2026-04-21 16:11:55 +08:00
old burden 8fcdb74507 fix:充值订单和业务订单bug修复 2026-04-21 15:57:35 +08:00
old burden 4800af812d fix:充值订单和业务订单bug修复 2026-04-21 15:38:53 +08:00
old burden fd2ed863b3 fix:充值订单和业务订单bug修复 2026-04-21 15:26:26 +08:00
old burden 181228bb59 fix:充值订单和业务订单bug修复 2026-04-21 15:09:17 +08:00
old burden bbcc14a395 Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance
# Conflicts:
#	admin-ui/src/views/ai/record/index.vue
2026-04-21 14:59:44 +08:00
old burden 5c4c62d729 fix:充值订单和业务订单bug修复 2026-04-21 14:59:07 +08:00
old burden 0385ac9640 fix:充值订单和业务订单bug修复 2026-04-21 14:50:14 +08:00
yys 9ffe5c8152 fix: 解决发现的问题 2026-04-21 14:37:54 +08:00
yys 20a063d6aa fix: 解决合并问题 2026-04-21 11:05:13 +08:00
yys e516105893 Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance
# Conflicts:
#	web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiUserMapper.java
#	web-api/ruoyi-system/src/main/resources/mapper/system/AiUserMapper.xml
2026-04-21 11:00:27 +08:00
yys 8d7cd70cfc feat: 火山配置从部门表独立出来,相关取值、缓存、保存重写逻辑 2026-04-21 10:59:03 +08:00
old burden 2498b4deed Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance 2026-04-21 10:48:14 +08:00
old burden 589bb9c7c8 fix:团队上限人数 2026-04-21 10:47:50 +08:00
yys cb72d4edae feat: 调整AI用户、部门的界面显示 2026-04-21 10:17:25 +08:00
yys b2f1bc8439 Merge branch 'seedance_balance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance_balance
# Conflicts:
#	web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiVideoReportDataMapper.java
2026-04-21 09:50:29 +08:00
yys 1a7205564d feat: 生成视频时记录统计表数据 2026-04-21 09:48:28 +08:00
old burden d1b27c42cd fix:操作列优化 2026-04-20 17:53:09 +08:00
old burden a3bc2e24da fix:新功能提交 子后台新增下放积分 2026-04-20 17:40:14 +08:00
old burden b3b54f14bc fix:新功能提交 2026-04-20 17:17:12 +08:00
old burden 56389e5154 fix:新功能提交 2026-04-20 17:14:01 +08:00
old burden 7177d7c404 fix:新功能提交 2026-04-20 17:10:16 +08:00
old burden 009886d9d8 Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance 2026-04-20 16:55:32 +08:00
old burden 6600f4f1f9 fix:新功能提交 2026-04-20 16:55:10 +08:00
yys 690bc43a55 Merge branch 'seedance_balance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance_balance 2026-04-20 16:48:14 +08:00
yys 4a55202d35 fix: 团队余额bug修复 2026-04-20 16:48:10 +08:00
old burden bfa30bb543 fix:新功能提交 2026-04-20 16:33:43 +08:00
old burden c7334ab7d9 fix:系统用户表实现通过部门关联ai_user 2026-04-20 16:28:27 +08:00
old burden f98ceb5785 fix:系统用户表实现通过部门关联ai_user 2026-04-20 16:28:17 +08:00
old burden e4292ee87d fix:客户端注册充值取消 2026-04-20 16:27:25 +08:00
yys 17d5e3a1c9 Merge branch 'seedance_balance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance_balance 2026-04-20 16:14:05 +08:00
yys 1ae99ae660 feat: 代码、索引优化 2026-04-20 16:13:56 +08:00
old burden ddd2a60f7e Merge remote-tracking branch 'origin/seedance_balance' into seedance_balance 2026-04-20 15:59:40 +08:00
old burden 8be42f7a4f feat: sql内容提交 2026-04-20 15:59:17 +08:00
yys 460cbc2a38 feat: 部门管理 - 积分更正 2026-04-20 14:38:57 +08:00
yys db2052b9fd feat: AI用户 - 下放/回收积分,去掉设置余额功能。恢复AiDeptController 2026-04-20 11:55:32 +08:00
yys c92f531f1b feat: 部门余额变更 2026-04-17 17:47:25 +08:00
yys 580e484dd0 feat: 修改缓存配置,redis key统一放在RedisKey中 2026-04-17 16:31:17 +08:00
yys d69d2a1df7 feat: 创建订单时填写部门ID;订单生成失败时积分与用量写为0 2026-04-17 16:22:40 +08:00
yys a275194cfb feat: 修改AiVideoReportData的表字段,查询近七天的数据 2026-04-17 16:21:38 +08:00
yys dba0b2d9ca feat:
团队余额版本更新的sql
	git不再忽略.sql文件
2026-04-17 15:08:57 +08:00
yys d975e4fc19 feat: 新表、新字段生成entity、mapper、service、enum 2026-04-17 14:29:31 +08:00
yys 5eaa4fe6df feat: 注释掉无用的工具类 QiniuUtils 2026-04-17 14:09:49 +08:00
yys edbebf0d5d fix: 根据阿里规约修改部份代码问题 2026-04-16 11:52:37 +08:00
yys 4f1240ab0b fix: 解决maven编译出现的一些问题
去掉了七牛工具类的@Component
           mail依赖了两次,删除一个
        AiBannerServiceImpl中的警告(该方法未使用无影响)
2026-04-15 16:59:54 +08:00
yys 391071c8f7 fix: 解决ai_user停用后仍可在portal登录的问题 2026-04-15 16:44:21 +08:00
yys ffc23990a7 Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-15 16:01:57 +08:00
yys b3459fc01f fix: 生成视频产生的流水的备注,用同一个语言 2026-04-15 16:01:49 +08:00
old burden 3dfa25d23b Merge remote-tracking branch 'origin/seedance' into seedance 2026-04-15 15:21:05 +08:00
old burden abae79949f feat: 作品库分页器,操作列优化 2026-04-15 15:20:55 +08:00
yys ff46210c14 fix: 生成视频产生的流水的备注,用同一个语言 2026-04-15 14:58:17 +08:00
yys b93dd9470c fix: 解决合并冲突 2026-04-15 14:06:01 +08:00
yys a26021dc1c Merge branch 'seedance_score' into seedance
# Conflicts:
#	admin-ui/src/views/ai/order/index.vue
2026-04-15 13:59:18 +08:00
yys e93fa6baa9 feat: 管理后台-订单记录,优化界面布局 2026-04-14 11:50:05 +08:00
yys 1bedde7fa7 fix:管理后台-订单记录,按订单查询报错 2026-04-14 11:49:33 +08:00
yys f31e4c38c6 feat: 多语言信息 2026-04-14 11:48:48 +08:00
yys fedaef2ff7 feat: 管理后台,优化订单表格显示列宽,生成结果如果是失败时只显示中文或编号,浮窗显示完整信息 2026-04-14 11:31:26 +08:00
yys b9e08938e0 feat: 生成视频任务失败时记录并在用户前台-对话记录、管理后台-订单显示 2026-04-14 11:18:46 +08:00
yys 85dc61b07d feat: 生成视频时,流水表写入备注,添加tokens用量 2026-04-14 11:01:55 +08:00
old burden 3328aae4e3 feat: 上传接口做优化(目前只使用了/api/file/upload,其他接口前端没使用) 2026-04-13 17:15:43 +08:00
old burden 73f4fcdaf6 feat: 小优化和作品库 2026-04-13 17:14:44 +08:00
yys f1e09eac1a fix: 后台订单预览bug修复 2026-04-13 17:05:30 +08:00
old burden b7d15052cd fix: 后台订单预览bug修复 2026-04-13 17:04:06 +08:00
yys f6408400ee feat: 火山方舟回调增加来源日志 2026-04-13 16:03:52 +08:00
yys 7068fad278 feat: 生成视频时产生的流水都加上备注 2026-04-13 11:39:16 +08:00
yys dda43f2de6 feat: 提交任务、按F5刷新时会读取最新的余额 2026-04-13 10:34:09 +08:00
yys 4d428b9d94 feat: 登录直接跳转到生成视频页面 2026-04-13 09:53:55 +08:00
yys 982ce1f179 feat: 积分先按aimanager配置扣减,任务终态时再回补 2026-04-13 09:53:37 +08:00
old burden 6279abb4f3 fix: 忽略cursor配置文件 2026-04-10 17:06:32 +08:00
yys a67a8e202a feat: 登录后直接跳转到视频生成页面 2026-04-10 14:30:56 +08:00
yys dbc292b058 feat: 去掉无用配置项 2026-04-10 14:28:54 +08:00
yys bb5daf02be fix: 生成视频、任务回调 - 检查代码并修改相关逻辑问题 2026-04-10 12:03:00 +08:00
yys 992d95992a feat: 去掉取消生成视频的功能,简化逻辑 2026-04-10 10:39:49 +08:00
yys f53f849ec8 fix: 生成视频、任务回调 - 检查代码并修改相关逻辑问题 2026-04-10 10:03:28 +08:00
yys 80c136ad18 feat: 生成视频时,按字节接口扣减用量(代码初步完成) 2026-04-09 16:52:50 +08:00
yys 38b26f9ffb feat: 生成视频时,按字节接口扣减用量 2026-04-08 16:56:31 +08:00
yys d807f71677 feat: 图生视频,选择素材窗口优化 2026-04-08 09:43:36 +08:00
yys 2e38bd7539 feat:添加默认的logo与banner 2026-04-08 09:43:06 +08:00
yys ae3be9b48d feat: 优化腾讯存储桶逻辑,不再每次都开关客户端 2026-04-07 13:56:03 +08:00
yys b567ba14b7 feat: 修改日志生成策略,按日期生成 2026-04-07 13:54:34 +08:00
yys b780a4e0d3 fix: 图生视频,解决选择首帧生成视频时编辑器消失的情况 2026-04-07 11:10:05 +08:00
yys 23817f7850 Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-07 10:40:31 +08:00
yys a2076fe18b feat: 修改富文本编辑器组件,实现以下功能
1、图片放大
2、显示区域加大可浮在组件外
3、选择区一行可显示多个
2026-04-07 10:40:23 +08:00
old burden 9250253238 fix: 问题7修复 2026-04-07 10:23:51 +08:00
yys 6037bae970 feat: 添加生成视频任务的扩展状态,在不动现有逻辑的情况下能显示队列中、执行中。并解决刚生成就显示失败的情况 2026-04-07 09:30:08 +08:00
yys 249565ef6d fix: 获取第二部门时的错误 2026-04-03 22:28:07 +08:00
old burden c2c1ff8063 fix: 端点问题调整 2026-04-03 16:14:52 +08:00
old burden eb8cab3f5a fix: 腾讯云cos上传警告 2026-04-03 14:24:33 +08:00
old burden fa99a4fb9b fix: 视频生成状态判断优化 2026-04-03 14:22:40 +08:00
yys b17aec4a1a fix:解决视频生成问题 2026-04-03 14:17:19 +08:00
yys 78b32dd252 Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-03 12:41:06 +08:00
yys 0b21a293b0 feat: admin-ui 部门编辑添加project 2026-04-03 12:40:26 +08:00
old burden 37f3237f61 fix: 视频生成问题 2026-04-03 12:38:46 +08:00
yys 4ea4d009a6 feat: 管理后台添加项目、apikey并加解密 2026-04-03 11:13:16 +08:00
old burden dc56530d47 fix: id大小写 2026-04-02 18:55:53 +08:00
old burden 9f7e43a21a fix: 参考图@插入图bug修复 2026-04-02 18:04:15 +08:00
old burden f3c3e78901 fix: 参考图bug修复 2026-04-02 17:55:33 +08:00
yys 353d8e0069 Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-02 17:14:29 +08:00
yys 67c252d174 fix: 上传素材接口问题 2026-04-02 17:14:24 +08:00
old burden 9a460e2de7 fix: 上传素材优化 2026-04-02 16:49:41 +08:00
yys 96646cf5ed feat: 上传素材接口,前端改传url 2026-04-02 16:43:07 +08:00
yys e888da137e feat: 修改mybatis-plus的日志配置,去掉sql日志 2026-04-02 16:30:14 +08:00
yys e2ab5bcb13 feat: 添加相关接口日志 2026-04-02 16:29:15 +08:00
yys 9c0f533aff Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-02 16:06:46 +08:00
yys 9705c4f472 feat: 部门表中的项目与apikey加密 2026-04-02 16:06:36 +08:00
old burden 0d4b1d18be fix: 生成页面优化布局 2026-04-02 15:56:46 +08:00
yys eb5211b9e4 fix: 修改注释中出现的Id,改成小写的id 2026-04-02 13:37:59 +08:00
old burden aec9dc0152 fix: 对接资产库和优化布局 2026-04-02 12:42:46 +08:00
yys 089ffca4f5 fix: 素材组ListAssetGroups方法去掉请求中的project属性 2026-04-02 10:41:52 +08:00
yys 8faff7562d Merge branch 'seedance' of https://gitea.06zk.com/best_yunwei/ai_images into seedance 2026-04-02 10:23:37 +08:00
yys 2d026feba3 feat: 素材组/素材管理 2026-04-02 10:23:27 +08:00
old burden f075e6b2b1 fix: 区域优化 2026-04-02 09:51:41 +08:00
old burden d2c1fff13f fix: keydown优化 2026-04-01 17:44:14 +08:00
old burden a347873a4e Merge remote-tracking branch 'origin/seedance' into seedance 2026-04-01 17:41:35 +08:00
yys 3ffd78f928 fix: 删除前段无效文件 2026-04-01 17:41:16 +08:00
old burden 2a91a33825 fix: 对接素材管理 2026-04-01 17:40:40 +08:00
yys b13e51a6ca feat: 添加gitignore目录 log 2026-04-01 17:15:08 +08:00
yys 0f6695207e feat: 添加gitignore目录 log 2026-04-01 14:32:52 +08:00
yys afca767422 feat: 火山素材组、素材管理 2026-04-01 14:31:27 +08:00
yys c04c1e572b Merge remote-tracking branch 'origin/seedance' into seedance 2026-04-01 13:28:25 +08:00
old burden ca2989902c fix: seedance 2026-04-01 13:20:15 +08:00
old burden ff18ca4f51 fix: 支持视频音频的参考图模式,资产管理模块初步上线 2026-04-01 10:31:01 +08:00
yys f9c12bb1e0 feat: 火山素材组、素材管理 2026-03-31 18:32:10 +08:00
yys a04bd71afc fix: 修改路径错误 2026-03-31 17:56:37 +08:00
old burden 4cb3f4eb88 fix: 交互优化 2026-03-31 14:40:53 +08:00
old burden 1edf9601c0 fix: 仿照即梦出一版 2026-03-31 13:49:48 +08:00
old burden adcfea0d3c fix: 多语言设置 2026-03-30 17:21:56 +08:00
old burden 33a10f55a5 fix: 个人中心隐藏有奖邀请和邀请消费记录,多语言选择改为disable 2026-03-30 17:21:38 +08:00
old burden 55588504a1 fix: 侧边栏优化,内敛图调大 2026-03-30 13:50:38 +08:00
old burden d69d16e196 fix: bug修改 result 写成 URL 2026-03-30 13:25:53 +08:00
old burden 5ba6c9f746 fix: 页面优化,工具栏显示 2026-03-30 13:05:05 +08:00
old burden 0e94beb477 fix: bug修改字段缺失 2026-03-30 12:30:05 +08:00
old burden 5ae8614b1d fix: 参数选择改到参数文件 2026-03-30 12:06:46 +08:00
old burden 4bba35a426 fix: 新页面 2026-03-30 11:07:36 +08:00
old burden abb8d279c4 fix: 新需求 对接火山seedance 2026-03-27 15:28:19 +08:00
old burden 8959b775a4 fix: 新需求 对接火山seedance 2026-03-27 15:27:28 +08:00
old burden aed52b0437 fix: 新需求 忽略dist 2026-03-27 10:55:10 +08:00
old burden 809b47215c fix: 新需求 2026-03-27 10:51:34 +08:00
320 changed files with 30188 additions and 4057 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
.DS_Store
node_modules/
unpackage
/dist/
/manager/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/test/unit/coverage/
/test/e2e/reports/
selenium-debug.log
nodejs/public/uploads
*.zip
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
package-lock.json
.history
/unpackage/resources/
/unpackage/dist/dev
/unpackage/dist/build/.automator
/unpackage/dist/build/app-plus
*.lock
/.logs/*
/.cursor/*

View File

@ -5,6 +5,8 @@
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" /> <excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/python" />
<excludePattern pattern="*.zip" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@ -5,4 +5,7 @@ VUE_APP_TITLE = 管理系统
ENV = 'production' ENV = 'production'
# 若依管理系统/生产环境 # 若依管理系统/生产环境
VUE_APP_BASE_API = 'https://admin-api.undressing.name' # VUE_APP_BASE_API = 'http://111.230.37.169:10009'
# VUE_APP_BASE_API = 'http://101.96.201.225:8011'
VUE_APP_BASE_API = 'http://47.86.170.114:8011'

View File

@ -8,5 +8,4 @@ NODE_ENV = production
# 测试环境配置 # 测试环境配置
ENV = 'staging' ENV = 'staging'
# 若依管理系统/测试环境 VUE_APP_BASE_API = 'http://101.96.201.225:8011'
VUE_APP_BASE_API = '/api'

1
admin-ui/.gitignore vendored
View File

@ -9,6 +9,7 @@ yarn-error.log*
tests/**/coverage/ tests/**/coverage/
tests/e2e/reports tests/e2e/reports
selenium-debug.log selenium-debug.log
*.zip
# Editor directories and files # Editor directories and files
.idea .idea

View File

@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询团队(部门)对应火山引擎配置列表
export function listConfig(query) {
return request({
url: '/ai/config/list',
method: 'get',
params: query
})
}
// 查询团队(部门)对应火山引擎配置详细
export function getConfig(id) {
return request({
url: '/ai/config/' + id,
method: 'get'
})
}
// 新增团队(部门)对应火山引擎配置
export function addConfig(data) {
return request({
url: '/ai/config',
method: 'post',
data: data
})
}
// 修改团队(部门)对应火山引擎配置
export function updateConfig(data) {
return request({
url: '/ai/config',
method: 'put',
data: data
})
}
// 删除团队(部门)对应火山引擎配置
export function delConfig(id) {
return request({
url: '/ai/config/' + id,
method: 'delete'
})
}

View File

@ -0,0 +1,10 @@
import request from '@/utils/request'
// 查询团队每日消耗统计
export function listData(query) {
return request({
url: '/ai/data/list',
method: 'get',
params: query
})
}

View File

@ -0,0 +1,54 @@
import request from '@/utils/request'
export function listDept(query) {
return request({
url: '/ai/dept/list',
method: 'get',
params: query
})
}
export function listDeptExcludeChild(deptId) {
return request({
url: '/ai/dept/list/exclude/' + deptId,
method: 'get'
})
}
export function getDept(deptId) {
return request({
url: '/ai/dept/' + deptId,
method: 'get'
})
}
export function addDept(data) {
return request({
url: '/ai/dept',
method: 'post',
data: data
})
}
export function updateDept(data) {
return request({
url: '/ai/dept',
method: 'put',
data: data
})
}
export function delDept(deptId) {
return request({
url: '/ai/dept/' + deptId,
method: 'delete'
})
}
export function chargeRefundDept(data) {
return request({
url: '/ai/dept/charge-refund',
method: 'post',
data: data
})
}

View File

@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询团队(部门)充值退款订单列表
export function listGroupChargeOrder(query) {
return request({
url: '/ai/group/chargeorder/list',
method: 'get',
params: query
})
}
// 查询团队(部门)充值退款订单详细
export function getGroupChargeOrder(id) {
return request({
url: '/ai/group/chargeorder/' + id,
method: 'get'
})
}
// 新增团队(部门)充值退款订单
export function addGroupChargeOrder(data) {
return request({
url: '/ai/group/chargeorder',
method: 'post',
data: data
})
}
// 修改团队(部门)充值退款订单
export function updateGroupChargeOrder(data) {
return request({
url: '/ai/group/chargeorder',
method: 'put',
data: data
})
}
// 删除团队(部门)充值退款订单
export function delGroupChargeOrder(id) {
return request({
url: '/ai/group/chargeorder/' + id,
method: 'delete'
})
}

View File

@ -1,63 +1,27 @@
import request from '@/utils/request' import request from '@/utils/request'
// 查询订单管理列表 // 查询团队(部门)充值退款订单列表
export function listOrder(query) { export function listOrder(query) {
return request({ return request({
url: '/ai/order/list', url: '/ai/video/order/',
method: 'get', method: 'get',
params: query params: query
}) })
} }
// 查询订单管理详细 // 查询当前用户所属部门的视频订单列表
export function listGroupOrder(query) {
return request({
url: '/ai/video/order/group/',
method: 'get',
params: query
})
}
// 查询团队(部门)充值退款订单详细(只读)
export function getOrder(id) { export function getOrder(id) {
return request({ return request({
url: '/ai/order/' + id, url: '/ai/video/order/' + id,
method: 'get'
})
}
// 新增订单管理
export function addOrder(data) {
return request({
url: '/ai/order',
method: 'post',
data: data
})
}
// 修改订单管理
export function updateOrder(data) {
return request({
url: '/ai/order',
method: 'put',
data: data
})
}
export function changeIsTop(id, isTop) {
const data = {
id,
isTop
}
return request({
url: '/ai/order',
method: 'put',
data: data
})
}
// 删除订单管理
export function delOrder(id) {
return request({
url: '/ai/order/' + id,
method: 'delete'
})
}
export function downloadVideo(id) {
return request({
url: '/api/ai/' + id,
method: 'get' method: 'get'
}) })
} }

View File

@ -0,0 +1,10 @@
import request from '@/utils/request'
// 查询团队(部门)余额变动列表
export function listRecord(query) {
return request({
url: '/ai/record/list',
method: 'get',
params: query
})
}

View File

@ -56,18 +56,18 @@ export function changeUserStatus(id, status) {
}) })
} }
export function changeBalance(id, balance) { // 修改余额接口已关闭(与后端 AiUserController.changeBalance 一致停用)
const data = { // export function changeBalance(id, balance) {
id, // const data = {
balance // id,
} // balance
return request({ // }
url: '/ai/user/changeBalance', // return request({
method: 'put', // url: '/ai/user/changeBalance',
data: data // method: 'put',
}) // data: data
} // })
// }
// 用户状态修改 // 用户状态修改
export function updatePassword(id, newPassword) { export function updatePassword(id, newPassword) {
@ -81,3 +81,30 @@ export function updatePassword(id, newPassword) {
data: data data: data
}) })
} }
/** 分配归属部门,deptId 可省略或 null 表示清空 */
export function assignAiUserDept(data) {
return request({
url: '/ai/user/dept',
method: 'put',
data
})
}
/** 部门积分下放至用户 */
export function issueDeptScore(data) {
return request({
url: '/ai/user/dept-score/issue',
method: 'put',
data
})
}
/** 用户积分回收至部门 */
export function reclaimDeptScore(data) {
return request({
url: '/ai/user/dept-score/reclaim',
method: 'put',
data
})
}

View File

@ -0,0 +1,96 @@
import request from '@/utils/request'
export function getSubteamOverview() {
return request({ url: '/subteam/overview', method: 'get' })
}
export function listSubteamUser(query) {
return request({ url: '/subteam/user/list', method: 'get', params: query })
}
export function getSubteamUser(id) {
const url = id != null && id !== '' ? '/subteam/user/' + id : '/subteam/user'
return request({ url, method: 'get' })
}
export function addSubteamUser(data) {
return request({ url: '/subteam/user', method: 'post', data })
}
export function updateSubteamUser(data) {
return request({ url: '/subteam/user', method: 'put', data })
}
export function delSubteamUser(userIds) {
return request({ url: '/subteam/user/' + userIds, method: 'delete' })
}
export function resetSubteamUserPwd(data) {
return request({ url: '/subteam/user/resetPwd', method: 'put', data })
}
export function changeSubteamUserStatus(data) {
return request({ url: '/subteam/user/changeStatus', method: 'put', data })
}
/** 团队后台:部门积分下放至本团队用户 */
export function issueSubteamUserDeptScore(data) {
return request({ url: '/subteam/user/dept-score/issue', method: 'put', data })
}
/** 团队后台:本团队用户积分回收至部门 */
export function reclaimSubteamUserDeptScore(data) {
return request({ url: '/subteam/user/dept-score/reclaim', method: 'put', data })
}
/** 团队后台视频/生成订单列表:GET /subteam/video-order/list */
export function listSubteamVideoOrder(query) {
return request({ url: '/subteam/video-order/list', method: 'get', params: query })
}
/**
* 导出:POST /subteam/video-order/export(与列表相同查询参数 + 创建时间区间)。
* 页面一般使用 this.download('subteam/video-order/export', addDateRange(...), filename),勿用 GET。
*/
export const subteamVideoOrderExportUrl = 'subteam/video-order/export'
export function getSubteamVideoOrder(id) {
return request({ url: '/subteam/video-order/' + id, method: 'get' })
}
export function listSubteamChargeOrder(query) {
return request({ url: '/subteam/charge-order/list', method: 'get', params: query })
}
/** 导出:POST /subteam/charge-order/export(与列表相同查询参数 + 创建时间区间) */
export const subteamChargeOrderExportUrl = 'subteam/charge-order/export'
export function getSubteamChargeOrder(id) {
return request({ url: '/subteam/charge-order/' + id, method: 'get' })
}
export function listSubteamUserBalance(query) {
return request({ url: '/subteam/user-balance/list', method: 'get', params: query })
}
/** 导出:POST /subteam/user-balance/export(与列表相同查询参数 + beginTime/endTime) */
export const subteamUserBalanceExportUrl = 'subteam/user-balance/export'
export function getSubteamUserBalance(id) {
return request({ url: '/subteam/user-balance/' + id, method: 'get' })
}
/** 列表:GET /subteam/consume-stat/list(参数与管理端 ai/data 一致:startDate、endDate、pageNum、pageSize) */
export function listSubteamConsumeStat(query) {
return request({ url: '/subteam/consume-stat/list', method: 'get', params: query })
}
/** 导出:POST /subteam/consume-stat/export */
export const subteamConsumeStatExportUrl = 'subteam/consume-stat/export'
export function listSubteamGroupBalance(query) {
return request({ url: '/subteam/group-balance/list', method: 'get', params: query })
}
/** 导出:POST /subteam/group-balance/export(与列表相同查询参数 + 创建时间区间) */
export const subteamGroupBalanceExportUrl = 'subteam/group-balance/export'

View File

@ -25,6 +25,23 @@ export function getDept(deptId) {
}) })
} }
// 部门火山引擎配置(解密后明文)
export function getDeptArk(deptId) {
return request({
url: '/system/dept/ark/' + deptId,
method: 'get'
})
}
// 保存部门火山引擎配置
export function updateDeptArk(data) {
return request({
url: '/system/dept/ark',
method: 'put',
data
})
}
// 新增部门 // 新增部门
export function addDept(data) { export function addDept(data) {
return request({ return request({
@ -50,3 +67,21 @@ export function delDept(deptId) {
method: 'delete' method: 'delete'
}) })
} }
// 部门充值/退款
export function chargeRefundDept(data) {
return request({
url: '/system/dept/charge-refund',
method: 'post',
data: data
})
}
// 部门积分更正
export function editScore(data) {
return request({
url: '/system/dept/edit-score',
method: 'post',
data: data
})
}

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="top-right-btn" :style="style"> <div class="top-right-btn" :style="style" v-if="search || showRefresh || columns">
<el-row> <el-row>
<el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search"> <el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
<el-button size="mini" circle icon="el-icon-search" @click="toggleSearch()" /> <el-button size="mini" circle icon="el-icon-search" @click="toggleSearch()" />
</el-tooltip> </el-tooltip>
<el-tooltip class="item" effect="dark" content="刷新" placement="top"> <el-tooltip class="item" effect="dark" content="刷新" placement="top" v-if="showRefresh">
<el-button size="mini" circle icon="el-icon-refresh" @click="refresh()" /> <el-button size="mini" circle icon="el-icon-refresh" @click="refresh()" />
</el-tooltip> </el-tooltip>
<el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="columns"> <el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="columns">
@ -59,10 +59,15 @@ export default {
columns: { columns: {
type: Array type: Array
}, },
/* 是否显示检索图标 */ /* 是否显示检索图标(隐藏搜索 / 显示搜索) */
search: { search: {
type: Boolean, type: Boolean,
default: true default: false
},
/* 是否显示刷新 */
showRefresh: {
type: Boolean,
default: false
}, },
/* 显隐列类型(transfer穿梭框、checkbox复选框) */ /* 显隐列类型(transfer穿梭框、checkbox复选框) */
showColumnsType: { showColumnsType: {

View File

@ -0,0 +1,31 @@
import { listDept as listAiDept } from "@/api/ai/dept"
/**
* 与「统计数据」页(ai/data)一致:仅启用状态,且 ancestors 深度为 2 的团队,供筛选下拉使用。
* 系统团队页(system/dept)请覆写 secondLevelDeptListRequest,改为调用 /system/dept/list。
*/
export default {
data() {
return {
secondLevelDeptOptions: []
}
},
methods: {
/** @param {object} params 如 { status: '0' } */
secondLevelDeptListRequest(params) {
return listAiDept(params)
},
loadSecondLevelDeptOptions() {
this.secondLevelDeptListRequest({ status: "0" }).then(response => {
const allDeptList = response.data || []
this.secondLevelDeptOptions = allDeptList.filter(item => this.isSecondLevelDept(item))
})
},
isSecondLevelDept(dept) {
if (!dept || !dept.ancestors) {
return false
}
return String(dept.ancestors).split(",").filter(Boolean).length === 2
}
}
}

View File

@ -0,0 +1,125 @@
/**
* 西式千分位(en-US):金额最多两位小数,积分为整数。
* 用于输入/粘贴后即时格式化展示。
*/
export const WESTERN_MONEY_MAX = 10000000
export const WESTERN_INT_MAX = 100000000
const MONEY_MAX = WESTERN_MONEY_MAX
const INT_MAX = WESTERN_INT_MAX
/** 只保留数字与至多一个小数点,小数位最多 2 位 */
export function sanitizeMoneyDigits(raw) {
let s = String(raw == null ? '' : raw).replace(/,/g, '').replace(/[^\d.]/g, '')
const firstDot = s.indexOf('.')
if (firstDot !== -1) {
s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, '').slice(0, 2)
}
return s
}
/** 将规范化后的金额字符串格式化为 9,999,999.99 形式(输入过程中允许末尾为小数点) */
export function formatMoneyWesternDisplay(sanitized) {
if (!sanitized) return ''
const s = sanitized
const dot = s.indexOf('.')
const intRaw = dot === -1 ? s : s.slice(0, dot)
const decRaw = dot === -1 ? '' : s.slice(dot + 1).slice(0, 2)
const intNum = intRaw === '' ? 0 : parseInt(intRaw, 10)
const intFmt = (isNaN(intNum) ? 0 : intNum).toLocaleString('en-US')
if (dot === -1) return intFmt
if (s.endsWith('.') && decRaw === '') return intFmt + '.'
return intFmt + '.' + decRaw
}
/** 规范化字符串 -> 金额数值(undefined 表示空) */
export function moneyStringToNumber(sanitized) {
if (sanitized === '' || sanitized === '.' || sanitized == null) return undefined
const n = parseFloat(sanitized)
if (isNaN(n)) return undefined
const rounded = Math.round(n * 100) / 100
if (rounded > MONEY_MAX) return MONEY_MAX
if (rounded < 0) return 0
return rounded
}
/** 失焦时金额固定两位小数展示 */
export function formatMoneyWesternFinal(n) {
if (n === undefined || n === null || isNaN(n)) return ''
const v = Math.min(MONEY_MAX, Math.max(0, Math.round(Number(n) * 100) / 100))
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** 积分:仅数字,并限制范围 */
export function sanitizeIntDigits(raw) {
let d = String(raw == null ? '' : raw).replace(/,/g, '').replace(/\D/g, '')
if (d === '') return ''
let n = parseInt(d, 10)
if (isNaN(n)) return ''
n = Math.min(INT_MAX, Math.max(0, n))
return String(n)
}
export function formatIntWesternDisplay(digits) {
if (!digits) return ''
const n = parseInt(digits, 10)
if (isNaN(n)) return ''
return n.toLocaleString('en-US')
}
export function intStringToNumber(digits) {
if (!digits) return undefined
const n = parseInt(digits, 10)
if (isNaN(n)) return undefined
return n
}
/** 有符号积分:供可输入负数的整数字段,范围 [-INT_MAX, INT_MAX] */
export function sanitizeSignedIntDigits(raw) {
let t = String(raw == null ? '' : raw).replace(/,/g, '')
t = t.trim()
if (t === '' || t === '+') {
return ''
}
if (t === '-') {
return '-'
}
const neg = t.startsWith('-')
let rest = neg ? t.slice(1) : t
rest = rest.replace(/\D/g, '')
if (rest === '') {
return neg ? '-' : ''
}
let n = parseInt(rest, 10)
if (isNaN(n)) {
return neg ? '-' : ''
}
n = Math.min(INT_MAX, Math.max(0, n))
return (neg ? '-' : '') + String(n)
}
export function formatSignedIntDisplay(s) {
if (s == null || s === '') {
return ''
}
if (s === '-') {
return '-'
}
const n = parseInt(String(s).replace(/,/g, ''), 10)
if (isNaN(n)) {
return ''
}
return n.toLocaleString('en-US')
}
export function signedIntStringToNumber(s) {
if (s == null || s === '' || s === '-') {
return undefined
}
const n = parseInt(String(s).replace(/,/g, ''), 10)
if (isNaN(n)) {
return undefined
}
return n
}

View File

@ -16,6 +16,22 @@
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
/> />
</el-form-item> </el-form-item>
<el-form-item label="团队" prop="deptId">
<el-select
v-model="queryParams.deptId"
placeholder="全部团队(可选)"
clearable
filterable
style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item>
<el-form-item label="操作类型" prop="type"> <el-form-item label="操作类型" prop="type">
<el-select <el-select
v-model="queryParams.type" v-model="queryParams.type"
@ -46,11 +62,6 @@
<el-form-item> <el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button <el-button
type="warning" type="warning"
plain plain
@ -59,23 +70,32 @@
@click="handleExport" @click="handleExport"
v-hasPermi="['ai:balanceChangeRecord:export']" v-hasPermi="['ai:balanceChangeRecord:export']"
>导出</el-button> >导出</el-button>
</el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-form-item>
</el-row> </el-form>
<el-table v-loading="loading" :data="recordList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="recordList">
<el-table-column type="selection" width="55" align="center" /> <el-table-column label="主键ID" align="center" prop="id" width="80"/>
<el-table-column label="主键ID" align="center" prop="id" /> <el-table-column label="用户ID" align="center" prop="uuid" width="100" show-overflow-tooltip />
<el-table-column label="用户ID" align="center" prop="uuid" /> <el-table-column label="用户昵称" align="center" prop="nickname" width="150" show-overflow-tooltip />
<el-table-column label="操作类型" align="center" prop="type" :formatter="formatType" /> <el-table-column label="团队名称" align="center" prop="deptName" width="120" show-overflow-tooltip />
<el-table-column label="关联订单号" align="center" prop="orderNo" width="180" show-overflow-tooltip />
<el-table-column label="操作类型" align="center" width="100" prop="type" :formatter="formatType" />
<el-table-column <el-table-column
label="变更金额" label="变更积分"
align="center" align="center"
prop="changeAmount" prop="changeAmount"
:formatter="formatChangeAmount" :formatter="formatChangeAmount"
width="120"
/> />
<el-table-column label="变更后金额" align="center" prop="resultAmount" /> <el-table-column
<el-table-column label="操作时间" align="center" prop="createTime" /> label="变更后积分"
align="center"
prop="resultAmount"
:formatter="formatResultAmount"
width="120"
/>
<el-table-column label="操作时间" align="center" prop="createTime" width="150" />
<el-table-column label="备注" align="center" prop="remark" /> <el-table-column label="备注" align="center" prop="remark" />
</el-table> </el-table>
@ -93,11 +113,11 @@
<el-form-item label="用户ID" prop="userId"> <el-form-item label="用户ID" prop="userId">
<el-input v-model="form.userId" placeholder="请输入用户ID" /> <el-input v-model="form.userId" placeholder="请输入用户ID" />
</el-form-item> </el-form-item>
<el-form-item label="变更金额" prop="changeAmount"> <el-form-item label="变更积分" prop="changeAmount">
<el-input v-model="form.changeAmount" placeholder="请输入变更金额" /> <el-input v-model="form.changeAmount" placeholder="请输入变更积分" />
</el-form-item> </el-form-item>
<el-form-item label="变更后金额" prop="resultAmount"> <el-form-item label="变更后积分" prop="resultAmount">
<el-input v-model="form.resultAmount" placeholder="请输入变更后金额" /> <el-input v-model="form.resultAmount" placeholder="请输入变更后积分" />
</el-form-item> </el-form-item>
<el-form-item label="操作类型" prop="type"> <el-form-item label="操作类型" prop="type">
<el-select <el-select
@ -137,27 +157,31 @@ import {
addRecord, addRecord,
updateRecord updateRecord
} from "@/api/ai/balanceChangeRecord"; } from "@/api/ai/balanceChangeRecord";
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter";
export default { export default {
name: "Record", name: "Record",
mixins: [secondLevelDeptFilter],
data() { data() {
return { return {
// 日期范围 // 日期范围
dateRange: [], dateRange: [],
// 操作类型映射关系 // 操作类型映射关系
typeMap: { typeMap: {
0: "充值", // 0: "充值",
1: "返佣", // 1: "返佣",
2: "充值赠送", // 2: "充值赠送",
3: "体验金赠送", // 3: "体验金赠送",
4: "体验金回收", // 4: "体验金回收",
5: "一键脱衣", // 5: "一键换衣",
6: "图生图2", // 6: "图生图2",
7: "一键换脸", // 7: "一键换脸",
8: "快捷生图", // 8: "快捷生图",
9: "快捷生视频", 9: "快捷生视频",
10: "退款", // 10: "退款",
11: "系统操作" 11: "系统操作",
12: "团队下发",
13: "团队收回"
}, },
// 遮罩层 // 遮罩层
loading: true, loading: true,
@ -182,7 +206,9 @@ export default {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 10,
type: null, type: null,
userId: null uuid: null,
userId: null,
deptId: null
}, },
// 表单参数 // 表单参数
form: {}, form: {},
@ -195,17 +221,43 @@ export default {
}; };
}, },
created() { created() {
this.loadSecondLevelDeptOptions();
this.getList(); this.getList();
}, },
methods: { methods: {
formatChangeAmount(row, column) { /** 西式千分位(en-US),积分为整数 */
const value = row.changeAmount; formatPointsWestern(value) {
// 检查值是否为数字且大于 0 if (value === null || value === undefined || value === "") {
if (typeof value === "number" && value > 0) { return "";
return `+${value}`;
} }
// 对于小于等于 0 的值,直接返回原始值(或根据需要格式化) const n = Number(value);
return value; if (Number.isNaN(n)) {
return String(value);
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
},
formatChangeAmount(row) {
const value = row.changeAmount;
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (Number.isNaN(n)) {
return String(value);
}
const formatted = Math.abs(n).toLocaleString("en-US", {
maximumFractionDigits: 0
});
if (n > 0) {
return `+${formatted}`;
}
if (n < 0) {
return `-${formatted}`;
}
return "0";
},
formatResultAmount(row) {
return this.formatPointsWestern(row.resultAmount);
}, },
handleTypeChange(val) { handleTypeChange(val) {
this.queryParams.type = val; this.queryParams.type = val;
@ -318,9 +370,7 @@ export default {
handleExport() { handleExport() {
this.download( this.download(
"ai/balance-change-record/export", "ai/balance-change-record/export",
{ this.addDateRange({ ...this.queryParams }, this.dateRange),
...this.queryParams
},
`record_${new Date().getTime()}.xlsx` `record_${new Date().getTime()}.xlsx`
); );
} }

View File

@ -0,0 +1,270 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="团队ID" prop="deptId">
<el-input
v-model="queryParams.deptId"
placeholder="请输入团队ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['ai:config:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['ai:config:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['ai:config:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['ai:config:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="configList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="ID" align="center" prop="id" />
<el-table-column label="团队ID" align="center" prop="deptId" />
<el-table-column label="视频模型列表JSON(label+value)" align="center" prop="modelParm" />
<el-table-column label="Byte project,加密" align="center" prop="project" />
<el-table-column label="Byte API Key,加密" align="center" prop="byteApiKey" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['ai:config:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ai:config:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改团队(部门)对应火山引擎配置对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="团队ID" prop="deptId">
<el-input v-model="form.deptId" placeholder="请输入团队ID" />
</el-form-item>
<el-form-item label="视频模型列表JSON(label+value)" prop="modelParm">
<el-input v-model="form.modelParm" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="Byte project,加密" prop="project">
<el-input v-model="form.project" placeholder="请输入Byte project,加密" />
</el-form-item>
<el-form-item label="Byte API Key,加密" prop="byteApiKey">
<el-input v-model="form.byteApiKey" placeholder="请输入Byte API Key,加密" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listConfig, getConfig, delConfig, addConfig, updateConfig } from "@/api/ai/config"
export default {
name: "Config",
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 团队(部门)对应火山引擎配置表格数据
configList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
deptId: null,
modelParm: null,
project: null,
byteApiKey: null,
},
// 表单参数
form: {},
// 表单校验
rules: {
deptId: [
{ required: true, message: "团队ID不能为空", trigger: "blur" }
],
}
}
},
created() {
this.getList()
},
methods: {
/** 查询团队(部门)对应火山引擎配置列表 */
getList() {
this.loading = true
listConfig(this.queryParams).then(response => {
this.configList = response.rows
this.total = response.total
this.loading = false
})
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
id: null,
deptId: null,
modelParm: null,
project: null,
byteApiKey: null,
createBy: null,
createTime: null,
updateBy: null,
updateTime: null
}
this.resetForm("form")
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加团队(团队)对应火山引擎配置"
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const id = row.id || this.ids
getConfig(id).then(response => {
this.form = response.data
this.open = true
this.title = "修改团队(团队)对应火山引擎配置"
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateConfig(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addConfig(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const ids = row.id || this.ids
this.$modal.confirm('是否确认删除团队(团队)对应火山引擎配置编号为"' + ids + '"的数据项?').then(function() {
return delConfig(ids)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('ai/config/export', {
...this.queryParams
}, `config_${new Date().getTime()}.xlsx`)
}
}
}
</script>

View File

@ -0,0 +1,283 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="96px">
<el-form-item label="统计日期">
<el-date-picker
v-model="dateRange"
type="daterange"
unlink-panels
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 280px"
/>
</el-form-item>
<el-form-item label="团队" prop="deptId">
<el-select
v-model="queryParams.deptId"
placeholder="全部团队(可选)"
clearable
filterable
style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['ai:data:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-form-item>
</el-form>
<p class="summary-line">
<template v-if="summaryState.mode === 'loading'">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span><span class="summary-muted">加载中…</span>
</template>
<template v-else-if="summaryState.mode === 'empty'">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span><span class="summary-muted">—</span>
</template>
<template v-else>
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span>
<span class="summary-label">总计</span>
<span class="summary-num">{{ summaryState.totalStr }}</span>
<span class="summary-label">条记录,实际充值积分</span>
<span class="summary-num">{{ summaryState.recharge }}</span>
<span class="summary-unit">分</span>
<span class="summary-label">,消耗积分</span>
<span class="summary-num">{{ summaryState.score }}</span>
<span class="summary-unit">分</span>
<span class="summary-label">,实际订单数量(成功)</span>
<span class="summary-num">{{ summaryState.orders }}</span>
<span class="summary-unit">单</span>
<span class="summary-label">,三方消耗tokens数量</span>
<span class="summary-num">{{ summaryState.tokens }}</span>
<span class="summary-unit">。</span>
</template>
</p>
<el-table v-loading="loading" :data="dataList">
<el-table-column label="日期" align="center" prop="dateKey" min-width="110" />
<el-table-column label="团队ID" align="center" prop="deptId" width="100" />
<el-table-column label="团队名称" align="center" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column
label="实际充值积分(充值-退款)"
align="center"
prop="rechargeScore"
min-width="200"
:formatter="formatWesternNumber"
/>
<el-table-column label="消耗积分" align="center" prop="score" width="120" :formatter="formatWesternNumber" />
<el-table-column
label="实际订单数量(成功)"
align="center"
prop="orderCount"
width="160"
:formatter="formatWesternNumber"
/>
<el-table-column
label="三方消耗tokens数量"
align="center"
prop="useTokens"
width="170"
:formatter="formatWesternNumber"
/>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listData } from "@/api/ai/data"
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
export default {
name: "TeamConsumeData",
mixins: [secondLevelDeptFilter],
data() {
return {
loading: false,
showSearch: true,
total: 0,
dataList: [],
aggregate: null,
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 10,
startDate: null,
endDate: null,
deptId: null
}
}
},
created() {
this.loadSecondLevelDeptOptions()
this.initDefaultDateRange()
this.syncDateRangeToQuery()
this.getList()
},
computed: {
summaryState() {
if (this.loading) {
return { mode: "loading" }
}
const a = this.aggregate
if (!a) {
return { mode: "empty" }
}
const fmt = (v) => this.formatWesternNumberValue(v)
const n = a.totalRows != null ? Number(a.totalRows) : 0
const totalStr = Number.isNaN(n) ? "0" : n.toLocaleString("en-US")
return {
mode: "ok",
totalStr,
recharge: fmt(a.sumRechargeScore),
score: fmt(a.sumScore),
orders: fmt(a.sumOrderCount),
tokens: fmt(a.sumUseTokens)
}
}
},
methods: {
formatWesternNumberValue(cellValue) {
if (cellValue === null || cellValue === undefined || cellValue === "") {
return (0).toLocaleString("en-US")
}
const n = Number(cellValue)
if (Number.isNaN(n)) {
return (0).toLocaleString("en-US")
}
return n.toLocaleString("en-US")
},
/** null/空 显示 0;数字按 en-US 千分位(如 99,999,999) */
formatWesternNumber(row, column, cellValue) {
return this.formatWesternNumberValue(cellValue)
},
buildLastMonthDateRange() {
const end = new Date()
const start = new Date(end.getTime())
start.setMonth(start.getMonth() - 1)
return [this.formatYmd(start), this.formatYmd(end)]
},
formatYmd(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
},
initDefaultDateRange() {
this.dateRange = this.buildLastMonthDateRange()
},
syncDateRangeToQuery() {
if (Array.isArray(this.dateRange) && this.dateRange.length === 2) {
this.queryParams.startDate = this.dateRange[0]
this.queryParams.endDate = this.dateRange[1]
} else {
this.queryParams.startDate = null
this.queryParams.endDate = null
}
},
getList() {
this.syncDateRangeToQuery()
this.loading = true
listData(this.queryParams)
.then(response => {
this.dataList = response.rows || []
this.total = response.total != null ? response.total : 0
this.aggregate =
response.param && response.param.aggregate != null ? response.param.aggregate : null
})
.finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.initDefaultDateRange()
this.resetForm("queryForm")
this.queryParams.pageNum = 1
this.syncDateRangeToQuery()
this.getList()
},
handleExport() {
this.syncDateRangeToQuery()
this.download(
"ai/data/export",
{
startDate: this.queryParams.startDate,
endDate: this.queryParams.endDate,
deptId: this.queryParams.deptId
},
`team_consume_${new Date().getTime()}.xlsx`
)
}
}
}
</script>
<style scoped>
.summary-line {
color: #606266;
font-size: 13px;
margin: 0 0 12px 0;
padding-left: 8px;
line-height: 1.75;
}
.summary-prefix {
color: #909399;
font-weight: 600;
margin-right: 2px;
}
.summary-muted {
color: #c0c4cc;
}
.summary-label {
color: #606266;
}
.summary-unit {
color: #909399;
font-size: 12px;
margin-left: 1px;
margin-right: 1px;
}
.summary-num {
color: #409eff;
font-weight: 600;
font-variant-numeric: tabular-nums;
margin: 0 2px;
}
</style>

View File

@ -0,0 +1,473 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="团队" prop="deptId">
<el-select
v-model="queryParams.deptId"
placeholder="全部团队(可选)"
clearable
filterable
style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="团队状态" clearable>
<el-option
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['ai:dept:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-sort"
size="mini"
@click="toggleExpandAll"
>展开/折叠</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table
v-if="refreshTable"
v-loading="loading"
:data="deptList"
row-key="deptId"
:default-expand-all="isExpandAll"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="deptName" label="团队名称" width="260"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="200">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['ai:dept:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-plus"
@click="handleAdd(scope.row)"
v-hasPermi="['ai:dept:add']"
>新增</el-button>
<el-button
v-if="scope.row.parentId != 0"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ai:dept:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="title" :visible.sync="open" width="760px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
<el-col :span="24" v-if="form.parentId !== 0">
<el-form-item label="上级团队" prop="parentId">
<treeselect v-model="form.parentId" :options="deptOptions" :normalizer="normalizer" placeholder="选择上级团队" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="团队名称" prop="deptName">
<el-input v-model="form.deptName" placeholder="请输入团队名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示排序" prop="orderNum">
<el-input-number v-model="form.orderNum" controls-position="right" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="负责人" prop="leader">
<el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="团队状态">
<el-radio-group v-model="form.status">
<el-radio
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.value"
>{{dict.label}}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isSecondLevelCompanyForm">
<el-col :span="24">
<el-form-item label="ApiKey">
<el-input
v-model="form.byteApiKey"
type="password"
show-password
placeholder="选填"
maxlength="255"
/>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isSecondLevelCompanyForm">
<el-col :span="24">
<el-form-item label="视频模型">
<div class="model-parm-block">
<div
v-for="(row, idx) in modelParamRows"
:key="'mp-' + idx"
class="model-parm-row"
>
<el-input
v-model="row.label"
placeholder="显示名称(如 Seedance 2.0)"
class="model-parm-input-label"
/>
<el-input
v-model="row.value"
placeholder="Endpoint / 模型 ID(ep-…)"
class="model-parm-input-value"
/>
<el-button
type="text"
icon="el-icon-delete"
:disabled="modelParamRows.length <= 1"
@click="removeModelParamRow(idx)"
/>
</div>
<el-button type="text" icon="el-icon-plus" @click="addModelParamRow">添加模型</el-button>
<p class="model-parm-hint">
保存为 JSON 写入库表 model_parm;门户「视频生成」按用户所属二级团队读取。
留空或未配置时,使用配置文件 portal.video.models。
</p>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.model-parm-block {
width: 100%;
}
.model-parm-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.model-parm-input-label {
width: 38%;
margin-right: 8px;
}
.model-parm-input-value {
flex: 1;
margin-right: 8px;
}
.model-parm-hint {
margin: 8px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
</style>
<script>
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/ai/dept"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
export default {
name: "AiDept",
dicts: ['sys_normal_disable'],
components: { Treeselect },
mixins: [secondLevelDeptFilter],
data() {
return {
loading: true,
showSearch: true,
deptList: [],
deptOptions: [],
title: "",
open: false,
isExpandAll: true,
refreshTable: true,
queryParams: {
deptId: null,
status: undefined
},
form: {},
modelParamRows: [{ label: '', value: '' }],
rules: {
parentId: [
{ required: true, message: "上级团队不能为空", trigger: "blur" }
],
deptName: [
{ required: true, message: "团队名称不能为空", trigger: "blur" }
],
orderNum: [
{ required: true, message: "显示排序不能为空", trigger: "blur" }
],
email: [
{
type: "email",
message: "请输入正确的邮箱地址",
trigger: ["blur", "change"]
}
],
phone: [
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur"
}
]
}
}
},
created() {
this.loadSecondLevelDeptOptions()
this.getList()
},
computed: {
isSecondLevelCompanyForm() {
if (this.form.ancestors === "0,100") {
return true
}
const pid = this.form.parentId
if (pid !== undefined && pid !== null && Number(pid) === 100) {
return true
}
return false
}
},
methods: {
getList() {
this.loading = true
const { deptId, ...deptListQuery } = this.queryParams
listDept(deptListQuery).then(response => {
let raw = response.data || []
if (deptId != null && deptId !== "") {
const rootId = Number(deptId)
raw = raw.filter(d => this.deptRowInSubtree(d, rootId))
}
this.deptList = this.handleTree(raw, "deptId")
this.loading = false
})
},
deptRowInSubtree(d, rootId) {
if (d.deptId != null && Number(d.deptId) === rootId) {
return true
}
if (!d.ancestors) {
return false
}
return String(d.ancestors)
.split(",")
.filter(Boolean)
.map(s => Number(s))
.includes(rootId)
},
normalizer(node) {
if (node.children && !node.children.length) {
delete node.children
}
return {
id: node.deptId,
label: node.deptName,
children: node.children
}
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
deptId: undefined,
parentId: undefined,
ancestors: undefined,
deptName: undefined,
orderNum: undefined,
leader: undefined,
phone: undefined,
email: undefined,
byteApiKey: undefined,
modelParm: undefined,
status: "0"
}
this.modelParamRows = [{ label: '', value: '' }]
this.resetForm("form")
},
handleQuery() {
this.getList()
},
resetQuery() {
this.resetForm("queryForm")
this.handleQuery()
},
handleAdd(row) {
this.reset()
if (row != undefined) {
this.form.parentId = row.deptId
}
this.open = true
this.title = "添加团队"
listDept().then(response => {
this.deptOptions = this.handleTree(response.data, "deptId")
})
},
toggleExpandAll() {
this.refreshTable = false
this.isExpandAll = !this.isExpandAll
this.$nextTick(() => {
this.refreshTable = true
})
},
syncModelRowsFromForm() {
this.modelParamRows = [{ label: '', value: '' }]
const raw = this.form.modelParm
if (!raw || String(raw).trim() === '') {
return
}
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr) && arr.length) {
this.modelParamRows = arr.map(x => ({
label: (x && x.label) ? String(x.label) : '',
value: (x && x.value) ? String(x.value) : ''
}))
}
} catch (e) {
this.modelParamRows = [{ label: '', value: '' }]
}
},
buildModelParmPayload() {
if (!this.isSecondLevelCompanyForm) {
return
}
const rows = (this.modelParamRows || []).filter(r => r.label && r.value)
this.form.modelParm = rows.length ? JSON.stringify(rows) : ''
},
addModelParamRow() {
this.modelParamRows.push({ label: '', value: '' })
},
removeModelParamRow(idx) {
if (this.modelParamRows.length <= 1) {
return
}
this.modelParamRows.splice(idx, 1)
},
handleUpdate(row) {
this.reset()
getDept(row.deptId).then(response => {
this.form = response.data
this.syncModelRowsFromForm()
this.open = true
this.title = "修改团队"
listDeptExcludeChild(row.deptId).then(response => {
this.deptOptions = this.handleTree(response.data, "deptId")
if (this.deptOptions.length == 0) {
const noResultsOptions = { deptId: this.form.parentId, deptName: this.form.parentName, children: [] }
this.deptOptions.push(noResultsOptions)
}
})
})
},
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
this.buildModelParmPayload()
if (this.form.deptId != undefined) {
updateDept(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addDept(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
}
})
},
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {
return delDept(row.deptId)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
}
}
}
</script>

View File

@ -0,0 +1,321 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="70px">
<el-form-item label="订单号" prop="orderNum">
<el-input
v-model="queryParams.orderNum"
placeholder="支持模糊搜索"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="团队" prop="deptId">
<el-select
v-model="queryParams.deptId"
placeholder="全部团队(可选)"
clearable
filterable
style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="orderType">
<el-select v-model="queryParams.orderType" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in orderTypeQueryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['ai:groupChargeOrder:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="groupChargeOrderList">
<el-table-column label="订单号" align="center" prop="orderNum" width="200" show-overflow-tooltip />
<el-table-column label="团队名称" align="center" prop="deptName" width="150" show-overflow-tooltip />
<el-table-column label="类型" align="center" width="120">
<template slot-scope="scope">
<span>{{ orderTypeLabel(scope.row.orderType) }}</span>
</template>
</el-table-column>
<el-table-column label="金额(元)" align="center" prop="money" width="120">
<template slot-scope="scope">
<span>{{ formatTableMoney(scope.row.money) }}</span>
</template>
</el-table-column>
<el-table-column label="积分" align="center" prop="amount" width="120">
<template slot-scope="scope">
<span>{{ formatTableAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="100">
<template slot-scope="scope">
<span>{{ statusLabel(scope.row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" show-overflow-tooltip />
<el-table-column label="创建时间" align="center" prop="createTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="团队ID" prop="deptId">
<el-input v-model="form.deptId" placeholder="请输入团队ID" />
</el-form-item>
<el-form-item label="类型" prop="orderType">
<el-select v-model="form.orderType" placeholder="请选择" style="width: 100%">
<el-option v-for="item in orderTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="金额(元)" prop="money">
<el-input v-model="form.money" placeholder="可选,对账用金额" />
</el-form-item>
<el-form-item label="积分" prop="amount">
<el-input v-model="form.amount" placeholder="充值/退款填正数;手动修改可填负数表示扣减" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option label="进行中" :value="0" />
<el-option label="已完成" :value="1" />
<el-option label="失败" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="订单编号" prop="orderNum" v-if="form.id != null">
<el-input v-model="form.orderNum" disabled />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="对账说明、打款信息等" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listGroupChargeOrder,
getGroupChargeOrder,
delGroupChargeOrder,
addGroupChargeOrder,
updateGroupChargeOrder
} from "@/api/ai/groupChargeOrder"
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
export default {
name: "GroupChargeOrder",
mixins: [secondLevelDeptFilter],
data() {
return {
loading: true,
showSearch: true,
dateRange: [],
total: 0,
groupChargeOrderList: [],
title: "",
open: false,
queryParams: {
pageNum: 1,
pageSize: 10,
orderNum: null,
deptId: null,
orderType: null
},
form: {},
rules: {
deptId: [{ required: true, message: "请输入团队ID", trigger: "blur" }],
orderType: [{ required: true, message: "请选择类型", trigger: "change" }],
amount: [{ required: true, message: "请填写积分", trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }]
},
orderTypeQueryOptions: [
{ label: "充值", value: 0 },
{ label: "退款", value: 1 }
],
orderTypeOptions: [
{ label: "充值", value: 0 },
{ label: "退款", value: 1 },
{ label: "手动修改", value: 2 }
],
statusOptions: [
{ label: "进行中", value: 0 },
{ label: "已完成", value: 1 },
{ label: "失败", value: 2 }
]
}
},
created() {
this.loadSecondLevelDeptOptions()
this.getList()
},
methods: {
/** 列表金额:西式千分位,最多两位小数 */
formatTableMoney(value) {
if (value === null || value === undefined || value === "") {
return "—"
}
const n = Number(value)
if (Number.isNaN(n)) {
return "—"
}
return n.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: 0 })
},
/** 列表积分:西式千分位整数 */
formatTableAmount(value) {
if (value === null || value === undefined || value === "") {
return "—"
}
const n = Number(value)
if (Number.isNaN(n)) {
return "—"
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 })
},
orderTypeLabel(orderType) {
const n = orderType !== undefined && orderType !== null ? Number(orderType) : null
const hit = this.orderTypeOptions.find(o => o.value === n)
return hit ? hit.label : "—"
},
statusLabel(status) {
const n = status !== undefined && status !== null ? Number(status) : null
const hit = this.statusOptions.find(o => o.value === n)
return hit ? hit.label : "—"
},
getList() {
this.loading = true
listGroupChargeOrder(this.addDateRange(this.queryParams, this.dateRange, "CreateTime")).then(response => {
this.groupChargeOrderList = response.rows
this.total = response.total
this.loading = false
})
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
id: null,
deptId: undefined,
orderType: 0,
money: undefined,
amount: undefined,
remark: undefined,
status: 1,
orderNum: undefined
}
this.resetForm("form")
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm("queryForm")
this.handleQuery()
},
handleAdd() {
this.reset()
this.open = true
this.title = "新增团队充值退款订单"
},
handleUpdate(row) {
this.reset()
const id = row && row.id
if (!id) {
return
}
getGroupChargeOrder(id).then(response => {
this.form = response.data
this.open = true
this.title = "修改团队充值退款订单"
})
},
submitForm() {
this.$refs["form"].validate(valid => {
if (!valid) {
return
}
const payload = { ...this.form }
if (payload.id == null) {
addGroupChargeOrder(payload).then(() => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
} else {
updateGroupChargeOrder(payload).then(() => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
}
})
},
handleDelete(row) {
const id = row && row.id
if (!id) {
return
}
this.$modal.confirm("是否确认删除所选团队充值退款订单?").then(() => {
return delGroupChargeOrder(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
handleExport() {
this.download("ai/group/chargeorder/export", {
...this.queryParams
}, `group_charge_order_${new Date().getTime()}.xlsx`)
}
}
}
</script>

View File

@ -29,11 +29,6 @@
<el-form-item> <el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button <el-button
type="primary" type="primary"
plain plain
@ -42,8 +37,6 @@
@click="handleAdd" @click="handleAdd"
v-hasPermi="['ai:manager:add']" v-hasPermi="['ai:manager:add']"
>新增</el-button> >新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button <el-button
type="success" type="success"
plain plain
@ -53,19 +46,6 @@
@click="handleUpdate" @click="handleUpdate"
v-hasPermi="['ai:manager:edit']" v-hasPermi="['ai:manager:edit']"
>修改</el-button> >修改</el-button>
</el-col>
<!-- <el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['ai:manager:remove']"
>删除</el-button>
</el-col>-->
<el-col :span="1.5">
<el-button <el-button
type="warning" type="warning"
plain plain
@ -74,8 +54,11 @@
@click="handleExport" @click="handleExport"
v-hasPermi="['ai:manager:export']" v-hasPermi="['ai:manager:export']"
>导出</el-button> >导出</el-button>
</el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</el-row> </el-row>
<el-table v-loading="loading" :data="managerList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="managerList" @selection-change="handleSelectionChange">
@ -93,9 +76,9 @@
></el-switch> ></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<!--
<el-table-column label="提示词" align="center" prop="prompt"> <el-table-column label="提示词" align="center" prop="prompt">
<template slot-scope="scope"> <template slot-scope="scope">
<!-- 核心:给 popover 加 ref,触发元素绑定 v-popover -->
<el-popover <el-popover
ref="promptPopover" ref="promptPopover"
placement="top" placement="top"
@ -103,7 +86,6 @@
trigger="hover" trigger="hover"
:content="scope.row.prompt || '无提示词'" :content="scope.row.prompt || '无提示词'"
></el-popover> ></el-popover>
<!-- 触发元素:绑定 v-popover 到 popover 的 ref -->
<div <div
class="text-ellipsis-two-lines" class="text-ellipsis-two-lines"
v-popover:promptPopover v-popover:promptPopover
@ -111,6 +93,7 @@
>{{ scope.row.prompt}}</div> >{{ scope.row.prompt}}</div>
</template> </template>
</el-table-column> </el-table-column>
-->
<el-table-column label="类型" align="center" prop="type"> <el-table-column label="类型" align="center" prop="type">
<template slot-scope="scope"> <template slot-scope="scope">
<dict-tag :options="dict.type.ai_function_type" :value="scope.row.type" /> <dict-tag :options="dict.type.ai_function_type" :value="scope.row.type" />
@ -119,13 +102,13 @@
<el-table-column label="备注" align="center" prop="remark" /> <el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button <!--<el-button
size="mini" size="mini"
type="text" type="text"
icon="el-icon-edit" icon="el-icon-edit"
@click="tagManager(scope.row)" @click="tagManager(scope.row)"
v-hasPermi="['ai:manager:edit']" v-hasPermi="['ai:manager:edit']"
>标签管理</el-button> >标签管理</el-button>-->
<el-button <el-button
size="mini" size="mini"
type="text" type="text"

View File

@ -1,65 +1,52 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<el-form <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="88px">
:model="queryParams" <el-form-item label="订单号" prop="orderNum">
ref="queryForm"
size="small"
:inline="true"
v-show="showSearch"
label-width="68px"
>
<el-form-item label="订单编号" prop="orderNum">
<el-input <el-input
v-model="queryParams.orderNum" v-model="queryParams.orderNum"
placeholder="请输入订单编号" placeholder="请输入订单号"
clearable clearable
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
/> />
</el-form-item> </el-form-item>
<el-form-item label="用户ID" prop="uuid"> <el-form-item label="三方订单号" prop="thirdPartyOrderNum">
<el-input <el-input
v-model="queryParams.uuid" v-model="queryParams.thirdPartyOrderNum"
placeholder="请输入用户ID" placeholder="支持模糊搜索"
style="width: 240px"
clearable clearable
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
/> />
</el-form-item> </el-form-item>
<el-form-item label="操作类型" prop="type"> <el-form-item label="团队" prop="deptId">
<el-select v-model="queryParams.type" placeholder="请选择操作类型" clearable>
<el-option
v-for="dict in dict.type.ai_function_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="是否置顶" prop="isTop">
<el-select v-model="queryParams.isTop" placeholder="请选择" clearable>
<el-option
v-for="dict in dict.type.sys_yes_no"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select <el-select
v-model="queryParams.status" v-model="queryParams.deptId"
placeholder="请选择状态" placeholder="全部团队(可选)"
filterable
clearable clearable
style="width: 200px; margin-bottom: 16px;" filterable
style="width: 220px"
> >
<el-option <el-option
v-for="(label, value) in statusMap" v-for="item in secondLevelDeptOptions"
:key="value" :key="item.deptId"
:label="label" :label="item.deptName"
:value="Number(value)" :value="item.deptId"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="用户名称" prop="userName">
<el-input
v-model="queryParams.userName"
placeholder="支持模糊搜索"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 207px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间"> <el-form-item label="创建时间">
<el-date-picker <el-date-picker
v-model="dateRange" v-model="dateRange"
@ -69,95 +56,67 @@
range-separator="-" range-separator="-"
start-placeholder="开始日期" start-placeholder="开始日期"
end-placeholder="结束日期" end-placeholder="结束日期"
></el-date-picker> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
&nbsp;&nbsp;&nbsp;&nbsp;
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button <el-button
type="warning" type="warning"
plain plain
icon="el-icon-download" icon="el-icon-download"
size="mini" size="mini"
@click="handleExport" @click="handleExport"
v-hasPermi="['ai:order:export']" v-hasPermi="['/ai/video/order/']"
>导出</el-button> >导出</el-button>
</el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-form-item>
</el-row> </el-form>
<el-table v-loading="loading" :data="orderList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="orderList">
<el-table-column type="selection" width="55" align="center" /> <el-table-column label="订单号" align="center" prop="orderNum" width="200" show-overflow-tooltip />
<el-table-column label="主键ID" align="center" prop="id" /> <el-table-column label="三方订单号" align="center" prop="thirdPartyOrderNum" width="200" show-overflow-tooltip />
<!-- <el-table-column label="备注" align="center" prop="remark" /> --> <el-table-column label="团队名称" align="center" prop="deptName" width="140" show-overflow-tooltip />
<el-table-column label="订单编号" align="center" prop="orderNum" /> <el-table-column label="用户名称" align="center" prop="userName" width="120" show-overflow-tooltip />
<el-table-column label="用户ID" align="center" prop="uuid" /> <el-table-column label="消耗积分" align="center" prop="amount" width="120">
<el-table-column label="操作类型" align="center" prop="type">
<template slot-scope="scope"> <template slot-scope="scope">
<dict-tag :options="dict.type.ai_function_type" :value="scope.row.type" /> <span>{{ formatThousands(scope.row.amount) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="金额" align="center" prop="amount" /> <el-table-column label="使用tokens" align="center" prop="totalUsage" width="120">
<el-table-column label="生成结果" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<!-- 判断是否为链接 --> <span>{{ formatThousands(scope.row.totalUsage) }}</span>
<template v-if="isUrl(scope.row.result)"> </template>
<!-- 图片链接 --> </el-table-column>
<img <el-table-column label="状态" align="center" width="100">
v-if="isImage(scope.row.result)" <template slot-scope="scope">
:src="scope.row.result" <span>{{ statusLabel(scope.row.status) }}</span>
class="preview-img" </template>
@click="viewImage(scope.row.result)" </el-table-column>
alt="预览图片" <el-table-column label="结果预览" min-width="185">
/> <template slot-scope="scope">
<!-- 视频链接 --> <div v-if="extractVideoUrl(scope.row.result)">
<video <video
v-else-if="isVideo(scope.row.result)" :src="extractVideoUrl(scope.row.result)"
:src="scope.row.result" controls
class="preview-video" preload="metadata"
@click="playVideo(scope.row.result)" style="width: 180px; max-height: 100px; border-radius: 4px;"
/> />
</template> </div>
<!-- 非链接内容 --> <span v-else>-</span>
<span v-else @click="handleOtherEvent(scope.row)">{{ scope.row.result }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="文字描述" align="center" prop="text"> <el-table-column label="创建时间" align="center" prop="createTime" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<!-- 核心:给 popover 加 ref,触发元素绑定 v-popover --> <span>{{ parseTime(scope.row.createTime) }}</span>
<el-popover
ref="promptPopover"
placement="top"
width="400"
trigger="hover"
:content="scope.row.text || '无提示词'"
></el-popover>
<!-- 触发元素:绑定 v-popover 到 popover 的 ref -->
<div
class="text-ellipsis-two-lines"
v-popover:promptPopover
style="cursor: pointer;"
>{{ scope.row.text}}</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="置顶" align="center" key="status"> <el-table-column label="操作" width="90" fixed="right">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch <el-button type="text" size="mini" @click="handleDetail(scope.row)">详情</el-button>
v-model="scope.row.isTop"
active-value="Y"
inactive-value="N"
@change="handleIsTopChange(scope.row)"
></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" />
<el-table-column label="状态" align="center" prop="status" :formatter="formatStatus" />
<el-table-column label="来源" align="center" prop="source" />
</el-table> </el-table>
<pagination <pagination
@ -168,366 +127,170 @@
@pagination="getList" @pagination="getList"
/> />
<!-- 添加或修改订单管理对话框 --> <el-dialog title="订单详情" :visible.sync="detailVisible" width="720px" append-to-body>
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body> <el-descriptions :column="1" border size="small" :labelStyle="{ width: '150px', minWidth: '150px' }">
<el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-descriptions-item v-for="(value, key) in detailData" :key="key" :label="key">
<el-form-item label="订单编号" prop="orderNum"> <pre v-if="isObjectValue(value)" style="margin: 0; white-space: pre-wrap;">{{ formatJson(value) }}</pre>
<el-input v-model="form.orderNum" placeholder="请输入订单编号" /> <span v-else>{{ value === null || value === undefined || value === "" ? "-" : value }}</span>
</el-form-item> </el-descriptions-item>
<el-form-item label="用户ID" prop="userId"> </el-descriptions>
<el-input v-model="form.userId" placeholder="请输入用户ID" /> <span slot="footer">
</el-form-item> <el-button size="mini" @click="detailVisible = false">关闭</el-button>
<el-form-item label="状态" prop="status"> </span>
<el-select
v-model="form.status"
placeholder="请选择状态"
filterable
clearable
style="width: 200px;"
>
<el-option
v-for="(label, value) in statusMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input v-model="form.amount" placeholder="请输入金额" />
</el-form-item>
<el-form-item label="生成结果" prop="result">
<el-input v-model="form.result" placeholder="请输入生成结果" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 图片预览弹窗 -->
<el-dialog :visible.sync="imageDialogVisible" title="图片预览" width="60%">
<img :src="previewImageUrl" class="dialog-img" alt="大图预览" />
</el-dialog>
<!-- 新增视频弹窗 -->
<el-dialog :visible.sync="videoDialogVisible" title="视频播放" width="60%">
<video :src="previewVideoUrl" controls class="dialog-video"></video>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script> <script>
import { import { listOrder } from "@/api/ai/order"
listOrder, import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
getOrder,
delOrder,
addOrder,
downloadVideo,
changeIsTop,
updateOrder
} from "@/api/ai/order";
export default { export default {
name: "Order", name: "TeamChargeOrder",
dicts: ["ai_function_type", "sys_yes_no"], mixins: [secondLevelDeptFilter],
data() { data() {
return { return {
imageDialogVisible: false,
videoDialogVisible: false,
previewVideoUrl: "",
previewImageUrl: "",
dateRange: [],
statusMap: {
0: "进行中",
1: "已完成",
2: "已失败"
},
// 遮罩层
loading: true, loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true, showSearch: true,
// 总条数
total: 0, total: 0,
// 订单管理表格数据
orderList: [], orderList: [],
// 弹出层标题 detailVisible: false,
title: "", detailData: {},
// 是否显示弹出层 orderTypeOptions: [
open: false, { label: "充值", value: 0 },
// 查询参数 { label: "退款", value: 1 }
],
statusOptions: [
{ label: "进行中", value: 0 },
{ label: "完成", value: 1 },
{ label: "失败", value: 2 }
],
dateRange: [],
queryParams: { queryParams: {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 10,
orderNum: null, orderNum: null,
userId: null, thirdPartyOrderNum: null,
type: null, userName: null,
amount: null, deptId: null,
result: null, orderType: null,
status: null status: null
},
// 表单参数
form: {},
// 表单校验
rules: {
delFlag: [
{ required: true, message: "删除标志不能为空", trigger: "blur" }
]
} }
}; }
}, },
created() { created() {
this.getList(); this.loadSecondLevelDeptOptions()
this.initDefaultDateRange()
this.getList()
}, },
methods: { methods: {
handleIsTopChange(row) { /** 千位分节(en-US),整数;空为 — */
let text = row.isTop === "Y" ? "置顶" : "取消置顶"; formatThousands(value) {
this.$modal if (value === null || value === undefined || value === "") {
.confirm('确认要"' + text + '""' + row.id + '"作品吗?') return "—"
.then(function() {
return changeIsTop(row.id, row.isTop);
})
.then(() => {
this.$modal.msgSuccess(text + "成功");
})
.catch(function() {
row.isTop = row.isTop === "Y" ? "N" : "Y";
});
},
// 判断是否为URL
isUrl(str) {
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
return urlRegex.test(str);
},
// 判断是否为图片链接
isImage(url) {
console.log(url)
const imageExtensions = /\.(jpg|jpeg|png|gif|bmp|webp)$/i;
var b = imageExtensions.test(url);
console.log(b)
return imageExtensions.test(url);
},
// 判断是否为视频链接
isVideo(url) {
const videoExtensions = /\.(mp4|avi|mov|wmv|flv|webm)$/i;
return videoExtensions.test(url);
},
// 查看图片
viewImage(url) {
this.previewImageUrl = url;
this.imageDialogVisible = true;
},
// 播放视频
playVideo(url) {
this.previewVideoUrl = url;
this.videoDialogVisible = true;
},
// 非链接内容点击事件
async handleOtherEvent(row) {
// 防止重复点击
if (row.isDownloading) return;
const originalResult = row.result;
try {
// 标记为下载中状态
row.isDownloading = true;
// 保存原始内容
// 修改显示内容为“下载中...”
row.result = "下载中...";
// 执行下载操作
const resultId = originalResult; // 假设result存储的是ID
var res = await downloadVideo(resultId);
if (res.status === "succeeded") {
row.result = res.content.video_url;
} else {
row.result = "视频生成中"
}
} catch (error) {
console.error('下载失败:', error);
// 失败后恢复原始内容
row.result = originalResult || row.result.replace("下载中...", "");
this.$message.error('下载失败,请重试');
} finally {
// 重置下载状态
row.isDownloading = false;
} }
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 })
}, },
initDefaultDateRange() {
formatStatus(row) { this.dateRange = this.buildLastMonthDateRange()
// 如果有匹配的类型则显示对应文字,否则显示"未知"
return this.statusMap[row.status] || "未知";
}, },
/** 查询订单管理列表 */ buildLastMonthDateRange() {
getList() { const end = new Date()
this.loading = true; const start = new Date(end.getTime())
listOrder(this.addDateRange(this.queryParams, this.dateRange)).then( start.setMonth(start.getMonth() - 1)
response => { return [this.formatYmd(start), this.formatYmd(end)]
this.orderList = response.rows; },
this.total = response.total; formatYmd(d) {
this.loading = false; const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
},
orderTypeLabel(orderType) {
const n = orderType !== undefined && orderType !== null ? Number(orderType) : null
const hit = this.orderTypeOptions.find(o => o.value === n)
return hit ? hit.label : "—"
},
statusLabel(status) {
const n = status !== undefined && status !== null ? Number(status) : null
const hit = this.statusOptions.find(o => o.value === n)
return hit ? hit.label : "—"
},
extractVideoUrl(result) {
if (!result) return ""
if (typeof result === "string") {
const value = result.trim()
if (!value) return ""
if (/^https?:\/\/|^\/\//.test(value)) return value
try {
const parsed = JSON.parse(value)
return this.extractVideoUrl(parsed)
} catch (e) {
return ""
} }
); }
}, if (Array.isArray(result)) {
// 取消按钮 for (let i = 0; i < result.length; i += 1) {
cancel() { const url = this.extractVideoUrl(result[i])
this.open = false; if (url) return url
this.reset(); }
}, return ""
// 表单重置 }
reset() { if (typeof result === "object") {
this.form = { const directKeys = ["videoUrl", "url", "resultUrl", "playUrl", "output", "fileUrl"]
id: null, for (let i = 0; i < directKeys.length; i += 1) {
delFlag: null, const key = directKeys[i]
createBy: null, if (result[key]) {
createTime: null, const url = this.extractVideoUrl(result[key])
updateBy: null, if (url) return url
updateTime: null,
remark: null,
orderNum: null,
userId: null,
type: null,
amount: null,
result: null,
status: null
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.dateRange = [];
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id);
this.single = selection.length !== 1;
this.multiple = !selection.length;
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.open = true;
this.title = "添加订单管理";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const id = row.id || this.ids;
getOrder(id).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改订单管理";
});
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateOrder(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addOrder(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
} }
} }
}); }
return ""
}, },
/** 删除按钮操作 */ handleDetail(row) {
handleDelete(row) { this.detailData = row || {}
const ids = row.id || this.ids; this.detailVisible = true
this.$modal },
.confirm('是否确认删除订单管理编号为"' + ids + '"的数据项?') isObjectValue(value) {
.then(function() { return value !== null && typeof value === "object"
return delOrder(ids); },
}) formatJson(value) {
.then(() => { try {
this.getList(); return JSON.stringify(value, null, 2)
this.$modal.msgSuccess("删除成功"); } catch (e) {
}) return String(value)
.catch(() => {}); }
},
getList() {
this.loading = true
listOrder(this.addDateRange(this.queryParams, this.dateRange, "CreateTime")).then(response => {
this.orderList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.initDefaultDateRange()
this.resetForm("queryForm")
this.handleQuery()
}, },
/** 导出按钮操作 */
handleExport() { handleExport() {
const q = { ...this.queryParams }
this.download( this.download(
"ai/order/export", "ai/video/order/export",
{ this.addDateRange(q, this.dateRange, "CreateTime"),
...this.queryParams `team_orders_${new Date().getTime()}.xlsx`
}, )
`order_${new Date().getTime()}.xlsx`
);
} }
} }
}; }
</script> </script>
<style>
.dialog-video {
width: 100%;
height: auto;
}
.preview-img {
width: 80px;
height: 60px;
object-fit: cover;
cursor: pointer;
border-radius: 4px;
}
.preview-video {
width: 120px;
height: 80px;
border-radius: 4px;
}
.dialog-img {
width: 100%;
height: auto;
}
.text-ellipsis-two-lines {
/* 必须设置宽度(继承表格列宽,也可手动指定) */
width: 100%;
/* 核心属性:多行文本溢出隐藏 */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2; /* 显示2行 */
line-clamp: 2; /* 标准属性,兼容现代浏览器 */
overflow: hidden;
/* 可选:调整行高,优化显示 */
line-height: 1.5;
max-height: 3em; /* 行高1.5 × 2行 = 3em,防止高度溢出 */
/* 可选:文本对齐 */
text-align: center;
/* 解决scoped样式穿透(如果需要) */
/deep/ & {
box-sizing: border-box;
padding: 0 4px;
}
}
</style>

View File

@ -0,0 +1,199 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="90px">
<el-form-item label="关联订单号" prop="relationOrderNo">
<el-input
v-model="queryParams.relationOrderNo"
placeholder="支持模糊查询,充值/退款订单号"
clearable
style="width: 240px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="团队" prop="deptId">
<el-select
v-model="queryParams.deptId"
placeholder="全部团队(可选)"
clearable
filterable
style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="queryParams.type" clearable placeholder="全部类型" style="width: 112px">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['ai:record:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="recordList">
<el-table-column label="ID" align="center" prop="id" width="100"/>
<el-table-column label="关联订单号" align="center" prop="relationOrderNo" width="200" show-overflow-tooltip />
<el-table-column label="团队名称" align="center" prop="deptName" width="150" show-overflow-tooltip />
<el-table-column label="操作类型" align="center" width="120">
<template slot-scope="scope">
<span>{{ typeLabel(scope.row.type) }}</span>
</template>
</el-table-column>
<el-table-column
label="变更积分"
align="center"
prop="changeAmount"
width="120"
:formatter="formatChangeAmount"
/>
<el-table-column
label="变更后积分"
align="center"
prop="resultAmount"
width="120"
:formatter="formatResultAmount"
/>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listRecord } from "@/api/ai/record"
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
export default {
name: "Record",
mixins: [secondLevelDeptFilter],
data() {
return {
loading: true,
showSearch: true,
dateRange: [],
total: 0,
recordList: [],
typeOptions: [
{ label: "充值", value: 0 },
{ label: "退款", value: 1 },
{ label: "下发", value: 2 },
{ label: "回收", value: 3 },
{ label: "手动修改", value: 4 }
],
queryParams: {
pageNum: 1,
pageSize: 10,
relationOrderNo: null,
deptId: null,
type: null
}
}
},
created() {
this.loadSecondLevelDeptOptions()
this.getList()
},
methods: {
/** 西式千分位,积分为整数 */
formatPointsWestern(value) {
if (value === null || value === undefined || value === "") {
return ""
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 })
},
formatChangeAmount(row) {
const value = row.changeAmount
if (value === null || value === undefined || value === "") {
return ""
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
const formatted = Math.abs(n).toLocaleString("en-US", { maximumFractionDigits: 0 })
if (n > 0) {
return `+${formatted}`
}
if (n < 0) {
return `-${formatted}`
}
return "0"
},
formatResultAmount(row) {
return this.formatPointsWestern(row.resultAmount)
},
typeLabel(type) {
const n = type !== undefined && type !== null ? Number(type) : null
const hit = this.typeOptions.find(item => item.value === n)
return hit ? hit.label : "-"
},
getList() {
this.loading = true
listRecord(this.addDateRange(this.queryParams, this.dateRange, "CreateTime")).then(response => {
this.recordList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm("queryForm")
this.handleQuery()
},
handleExport() {
this.download(
'ai/record/export',
this.addDateRange({ ...this.queryParams }, this.dateRange, 'CreateTime'),
`record_${new Date().getTime()}.xlsx`
)
}
}
}
</script>

View File

@ -8,40 +8,26 @@
v-show="showSearch" v-show="showSearch"
label-width="68px" label-width="68px"
> >
<el-form-item label="主键ID" prop="id">
<el-input
v-model="queryParams.id"
placeholder="请输入主键ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="用户ID" prop="userId"> <el-form-item label="用户ID" prop="userId">
<el-input <el-input
v-model="queryParams.userId" v-model="queryParams.userId"
placeholder="请输入用户ID" placeholder="请输入用户ID"
clearable clearable
style="width: 150px"
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
/> />
</el-form-item> </el-form-item>
<el-form-item label="用户账号" prop="username"> <el-form-item label="账号" prop="username">
<el-input <el-input
v-model="queryParams.username" v-model="queryParams.username"
placeholder="请输入用户账号" placeholder="请输入账号"
clearable clearable
style="width: 150px"
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
/> />
</el-form-item> </el-form-item>
<el-form-item label="上级ID" prop="superisuperiorUuidorId"> <el-form-item label="状态" prop="status" label-width="40px">
<el-input <el-select v-model="queryParams.status" placeholder="用户状态" clearable style="width: 80px">
v-model="queryParams.superiorUuid"
placeholder="请输入上级ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="用户状态" clearable style="width: 240px">
<el-option <el-option
v-for="dict in dict.type.sys_normal_disable" v-for="dict in dict.type.sys_normal_disable"
:key="dict.value" :key="dict.value"
@ -50,14 +36,36 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="归属团队" prop="deptId">
<treeselect
v-model="queryParams.deptId"
:options="deptOptions"
:normalizer="deptNormalizer"
placeholder="全部"
clearable
style="width: 220px"
/>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item> <el-button
</el-form> type="primary"
plain
<el-row :gutter="10" class="mb8"> icon="el-icon-plus"
<el-col :span="1.5"> size="mini"
@click="handleAdd"
v-hasPermi="['ai:user:add']"
>新增</el-button>
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete()"
v-hasPermi="['ai:user:remove']"
>删除</el-button>
<el-button <el-button
type="warning" type="warning"
plain plain
@ -66,18 +74,22 @@
@click="handleExport" @click="handleExport"
v-hasPermi="['ai:user:export']" v-hasPermi="['ai:user:export']"
>导出</el-button> >导出</el-button>
</el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-form-item>
</el-row> </el-form>
<el-table v-loading="loading" :data="userList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="userList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" /> <el-table-column type="selection" width="50" align="center" />
<el-table-column label="主键ID" align="center" prop="id" width="90" /> <el-table-column label="主键ID" align="center" prop="id" width="60" />
<el-table-column label="用户ID" align="center" prop="userId" /> <el-table-column label="用户ID" align="center" prop="userId" />
<el-table-column label="用户账号" align="center" prop="username" /> <el-table-column label="账号" align="center" prop="username" />
<!--
<el-table-column label="上级ID" align="center" prop="superiorUuid" /> <el-table-column label="上级ID" align="center" prop="superiorUuid" />
<el-table-column label="上级账号" align="center" prop="superiorName" /> <el-table-column label="上级账号" align="center" prop="superiorName" />
-->
<el-table-column label="用户昵称" align="center" prop="nickname" /> <el-table-column label="用户昵称" align="center" prop="nickname" />
<el-table-column label="归属团队" align="center" prop="deptName" width="120" show-overflow-tooltip />
<!--
<el-table-column label="邮箱" align="center" prop="email" /> <el-table-column label="邮箱" align="center" prop="email" />
<el-table-column label="性别" align="center" prop="gender"> <el-table-column label="性别" align="center" prop="gender">
<template slot-scope="scope"> <template slot-scope="scope">
@ -85,7 +97,8 @@
<el-tag v-else type="danger">女</el-tag> <el-tag v-else type="danger">女</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" align="center" key="status"> -->
<el-table-column label="状态" align="center" key="status" width="60">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch <el-switch
v-model="scope.row.status" v-model="scope.row.status"
@ -95,16 +108,28 @@
></el-switch> ></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<!--
<el-table-column label="邀请码" align="center" prop="invitationCode" /> <el-table-column label="邀请码" align="center" prop="invitationCode" />
-->
<el-table-column label="登录时间" align="center" prop="loginTime" width="150"> <el-table-column label="登录时间" align="center" prop="loginTime" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.loginTime, '{y}-{m}-{d}') }}</span> <span>{{ parseTime(scope.row.loginTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="余额" align="center" prop="balance" /> <el-table-column label="剩余积分" align="center" prop="balance" width="120">
<el-table-column label="source" align="center" prop="source" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ formatThousands(scope.row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="460">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-office-building"
@click="handleOpenAssignDept(scope.row)"
v-hasPermi="['ai:user:edit']"
>分配团队</el-button>
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@ -112,6 +137,21 @@
@click="updatePassword(scope.row)" @click="updatePassword(scope.row)"
v-hasPermi="['ai:user:edit']" v-hasPermi="['ai:user:edit']"
>修改密码</el-button> >修改密码</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-download"
@click="openDeptScoreDialog(scope.row, 'issue')"
v-hasPermi="['ai:user:deptScoreIssue']"
>下放积分</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-upload2"
@click="openDeptScoreDialog(scope.row, 'reclaim')"
v-hasPermi="['ai:user:deptScoreReclaim']"
>回收积分</el-button>
<!-- 修改余额功能已关闭
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@ -119,6 +159,21 @@
@click="updateBalance(scope.row)" @click="updateBalance(scope.row)"
v-hasPermi="['ai:user:remove']" v-hasPermi="['ai:user:remove']"
>修改余额</el-button> >修改余额</el-button>
-->
<el-button
size="mini"
type="text"
icon="el-icon-edit-outline"
@click="handleUpdate(scope.row)"
v-hasPermi="['ai:user:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ai:user:remove']"
>删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -132,8 +187,24 @@
/> />
<!-- 添加或修改ai-用户信息对话框 --> <!-- 添加或修改ai-用户信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body> <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form ref="form" :model="form" :rules="rules" label-width="88px">
<el-form-item label="用户账号" prop="username">
<el-input
v-model="form.username"
placeholder="门户登录账号,必填"
:disabled="form.id != null"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
:placeholder="form.id != null ? '不填表示不修改密码' : '请输入登录密码'"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="用户昵称" prop="nickname"> <el-form-item label="用户昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入用户昵称" /> <el-input v-model="form.nickname" placeholder="请输入用户昵称" />
</el-form-item> </el-form-item>
@ -155,13 +226,16 @@
placeholder="请选择生日" placeholder="请选择生日"
></el-date-picker> ></el-date-picker>
</el-form-item> </el-form-item>
<el-form-item label="余额" prop="balance"> <el-form-item v-if="form.id != null" label="剩余积分" prop="balance">
<el-input v-model="form.balance" placeholder="请输入余额" /> <el-input v-model="form.balance" disabled placeholder="-" />
<div style="color: #909399; font-size: 12px; line-height: 1.5; margin-top: 4px">
剩余积分仅可通过「下放积分」「回收积分」调整
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item>
<div slot="footer" class="dialog-footer"> <div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button> <el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button> <el-button @click="cancel">取 消</el-button>
@ -182,7 +256,69 @@
</div> </div>
</el-dialog> </el-dialog>
<!-- 修改余额对话框 --> <!-- 分配团队 -->
<el-dialog title="分配归属团队" :visible.sync="assignDeptOpen" width="480px" append-to-body @close="cancelAssignDept">
<el-form label-width="88px">
<el-form-item label="归属团队">
<treeselect
v-model="assignForm.deptId"
:options="deptOptions"
:normalizer="deptNormalizer"
placeholder="不选则不归属任何团队"
clearable
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAssignDept">确 定</el-button>
<el-button @click="cancelAssignDept">取 消</el-button>
</div>
</el-dialog>
<!-- 部门积分下放 / 回收 -->
<el-dialog
:title="deptScoreMode === 'issue' ? '下放积分' : '回收积分'"
:visible.sync="deptScoreOpen"
width="480px"
append-to-body
@close="cancelDeptScore"
>
<el-form ref="deptScoreFormRef" :model="deptScoreForm" :rules="deptScoreRules" label-width="88px">
<el-form-item label="所属团队">
<span>{{ deptScoreForm.deptName || "—" }}</span>
</el-form-item>
<el-form-item label="用户账号">
<span>{{ deptScoreForm.username }}</span>
</el-form-item>
<el-form-item label="积分数量" prop="amount">
<el-input-number
v-model="deptScoreForm.amount"
:min="1"
:max="100000000"
:precision="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="deptScoreForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填,最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitDeptScore">确 定</el-button>
<el-button @click="cancelDeptScore">取 消</el-button>
</div>
</el-dialog>
<!-- 修改余额功能已关闭
<el-dialog :title="title" :visible.sync="openUpdateBalance" width="500px" append-to-body> <el-dialog :title="title" :visible.sync="openUpdateBalance" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="余额" prop="balance"> <el-form-item label="余额" prop="balance">
@ -195,6 +331,7 @@
<el-button @click="cancelBalance">取 消</el-button> <el-button @click="cancelBalance">取 消</el-button>
</div> </div>
</el-dialog> </el-dialog>
-->
</div> </div>
</template> </template>
@ -205,14 +342,20 @@ import {
delUser, delUser,
addUser, addUser,
updateUser, updateUser,
changeBalance,
changeUserStatus, changeUserStatus,
updatePassword updatePassword,
assignAiUserDept,
issueDeptScore,
reclaimDeptScore
} from "@/api/ai/user"; } from "@/api/ai/user";
import { listDept } from "@/api/ai/dept";
import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
export default { export default {
name: "User", name: "User",
dicts: ["sys_normal_disable", "sys_user_sex"], dicts: ["sys_normal_disable", "sys_user_sex"],
components: { Treeselect },
data() { data() {
return { return {
// 遮罩层 // 遮罩层
@ -234,7 +377,34 @@ export default {
// 是否显示弹出层 // 是否显示弹出层
open: false, open: false,
openUpdatePassword: false, openUpdatePassword: false,
openUpdateBalance: false, deptScoreOpen: false,
deptScoreMode: "issue",
deptScoreForm: {
userId: null,
username: "",
deptName: "",
amount: undefined,
remark: ""
},
deptScoreRules: {
amount: [
{ required: true, message: "请输入积分数量", trigger: "blur" },
{
type: "number",
min: 1,
max: 100000000,
message: "请输入 1~100000000 的整数",
trigger: "blur"
}
],
remark: [{ max: 50, message: "备注最多50个字", trigger: "blur" }]
},
assignDeptOpen: false,
deptOptions: [],
assignForm: {
id: null,
deptId: null
},
// 查询参数 // 查询参数
queryParams: { queryParams: {
pageNum: 1, pageNum: 1,
@ -245,41 +415,100 @@ export default {
phone: null, phone: null,
password: null, password: null,
openid: null, openid: null,
status: null, status: "0",
email: null, email: null,
birthday: null, birthday: null,
invitationCode: null, invitationCode: null,
paymentUrl: null, paymentUrl: null,
loginTime: null, loginTime: null,
balance: null, balance: null,
superiorName: null superiorName: null,
deptId: null
}, },
// 表单参数 // 表单参数
form: {}, form: {},
// 表单校验 // 表单校验
rules: { rules: {
delFlag: [ username: [{ required: true, message: "用户账号不能为空", trigger: "blur" }],
{ required: true, message: "删除标志不能为空", trigger: "blur" } password: [
], {
phone: [ validator: (rule, value, callback) => {
{ required: true, message: "手机号码不能为空", trigger: "blur" } if (this.form.id == null && (!value || !String(value).trim())) {
callback(new Error("密码不能为空"));
} else {
callback();
}
},
trigger: "blur"
}
] ]
} }
}; };
}, },
created() { created() {
this.loadDeptTree();
this.getList(); this.getList();
}, },
methods: { methods: {
/** 千位分节(en-US),整数;空为 — */
formatThousands(value) {
if (value === null || value === undefined || value === "") {
return "—";
}
const n = Number(value);
if (Number.isNaN(n)) {
return String(value);
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
},
loadDeptTree() {
listDept().then(res => {
this.deptOptions = this.handleTree(res.data, "deptId");
});
},
deptNormalizer(node) {
if (node.children && !node.children.length) {
delete node.children;
}
return {
id: node.deptId,
label: node.deptName,
children: node.children
};
},
handleOpenAssignDept(row) {
getUser(row.id).then(res => {
this.assignForm = {
id: res.data.id,
deptId: res.data.deptId
};
this.assignDeptOpen = true;
});
},
submitAssignDept() {
const payload = {
id: this.assignForm.id,
deptId: this.assignForm.deptId != null ? this.assignForm.deptId : null
};
assignAiUserDept(payload).then(() => {
this.$modal.msgSuccess("已保存");
this.assignDeptOpen = false;
this.getList();
});
},
cancelAssignDept() {
this.assignDeptOpen = false;
this.assignForm = { id: null, deptId: null };
},
// 更多操作触发 // 更多操作触发
handleCommand(command, row) { handleCommand(command, row) {
switch (command) { switch (command) {
case "updatePassword": case "updatePassword":
this.updatePassword(row); this.updatePassword(row);
break; break;
case "updateBalance": // case "updateBalance":
this.updateBalance(row); // this.updateBalance(row);
break; // break;
default: default:
break; break;
} }
@ -297,15 +526,63 @@ export default {
}); });
return; return;
}, },
updateBalance(row) { openDeptScoreDialog(row, mode) {
this.reset(); if (!row.deptId) {
const id = row.id || this.ids; this.$modal.msgWarning("请先分配归属团队后再操作积分");
getUser(id).then(response => { return;
this.form = response.data; }
this.openUpdateBalance = true; this.deptScoreMode = mode;
this.title = "修改余额"; this.deptScoreForm = {
userId: row.id,
username: row.username || row.userId || "",
deptName: row.deptName || "",
amount: undefined,
remark: ""
};
this.deptScoreOpen = true;
this.$nextTick(() => {
if (this.$refs.deptScoreFormRef) {
this.$refs.deptScoreFormRef.clearValidate();
}
}); });
}, },
submitDeptScore() {
this.$refs.deptScoreFormRef.validate(valid => {
if (!valid) {
return;
}
const raw = this.deptScoreForm.amount;
const amount = typeof raw === "number" ? Math.trunc(raw) : parseInt(String(raw), 10);
if (!Number.isFinite(amount) || amount < 1 || amount > 100000000) {
this.$modal.msgWarning("请输入 1~100000000 的整数积分");
return;
}
const payload = {
userId: this.deptScoreForm.userId,
amount,
remark: this.deptScoreForm.remark || undefined
};
const req = this.deptScoreMode === "issue" ? issueDeptScore : reclaimDeptScore;
req(payload).then(() => {
this.$modal.msgSuccess("操作成功");
this.deptScoreOpen = false;
this.getList();
});
});
},
cancelDeptScore() {
this.deptScoreOpen = false;
this.deptScoreForm = { userId: null, username: "", deptName: "", amount: undefined, remark: "" };
},
// updateBalance(row) {
// this.reset();
// const id = row.id || this.ids;
// getUser(id).then(response => {
// this.form = response.data;
// this.openUpdateBalance = true;
// this.title = "修改余额";
// });
// },
/** 查询ai-用户信息列表 */ /** 查询ai-用户信息列表 */
getList() { getList() {
this.loading = true; this.loading = true;
@ -335,10 +612,10 @@ export default {
this.open = false; this.open = false;
this.reset(); this.reset();
}, },
cancelBalance() { // cancelBalance() {
this.openUpdateBalance = false; // this.openUpdateBalance = false;
this.reset(); // this.reset();
}, // },
cancelPassword() { cancelPassword() {
this.openUpdatePassword = false; this.openUpdatePassword = false;
this.reset(); this.reset();
@ -353,6 +630,7 @@ export default {
updateBy: null, updateBy: null,
updateTime: null, updateTime: null,
remark: null, remark: null,
username: null,
nickname: null, nickname: null,
gender: null, gender: null,
avatar: null, avatar: null,
@ -407,13 +685,19 @@ export default {
this.$refs["form"].validate(valid => { this.$refs["form"].validate(valid => {
if (valid) { if (valid) {
if (this.form.id != null) { if (this.form.id != null) {
updateUser(this.form).then(response => { const payload = { ...this.form };
if (!payload.password || !String(payload.password).trim()) {
delete payload.password;
}
updateUser(payload).then(() => {
this.$modal.msgSuccess("修改成功"); this.$modal.msgSuccess("修改成功");
this.open = false; this.open = false;
this.getList(); this.getList();
}); });
} else { } else {
addUser(this.form).then(response => { const payload = { ...this.form };
delete payload.balance;
addUser(payload).then(() => {
this.$modal.msgSuccess("新增成功"); this.$modal.msgSuccess("新增成功");
this.open = false; this.open = false;
this.getList(); this.getList();
@ -422,23 +706,28 @@ export default {
} }
}); });
}, },
handleChangeBalance() { // handleChangeBalance() {
changeBalance; // this.$refs["form"].validate(valid => {
this.$refs["form"].validate(valid => { // if (!valid) return;
changeBalance(this.form.id, this.form.balance).then(response => { // changeBalance(this.form.id, this.form.balance).then(() => {
this.$modal.msgSuccess("修改成功"); // this.$modal.msgSuccess("修改成功");
this.openUpdateBalance = false; // this.openUpdateBalance = false;
this.getList(); // this.getList();
}); // });
}); // });
}, // },
/** 删除按钮操作 */ /** 删除按钮操作 */
handleDelete(row) { handleDelete(row) {
const ids = row.id || this.ids; const ids = row && row.id != null ? row.id : this.ids;
if (ids == null || (Array.isArray(ids) && !ids.length)) {
this.$modal.msgWarning("请选择要删除的数据");
return;
}
const idParam = Array.isArray(ids) ? ids.join(",") : ids;
this.$modal this.$modal
.confirm('是否确认删除ai-用户信息编号为"' + ids + '"的数据项?') .confirm('是否确认删除ai-用户信息编号为"' + idParam + '"的数据项?')
.then(function() { .then(() => {
return delUser(ids); return delUser(idParam);
}) })
.then(() => { .then(() => {
this.getList(); this.getList();

View File

@ -218,7 +218,7 @@
<!-- </el-collapse-item>--> <!-- </el-collapse-item>-->
<!-- <el-collapse-item title="v3.8.7 - 2023-12-08">--> <!-- <el-collapse-item title="v3.8.7 - 2023-12-08">-->
<!-- <ol>--> <!-- <ol>-->
<!-- <li>操作日志记录部门名称</li>--> <!-- <li>操作日志记录团队名称</li>-->
<!-- <li>全局数据存储用户编号</li>--> <!-- <li>全局数据存储用户编号</li>-->
<!-- <li>新增编程式判断资源访问权限</li>--> <!-- <li>新增编程式判断资源访问权限</li>-->
<!-- <li>操作日志列表新增IP地址查询</li>--> <!-- <li>操作日志列表新增IP地址查询</li>-->

View File

@ -35,7 +35,7 @@
</el-table-column> </el-table-column>
<el-table-column label="会话编号" align="center" prop="tokenId" :show-overflow-tooltip="true" /> <el-table-column label="会话编号" align="center" prop="tokenId" :show-overflow-tooltip="true" />
<el-table-column label="登录名称" align="center" prop="userName" :show-overflow-tooltip="true" /> <el-table-column label="登录名称" align="center" prop="userName" :show-overflow-tooltip="true" />
<el-table-column label="部门名称" align="center" prop="deptName" /> <el-table-column label="团队名称" align="center" prop="deptName" />
<el-table-column label="主机" align="center" prop="ipaddr" :show-overflow-tooltip="true" /> <el-table-column label="主机" align="center" prop="ipaddr" :show-overflow-tooltip="true" />
<el-table-column label="登录地点" align="center" prop="loginLocation" :show-overflow-tooltip="true" /> <el-table-column label="登录地点" align="center" prop="loginLocation" :show-overflow-tooltip="true" />
<el-table-column label="浏览器" align="center" prop="browser" /> <el-table-column label="浏览器" align="center" prop="browser" />

View File

@ -0,0 +1,182 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="70px">
<el-form-item label="订单号" prop="orderNum">
<el-input
v-model="queryParams.orderNum"
placeholder="支持模糊搜索"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="类型" prop="orderType">
<el-select v-model="queryParams.orderType" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in orderTypeQueryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['subteam:charge:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="groupChargeOrderList">
<el-table-column label="订单号" align="center" prop="orderNum" width="200" show-overflow-tooltip />
<el-table-column label="类型" align="center" width="120">
<template slot-scope="scope">
<span>{{ orderTypeLabel(scope.row.orderType) }}</span>
</template>
</el-table-column>
<el-table-column label="金额(元)" align="center" prop="money" width="120">
<template slot-scope="scope">
<span>{{ formatTableMoney(scope.row.money) }}</span>
</template>
</el-table-column>
<el-table-column label="积分" align="center" prop="amount" width="120">
<template slot-scope="scope">
<span>{{ formatTableAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="100">
<template slot-scope="scope">
<span>{{ statusLabel(scope.row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" show-overflow-tooltip />
<el-table-column label="创建时间" align="center" prop="createTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listSubteamChargeOrder, subteamChargeOrderExportUrl } from '@/api/subteam'
export default {
name: 'SubteamChargeOrder',
data() {
return {
loading: true,
showSearch: true,
dateRange: [],
total: 0,
groupChargeOrderList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
orderNum: null,
orderType: null
},
orderTypeQueryOptions: [
{ label: '充值', value: 0 },
{ label: '退款', value: 1 }
],
orderTypeOptions: [
{ label: '充值', value: 0 },
{ label: '退款', value: 1 },
{ label: '手动修改', value: 2 }
],
statusOptions: [
{ label: '进行中', value: 0 },
{ label: '已完成', value: 1 },
{ label: '失败', value: 2 }
]
}
},
created() {
this.getList()
},
methods: {
formatTableMoney(value) {
if (value === null || value === undefined || value === '') {
return '—'
}
const n = Number(value)
if (Number.isNaN(n)) {
return '—'
}
return n.toLocaleString('en-US', { maximumFractionDigits: 2, minimumFractionDigits: 0 })
},
formatTableAmount(value) {
if (value === null || value === undefined || value === '') {
return '—'
}
const n = Number(value)
if (Number.isNaN(n)) {
return '—'
}
return n.toLocaleString('en-US', { maximumFractionDigits: 0 })
},
orderTypeLabel(orderType) {
const n = orderType !== undefined && orderType !== null ? Number(orderType) : null
const hit = this.orderTypeOptions.find(o => o.value === n)
return hit ? hit.label : '—'
},
statusLabel(status) {
const n = status !== undefined && status !== null ? Number(status) : null
const hit = this.statusOptions.find(o => o.value === n)
return hit ? hit.label : '—'
},
getList() {
this.loading = true
listSubteamChargeOrder(this.addDateRange(this.queryParams, this.dateRange, 'CreateTime')).then(response => {
this.groupChargeOrderList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleExport() {
this.download(
subteamChargeOrderExportUrl,
this.addDateRange({ ...this.queryParams }, this.dateRange, 'CreateTime'),
`group_charge_order_${new Date().getTime()}.xlsx`
)
}
}
}
</script>

View File

@ -0,0 +1,258 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="96px">
<el-form-item label="统计日期">
<el-date-picker
v-model="dateRange"
type="daterange"
unlink-panels
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 280px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['subteam:consume:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-form-item>
</el-form>
<p class="summary-line">
<template v-if="summaryState.mode === 'loading'">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span><span class="summary-muted">加载中…</span>
</template>
<template v-else-if="summaryState.mode === 'empty'">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span><span class="summary-muted">—</span>
</template>
<template v-else>
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="summary-prefix">汇总:</span>
<span class="summary-label">总计</span>
<span class="summary-num">{{ summaryState.totalStr }}</span>
<span class="summary-label">条记录,实际充值积分</span>
<span class="summary-num">{{ summaryState.recharge }}</span>
<span class="summary-unit">分</span>
<span class="summary-label">,消耗积分</span>
<span class="summary-num">{{ summaryState.score }}</span>
<span class="summary-unit">分</span>
<span class="summary-label">,实际订单数量(成功)</span>
<span class="summary-num">{{ summaryState.orders }}</span>
<span class="summary-unit">单</span>
<span class="summary-label">,三方消耗tokens数量</span>
<span class="summary-num">{{ summaryState.tokens }}</span>
<span class="summary-unit">。</span>
</template>
</p>
<el-table v-loading="loading" :data="dataList">
<el-table-column label="日期" align="center" prop="dateKey" min-width="110" />
<el-table-column
label="实际充值积分(充值-退款)"
align="center"
prop="rechargeScore"
min-width="200"
:formatter="formatWesternNumber"
/>
<el-table-column label="消耗积分" align="center" prop="score" width="120" :formatter="formatWesternNumber" />
<el-table-column
label="实际订单数量(成功)"
align="center"
prop="orderCount"
width="160"
:formatter="formatWesternNumber"
/>
<el-table-column
label="三方消耗tokens数量"
align="center"
prop="useTokens"
width="170"
:formatter="formatWesternNumber"
/>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listSubteamConsumeStat, subteamConsumeStatExportUrl } from '@/api/subteam'
export default {
name: 'SubteamConsumeStat',
data() {
return {
loading: false,
showSearch: true,
total: 0,
dataList: [],
aggregate: null,
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 10,
startDate: null,
endDate: null
}
}
},
created() {
this.initDefaultDateRange()
this.syncDateRangeToQuery()
this.getList()
},
computed: {
summaryState() {
if (this.loading) {
return { mode: 'loading' }
}
const a = this.aggregate
if (!a) {
return { mode: 'empty' }
}
const fmt = (v) => this.formatWesternNumberValue(v)
const n = a.totalRows != null ? Number(a.totalRows) : 0
const totalStr = Number.isNaN(n) ? '0' : n.toLocaleString('en-US')
return {
mode: 'ok',
totalStr,
recharge: fmt(a.sumRechargeScore),
score: fmt(a.sumScore),
orders: fmt(a.sumOrderCount),
tokens: fmt(a.sumUseTokens)
}
}
},
methods: {
formatWesternNumberValue(cellValue) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return (0).toLocaleString('en-US')
}
const n = Number(cellValue)
if (Number.isNaN(n)) {
return (0).toLocaleString('en-US')
}
return n.toLocaleString('en-US')
},
formatWesternNumber(row, column, cellValue) {
return this.formatWesternNumberValue(cellValue)
},
buildLastMonthDateRange() {
const end = new Date()
const start = new Date(end.getTime())
start.setMonth(start.getMonth() - 1)
return [this.formatYmd(start), this.formatYmd(end)]
},
formatYmd(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
},
initDefaultDateRange() {
this.dateRange = this.buildLastMonthDateRange()
},
syncDateRangeToQuery() {
if (Array.isArray(this.dateRange) && this.dateRange.length === 2) {
this.queryParams.startDate = this.dateRange[0]
this.queryParams.endDate = this.dateRange[1]
} else {
this.queryParams.startDate = null
this.queryParams.endDate = null
}
},
getList() {
this.syncDateRangeToQuery()
this.loading = true
listSubteamConsumeStat(this.queryParams)
.then(response => {
this.dataList = response.rows || []
this.total = response.total != null ? response.total : 0
this.aggregate =
response.param && response.param.aggregate != null ? response.param.aggregate : null
})
.finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.initDefaultDateRange()
this.resetForm('queryForm')
this.queryParams.pageNum = 1
this.syncDateRangeToQuery()
this.getList()
},
handleExport() {
this.syncDateRangeToQuery()
this.download(
subteamConsumeStatExportUrl,
{
startDate: this.queryParams.startDate,
endDate: this.queryParams.endDate
},
`team_consume_${new Date().getTime()}.xlsx`
)
}
}
}
</script>
<style scoped>
.summary-line {
color: #606266;
font-size: 13px;
margin: 0 0 12px 0;
padding-left: 8px;
line-height: 1.75;
}
.summary-prefix {
color: #909399;
font-weight: 600;
margin-right: 2px;
}
.summary-muted {
color: #c0c4cc;
}
.summary-label {
color: #606266;
}
.summary-unit {
color: #909399;
font-size: 12px;
margin-left: 1px;
margin-right: 1px;
}
.summary-num {
color: #409eff;
font-weight: 600;
font-variant-numeric: tabular-nums;
margin: 0 2px;
}
</style>

View File

@ -0,0 +1,177 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="90px">
<el-form-item label="关联订单号" prop="relationOrderNo">
<el-input
v-model="queryParams.relationOrderNo"
placeholder="支持模糊查询,充值/退款订单号"
clearable
style="width: 240px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="queryParams.type" clearable placeholder="全部类型" style="width: 112px">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['subteam:groupBalance:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="recordList">
<el-table-column label="ID" align="center" prop="id" width="100" />
<el-table-column label="关联订单号" align="center" prop="relationOrderNo" width="200" show-overflow-tooltip />
<el-table-column label="操作类型" align="center" width="120">
<template slot-scope="scope">
<span>{{ typeLabel(scope.row.type) }}</span>
</template>
</el-table-column>
<el-table-column
label="变更积分"
align="center"
prop="changeAmount"
width="120"
:formatter="formatChangeAmount"
/>
<el-table-column
label="变更后积分"
align="center"
prop="resultAmount"
width="120"
:formatter="formatResultAmount"
/>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listSubteamGroupBalance, subteamGroupBalanceExportUrl } from '@/api/subteam'
export default {
name: 'SubteamGroupBalance',
data() {
return {
loading: true,
showSearch: true,
dateRange: [],
total: 0,
recordList: [],
typeOptions: [
{ label: '充值', value: 0 },
{ label: '退款', value: 1 },
{ label: '下发', value: 2 },
{ label: '回收', value: 3 },
{ label: '手动修改', value: 4 }
],
queryParams: {
pageNum: 1,
pageSize: 10,
relationOrderNo: null,
type: null
}
}
},
created() {
this.getList()
},
methods: {
formatPointsWestern(value) {
if (value === null || value === undefined || value === '') {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 0 })
},
formatChangeAmount(row) {
const value = row.changeAmount
if (value === null || value === undefined || value === '') {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
const formatted = Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 0 })
if (n > 0) {
return `+${formatted}`
}
if (n < 0) {
return `-${formatted}`
}
return '0'
},
formatResultAmount(row) {
return this.formatPointsWestern(row.resultAmount)
},
typeLabel(type) {
const n = type !== undefined && type !== null ? Number(type) : null
const hit = this.typeOptions.find(item => item.value === n)
return hit ? hit.label : '-'
},
getList() {
this.loading = true
listSubteamGroupBalance(this.addDateRange(this.queryParams, this.dateRange, 'CreateTime')).then(response => {
this.recordList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleExport() {
this.download(
subteamGroupBalanceExportUrl,
this.addDateRange({ ...this.queryParams }, this.dateRange, 'CreateTime'),
`group_balance_record_${new Date().getTime()}.xlsx`
)
}
}
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<div class="app-container">
<div class="overview-toolbar">
<el-tooltip content="刷新" placement="top">
<el-button type="default" size="mini" circle icon="el-icon-refresh" @click="load" />
</el-tooltip>
</div>
<el-row :gutter="16" v-loading="loading">
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">团队 ID</div><div class="metric-val">{{ info.deptId }}</div></el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">团队名称</div><div class="metric-val">{{ info.deptName }}</div></el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">团队剩余积分</div><div class="metric-val">{{ info.balance }}</div></el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">AI用户数(实时)</div><div class="metric-val">{{ aiUserCountText }}</div></el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">近 7 日消耗积分 <span class="hint">(5 分钟缓存)</span></div><div class="metric-val">{{ info.last7DaysConsumeScore }}</div></el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover"><div class="metric-title">近 7 日成功订单数 <span class="hint">(5 分钟缓存)</span></div><div class="metric-val">{{ info.last7DaysOrderCount }}</div></el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getSubteamOverview } from '@/api/subteam'
export default {
name: 'SubteamOverview',
data() {
return {
loading: true,
info: {}
}
},
created() {
this.load()
},
computed: {
aiUserCountText() {
const cur = this.info.aiUserCount
const max = this.info.aiUserMaxCount
const left = cur != null && cur !== '' ? cur : 0
if (max == null || max <= 0) {
return left + '/无限制'
}
return left + '/' + max
}
},
methods: {
load() {
this.loading = true
getSubteamOverview()
.then(res => {
this.info = res.data || {}
})
.finally(() => {
this.loading = false
})
}
}
}
</script>
<style scoped>
.overview-toolbar {
margin-bottom: 12px;
}
.metric-title { color: #909399; font-size: 13px; }
.metric-val { font-size: 22px; margin-top: 8px; font-weight: 600; }
.hint { font-size: 12px; color: #909399; }
</style>

View File

@ -0,0 +1,325 @@
<template>
<div class="app-container">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="账号" prop="username">
<el-input v-model="queryParams.username" placeholder="账号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="queryParams.phone" placeholder="手机号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="状态" clearable>
<el-option label="正常" :value="0" />
<el-option label="停用" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button v-hasPermi="['subteam:user:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="userList">
<el-table-column label="主键ID" prop="id" width="100" />
<el-table-column label="用户ID" prop="userId" width="150" show-overflow-tooltip />
<el-table-column label="账号" prop="username" width="150"/>
<el-table-column label="昵称" prop="nickname"/>
<el-table-column label="登录时间" prop="loginTime" width="160" align="center" header-align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.loginTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template>
</el-table-column>
<el-table-column label="剩余积分" prop="balance" width="150" align="center" header-align="center">
<template slot-scope="scope">
<span>{{ formatThousands(scope.row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="手机" prop="phone" width="160" />
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="String(scope.row.status)" />
</template>
</el-table-column>
<el-table-column label="操作" width="400">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-download"
@click="openDeptScoreDialog(scope.row, 'issue')"
v-hasPermi="['subteam:user:deptScoreIssue']"
>下放积分</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-upload2"
@click="openDeptScoreDialog(scope.row, 'reclaim')"
v-hasPermi="['subteam:user:deptScoreReclaim']"
>回收积分</el-button>
<el-button v-hasPermi="['subteam:user:edit']" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button v-hasPermi="['subteam:user:resetPwd']" size="mini" type="text" icon="el-icon-key" @click="handleResetPwd(scope.row)">密码</el-button>
<el-button v-hasPermi="['subteam:user:remove']" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog :title="title" :visible.sync="open" width="640px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="88px">
<el-form-item label="昵称" prop="nickname"><el-input v-model="form.nickname" /></el-form-item>
<el-row v-if="!form.id">
<el-col :span="12"><el-form-item label="账号" prop="username"><el-input v-model="form.username" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="密码" prop="password"><el-input v-model="form.password" type="password" show-password /></el-form-item></el-col>
</el-row>
<el-form-item label="手机" prop="phone"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="0">正常</el-radio>
<el-radio :label="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<el-dialog
:title="deptScoreMode === 'issue' ? '下放积分' : '回收积分'"
:visible.sync="deptScoreOpen"
width="480px"
append-to-body
@close="cancelDeptScore"
>
<el-form ref="deptScoreFormRef" :model="deptScoreForm" :rules="deptScoreRules" label-width="88px">
<el-form-item label="所属团队">
<span>{{ deptScoreForm.deptName || '—' }}</span>
</el-form-item>
<el-form-item label="用户账号">
<span>{{ deptScoreForm.username }}</span>
</el-form-item>
<el-form-item label="积分数量" prop="amount">
<el-input-number
v-model="deptScoreForm.amount"
:min="1"
:max="100000000"
:precision="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="deptScoreForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填,最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitDeptScore">确 定</el-button>
<el-button @click="cancelDeptScore">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listSubteamUser, getSubteamUser, addSubteamUser, updateSubteamUser, delSubteamUser,
resetSubteamUserPwd, issueSubteamUserDeptScore, reclaimSubteamUserDeptScore
} from '@/api/subteam'
export default {
name: 'SubteamUser',
dicts: ['sys_normal_disable'],
data() {
return {
loading: true,
showSearch: true,
dateRange: [],
userList: [],
total: 0,
ids: [],
single: true,
multiple: true,
title: '',
open: false,
deptScoreOpen: false,
deptScoreMode: 'issue',
deptScoreForm: {
userId: null,
username: '',
deptName: '',
amount: undefined,
remark: ''
},
deptScoreRules: {
amount: [
{ required: true, message: '请输入积分数量', trigger: 'blur' },
{
type: 'number',
min: 1,
max: 100000000,
message: '请输入 1~100000000 的整数',
trigger: 'blur'
}
],
remark: [{ max: 50, message: '备注最多50个字', trigger: 'blur' }]
},
queryParams: { pageNum: 1, pageSize: 10, username: undefined, phone: undefined, status: undefined },
form: {},
rules: {
nickname: [{ required: true, message: '必填', trigger: 'blur' }],
username: [{ required: true, message: '必填', trigger: 'blur' }],
password: [{ required: true, message: '必填', trigger: 'blur' }],
email: [{ type: 'email', message: '邮箱格式', trigger: ['blur', 'change'] }]
}
}
},
created() {
this.getList()
},
methods: {
formatThousands(value) {
if (value === null || value === undefined || value === '') {
return '—'
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 0 })
},
getList() {
this.loading = true
listSubteamUser(this.addDateRange(this.queryParams, this.dateRange, 'CreateTime')).then(res => {
this.userList = res.rows
this.total = res.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(rows) {
this.ids = rows.map(r => r.id)
this.single = rows.length !== 1
this.multiple = !rows.length
},
reset() {
this.form = { id: undefined, username: undefined, nickname: undefined, password: undefined,
phone: undefined, email: undefined, status: 0, remark: undefined }
this.resetForm('form')
},
handleAdd() {
this.reset()
this.open = true
this.title = '新增AI用户'
},
handleUpdate(row) {
this.reset()
const id = row.id || this.ids[0]
getSubteamUser(id).then(r => {
this.form = r.data || {}
this.form.status = Number(this.form.status || 0)
this.open = true
this.title = '修改AI用户'
})
},
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.id) {
updateSubteamUser(this.form).then(() => { this.$modal.msgSuccess('成功'); this.open = false; this.getList() })
} else {
addSubteamUser(this.form).then(() => { this.$modal.msgSuccess('成功'); this.open = false; this.getList() })
}
})
},
cancel() { this.open = false },
handleDelete(row) {
const userIds = row.id || this.ids
this.$modal.confirm('确认删除?').then(() => delSubteamUser(userIds)).then(() => { this.getList(); this.$modal.msgSuccess('已删除') }).catch(() => {})
},
handleResetPwd(row) {
this.$prompt('新密码', '重置', { inputType: 'password' }).then(({ value }) => {
resetSubteamUserPwd({ id: row.id, password: value }).then(() => this.$modal.msgSuccess('已重置'))
}).catch(() => {})
},
openDeptScoreDialog(row, mode) {
if (!row.deptId) {
this.$modal.msgWarning('请先分配归属部门后再操作积分')
return
}
this.deptScoreMode = mode
this.deptScoreForm = {
userId: row.id,
username: row.username || row.userId || '',
deptName: row.deptName || '',
amount: undefined,
remark: ''
}
this.deptScoreOpen = true
this.$nextTick(() => {
if (this.$refs.deptScoreFormRef) {
this.$refs.deptScoreFormRef.clearValidate()
}
})
},
submitDeptScore() {
this.$refs.deptScoreFormRef.validate(valid => {
if (!valid) {
return
}
const raw = this.deptScoreForm.amount
const amount = typeof raw === 'number' ? Math.trunc(raw) : parseInt(String(raw), 10)
if (!Number.isFinite(amount) || amount < 1 || amount > 100000000) {
this.$modal.msgWarning('请输入 1~100000000 的整数积分')
return
}
const payload = {
userId: this.deptScoreForm.userId,
amount,
remark: this.deptScoreForm.remark || undefined
}
const req = this.deptScoreMode === 'issue' ? issueSubteamUserDeptScore : reclaimSubteamUserDeptScore
req(payload).then(() => {
this.$modal.msgSuccess('操作成功')
this.deptScoreOpen = false
this.getList()
})
})
},
cancelDeptScore() {
this.deptScoreOpen = false
this.deptScoreForm = { userId: null, username: '', deptName: '', amount: undefined, remark: '' }
}
}
}
</script>

View File

@ -0,0 +1,189 @@
<template>
<div class="app-container">
<el-form
:model="queryParams"
ref="queryForm"
size="small"
:inline="true"
v-show="showSearch"
label-width="68px"
>
<el-form-item label="用户ID" prop="uuid">
<el-input
v-model="queryParams.uuid"
placeholder="请输入用户ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="操作类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择操作类型"
filterable
clearable
style="width: 200px; margin-bottom: 16px;"
>
<el-option
v-for="(label, value) in typeMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['subteam:userBalance:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="recordList">
<el-table-column label="主键ID" align="center" prop="id" width="80" />
<el-table-column label="用户ID" align="center" prop="uuid" width="100" show-overflow-tooltip />
<el-table-column label="用户昵称" align="center" prop="nickname" width="150" show-overflow-tooltip />
<el-table-column label="关联订单号" align="center" prop="orderNo" width="180" show-overflow-tooltip />
<el-table-column label="操作类型" align="center" width="100" prop="type" :formatter="formatType" />
<el-table-column
label="变更积分"
align="center"
prop="changeAmount"
:formatter="formatChangeAmount"
width="120"
/>
<el-table-column
label="变更后积分"
align="center"
prop="resultAmount"
:formatter="formatResultAmount"
width="120"
/>
<el-table-column label="操作时间" align="center" prop="createTime" width="150" />
<el-table-column label="备注" align="center" prop="remark" />
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listSubteamUserBalance, subteamUserBalanceExportUrl } from "@/api/subteam";
export default {
name: "SubteamUserBalance",
data() {
return {
dateRange: [],
typeMap: {
9: "快捷生视频",
11: "系统操作",
12: "团队下发",
13: "团队收回"
},
loading: true,
showSearch: true,
total: 0,
recordList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
type: null,
uuid: null
}
};
},
created() {
this.getList();
},
methods: {
formatPointsWestern(value) {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (Number.isNaN(n)) {
return String(value);
}
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
},
formatChangeAmount(row) {
const value = row.changeAmount;
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (Number.isNaN(n)) {
return String(value);
}
const formatted = Math.abs(n).toLocaleString("en-US", {
maximumFractionDigits: 0
});
if (n > 0) {
return `+${formatted}`;
}
if (n < 0) {
return `-${formatted}`;
}
return "0";
},
formatResultAmount(row) {
return this.formatPointsWestern(row.resultAmount);
},
formatType(row) {
return this.typeMap[row.type] || "未知";
},
getList() {
this.loading = true;
listSubteamUserBalance(
this.addDateRange(this.queryParams, this.dateRange)
).then(response => {
this.recordList = response.rows;
this.total = response.total;
this.loading = false;
});
},
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
resetQuery() {
this.dateRange = [];
this.resetForm("queryForm");
this.handleQuery();
},
handleExport() {
this.download(
subteamUserBalanceExportUrl,
this.addDateRange({ ...this.queryParams }, this.dateRange),
`user_balance_${new Date().getTime()}.xlsx`
);
}
}
};
</script>

View File

@ -0,0 +1,270 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="88px">
<el-form-item label="订单号" prop="orderNum">
<el-input
v-model="queryParams.orderNum"
placeholder="请输入订单号"
style="width: 140px"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="三方订单号" prop="thirdPartyOrderNum">
<el-input
v-model="queryParams.thirdPartyOrderNum"
placeholder="支持模糊搜索"
style="width: 210px"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="用户名称" prop="userName">
<el-input
v-model="queryParams.userName"
placeholder="支持模糊搜索"
style="width: 120px"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 100px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
&nbsp;&nbsp;&nbsp;&nbsp;
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['subteam:videoOrder:export']"
>导出</el-button>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="orderList">
<el-table-column label="订单号" align="center" prop="orderNum" width="200" show-overflow-tooltip />
<el-table-column label="三方订单号" align="center" prop="thirdPartyOrderNum" width="200" show-overflow-tooltip />
<el-table-column label="用户名称" align="center" prop="userName" width="120" show-overflow-tooltip />
<el-table-column label="消耗积分" align="center" prop="amount" width="120">
<template slot-scope="scope">
<span>{{ formatThousands(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="使用tokens" align="center" prop="totalUsage" width="120">
<template slot-scope="scope">
<span>{{ formatThousands(scope.row.totalUsage) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="100">
<template slot-scope="scope">
<span>{{ statusLabel(scope.row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="结果预览" min-width="185">
<template slot-scope="scope">
<div v-if="extractVideoUrl(scope.row.result)">
<video
:src="extractVideoUrl(scope.row.result)"
controls
preload="metadata"
style="width: 180px; max-height: 100px; border-radius: 4px;"
/>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog title="订单详情" :visible.sync="detailVisible" width="800px" append-to-body>
<el-descriptions :column="1" border size="small" :labelStyle="{ width: '150px', minWidth: '150px' }">
<el-descriptions-item v-for="(value, key) in detailData" :key="key" :label="key">
<pre v-if="isObjectValue(value)" style="margin: 0; white-space: pre-wrap;">{{ formatJson(value) }}</pre>
<span v-else>{{ value === null || value === undefined || value === '' ? '-' : value }}</span>
</el-descriptions-item>
</el-descriptions>
<span slot="footer">
<el-button size="mini" @click="detailVisible = false">关闭</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { listSubteamVideoOrder, subteamVideoOrderExportUrl } from '@/api/subteam'
export default {
name: 'SubteamVideoOrder',
data() {
return {
loading: true,
showSearch: true,
total: 0,
orderList: [],
detailVisible: false,
detailData: {},
statusOptions: [
{ label: '进行中', value: 0 },
{ label: '完成', value: 1 },
{ label: '失败', value: 2 }
],
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 10,
orderNum: null,
thirdPartyOrderNum: null,
userName: null,
status: null
}
}
},
created() {
this.initDefaultDateRange()
this.getList()
},
methods: {
formatThousands(value) {
if (value === null || value === undefined || value === '') {
return '—'
}
const n = Number(value)
if (Number.isNaN(n)) {
return String(value)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 0 })
},
initDefaultDateRange() {
this.dateRange = this.buildLastMonthDateRange()
},
buildLastMonthDateRange() {
const end = new Date()
const start = new Date(end.getTime())
start.setMonth(start.getMonth() - 1)
return [this.formatYmd(start), this.formatYmd(end)]
},
formatYmd(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
},
statusLabel(status) {
const n = status !== undefined && status !== null ? Number(status) : null
const hit = this.statusOptions.find(o => o.value === n)
return hit ? hit.label : '—'
},
extractVideoUrl(result) {
if (!result) return ''
if (typeof result === 'string') {
const value = result.trim()
if (!value) return ''
if (/^https?:\/\/|^\/\//.test(value)) return value
try {
const parsed = JSON.parse(value)
return this.extractVideoUrl(parsed)
} catch (e) {
return ''
}
}
if (Array.isArray(result)) {
for (let i = 0; i < result.length; i += 1) {
const url = this.extractVideoUrl(result[i])
if (url) return url
}
return ''
}
if (typeof result === 'object') {
const directKeys = ['videoUrl', 'url', 'resultUrl', 'playUrl', 'output', 'fileUrl']
for (let i = 0; i < directKeys.length; i += 1) {
const key = directKeys[i]
if (result[key]) {
const url = this.extractVideoUrl(result[key])
if (url) return url
}
}
}
return ''
},
handleDetail(row) {
const copy = row && typeof row === 'object' ? { ...row } : {}
if (Object.prototype.hasOwnProperty.call(copy, 'deptName')) {
delete copy.deptName
}
this.detailData = copy
this.detailVisible = true
},
isObjectValue(value) {
return value !== null && typeof value === 'object'
},
formatJson(value) {
try {
return JSON.stringify(value, null, 2)
} catch (e) {
return String(value)
}
},
getList() {
this.loading = true
listSubteamVideoOrder(this.addDateRange(this.queryParams, this.dateRange, 'CreateTime')).then(response => {
this.orderList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.initDefaultDateRange()
this.resetForm('queryForm')
this.handleQuery()
},
handleExport() {
const q = { ...this.queryParams }
this.download(
subteamVideoOrderExportUrl,
this.addDateRange(q, this.dateRange, 'CreateTime'),
`subteam_video_orders_${new Date().getTime()}.xlsx`
)
}
}
}
</script>

View File

@ -1,16 +1,24 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch"> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="部门名称" prop="deptName"> <el-form-item label="团队" prop="deptId">
<el-input <el-select
v-model="queryParams.deptName" v-model="queryParams.deptId"
placeholder="请输入部门名称" placeholder="全部团队(可选)"
clearable clearable
@keyup.enter.native="handleQuery" filterable
/> style="width: 220px"
>
<el-option
v-for="item in secondLevelDeptOptions"
:key="item.deptId"
:label="item.deptName"
:value="item.deptId"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态" prop="status"> <el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="部门状态" clearable> <el-select v-model="queryParams.status" placeholder="团队状态" clearable>
<el-option <el-option
v-for="dict in dict.type.sys_normal_disable" v-for="dict in dict.type.sys_normal_disable"
:key="dict.value" :key="dict.value"
@ -22,21 +30,6 @@
<el-form-item> <el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['system:dept:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button <el-button
type="info" type="info"
plain plain
@ -44,9 +37,9 @@
size="mini" size="mini"
@click="toggleExpandAll" @click="toggleExpandAll"
>展开/折叠</el-button> >展开/折叠</el-button>
</el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-form-item>
</el-row> </el-form>
<el-table <el-table
v-if="refreshTable" v-if="refreshTable"
@ -56,8 +49,18 @@
:default-expand-all="isExpandAll" :default-expand-all="isExpandAll"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}" :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
> >
<el-table-column prop="deptName" label="部门名称" width="260"></el-table-column> <el-table-column prop="deptName" label="团队名称" width="260"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200"></el-table-column> <el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
<el-table-column prop="maxUserCount" label="账号上限" width="100" align="center">
<template slot-scope="scope">
<span>{{ scope.row.maxUserCount != null && scope.row.maxUserCount > 0 ? scope.row.maxUserCount : '不限制' }}</span>
</template>
</el-table-column>
<el-table-column prop="balance" label="剩余积分" width="120" align="right">
<template slot-scope="scope">
<span>{{ formatDeptBalance(scope.row.balance) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope"> <template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/> <dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
@ -68,8 +71,32 @@
<span>{{ parseTime(scope.row.createTime) }}</span> <span>{{ parseTime(scope.row.createTime) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <el-table-column label="操作" align="center" width="480" class-name="small-padding fixed-width">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button
v-if="isFirstLevelRow(scope.row) || isSecondLevelRow(scope.row)"
size="mini"
type="text"
icon="el-icon-setting"
@click="handleArkConfig(scope.row)"
v-hasPermi="['system:dept:query', 'system:dept:edit']"
>火山引擎</el-button>
<el-button
v-if="isFirstLevelRow(scope.row) || isSecondLevelRow(scope.row)"
size="mini"
type="text"
icon="el-icon-wallet"
@click="handleChargeRefund(scope.row)"
v-hasPermi="['system:dept:chargeRefund']"
>充值/退款</el-button>
<el-button
v-if="isFirstLevelRow(scope.row) || isSecondLevelRow(scope.row)"
size="mini"
type="text"
icon="el-icon-s-operation"
@click="handleEditScore(scope.row)"
v-hasPermi="['system:dept:editScore']"
>积分更正</el-button>
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@ -82,6 +109,7 @@
type="text" type="text"
icon="el-icon-plus" icon="el-icon-plus"
@click="handleAdd(scope.row)" @click="handleAdd(scope.row)"
v-if="isFirstLevelRow(scope.row)"
v-hasPermi="['system:dept:add']" v-hasPermi="['system:dept:add']"
>新增</el-button> >新增</el-button>
<el-button <el-button
@ -96,49 +124,49 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 添加或修改部门对话框 --> <!-- 添加或修改团队对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body> <el-dialog :title="title" :visible.sync="open" width="800px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row> <el-row>
<el-col :span="24" v-if="form.parentId !== 0"> <el-col :span="24" v-if="form.parentId !== 0">
<el-form-item label="上级部门" prop="parentId"> <el-form-item label="上级团队" prop="parentId">
<treeselect v-model="form.parentId" :options="deptOptions" :normalizer="normalizer" placeholder="选择上级部门" /> <treeselect v-model="form.parentId" :options="deptOptions" :normalizer="normalizer" placeholder="选择上级团队" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="部门名称" prop="deptName"> <el-form-item label="团队名称" prop="deptName">
<el-input v-model="form.deptName" placeholder="请输入部门名称" /> <el-input v-model="form.deptName" placeholder="请输入团队名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="显示排序" prop="orderNum"> <el-form-item label="显示排序" prop="orderNum">
<el-input-number v-model="form.orderNum" controls-position="right" :min="0" /> <el-input-number v-model="form.orderNum" controls-position="right" :min="0" :disabled="isFirstLevelEditForm" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="负责人" prop="leader"> <el-form-item label="负责人" prop="leader">
<el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" /> <el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" :disabled="isFirstLevelEditForm" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="联系电话" prop="phone"> <el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" /> <el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" :disabled="isFirstLevelEditForm" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="邮箱" prop="email"> <el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" /> <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" :disabled="isFirstLevelEditForm" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="部门状态"> <el-form-item label="团队状态">
<el-radio-group v-model="form.status"> <el-radio-group v-model="form.status" :disabled="isFirstLevelEditForm">
<el-radio <el-radio
v-for="dict in dict.type.sys_normal_disable" v-for="dict in dict.type.sys_normal_disable"
:key="dict.value" :key="dict.value"
@ -148,24 +176,244 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<el-col :span="12">
<el-form-item label="账号上限" prop="maxUserCount">
<el-input-number
v-model="form.maxUserCount"
:min="0"
:max="999999"
:precision="0"
controls-position="right"
placeholder="0 表示不限制"
style="width: 100%"
:disabled="isFirstLevelEditForm"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<p class="model-parm-hint" style="margin: 0; padding-top: 8px">&nbsp;&nbsp;限制本团队下「启用」状态账号数量;0 表示不限制。</p>
</el-col>
</el-row>
</el-form> </el-form>
<div slot="footer" class="dialog-footer"> <div slot="footer" class="dialog-footer">
<span v-if="isFirstLevelEditForm" class="form-tip">一级团队仅允许修改名称</span>
<el-button type="primary" @click="submitForm">确 定</el-button> <el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button> <el-button @click="cancel">取 消</el-button>
</div> </div>
</el-dialog> </el-dialog>
<el-dialog title="火山引擎" :visible.sync="arkOpen" width="800px" append-to-body @close="resetArkForm">
<el-form ref="arkFormRef" :model="arkForm" label-width="120px">
<el-row>
<el-col :span="24">
<el-form-item label="ApiKey">
<el-input
v-model="arkForm.byteApiKey"
type="password"
show-password
placeholder="选填"
maxlength="255"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="火山项目">
<el-input
v-model="arkForm.project"
type="password"
show-password
placeholder="选填"
maxlength="255"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="视频模型">
<div class="model-parm-block">
<div
v-for="(row, idx) in arkModelParamRows"
:key="'ark-mp-' + idx"
class="model-parm-row"
>
<el-input
v-model="row.label"
placeholder="显示名称(如 Seedance 2.0)"
class="model-parm-input-label"
/>
<el-input
v-model="row.value"
placeholder="Endpoint / 模型 ID(ep-…)"
class="model-parm-input-value"
/>
<el-button
type="text"
icon="el-icon-delete"
:disabled="arkModelParamRows.length <= 1"
@click="removeArkModelParamRow(idx)"
/>
</div>
<el-button type="text" icon="el-icon-plus" @click="addArkModelParamRow">添加模型</el-button>
<p class="model-parm-hint">
保存为 JSON 写入 ai_dept_ark_config.model_parm;门户「视频生成」按 ai_user.dept_id 与本团队配置读取。
留空则使用 portal.video.models。
</p>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitArkForm" v-hasPermi="['system:dept:edit']">确 定</el-button>
<el-button @click="arkOpen = false">取 消</el-button>
</div>
</el-dialog>
<el-dialog
:title="'充值/退款 — ' + (chargeRefundForm.deptName || '')"
:visible.sync="chargeRefundOpen"
width="520px"
append-to-body
@close="resetChargeRefund"
>
<el-form ref="chargeRefundFormRef" :model="chargeRefundForm" :rules="chargeRefundRules" label-width="88px">
<el-form-item label="类型" prop="orderType">
<el-radio-group v-model="chargeRefundForm.orderType">
<el-radio :label="0">充值</el-radio>
<el-radio :label="1">退款</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="金额" prop="money">
<el-input
:value="chargeRefundMoneyDisplay"
placeholder="财务记录(元),如 9,999,999.00"
clearable
@input="onChargeRefundMoneyInput"
@blur="onChargeRefundMoneyBlur"
/>
</el-form-item>
<el-form-item label="积分" prop="amount">
<el-input
:value="chargeRefundAmountDisplay"
placeholder="变动积分,如 99,999,999"
clearable
@input="onChargeRefundAmountInput"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="chargeRefundForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填,最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitChargeRefund">确 定</el-button>
<el-button @click="chargeRefundOpen = false">取 消</el-button>
</div>
</el-dialog>
<el-dialog
:title="'积分更正 — ' + (editScoreForm.deptName || '')"
:visible.sync="editScoreOpen"
width="520px"
append-to-body
@close="resetEditScore"
>
<el-form ref="editScoreFormRef" :model="editScoreForm" :rules="editScoreRules" label-width="88px">
<el-form-item label="积分" prop="score">
<el-input
:value="editScoreScoreDisplay"
class="edit-score-input"
clearable
placeholder="正数增加,负数扣减,不能为0,如 9,999,999 或 -1,000,000"
@input="onEditScoreInput"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="editScoreForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="必填,最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitEditScore">确 定</el-button>
<el-button @click="editScoreOpen = false">取 消</el-button>
</div>
</el-dialog>
</div> </div>
</template> </template>
<style scoped>
.model-parm-block {
width: 100%;
}
.model-parm-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.model-parm-input-label {
width: 38%;
margin-right: 8px;
}
.model-parm-input-value {
flex: 1;
margin-right: 8px;
}
.model-parm-hint {
margin: 8px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.form-tip {
margin-right: 16px;
color: #e6a23c;
font-size: 12px;
}
.edit-score-input {
width: 100%;
}
</style>
<script> <script>
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept" import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild, getDeptArk, updateDeptArk, chargeRefundDept, editScore } from "@/api/system/dept"
import {
WESTERN_MONEY_MAX,
sanitizeMoneyDigits,
formatMoneyWesternDisplay,
moneyStringToNumber,
formatMoneyWesternFinal,
sanitizeIntDigits,
formatIntWesternDisplay,
intStringToNumber,
sanitizeSignedIntDigits,
formatSignedIntDisplay,
signedIntStringToNumber
} from "@/utils/westernNumberFormat"
import Treeselect from "@riophae/vue-treeselect" import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css" import "@riophae/vue-treeselect/dist/vue-treeselect.css"
import secondLevelDeptFilter from "@/mixins/secondLevelDeptFilter"
export default { export default {
name: "Dept", name: "Dept",
dicts: ['sys_normal_disable'], dicts: ['sys_normal_disable'],
components: { Treeselect }, components: { Treeselect },
mixins: [secondLevelDeptFilter],
data() { data() {
return { return {
// 遮罩层 // 遮罩层
@ -180,24 +428,103 @@ export default {
title: "", title: "",
// 是否显示弹出层 // 是否显示弹出层
open: false, open: false,
arkOpen: false,
arkDeptId: null,
arkForm: {
byteApiKey: undefined,
project: undefined,
modelParm: undefined
},
arkModelParamRows: [{ label: '', value: '' }],
chargeRefundOpen: false,
chargeRefundMoneyDisplay: "",
chargeRefundAmountDisplay: "",
chargeRefundForm: {
deptId: undefined,
deptName: "",
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
},
chargeRefundRules: {
orderType: [
{ required: true, message: "类型不能为空", trigger: "change" }
],
money: [
{ required: true, message: "金额不能为空", trigger: "blur" },
{ type: "number", min: 0, max: 10000000, message: "金额须在 0~10000000 之间", trigger: "blur" }
],
amount: [
{ required: true, message: "积分不能为空", trigger: "blur" },
{ type: "number", min: 1, max: 100000000, message: "积分须在 1~100000000 之间", trigger: "blur" }
],
remark: [
{ max: 50, message: "备注不能超过50个字符", trigger: "blur" }
]
},
editScoreOpen: false,
editScoreScoreDisplay: "",
editScoreForm: {
deptId: undefined,
deptName: "",
score: undefined,
remark: ""
},
editScoreRules: {
score: [
{ required: true, message: "积分不能为空", trigger: "blur" },
{
validator(rule, value, callback) {
if (value === undefined || value === null || value === "") {
callback(new Error("积分不能为空"))
} else if (!Number.isInteger(Number(value))) {
callback(new Error("积分须为整数"))
} else if (Number(value) === 0) {
callback(new Error("积分不能为0"))
} else if (Number(value) < -100000000 || Number(value) > 100000000) {
callback(new Error("积分须在 -100000000~100000000 之间(不含0)"))
} else {
callback()
}
},
trigger: "blur"
}
],
remark: [
{ required: true, message: "备注不能为空", trigger: "blur" },
{
validator(rule, value, callback) {
if (!value || String(value).trim() === "") {
callback(new Error("备注不能为空"))
} else {
callback()
}
},
trigger: "blur"
},
{ max: 50, message: "备注不能超过50个字符", trigger: "blur" }
]
},
// 是否展开,默认全部展开 // 是否展开,默认全部展开
isExpandAll: true, isExpandAll: true,
// 重新渲染表格状态 // 重新渲染表格状态
refreshTable: true, refreshTable: true,
// 查询参数 // 查询参数
queryParams: { queryParams: {
deptName: undefined, deptId: null,
status: undefined status: undefined
}, },
// 表单参数 // 表单参数
form: {}, form: {},
originalForm: {},
// 表单校验 // 表单校验
rules: { rules: {
parentId: [ parentId: [
{ required: true, message: "上级部门不能为空", trigger: "blur" } { required: true, message: "上级团队不能为空", trigger: "blur" }
], ],
deptName: [ deptName: [
{ required: true, message: "部门名称不能为空", trigger: "blur" } { required: true, message: "团队名称不能为空", trigger: "blur" }
], ],
orderNum: [ orderNum: [
{ required: true, message: "显示排序不能为空", trigger: "blur" } { required: true, message: "显示排序不能为空", trigger: "blur" }
@ -215,19 +542,304 @@ export default {
message: "请输入正确的手机号码", message: "请输入正确的手机号码",
trigger: "blur" trigger: "blur"
} }
],
maxUserCount: [
{ required: true, message: "账号上限不能为空", trigger: "change" },
{
type: "number",
min: 0,
max: 999999,
message: "账号上限须为 0–999999 的整数,0 表示不限制",
trigger: "change"
}
] ]
} }
} }
}, },
created() { created() {
this.loadSecondLevelDeptOptions()
this.getList() this.getList()
}, },
computed: {
isFirstLevelEditForm() {
return this.form.deptId !== undefined && this.isFirstLevelRow(this.form)
}
},
methods: { methods: {
/** 列表「剩余积分」:西式千分位,与业务积分展示一致;空为 — */
formatDeptBalance(value) {
if (value == null || value === "") {
return "—"
}
const n = Number(value)
if (Number.isNaN(n)) {
return "—"
}
return n.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: 0 })
},
onEditScoreInput(val) {
const s = sanitizeSignedIntDigits(val)
this.editScoreScoreDisplay = s === "" ? "" : s === "-" ? "-" : formatSignedIntDisplay(s)
this.editScoreForm.score = signedIntStringToNumber(s)
},
isFirstLevelRow(row) {
if (!row) {
return false
}
return Number(row.parentId) === 0 && String(row.ancestors || "") === "0"
},
isSecondLevelRow(row) {
if (!row) {
return false
}
const ancestors = String(row.ancestors || "").split(",").filter(Boolean)
return ancestors.length === 2
},
getFirstLevelDeptOptions(nodes) {
if (!Array.isArray(nodes)) {
return []
}
return nodes.filter(item => this.isFirstLevelRow(item)).map(item => {
const current = { ...item }
delete current.children
return current
})
},
syncArkModelRowsFromForm() {
this.arkModelParamRows = [{ label: '', value: '' }]
const raw = this.arkForm.modelParm
if (!raw || String(raw).trim() === '') {
return
}
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr) && arr.length) {
this.arkModelParamRows = arr.map(x => ({
label: (x && x.label) ? String(x.label) : '',
value: (x && x.value) ? String(x.value) : ''
}))
}
} catch (e) {
this.arkModelParamRows = [{ label: '', value: '' }]
}
},
buildArkModelParmPayload() {
const rows = (this.arkModelParamRows || []).filter(r => r.label && r.value)
this.arkForm.modelParm = rows.length ? JSON.stringify(rows) : ''
},
addArkModelParamRow() {
this.arkModelParamRows.push({ label: '', value: '' })
},
removeArkModelParamRow(idx) {
if (this.arkModelParamRows.length <= 1) {
return
}
this.arkModelParamRows.splice(idx, 1)
},
resetArkForm() {
this.arkDeptId = null
this.arkForm = {
byteApiKey: undefined,
project: undefined,
modelParm: undefined
}
this.arkModelParamRows = [{ label: '', value: '' }]
if (this.$refs.arkFormRef) {
this.$refs.arkFormRef.resetFields()
}
},
handleArkConfig(row) {
if (!this.isFirstLevelRow(row) && !this.isSecondLevelRow(row)) {
return
}
this.arkDeptId = row.deptId
getDeptArk(row.deptId).then(response => {
const d = response.data || {}
this.arkForm = {
byteApiKey: d.byteApiKey,
project: d.project,
modelParm: d.modelParm
}
this.syncArkModelRowsFromForm()
this.arkOpen = true
})
},
resetChargeRefund() {
this.chargeRefundMoneyDisplay = ""
this.chargeRefundAmountDisplay = ""
this.chargeRefundForm = {
deptId: undefined,
deptName: "",
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
}
this.$nextTick(() => {
if (this.$refs.chargeRefundFormRef) {
this.$refs.chargeRefundFormRef.clearValidate()
}
})
},
handleChargeRefund(row) {
if (!this.isFirstLevelRow(row) && !this.isSecondLevelRow(row)) {
return
}
this.chargeRefundMoneyDisplay = ""
this.chargeRefundAmountDisplay = ""
this.chargeRefundForm = {
deptId: row.deptId,
deptName: row.deptName,
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
}
this.chargeRefundOpen = true
this.$nextTick(() => {
if (this.$refs.chargeRefundFormRef) {
this.$refs.chargeRefundFormRef.clearValidate()
}
})
},
onChargeRefundMoneyInput(val) {
const sanitized = sanitizeMoneyDigits(val)
if (!sanitized) {
this.chargeRefundMoneyDisplay = ""
this.chargeRefundForm.money = undefined
return
}
const raw = parseFloat(sanitized)
if (!isNaN(raw) && raw > WESTERN_MONEY_MAX) {
this.chargeRefundForm.money = WESTERN_MONEY_MAX
this.chargeRefundMoneyDisplay = formatMoneyWesternFinal(WESTERN_MONEY_MAX)
return
}
this.chargeRefundMoneyDisplay = formatMoneyWesternDisplay(sanitized)
this.chargeRefundForm.money = moneyStringToNumber(sanitized)
},
onChargeRefundMoneyBlur() {
if (this.chargeRefundForm.money !== undefined && this.chargeRefundForm.money !== null) {
this.chargeRefundMoneyDisplay = formatMoneyWesternFinal(this.chargeRefundForm.money)
}
},
onChargeRefundAmountInput(val) {
const digits = sanitizeIntDigits(val)
this.chargeRefundAmountDisplay = digits === "" ? "" : formatIntWesternDisplay(digits)
this.chargeRefundForm.amount = intStringToNumber(digits)
},
submitChargeRefund() {
this.onChargeRefundMoneyBlur()
this.$refs["chargeRefundFormRef"].validate(valid => {
if (!valid) {
return
}
const data = {
deptId: this.chargeRefundForm.deptId,
orderType: this.chargeRefundForm.orderType,
money: this.chargeRefundForm.money,
amount: this.chargeRefundForm.amount,
remark: this.chargeRefundForm.remark ? this.chargeRefundForm.remark.trim() : undefined
}
chargeRefundDept(data).then(() => {
this.$modal.msgSuccess("操作成功")
this.chargeRefundOpen = false
this.getList()
})
})
},
resetEditScore() {
this.editScoreScoreDisplay = ""
this.editScoreForm = {
deptId: undefined,
deptName: "",
score: undefined,
remark: ""
}
this.$nextTick(() => {
if (this.$refs.editScoreFormRef) {
this.$refs.editScoreFormRef.clearValidate()
}
})
},
handleEditScore(row) {
if (!this.isFirstLevelRow(row) && !this.isSecondLevelRow(row)) {
return
}
this.editScoreScoreDisplay = ""
this.editScoreForm = {
deptId: row.deptId,
deptName: row.deptName,
score: undefined,
remark: ""
}
this.editScoreOpen = true
this.$nextTick(() => {
if (this.$refs.editScoreFormRef) {
this.$refs.editScoreFormRef.clearValidate()
}
})
},
submitEditScore() {
this.$refs["editScoreFormRef"].validate(valid => {
if (!valid) {
return
}
const remark = (this.editScoreForm.remark || "").trim()
const data = {
deptId: this.editScoreForm.deptId,
score: this.editScoreForm.score,
remark: remark
}
editScore(data).then(() => {
this.$modal.msgSuccess("操作成功")
this.editScoreOpen = false
this.getList()
})
})
},
submitArkForm() {
this.buildArkModelParmPayload()
const payload = {
deptId: this.arkDeptId,
byteApiKey: this.arkForm.byteApiKey !== undefined && this.arkForm.byteApiKey !== null ? this.arkForm.byteApiKey : '',
project: this.arkForm.project !== undefined && this.arkForm.project !== null ? this.arkForm.project : '',
modelParm: this.arkForm.modelParm !== undefined && this.arkForm.modelParm !== null ? this.arkForm.modelParm : ''
}
updateDeptArk(payload).then(() => {
this.$modal.msgSuccess('保存成功')
this.arkOpen = false
this.resetArkForm()
})
},
/** 二级团队下拉:走系统部门接口(与页面列表权限一致) */
secondLevelDeptListRequest(params) {
return listDept(params)
},
deptRowInSubtree(d, rootId) {
if (d.deptId != null && Number(d.deptId) === rootId) {
return true
}
if (!d.ancestors) {
return false
}
return String(d.ancestors)
.split(",")
.filter(Boolean)
.map(s => Number(s))
.includes(rootId)
},
/** 查询部门列表 */ /** 查询部门列表 */
getList() { getList() {
this.loading = true this.loading = true
listDept(this.queryParams).then(response => { const { deptId, ...deptListQuery } = this.queryParams
this.deptList = this.handleTree(response.data, "deptId") listDept(deptListQuery).then(response => {
let raw = response.data || []
if (deptId != null && deptId !== "") {
const rootId = Number(deptId)
raw = raw.filter(d => this.deptRowInSubtree(d, rootId))
}
this.deptList = this.handleTree(raw, "deptId")
this.loading = false this.loading = false
}) })
}, },
@ -252,13 +864,16 @@ export default {
this.form = { this.form = {
deptId: undefined, deptId: undefined,
parentId: undefined, parentId: undefined,
ancestors: undefined,
deptName: undefined, deptName: undefined,
orderNum: undefined, orderNum: undefined,
leader: undefined, leader: undefined,
phone: undefined, phone: undefined,
email: undefined, email: undefined,
maxUserCount: 0,
status: "0" status: "0"
} }
this.originalForm = {}
this.resetForm("form") this.resetForm("form")
}, },
/** 搜索按钮操作 */ /** 搜索按钮操作 */
@ -273,13 +888,18 @@ export default {
/** 新增按钮操作 */ /** 新增按钮操作 */
handleAdd(row) { handleAdd(row) {
this.reset() this.reset()
if (row != undefined) { if (row !== undefined) {
if (!this.isFirstLevelRow(row)) {
this.$modal.msgError("仅允许在一级团队下新增二级团队")
return
}
this.form.parentId = row.deptId this.form.parentId = row.deptId
} }
this.form.maxUserCount = 0
this.open = true this.open = true
this.title = "添加部门" this.title = "添加二级团队"
listDept().then(response => { listDept().then(response => {
this.deptOptions = this.handleTree(response.data, "deptId") this.deptOptions = this.getFirstLevelDeptOptions(this.handleTree(response.data, "deptId"))
}) })
}, },
/** 展开/折叠操作 */ /** 展开/折叠操作 */
@ -294,11 +914,16 @@ export default {
handleUpdate(row) { handleUpdate(row) {
this.reset() this.reset()
getDept(row.deptId).then(response => { getDept(row.deptId).then(response => {
this.form = response.data this.form = { ...response.data }
this.originalForm = { ...response.data }
if (this.form.maxUserCount == null) {
this.form.maxUserCount = 0
}
this.open = true this.open = true
this.title = "修改部门" this.title = "修改团队"
listDeptExcludeChild(row.deptId).then(response => { listDeptExcludeChild(row.deptId).then(response => {
this.deptOptions = this.handleTree(response.data, "deptId") const allOptions = this.handleTree(response.data, "deptId")
this.deptOptions = this.getFirstLevelDeptOptions(allOptions)
if (this.deptOptions.length == 0) { if (this.deptOptions.length == 0) {
const noResultsOptions = { deptId: this.form.parentId, deptName: this.form.parentName, children: [] } const noResultsOptions = { deptId: this.form.parentId, deptName: this.form.parentName, children: [] }
this.deptOptions.push(noResultsOptions) this.deptOptions.push(noResultsOptions)
@ -310,7 +935,21 @@ export default {
submitForm: function() { submitForm: function() {
this.$refs["form"].validate(valid => { this.$refs["form"].validate(valid => {
if (valid) { if (valid) {
if (this.form.deptId == undefined && !this.deptOptions.some(item => Number(item.deptId) === Number(this.form.parentId))) {
this.$modal.msgError("仅允许在一级团队下创建二级团队")
return
}
if (this.form.deptId != undefined) { if (this.form.deptId != undefined) {
if (this.isFirstLevelEditForm) {
const oldDept = this.originalForm || {}
this.form.parentId = oldDept.parentId
this.form.orderNum = oldDept.orderNum
this.form.leader = oldDept.leader
this.form.phone = oldDept.phone
this.form.email = oldDept.email
this.form.status = oldDept.status
this.form.maxUserCount = oldDept.maxUserCount
}
updateDept(this.form).then(response => { updateDept(this.form).then(response => {
this.$modal.msgSuccess("修改成功") this.$modal.msgSuccess("修改成功")
this.open = false this.open = false

View File

@ -298,11 +298,11 @@ export default {
}, },
{ {
value: "3", value: "3",
label: "本部门数据权限" label: "本团队数据权限"
}, },
{ {
value: "4", value: "4",
label: "本部门及以下数据权限" label: "本团队及以下数据权限"
}, },
{ {
value: "5", value: "5",

View File

@ -6,7 +6,7 @@
<pane size="16"> <pane size="16">
<el-col> <el-col>
<div class="head-container"> <div class="head-container">
<el-input v-model="deptName" placeholder="请输入部门名称" clearable size="small" prefix-icon="el-icon-search" style="margin-bottom: 20px" /> <el-input v-model="deptName" placeholder="请输入团队名称" clearable size="small" prefix-icon="el-icon-search" style="margin-bottom: 20px" />
</div> </div>
<div class="head-container"> <div class="head-container">
<el-tree :data="deptOptions" :props="defaultProps" :expand-on-click-node="false" :filter-node-method="filterNode" ref="tree" node-key="id" default-expand-all highlight-current @node-click="handleNodeClick" /> <el-tree :data="deptOptions" :props="defaultProps" :expand-on-click-node="false" :filter-node-method="filterNode" ref="tree" node-key="id" default-expand-all highlight-current @node-click="handleNodeClick" />
@ -61,7 +61,7 @@
<el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" /> <el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" />
<el-table-column label="用户名称" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" /> <el-table-column label="用户名称" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" />
<el-table-column label="用户昵称" align="center" key="nickName" prop="nickName" v-if="columns[2].visible" :show-overflow-tooltip="true" /> <el-table-column label="用户昵称" align="center" key="nickName" prop="nickName" v-if="columns[2].visible" :show-overflow-tooltip="true" />
<el-table-column label="部门" align="center" key="deptName" prop="dept.deptName" v-if="columns[3].visible" :show-overflow-tooltip="true" /> <el-table-column label="团队" align="center" key="deptName" prop="dept.deptName" v-if="columns[3].visible" :show-overflow-tooltip="true" />
<el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" /> <el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" />
<el-table-column label="状态" align="center" key="status" v-if="columns[5].visible"> <el-table-column label="状态" align="center" key="status" v-if="columns[5].visible">
<template slot-scope="scope"> <template slot-scope="scope">
@ -236,7 +236,7 @@ export default {
enabledDeptOptions: undefined, enabledDeptOptions: undefined,
// 是否显示弹出层 // 是否显示弹出层
open: false, open: false,
// 部门名称 // 团队名称
deptName: undefined, deptName: undefined,
// 默认密码 // 默认密码
initPassword: undefined, initPassword: undefined,

View File

@ -24,7 +24,7 @@
<div class="pull-right">{{ user.email }}</div> <div class="pull-right">{{ user.email }}</div>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<svg-icon icon-class="tree" />所属部门 <svg-icon icon-class="tree" />所属团队
<div class="pull-right" v-if="user.dept">{{ user.dept.deptName }} / {{ postGroup }}</div> <div class="pull-right" v-if="user.dept">{{ user.dept.deptName }} / {{ postGroup }}</div>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">

View File

@ -4,3 +4,4 @@ VITE_AMAP_SAFE_KEY = 23d3fdbc6b4eaf65d4a82a16510938c9
VITE_REGISTER_SOURCE = ALL VITE_REGISTER_SOURCE = ALL
#VITE_REGISTER_SOURCE = XM001 #VITE_REGISTER_SOURCE = XM001
VITE_SHOW_PAY = SUCCESS VITE_SHOW_PAY = SUCCESS
VITE_API_URL = http://47.86.170.114:8011/api

View File

@ -1,3 +1,3 @@
# 端口 # 端口
VITE_PORT = 8887 VITE_PORT = 8887
VITE_API_URL = https://api.undressing.name/api VITE_API_URL = http://47.86.170.114:8011/api

View File

@ -10,7 +10,7 @@ yarn-error.log*
/test/e2e/reports/ /test/e2e/reports/
selenium-debug.log selenium-debug.log
nodejs/public/uploads nodejs/public/uploads
*.zip
# Editor directories and files # Editor directories and files
.idea .idea
.vscode .vscode

View File

@ -7,8 +7,9 @@
content="width=device-width,initial-scale=1.0, maximum-scale=1.0,minimum-scale=1.0" /> content="width=device-width,initial-scale=1.0, maximum-scale=1.0,minimum-scale=1.0" />
<link <link
rel="icon" rel="icon"
href="/images/logo.png" /> type="image/svg+xml"
<title>asio</title> href="/favicon.svg" />
<title>智绘影音</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -23,25 +23,30 @@ body {
position: relative; position: relative;
} }
body[arco-theme='dark'] {
color: #e5e7eb;
background-color: #121826;
}
// firefox 滚动条样式 // firefox 滚动条样式
* { * {
scrollbar-color: rgba(144, 147, 153, 0.5) #0f0f12; scrollbar-color: rgba(77, 137, 255, 0.35) #121826;
} }
/*滚动条整体样式*/ /*滚动条整体样式*/
::-webkit-scrollbar { ::-webkit-scrollbar {
background-color: #0f0f12; background-color: #121826;
} }
/*滚动条里面小方块*/ /*滚动条里面小方块*/
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: red; background-color: rgba(77, 137, 255, 0.35);
border: 3px solid #0f0f12; border: 3px solid #121826;
} }
/*滚动条里面轨道*/ /*滚动条里面轨道*/
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0f0f12; background: #121826;
} }
:focus { :focus {
@ -67,11 +72,22 @@ body {
// 正常页面切换动画 // 正常页面切换动画
.page-enter-active { .page-enter-active {
animation: page-in 0.3s; animation: page-in 0.35s ease-out;
} }
.page-leave-active { .page-leave-active {
animation: page-in 0.3s reverse; animation: page-in 0.28s ease-in reverse;
}
/* 路由视图淡入淡出(与 appMain 中 portal-page 过渡配合) */
.portal-page-enter-active,
.portal-page-leave-active {
transition: opacity 0.32s ease;
}
.portal-page-enter-from,
.portal-page-leave-to {
opacity: 0;
} }
@keyframes page-in { @keyframes page-in {
@ -195,11 +211,11 @@ fieldset {
padding: 20px 10px; padding: 20px 10px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
color: #fd8000; color: #f59e0b;
} }
.page-error { .page-error {
color: red; color: #ef4444;
font-size: 20px; font-size: 20px;
padding: 30px; padding: 30px;
} }
@ -240,33 +256,51 @@ input:-webkit-autofill {
width: 100vw !important; width: 100vw !important;
height: 100vh !important; height: 100vh !important;
z-index: 100 !important; z-index: 100 !important;
background: #fff; background: #121826;
padding: 12px !important; padding: 12px !important;
} }
.arco-modal-simple { .arco-modal-simple {
background: #0f0f12; background: #1a1f2e;
border-radius: 10px; border-radius: 12px;
border: 2px solid #e6217a; border: none;
box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.12),
0 4px 20px rgba(0, 0, 0, 0.35);
.arco-modal-title { .arco-modal-title {
color: #fff; color: #e5e7eb;
} }
.arco-modal-body { .arco-modal-body {
color: #fff; color: #e5e7eb;
} }
.arco-modal-footer { .arco-modal-footer {
.arco-btn-secondary { .arco-btn-secondary {
color: #ffffff; color: #e5e7eb;
background-color: #1a1a1a; background-color: #252b3a;
border: 1px solid #1a1a1a; border: 1px solid rgba(77, 137, 255, 0.15);
&:hover { &:hover {
background-color: #262626; background-color: rgba(77, 137, 255, 0.14);
box-shadow: 0 0 12px rgba(77, 137, 255, 0.25);
} }
} }
} }
} }
/* 主按钮 hover:轻微提亮 + 外发光 */
.arco-btn-primary:not(.arco-btn-disabled):hover {
filter: brightness(1.06);
box-shadow: 0 0 12px rgba(77, 137, 255, 0.4);
}
/* 输入类 focus 同色系发光边框 */
.arco-input-wrapper:focus-within:not(.arco-input-disabled),
.arco-textarea-wrapper:focus-within:not(.arco-textarea-disabled),
.arco-select-view:focus-within:not(.arco-select-view-disabled) {
border-color: rgb(var(--primary-6)) !important;
box-shadow: 0 0 0 2px rgba(77, 137, 255, 0.22) !important;
}
@media (max-width: 576px) { @media (max-width: 576px) {
.arco-modal-simple { .arco-modal-simple {
width: calc(100% - 20px); width: calc(100% - 20px);

File diff suppressed because one or more lines are too long

View File

@ -615,4 +615,4 @@
right: 15px right: 15px
} }
/*# sourceMappingURL=quill.core.css.map*/ /* Quill core styles - source map removed to fix build error */

View File

@ -1286,4 +1286,4 @@
border: 1px solid #ccc border: 1px solid #ccc
} }
/*# sourceMappingURL=quill.snow.css.map*/ /* Quill snow styles - source map removed to fix build error */

View File

@ -1,10 +1,40 @@
/* portal-ui 暗色科技主题 — 设计令牌(与 Arco 暗色模式协同) */
body { body {
// 背景色 - 浅 --portal-bg-page: #121826;
--mf-color-bg-3: #f8f8fa; --portal-bg-card: #1a1f2e;
--portal-bg-sub: #252b3a;
--portal-text: #e5e7eb;
--portal-text-2: #9ca3af;
--portal-text-disabled: #6b7280;
--portal-primary: #4d89ff;
--portal-accent: #9d6bff;
--portal-success: #10b981;
--portal-warning: #f59e0b;
--portal-danger: #ef4444;
--portal-glow-border: rgba(77, 137, 255, 0.1);
--portal-shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.3);
--portal-shadow-glow: 0 0 12px rgba(77, 137, 255, 0.4);
--mf-color-bg-3: #f8f8fa;
--his-border-color: rgb(166, 124, 82, 0.3); --his-border-color: rgb(166, 124, 82, 0.3);
} }
body[arco-theme='dark'] { body[arco-theme='dark'] {
// 背景色 - 浅 --mf-color-bg-3: #252b3a;
--mf-color-bg-3: #17171a; /* Arco 语义色贴近本主题层级 */
--color-bg-1: #121826;
--color-bg-2: #1a1f2e;
--color-bg-3: #252b3a;
--color-bg-4: #2e3548;
--color-bg-5: #363d52;
--color-fill-1: rgba(255, 255, 255, 0.04);
--color-fill-2: rgba(255, 255, 255, 0.06);
--color-fill-3: rgba(255, 255, 255, 0.08);
--color-border-1: rgba(77, 137, 255, 0.12);
--color-border-2: rgba(77, 137, 255, 0.18);
--color-border-3: rgba(77, 137, 255, 0.25);
--color-text-1: #e5e7eb;
--color-text-2: #9ca3af;
--color-text-3: #9ca3af;
--color-text-4: #6b7280;
} }

View File

@ -8,10 +8,6 @@
fullscreen fullscreen
:class="`${prefixCls}-wrapper`" :class="`${prefixCls}-wrapper`"
:modal-class="`${prefixCls}-dialog`"> :modal-class="`${prefixCls}-dialog`">
<mf-video
autoplay
:controls="false"
modelValue="https://images.iqyjsnwv.com/tmp/hello-BCSGJ8fP.mp4" />
<div :class="`${prefixCls}-shadow`"> <div :class="`${prefixCls}-shadow`">
<div :class="`${prefixCls}-title`"> <div :class="`${prefixCls}-title`">
{{ $t('common.fbTitle') }} {{ $t('common.fbTitle') }}
@ -38,6 +34,8 @@
</template> </template>
<script> <script>
import i18n from '@/lang/i18n'
export default { export default {
name: 'mf-forbidden', name: 'mf-forbidden',
data() { data() {
@ -53,7 +51,13 @@ export default {
this.$router.replace('/403') this.$router.replace('/403')
}, },
ok() { ok() {
this.$store.dispatch('main/setForbidden', false) // 满18+:关闭弹窗并进入首页
Promise.resolve(this.$store.dispatch('main/setForbidden', false)).finally(() => {
// 默认语言:繁体中文(zh_HK)
this.$store.dispatch('main/setLanguage', 'zh_HK')
i18n.global.locale = 'zh_HK'
this.$router.push({ name: 'index' })
})
} }
} }
} }
@ -77,9 +81,9 @@ export default {
overflow: hidden; overflow: hidden;
// width: 428px; // width: 428px;
// height: 258px; // height: 258px;
// background: linear-gradient(0deg, #271433 0%, #e6217a 49%); // background: linear-gradient(160deg, #121826 0%, #1a1f2e 100%);
// border-radius: 20px; // border-radius: 20px;
// border: 2px solid #e6217a; // border: none; box-shadow: inset 0 0 0 1px rgba(77,137,255,0.1);
// top: 45% !important; // top: 45% !important;
// transform: translateY(-45%) !important; // transform: translateY(-45%) !important;

View File

@ -41,9 +41,12 @@ export default {
<style lang="less"> <style lang="less">
.preview-dialog { .preview-dialog {
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #1a1f2e;
border-radius: 20px; border-radius: 12px;
border: 2px solid #e6217a; border: none;
box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.3);
height: 520px; height: 520px;
width: 864px; width: 864px;
top: 45% !important; top: 45% !important;

View File

@ -520,15 +520,18 @@ export default {
<style lang="less"> <style lang="less">
.test-pay-dialog { .test-pay-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 500px; width: 500px;
// height: 320px; // height: 320px;
top: 45% !important; top: 45% !important;
@ -621,15 +624,18 @@ export default {
} }
.vm-card-dialog { .vm-card-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 600px; width: 600px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
@ -700,19 +706,20 @@ export default {
} }
:deep(.arco-select-dropdown) { :deep(.arco-select-dropdown) {
background-color: rgba(39, 20, 51, 0.95); background-color: #1a1f2e;
border: 1px solid #e6217a; border: 1px solid rgba(77, 137, 255, 0.25);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
} }
:deep(.arco-select-option) { :deep(.arco-select-option) {
color: #ffffff; color: #ffffff;
&:hover { &:hover {
background-color: rgba(230, 33, 122, 0.3); background-color: rgba(77, 137, 255, 0.14);
} }
&.arco-select-option-selected { &.arco-select-option-selected {
background-color: rgba(230, 33, 122, 0.5); background-color: rgba(77, 137, 255, 0.22);
} }
} }
} }

View File

@ -122,11 +122,13 @@ export default {
display: flex; display: flex;
flex-flow: wrap; flex-flow: wrap;
&-dialog { &-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #1a1f2e;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.3);
width: 500px; width: 500px;
top: 45% !important; top: 45% !important;
transform: translateY(-45%) !important; transform: translateY(-45%) !important;

View File

@ -0,0 +1,460 @@
<template>
<div class="rich-editor-root">
<!-- 工具栏 -->
<div class="toolbar">
<button class="tool-btn" type="button" @click="execCommand('bold')"><b>B</b></button>
<button class="tool-btn" type="button" @click="execCommand('italic')"><i>I</i></button>
<button class="tool-btn" type="button" @click="openImagePicker">{{ $t('common.insertImage') || '插入图片' }}</button>
<button class="tool-btn" type="button" @click="clear">清空</button>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept="image/*"
multiple
@change="handleSelectFiles"
/>
<!-- 编辑器区域 -->
<div
ref="editorRef"
class="user-input rich-editor"
contenteditable="true"
:data-placeholder="placeholder || '请输入文本...'"
@input="handleInput"
@keyup="handleKeyup"
@click="handleEditorClick"
@keydown="handleKeydown"
@paste="handlePaste"
></div>
<!-- @ 图片选择面板 -->
<div v-if="mentionVisible" class="mention-panel">
<div
v-for="(item, idx) in mentionImageList"
:key="item.url"
class="mention-item"
@mousedown.prevent="insertMentionImage(item)"
>
<img :src="item.url" class="mention-thumb" />
<span class="mention-label">@图{{ idx + 1 }}</span>
</div>
<div v-if="mentionImageList.length === 0" class="mention-empty">
{{ $t('common.noImageToMention') || '暂无可引用图片' }}
</div>
</div>
</div>
</template>
<script>
import { nextTick, onMounted, ref, watch } from 'vue'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
export default {
name: 'RichTextEditor',
props: {
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入文本生成视频...'
},
uploadedImages: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'text-change', 'image-upload'],
setup(props, { emit }) {
const editorRef = ref(null)
const fileInputRef = ref(null)
const savedSelectionRange = ref(null)
const mentionVisible = ref(false)
const mentionImageList = ref([])
const MAX_IMAGE_COUNT = 4
// 执行编辑器命令
const execCommand = (command) => {
if (!editorRef.value) return
editorRef.value.focus()
document.execCommand(command, false, null)
handleInput()
}
// 打开图片选择器
const openImagePicker = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择 - 上传到服务器
const handleSelectFiles = async (event) => {
const files = event.target.files
if (!files.length) return
for (let file of files) {
if (file.type.startsWith('image/')) {
try {
// 显示上传中提示
const loading = window.$message ? window.$message.loading('上传中...') : null
// 上传到后端
const res = await uploadFile({
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file: file,
name: 'file'
})
const imageUrl = extractUploadUrlFromResponse(res)
if (res && (Number(res.code) === 200 || res.code === 200) && imageUrl) {
insertImage(imageUrl, file.name)
// 通知父组件有新图片上传成功(用于@功能)
emit('image-upload', { url: imageUrl, name: file.name })
} else {
console.error('上传失败:', res)
alert('图片上传失败')
}
} catch (error) {
console.error('上传图片出错:', error)
alert('图片上传失败: ' + (error.message || '未知错误'))
}
}
}
// 清空input
event.target.value = ''
}
// 插入图片
const insertImage = (url, name = 'image') => {
if (!editorRef.value) return
editorRef.value.focus()
const selection = window.getSelection()
if (!selection) return
const img = document.createElement('img')
img.src = url
img.alt = name
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.margin = '4px 0'
img.setAttribute('data-image-name', name)
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(img)
// 添加空格
const space = document.createTextNode(' ')
range.setStartAfter(img)
range.setEndAfter(img)
range.insertNode(space)
handleInput()
}
// 处理输入
const handleInput = () => {
if (!editorRef.value) return
const content = editorRef.value.innerHTML
emit('update:modelValue', content)
emit('text-change', content)
}
// 处理按键
const handleKeyup = (e) => {
if (e.key === '@') {
showMentionPanel()
} else if (e.key === 'Escape' && mentionVisible.value) {
mentionVisible.value = false
}
}
const handleKeydown = (e) => {
if (e.key === '@') {
// 保存当前选择位置
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
savedSelectionRange.value = selection.getRangeAt(0).cloneRange()
}
}
}
const insertPlainTextAtCursor = (text) => {
if (!editorRef.value || text == null) return
const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
editorRef.value.focus()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
let range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) {
const r = document.createRange()
r.selectNodeContents(editorRef.value)
r.collapse(false)
selection.removeAllRanges()
selection.addRange(r)
range = selection.getRangeAt(0)
}
range.deleteContents()
const lines = normalized.split('\n')
const fragment = document.createDocumentFragment()
lines.forEach((line, index) => {
if (line) fragment.appendChild(document.createTextNode(line))
if (index < lines.length - 1) fragment.appendChild(document.createElement('br'))
})
range.insertNode(fragment)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
const handlePaste = (e) => {
if (!editorRef.value) return
const cd = e.clipboardData
if (!cd) return
const items = cd.items
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const blob = items[i].getAsFile()
const reader = new FileReader()
reader.onload = (event) => {
insertImage(event.target.result, 'pasted-image')
}
reader.readAsDataURL(blob)
e.preventDefault()
return
}
}
}
let text = cd.getData('text/plain') || ''
if (!text && cd.getData('text/html')) {
const tmp = document.createElement('div')
tmp.innerHTML = cd.getData('text/html')
text = tmp.innerText || ''
}
e.preventDefault()
insertPlainTextAtCursor(text)
handleInput()
}
// 显示 @ 面板
const showMentionPanel = () => {
if (props.uploadedImages && props.uploadedImages.length > 0) {
mentionImageList.value = props.uploadedImages.slice(0, MAX_IMAGE_COUNT)
mentionVisible.value = true
}
}
// 插入 @ 图片
const insertMentionImage = (imageItem) => {
if (!editorRef.value || !savedSelectionRange.value) {
mentionVisible.value = false
return
}
editorRef.value.focus()
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(savedSelectionRange.value)
}
const img = document.createElement('img')
img.src = imageItem.url
img.alt = '@图片'
img.style.maxWidth = '60px'
img.style.height = 'auto'
img.style.verticalAlign = 'middle'
img.setAttribute('data-mention', 'true')
const range = savedSelectionRange.value
range.deleteContents()
range.insertNode(img)
// 添加空格
const space = document.createTextNode(' ')
range.setStartAfter(img)
range.setEndAfter(img)
range.insertNode(space)
mentionVisible.value = false
handleInput()
}
// 清空编辑器
const clear = () => {
if (!editorRef.value) return
editorRef.value.innerHTML = ''
handleInput()
}
// 处理编辑器点击
const handleEditorClick = () => {
if (mentionVisible.value) {
mentionVisible.value = false
}
}
// 监听 props 变化
watch(() => props.modelValue, (newValue) => {
if (editorRef.value && editorRef.value.innerHTML !== newValue) {
editorRef.value.innerHTML = newValue || ''
}
})
// 监听上传的图片列表更新
watch(() => props.uploadedImages, (newImages) => {
mentionImageList.value = newImages || []
}, { immediate: true })
onMounted(() => {
if (editorRef.value) {
editorRef.value.innerHTML = props.modelValue || ''
}
})
return {
editorRef,
fileInputRef,
mentionVisible,
mentionImageList,
execCommand,
openImagePicker,
handleSelectFiles,
handleInput,
handleKeyup,
handleKeydown,
handlePaste,
handleEditorClick,
insertMentionImage,
clear
}
}
}
</script>
<style lang="less" scoped>
.rich-editor-root {
position: relative;
margin-bottom: 16px;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
padding: 8px;
background: #1a1b20;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
.tool-btn {
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
&:hover {
background: rgba(255,255,255,0.2);
}
}
}
.rich-editor {
min-height: 120px;
padding: 12px;
background: #1a1b20;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
line-height: 1.6;
font-size: 14px;
outline: none;
overflow-y: auto;
/* 覆盖粘贴残留的内联颜色,保证深底上始终可读 */
:deep(*) {
color: #fff !important;
}
&:empty:before {
content: attr(data-placeholder);
color: #666;
pointer-events: none;
}
img {
max-width: 100%;
height: auto;
margin: 4px 0;
border-radius: 4px;
}
}
.mention-panel {
position: absolute;
top: 100%;
left: 0;
background: #1f2128;
border: 1px solid #3a3d47;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
width: 280px;
.mention-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.1);
&:hover {
background: #2a2d38;
}
&:last-child {
border-bottom: none;
}
.mention-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
margin-right: 12px;
}
.mention-label {
color: #a1a4b3;
font-size: 13px;
}
}
.mention-empty {
padding: 20px;
text-align: center;
color: #666;
font-size: 13px;
}
}
.hidden-input {
display: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,600 @@
<template>
<div class="video-editor-root">
<div v-if="showToolbar" class="toolbar">
<button class="tool-btn" type="button" @click="openImagePicker">插入参考素材</button>
<button class="tool-btn" type="button" @click="clear">清空所有参考素材和文字</button>
</div>
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept="image/*,video/*,audio/*"
multiple
@change="handleSelectFiles" />
<div class="editor-wrapper">
<div
ref="editorRef"
class="user-input video-rich-editor"
contenteditable="true"
:data-placeholder="placeholder || '请输入文本生成视频...'"
@input="handleInput"
@paste="handlePaste"
@keyup="handleKeyup"
@click="handleEditorClick"></div>
<div v-if="mentionVisible" class="mention-panel">
<div
v-for="item in mentionImageList"
:key="item.url"
class="mention-item"
@mousedown.prevent="insertMentionImage(item)">
<img v-if="item.mediaType === 'image'" :src="item.url" class="mention-thumb" alt="" />
<span v-else class="mention-type-badge">{{ item.mediaType === 'video' ? '视频' : '音频' }}</span>
<span class="mention-label">参考{{ item.refNo }}</span>
</div>
<div v-if="mentionImageList.length === 0" class="mention-empty">暂无可引用素材</div>
</div>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
const props = defineProps({
placeholder: {
type: String,
default: ''
},
/** 文生视频等场景可隐藏工具栏,仅保留纯文本编辑 */
showToolbar: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['text-change'])
const editorRef = ref(null)
const fileInputRef = ref(null)
const savedSelectionRange = ref(null)
const MAX_IMAGE_COUNT = 9
const mentionVisible = ref(false)
const mentionImageList = ref([])
const detectMediaType = (file) => {
const mime = (file?.type || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
const lowerName = (file?.name || '').toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(lowerName)) return 'video'
if (/\.(mp3|wav|aac|m4a|ogg|flac)$/.test(lowerName)) return 'audio'
return 'image'
}
const getPlainText = () => (editorRef.value?.innerText || '').trim()
const focusEditorToEnd = () => {
if (!editorRef.value) return
editorRef.value.focus()
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(editorRef.value)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
savedSelectionRange.value = range.cloneRange()
}
const saveSelection = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (editorRef.value?.contains(range.commonAncestorContainer)) {
savedSelectionRange.value = range.cloneRange()
}
}
const restoreSelection = () => {
const selection = window.getSelection()
if (!selection || !editorRef.value) return
selection.removeAllRanges()
if (savedSelectionRange.value) {
selection.addRange(savedSelectionRange.value)
} else {
const range = document.createRange()
range.selectNodeContents(editorRef.value)
range.collapse(false)
selection.addRange(range)
}
}
const handleInput = () => {
emit('text-change', getPlainText())
saveSelection()
syncMentionPanelVisibility()
}
const insertPlainTextAtCursor = (text) => {
if (!editorRef.value) return
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
editorRef.value.focus()
restoreSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
let range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) {
focusEditorToEnd()
const currentSelection = window.getSelection()
if (!currentSelection || currentSelection.rangeCount === 0) return
range = currentSelection.getRangeAt(0)
}
range.deleteContents()
const lines = normalizedText.split('\n')
const fragment = document.createDocumentFragment()
lines.forEach((line, index) => {
if (line) fragment.appendChild(document.createTextNode(line))
if (index < lines.length - 1) fragment.appendChild(document.createElement('br'))
})
range.insertNode(fragment)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
saveSelection()
}
const handlePaste = (event) => {
if (!editorRef.value) return
event.preventDefault()
const clipboardText = event.clipboardData?.getData('text/plain') || ''
if (!clipboardText) return
insertPlainTextAtCursor(clipboardText)
emit('text-change', getPlainText())
syncMentionPanelVisibility()
}
const updateMentionImageList = () => {
mentionImageList.value = mentionImageList.value.slice(0, MAX_IMAGE_COUNT)
}
const hideMentionPanel = () => {
mentionVisible.value = false
}
const hasActiveMentionTriggerBeforeCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!range.collapsed) return false
if (!editorRef.value?.contains(range.commonAncestorContainer)) return false
const container = range.startContainer
if (container.nodeType !== Node.TEXT_NODE) return false
const before = container.data.slice(0, range.startOffset)
const atIndex = before.lastIndexOf('@')
if (atIndex < 0) return false
const afterAt = before.slice(atIndex + 1)
return !/\s/.test(afterAt)
}
const syncMentionPanelVisibility = () => {
const shouldShow = hasActiveMentionTriggerBeforeCursor()
if (!shouldShow) {
hideMentionPanel()
return
}
updateMentionImageList()
mentionVisible.value = true
}
const removeMentionKeywordBeforeCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!range.collapsed) return
const container = range.startContainer
if (container.nodeType !== Node.TEXT_NODE) return
const before = container.data.slice(0, range.startOffset)
const atIndex = before.lastIndexOf('@')
if (atIndex < 0) return
const afterAt = before.slice(atIndex + 1)
if (/\s/.test(afterAt)) return
const removeRange = document.createRange()
removeRange.setStart(container, atIndex)
removeRange.setEnd(container, range.startOffset)
removeRange.deleteContents()
}
const insertReference = (item) => {
if (!item?.url || !editorRef.value) return
editorRef.value.focus()
restoreSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) return
const refNoText = `[图${item.refNo}]`
let node
if (item.mediaType === 'image') {
const img = document.createElement('img')
img.src = item.url
img.alt = `reference-${item.refNo}`
img.className = 'inline-rich-image'
img.setAttribute('data-image-url', item.url)
img.setAttribute('data-mention-reference', '1')
img.setAttribute('data-reference-no', String(item.refNo))
node = img
} else {
const tag = document.createElement('span')
tag.className = 'inline-rich-reference'
tag.textContent = refNoText
tag.setAttribute('data-reference-url', item.url)
tag.setAttribute('data-mention-reference', '1')
tag.setAttribute('data-reference-no', String(item.refNo))
tag.setAttribute('data-reference-type', item.mediaType)
node = tag
}
range.insertNode(node)
const space = document.createTextNode(' ')
range.setStartAfter(node)
range.insertNode(space)
range.setStartAfter(space)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
saveSelection()
}
const insertMentionImage = (item) => {
if (!item?.url || !editorRef.value) return
editorRef.value.focus()
restoreSelection()
removeMentionKeywordBeforeCursor()
// 删除 @关键字 后必须重新记录选区,否则 insertReference 里 restoreSelection 仍用删除前的 Range,会插错位置甚至覆盖后文
saveSelection()
insertReference(item)
hideMentionPanel()
emit('text-change', getPlainText())
}
const handleKeyup = (event) => {
saveSelection()
if (event.key === '@') {
syncMentionPanelVisibility()
return
}
if (event.key === 'Escape') {
hideMentionPanel()
return
}
syncMentionPanelVisibility()
}
const handleEditorClick = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
focusEditorToEnd()
return
}
const range = selection.getRangeAt(0)
if (!editorRef.value?.contains(range.commonAncestorContainer)) {
focusEditorToEnd()
return
}
saveSelection()
syncMentionPanelVisibility()
}
const getInsertedUniqueImageCount = () => mentionImageList.value.length
const openImagePicker = () => {
const current = getInsertedUniqueImageCount()
if (current >= MAX_IMAGE_COUNT) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
return
}
fileInputRef.value?.click()
}
const uploadToCos = async (file) => {
const res = await uploadFile({
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file,
name: 'file'
})
const codeOk = res && (Number(res.code) === 200 || res.code === 200)
const url = extractUploadUrlFromResponse(res)
if (codeOk && url) return url
throw new Error(res?.msg || '上传失败:未返回文件地址')
}
const handleSelectFiles = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
if (!files.length) return
const remain = MAX_IMAGE_COUNT - getInsertedUniqueImageCount()
if (remain <= 0) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
input.value = ''
return
}
const selected = files.slice(0, remain)
if (files.length > remain) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材,本次仅插入 ${remain} 个`)
}
for (const file of selected) {
const mediaType = detectMediaType(file)
try {
const url = await uploadToCos(file)
if (url && !mentionImageList.value.some((item) => item.url === url)) {
const refNo = mentionImageList.value.length + 1
mentionImageList.value.push({ url, refNo, mediaType })
}
} catch (e) {
Message.error('参考素材上传失败')
console.error(e)
}
}
input.value = ''
emit('text-change', getPlainText())
}
const getContentItems = () => {
const editor = editorRef.value
const items = []
if (!editor) return items
let textBuffer = ''
const flushText = () => {
const normalized = textBuffer.replace(/\u00a0/g, ' ').replace(/\s+\n/g, '\n')
if (normalized.trim()) items.push({ type: 'text', text: normalized.trim() })
textBuffer = ''
}
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
textBuffer += node.textContent || ''
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node
const referenceUrl = (
el.dataset?.referenceUrl ||
el.dataset?.imageUrl ||
el.getAttribute('src') ||
''
).trim()
if (el.dataset?.mentionReference === '1' && referenceUrl) {
flushText()
items.push({
type: 'image_url',
image_url: { url: referenceUrl },
role: 'reference_image'
})
return
}
if (el.tagName === 'IMG' && (el.dataset?.imageUrl || el.getAttribute('src'))) {
return
}
if (el.tagName === 'BR') {
textBuffer += '\n'
return
}
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
Array.from(el.childNodes).forEach(walk)
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
}
Array.from(editor.childNodes).forEach(walk)
flushText()
return items
}
const clear = () => {
if (!editorRef.value) return
editorRef.value.innerHTML = ''
emit('text-change', '')
savedSelectionRange.value = null
mentionImageList.value = []
hideMentionPanel()
}
/** 供父组件持久化:参考图模式的 HTML + 素材列表 */
const getDraftState = () => ({
html: editorRef.value ? editorRef.value.innerHTML : '',
mentionList: mentionImageList.value.map(({ url, refNo, mediaType }) => ({
url,
refNo,
mediaType
}))
})
const applyDraftState = (draft) => {
hideMentionPanel()
savedSelectionRange.value = null
if (!draft || typeof draft !== 'object') {
clear()
return
}
mentionImageList.value = Array.isArray(draft.mentionList)
? draft.mentionList.map((x) => ({
url: x.url,
refNo: Number(x.refNo) || 0,
mediaType: x.mediaType || 'image'
}))
: []
if (editorRef.value) {
editorRef.value.innerHTML = typeof draft.html === 'string' ? draft.html : ''
}
nextTick(() => {
emit('text-change', getPlainText())
})
}
defineExpose({
getContentItems,
getPlainText,
clear,
getDraftState,
applyDraftState
})
onMounted(() => {
nextTick(() => {
focusEditorToEnd()
})
})
</script>
<style scoped>
.video-editor-root {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hidden-input {
display: none;
}
.editor-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
}
.tool-btn {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.88);
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s, background 0.2s;
}
.tool-btn:hover {
border-color: rgba(0, 202, 224, 0.4);
background: rgba(0, 202, 224, 0.08);
}
.video-rich-editor {
width: 100%;
flex: 1;
min-height: 220px;
font-size: 15px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
color: rgba(255, 255, 255, 0.9);
caret-color: #4d89ff;
:deep(*) {
color: rgba(255, 255, 255, 0.9) !important;
}
:deep(.inline-rich-reference) {
color: #5eebf5 !important;
background: rgba(0, 202, 224, 0.15);
}
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 16px 18px;
background: rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.video-rich-editor:focus {
border-color: rgba(0, 202, 224, 0.45);
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.15);
outline: none;
}
.video-rich-editor:empty::before {
content: attr(data-placeholder);
color: rgba(255, 255, 255, 0.35);
pointer-events: none;
display: block;
}
.video-rich-editor :deep(.inline-rich-image) {
display: inline-block;
width: auto;
height: auto;
max-width: min(260px, 100%);
max-height: 148px;
object-fit: contain;
vertical-align: middle;
margin: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.video-rich-editor :deep(.inline-rich-reference) {
display: inline-flex;
align-items: center;
padding: 0 6px;
margin: 0 3px;
border-radius: 4px;
background: rgba(0, 202, 224, 0.15);
color: #5eebf5;
font-size: 0.85em;
line-height: 1.5;
}
.mention-panel {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
max-height: 180px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(22, 24, 30, 0.98);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
z-index: 20;
}
.mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
}
.mention-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.mention-thumb {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.mention-type-badge {
min-width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
background: rgba(0, 0, 0, 0.25);
}
.mention-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.mention-empty {
padding: 8px 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>

View File

@ -0,0 +1,179 @@
export default {
image1: 'خلع بضغطة واحدة',
image2: 'صورة إلى صورة 2',
uploadImage: 'رفع صورة',
uploadImageTip: 'PNG/JPG، بحد أقصى 10 ميجا',
uploadPlaceholder: 'انقر لرفع صورة',
selectImageSource: 'اختر مصدر الصورة',
selectTemplate: 'اختر القالب',
reselectTemplate: 'إعادة اختيار القالب',
noTemplates: 'لا توجد قوالب',
tag1: 'نوع الوسم 1',
tag2: 'نوع الوسم 2',
tag3: 'نوع الوسم 3',
generateImage: 'إنشاء الآن (يخصم {score} من الرصيد)',
generateImageNow: 'إنشاء الآن',
generateTip: 'بعد الإرسال يمكنك المشاهدة في "أعمالي"',
generateVideo: 'إنشاء فيديو',
imageFace: 'تبديل الوجه في صورة',
videoFace: 'تبديل الوجه في فيديو',
uploadImageFace: 'انقر لرفع صورة الوجه',
uploadTemplate: 'انقر لرفع قالب مخصص',
textPlaceholder: 'صف الصورة التي تريد إنشاءها',
uploadImageError: 'يرجى رفع صورة',
replaceImage: 'استبدال الصورة',
uploadFaceImageError: 'يرجى رفع صورة الوجه',
uploadTemplateError: 'يرجى رفع قالب مخصص',
textError: 'يرجى إدخال النص التوجيهي',
textVideoPlaceholder: 'صف الفيديو الذي تريد إنشاءه',
uploadFirstPlaceholder: 'انقر لرفع الإطار الأول',
uploadLastPlaceholder: 'انقر لرفع الإطار الأخير',
uploadFirstImageError: 'يرجى رفع صورة الإطار الأول',
uploadWaitImageError: 'يرجى انتظار انتهاء الرفع',
saveVideo: 'تحميل الفيديو',
videoLoadingText: 'جاري إنشاء الفيديو...',
viewVideo: 'عرض الفيديو',
changeFacePrompt: 'أخذ الوجه من الصورة الثانية واستبداله بالوجه في الأولى',
rechartTip1: 'عند الشحن via المحفظة تأكد من شبكة المحفظة.',
rechartTip2: 'قد يتأخر الشحن؛ انتظر 3–5 دقائق ثم حدّث.',
walletAddr: 'عنوان المحفظة:',
fbTitle: 'تحذير! هذا الموقع للبالغين فقط!',
fbContent: 'بالدخول أؤكد أن عمري 18 سنة أو أكثر',
fbCancel: 'أقل من 18',
fbOK: 'عمري 18+',
sorry: 'عذراً!',
useLess: 'لا يمكنك استخدام هذا الموقع...',
loginAccount: 'تسجيل الدخول',
logout: 'تسجيل الخروج',
userEmailPlaceholder: 'أدخل البريد أو اسم المستخدم',
passwordPlaceholder: 'أدخل كلمة المرور',
forgetPassword: 'نسيت كلمة المرور؟',
register: 'التسجيل',
login: 'دخول',
forgotPassword: 'نسيت كلمة المرور',
registerAccount: 'إنشاء حساب',
usernamePlaceholder: 'أدخل اسم المستخدم',
codePlaceholder: 'أدخل رمز التحقق',
confirmPasswordPlaceholder: 'تأكيد كلمة المرور',
backToLogin: 'العودة لتسجيل الدخول',
send: 'إرسال',
emailValidPlaceholder: 'أدخل بريداً إلكترونياً صحيحاً',
confirmPwdValidMsg: 'كلمات المرور غير متطابقة',
editPassword: 'تغيير كلمة المرور',
recharge: 'شحن',
myAccount: 'حسابي',
moneyInvite: 'دعوة مكافآت',
rechargeRecord: 'سجل الشحن',
resumeRecord: 'سجل الاستهلاك',
inviteRecord: 'سجل الدعوات',
username: 'اسم المستخدم',
userId: 'معرف المستخدم',
accountAmount: 'الرصيد',
editUserInfo: 'تعديل البيانات',
contact: 'تواصل معنا',
backToUser: 'رجوع',
moneyTips: 'عند دعوة صديق للتسجيل تحصل على {rate} من مبلغ شحنه في كل مرة.',
inviteCode: 'رمز الدعوة',
inviteLink: 'رابط الدعوة',
saveImage: 'حفظ الصورة',
totalAmount: 'إجمالي الشحن',
amount: 'المبلغ',
sendAmount: 'مبلغ الهدية',
rechargeType: 'طريقة الشحن',
rechargeTime: 'وقت الشحن',
emptyText: 'لا توجد بيانات',
product: 'عمل',
resumeAmount: 'المبلغ المستهلك',
productType: 'نوع العمل',
productTime: 'وقت الإنشاء',
totalReward: 'رصيد المكافآت',
rewardAmount: 'مبلغ المكافأة',
rewardTime: 'وقت المكافأة',
reSend: 'إعادة الإرسال',
registerSuccessfully: 'تم التسجيل بنجاح',
loginSuccessfully: 'تم تسجيل الدخول',
passwordResetSuccessfully: 'تم إعادة تعيين كلمة المرور',
rechargeSuccessfully: 'تم الشحن بنجاح',
avatar: 'الصورة الرمزية',
input: 'أدخل',
save: 'حفظ',
editEmail: 'تغيير البريد',
editEmailSuccessfully: 'تم تحديث البريد',
updateAvatarSuccessfully: 'تم تحديث الصورة الرمزية',
balenceLow: 'رصيدك غير كافٍ، يرجى الشحن',
confirm: 'تأكيد',
createFailed: 'فشل الإنشاء، تمت إعادة الرصيد',
notice: 'تنبيه',
oldPasswordPlaceholder: 'أدخل كلمة المرور القديمة',
newpasswordPlaceholder: 'أدخل كلمة المرور الجديدة',
switchPageTip: 'الفيديو قيد الإنشاء؛ اخرج وتحقق من سجل الاستهلاك.',
loginless: 'انتهت الجلسة! يرجى تسجيل الدخول مجدداً.',
createVideo: 'إنشاء فيديو (يخصم {price} من الرصيد)',
ok: 'تأكيد',
rechartNotice: 'تعليمات الشحن',
rechargeNotice1: '1. يُضاف الشحن فوراً. إن لم يتغير الرصيد حدّث الصفحة.',
rechargeNotice2: '2. الباقات المحدودة عروض؛ يمكنك الشحن أكثر من مرة.',
rechargeNotice3: '3. الرصيد المشحون لا ينتهي صلاحيته.',
rechargeNotice4: 'راجع الباقة قبل الشراء؛ لا استرداد.',
rechargeLeft: 'الرصيد',
dollor: 'USD',
isDevelop: 'قيد التطوير.',
copySuccessfully: 'تم النسخ',
copyLink: 'نسخ الرابط',
goPay: 'الذهاب للدفع',
filePreview: 'معاينة',
doSame: 'عمل نسخة مشابهة',
saveQRCode: 'حفظ رمز QR',
invitationCodePlaceholder: 'أدخل رمز الدعوة',
testRecharge: 'شحن تجريبي',
orderNo: 'رقم الطلب',
orderNoP: 'رقم الطلب مطلوب',
emailNotExists: 'هذا البريد غير مسجّل',
gearNotExists: 'خطة الشحن المختارة غير موجودة؛ اختر غيرها.',
myProduct: 'أعمالي',
permissionError: 'إجمالي شحنك {total}، لا يكفي للمعاينة.',
createTagFailed: 'فشل الإنشاء؛ حدّث وجرب مرة أخرى.',
loadingText: 'جاري التحميل...',
hasMore: 'اسحب لأعلى للمزيد',
noMore: 'لا مزيد',
giftAmount: 'المبلغ المُستَوفى',
cardNo: 'رقم البطاقة',
cardName: 'اسم صاحب البطاقة',
cardNoRequired: 'أدخل رقم البطاقة',
cardNameRequired: 'أدخل اسم صاحب البطاقة',
rechargeFailed: 'فشل الشحن',
vmCardInfo: 'بيانات البطاقة',
cardNumber: 'رقم البطاقة',
cardNumberPlaceholder: 'أدخل رقم البطاقة',
cardNumberRequired: 'أدخل رقم البطاقة',
cardNumberInvalid: 'رقم البطاقة يجب أن يكون 13–19 رقماً',
cvc: 'CVC',
cvcPlaceholder: 'أدخل CVC',
cvcRequired: 'أدخل CVC',
cvcInvalid: 'CVC يجب أن يكون 3 أرقام',
expYear: 'سنة انتهاء الصلاحية',
expYearPlaceholder: 'أدخل السنة (مثل 2027)',
expYearRequired: 'أدخل سنة انتهاء الصلاحية',
expYearInvalid: 'السنة يجب أن تكون 4 أرقام',
expMonth: 'شهر انتهاء الصلاحية',
expMonthPlaceholder: 'اختر الشهر',
expMonthRequired: 'اختر الشهر',
email: 'البريد',
emailPlaceholder: 'أدخل البريد',
emailRequired: 'أدخل البريد',
emailInvalid: 'صيغة البريد غير صحيحة',
emailMaxLength: 'البريد لا يتجاوز 30 حرفاً',
firstName: 'الاسم الأول',
firstNamePlaceholder: 'أدخل الاسم الأول',
firstNameRequired: 'أدخل الاسم الأول',
firstNameMaxLength: 'الاسم لا يتجاوز 30 حرفاً',
lastName: 'اسم العائلة',
lastNamePlaceholder: 'أدخل اسم العائلة',
lastNameRequired: 'أدخل اسم العائلة',
lastNameMaxLength: 'اسم العائلة لا يتجاوز 30 حرفاً',
country: 'الدولة',
countryPlaceholder: 'اختر الدولة',
countryRequired: 'اختر الدولة',
submit: 'إرسال',
cancel: 'إلغاء'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'الرئيسية',
imageToImage: 'خلع بضغطة واحدة',
imageToImage2: 'صورة إلى صورة 2',
changeFace: 'تبديل الوجه',
changeFaceVideo: 'تبديل الوجه في الفيديو',
fastImage: 'إنشاء صورة',
fastVideo: 'إنشاء فيديو',
recharge: 'شحن سريع',
help: 'مركز المساعدة',
moneyInvite: 'دعوة مكافآت',
assetGroupManage: 'إدارة مجموعات الأصول',
assetManage: 'إدارة الأصول'
}

View File

@ -143,6 +143,14 @@ export default {
cardNoRequired: 'Please enter card number', cardNoRequired: 'Please enter card number',
cardNameRequired: 'Please enter cardholder name', cardNameRequired: 'Please enter cardholder name',
rechargeFailed: 'Recharge failed', rechargeFailed: 'Recharge failed',
// Model selection
selectModel: 'Select Model',
seedance20: 'Seedance 2.0',
seedance20Fast: 'Seedance 2.0 Fast',
// Rich text editor
insertImage: 'Insert Image',
mentionImage: 'Mention Image',
noImageToMention: 'No images to mention',
// VM支付相关 // VM支付相关
vmCardInfo: 'Credit Card Information', vmCardInfo: 'Credit Card Information',
cardNumber: 'Card Number', cardNumber: 'Card Number',
@ -175,5 +183,9 @@ export default {
country: 'Country', country: 'Country',
countryPlaceholder: 'Select country', countryPlaceholder: 'Select country',
countryRequired: 'Please select country', countryRequired: 'Please select country',
submit: 'Submit' videoGen: 'Video Generation',
uploadFirstImage: 'Upload Reference Image',
insertImage: 'Insert Image',
submit: 'Submit',
cancel: 'Cancel'
} }

View File

@ -1,12 +1,15 @@
export default { export default {
index: 'Home', index: 'Home',
imageToImage: 'One-click undressing', imageToImage: 'Image-to-Image 1',
imageToImage2: 'Image-to-Image2', imageToImage2: 'Image-to-Image2',
changeFace: 'Swap Face', changeFace: 'Swap Face',
changeFaceVideo: 'Swap Video Face', changeFaceVideo: 'Swap Video Face',
fastImage: 'Gen Image', fastImage: 'Gen Image',
fastVideo: 'Gen Video', fastVideo: 'Gen Video',
videoGen: 'Video Generation',
recharge: 'Quick Recharge', recharge: 'Quick Recharge',
help: 'Help Center', help: 'Help Center',
moneyInvite: 'Reward Invitation' moneyInvite: 'Reward Invitation',
assetGroupManage: 'Asset Group Manage',
assetManage: 'Asset Manage'
} }

View File

@ -0,0 +1,179 @@
export default {
image1: 'Desvestir en un clic',
image2: 'Imagen a Imagen 2',
uploadImage: 'Subir imagen',
uploadImageTip: 'Soporta PNG/JPG, máx. 10MB',
uploadPlaceholder: 'Clic para subir imagen',
selectImageSource: 'Seleccionar fuente de imagen',
selectTemplate: 'Seleccionar plantilla',
reselectTemplate: 'Reseleccionar plantilla',
noTemplates: 'Sin plantillas',
tag1: 'Tipo de etiqueta 1',
tag2: 'Tipo de etiqueta 2',
tag3: 'Tipo de etiqueta 3',
generateImage: 'Generar ahora (consume {score} de saldo)',
generateImageNow: 'Generar ahora',
generateTip: 'Después de enviar, puede ver en "Mis obras"',
generateVideo: 'Generar video',
imageFace: 'Cambiar rostro en imagen',
videoFace: 'Cambiar rostro en video',
uploadImageFace: 'Clic para subir imagen de rostro',
uploadTemplate: 'Clic para subir plantilla personalizada',
textPlaceholder: 'Describa la imagen que desea generar',
uploadImageError: 'Por favor suba una imagen',
replaceImage: 'Reemplazar imagen',
uploadFaceImageError: 'Por favor suba una imagen de rostro',
uploadTemplateError: 'Por favor suba una plantilla personalizada',
textError: 'Por favor escriba el texto de indicación',
textVideoPlaceholder: 'Describa el video que desea generar',
uploadFirstPlaceholder: 'Clic para subir imagen del primer frame',
uploadLastPlaceholder: 'Clic para subir imagen del último frame',
uploadFirstImageError: 'Por favor suba la imagen del primer frame',
uploadWaitImageError: 'Espere a que termine la subida',
saveVideo: 'Descargar video',
videoLoadingText: 'Generando video...',
viewVideo: 'Ver video',
changeFacePrompt: 'Extraer el rostro de la segunda imagen y reemplazar en la primera',
rechartTip1: 'Al recargar con transferencia de billetera, confirme la red del monedero.',
rechartTip2: 'La recarga puede tardar; espere 3-5 minutos antes de actualizar.',
walletAddr: 'Dirección del monedero:',
fbTitle: 'Advertencia: este sitio es solo para adultos.',
fbContent: 'Al entrar confirmo que tengo 18 años o más',
fbCancel: 'Menor de 18',
fbOK: 'Tengo 18 o más',
sorry: '¡Lo sentimos!',
useLess: 'No puede usar este sitio...',
loginAccount: 'Iniciar sesión',
logout: 'Cerrar sesión',
userEmailPlaceholder: 'Introduzca correo o usuario',
passwordPlaceholder: 'Introduzca contraseña',
forgetPassword: '¿Olvidó la contraseña?',
register: 'Registrarse',
login: 'Iniciar sesión',
forgotPassword: 'Olvidé la contraseña',
registerAccount: 'Crear cuenta',
usernamePlaceholder: 'Introduzca usuario',
codePlaceholder: 'Introduzca código de verificación',
confirmPasswordPlaceholder: 'Confirme la contraseña',
backToLogin: 'Volver al inicio de sesión',
send: 'Enviar',
emailValidPlaceholder: 'Introduzca un correo válido',
confirmPwdValidMsg: 'Las contraseñas no coinciden',
editPassword: 'Cambiar contraseña',
recharge: 'Recargar',
myAccount: 'Mi cuenta',
moneyInvite: 'Invitación con recompensa',
rechargeRecord: 'Historial de recargas',
resumeRecord: 'Historial de consumo',
inviteRecord: 'Historial de invitaciones',
username: 'Usuario',
userId: 'ID de usuario',
accountAmount: 'Saldo',
editUserInfo: 'Editar datos',
contact: 'Contacto',
backToUser: 'Volver',
moneyTips: 'Al invitar a un amigo a registrarse, obtiene {rate} de su recarga cada vez.',
inviteCode: 'Código de invitación',
inviteLink: 'Enlace de invitación',
saveImage: 'Guardar imagen',
totalAmount: 'Total recargado',
amount: 'Monto',
sendAmount: 'Monto de regalo',
rechargeType: 'Método de recarga',
rechargeTime: 'Fecha de recarga',
emptyText: 'Sin datos',
product: 'Obra',
resumeAmount: 'Monto consumido',
productType: 'Tipo de obra',
productTime: 'Fecha de creación',
totalReward: 'Saldo de recompensas',
rewardAmount: 'Monto de recompensa',
rewardTime: 'Fecha de recompensa',
reSend: 'Reenviar',
registerSuccessfully: 'Registro exitoso',
loginSuccessfully: 'Sesión iniciada',
passwordResetSuccessfully: 'Contraseña restablecida',
rechargeSuccessfully: 'Recarga exitosa',
avatar: 'Avatar',
input: 'Introduzca',
save: 'Guardar',
editEmail: 'Cambiar correo',
editEmailSuccessfully: 'Correo actualizado',
updateAvatarSuccessfully: 'Avatar actualizado',
balenceLow: 'Saldo insuficiente, recargue',
confirm: 'Aceptar',
createFailed: 'Error al generar, saldo devuelto',
notice: 'Aviso',
oldPasswordPlaceholder: 'Introduzca la contraseña anterior',
newpasswordPlaceholder: 'Introduzca la nueva contraseña',
switchPageTip: 'El video se está generando; salga y revise el historial de consumo.',
loginless: 'Sesión expirada; inicie sesión de nuevo.',
createVideo: 'Generar video (consume {price} de saldo)',
ok: 'Aceptar',
rechartNotice: 'Instrucciones de recarga',
rechargeNotice1: '1. La recarga se acredita al instante. Si no cambia, actualice la página.',
rechargeNotice2: '2. Los paquetes por tiempo limitado son promociones; puede recargar varias veces.',
rechargeNotice3: '3. El saldo recargado no tiene límite de tiempo.',
rechargeNotice4: 'Revise el paquete antes de comprar; no hay devoluciones.',
rechargeLeft: 'Saldo',
dollor: 'USD',
isDevelop: 'En desarrollo.',
copySuccessfully: 'Copiado',
copyLink: 'Copiar enlace',
goPay: 'Ir a pagar',
filePreview: 'Vista previa',
doSame: 'Hacer similar',
saveQRCode: 'Guardar código QR',
invitationCodePlaceholder: 'Introduzca código de invitación',
testRecharge: 'Recarga de prueba',
orderNo: 'Número de pedido',
orderNoP: 'El número de pedido es obligatorio',
emailNotExists: 'El correo no está registrado',
gearNotExists: 'El plan de recarga no existe; elija otro.',
myProduct: 'Mis obras',
permissionError: 'Su recarga acumulada es {total}, no cumple para previsualizar.',
createTagFailed: 'Error al generar; actualice e intente de nuevo.',
loadingText: 'Cargando...',
hasMore: 'Deslizar para más',
noMore: 'No hay más',
giftAmount: 'Monto acreditado',
cardNo: 'Número de tarjeta',
cardName: 'Nombre en tarjeta',
cardNoRequired: 'Introduzca número de tarjeta',
cardNameRequired: 'Introduzca nombre en tarjeta',
rechargeFailed: 'Recarga fallida',
vmCardInfo: 'Datos de tarjeta',
cardNumber: 'Número de tarjeta',
cardNumberPlaceholder: 'Introduzca número de tarjeta',
cardNumberRequired: 'Introduzca número de tarjeta',
cardNumberInvalid: 'El número debe tener 13-19 dígitos',
cvc: 'CVC',
cvcPlaceholder: 'Introduzca CVC',
cvcRequired: 'Introduzca CVC',
cvcInvalid: 'CVC debe ser 3 dígitos',
expYear: 'Año de vencimiento',
expYearPlaceholder: 'Introduzca año (ej.: 2027)',
expYearRequired: 'Introduzca año de vencimiento',
expYearInvalid: 'El año debe ser 4 dígitos',
expMonth: 'Mes de vencimiento',
expMonthPlaceholder: 'Seleccione mes',
expMonthRequired: 'Seleccione mes',
email: 'Correo',
emailPlaceholder: 'Introduzca correo',
emailRequired: 'Introduzca correo',
emailInvalid: 'Formato de correo no válido',
emailMaxLength: 'Máx. 30 caracteres',
firstName: 'Nombre',
firstNamePlaceholder: 'Introduzca nombre',
firstNameRequired: 'Introduzca nombre',
firstNameMaxLength: 'Máx. 30 caracteres',
lastName: 'Apellido',
lastNamePlaceholder: 'Introduzca apellido',
lastNameRequired: 'Introduzca apellido',
lastNameMaxLength: 'Máx. 30 caracteres',
country: 'País',
countryPlaceholder: 'Seleccione país',
countryRequired: 'Seleccione país',
submit: 'Enviar',
cancel: 'Cancelar'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'Inicio',
imageToImage: 'Desvestir en un clic',
imageToImage2: 'Imagen a Imagen 2',
changeFace: 'Cambiar rostro',
changeFaceVideo: 'Cambiar rostro en video',
fastImage: 'Generar imagen',
fastVideo: 'Generar video',
recharge: 'Recarga rápida',
help: 'Centro de ayuda',
moneyInvite: 'Invitación con recompensa',
assetGroupManage: 'Gestión de grupos de activos',
assetManage: 'Gestión de activos'
}

View File

@ -0,0 +1,179 @@
export default {
image1: 'Déshabiller en un clic',
image2: 'Image vers Image 2',
uploadImage: 'Télécharger une image',
uploadImageTip: 'PNG/JPG, max. 10 Mo',
uploadPlaceholder: 'Cliquez pour télécharger',
selectImageSource: 'Choisir la source de l\'image',
selectTemplate: 'Choisir un modèle',
reselectTemplate: 'Resélectionner un modèle',
noTemplates: 'Aucun modèle',
tag1: 'Type d\'étiquette 1',
tag2: 'Type d\'étiquette 2',
tag3: 'Type d\'étiquette 3',
generateImage: 'Générer maintenant (coûte {score} de solde)',
generateImageNow: 'Générer maintenant',
generateTip: 'Après envoi, voir dans « Mes œuvres »',
generateVideo: 'Générer une vidéo',
imageFace: 'Changer le visage sur image',
videoFace: 'Changer le visage en vidéo',
uploadImageFace: 'Cliquez pour télécharger une photo de visage',
uploadTemplate: 'Cliquez pour télécharger un modèle personnalisé',
textPlaceholder: 'Décrivez l\'image que vous voulez générer',
uploadImageError: 'Veuillez télécharger une image',
replaceImage: 'Remplacer l\'image',
uploadFaceImageError: 'Veuillez télécharger une photo de visage',
uploadTemplateError: 'Veuillez télécharger un modèle personnalisé',
textError: 'Veuillez saisir le texte d\'invite',
textVideoPlaceholder: 'Décrivez la vidéo que vous voulez générer',
uploadFirstPlaceholder: 'Cliquez pour télécharger la première image',
uploadLastPlaceholder: 'Cliquez pour télécharger la dernière image',
uploadFirstImageError: 'Veuillez télécharger l\'image du premier plan',
uploadWaitImageError: 'Veuillez attendre la fin du téléchargement',
saveVideo: 'Télécharger la vidéo',
videoLoadingText: 'Génération de la vidéo...',
viewVideo: 'Voir la vidéo',
changeFacePrompt: 'Extraire le visage de la 2e image et remplacer celui de la 1re',
rechartTip1: 'Lors d\'une recharge par portefeuille, vérifiez le réseau du portefeuille.',
rechartTip2: 'La recharge peut être retardée ; attendez 3–5 min avant d’actualiser.',
walletAddr: 'Adresse du portefeuille : ',
fbTitle: 'Attention ! Ce site est réservé aux adultes.',
fbContent: 'En entrant je confirme avoir 18 ans ou plus',
fbCancel: 'Moins de 18 ans',
fbOK: 'J\'ai 18 ans ou plus',
sorry: 'Désolé !',
useLess: 'Vous ne pouvez pas utiliser ce site...',
loginAccount: 'Connexion',
logout: 'Déconnexion',
userEmailPlaceholder: 'Saisissez l\'email ou l’identifiant',
passwordPlaceholder: 'Saisissez le mot de passe',
forgetPassword: 'Mot de passe oublié ?',
register: 'S\'inscrire',
login: 'Connexion',
forgotPassword: 'Mot de passe oublié',
registerAccount: 'Créer un compte',
usernamePlaceholder: 'Saisissez l’identifiant',
codePlaceholder: 'Saisissez le code de vérification',
confirmPasswordPlaceholder: 'Confirmez le mot de passe',
backToLogin: 'Retour à la connexion',
send: 'Envoyer',
emailValidPlaceholder: 'Saisissez un email valide',
confirmPwdValidMsg: 'Les mots de passe ne correspondent pas',
editPassword: 'Modifier le mot de passe',
recharge: 'Recharger',
myAccount: 'Mon compte',
moneyInvite: 'Invitation avec récompense',
rechargeRecord: 'Historique des recharges',
resumeRecord: 'Historique des consommations',
inviteRecord: 'Historique des invitations',
username: 'Identifiant',
userId: 'ID utilisateur',
accountAmount: 'Solde',
editUserInfo: 'Modifier les informations',
contact: 'Nous contacter',
backToUser: 'Retour',
moneyTips: 'Quand vous parrainez un ami, vous gagnez {rate} de chaque recharge qu’il fait.',
inviteCode: 'Code de parrainage',
inviteLink: 'Lien d\'invitation',
saveImage: 'Enregistrer l\'image',
totalAmount: 'Total rechargé',
amount: 'Montant',
sendAmount: 'Montant offert',
rechargeType: 'Méthode de recharge',
rechargeTime: 'Date de recharge',
emptyText: 'Aucune donnée',
product: 'Œuvre',
resumeAmount: 'Montant consommé',
productType: 'Type d\'œuvre',
productTime: 'Date de création',
totalReward: 'Solde de récompenses',
rewardAmount: 'Montant de la récompense',
rewardTime: 'Date de la récompense',
reSend: 'Renvoyer',
registerSuccessfully: 'Inscription réussie',
loginSuccessfully: 'Connexion réussie',
passwordResetSuccessfully: 'Mot de passe réinitialisé',
rechargeSuccessfully: 'Recharge réussie',
avatar: 'Avatar',
input: 'Saisir',
save: 'Enregistrer',
editEmail: 'Modifier l\'email',
editEmailSuccessfully: 'Email mis à jour',
updateAvatarSuccessfully: 'Avatar mis à jour',
balenceLow: 'Solde insuffisant, veuillez recharger',
confirm: 'Confirmer',
createFailed: 'Échec de génération, solde remboursé',
notice: 'Avis',
oldPasswordPlaceholder: 'Saisissez l’ancien mot de passe',
newpasswordPlaceholder: 'Saisissez le nouveau mot de passe',
switchPageTip: 'Une vidéo est en cours de génération ; consultez l’historique des consommations.',
loginless: 'Session expirée ! Veuillez vous reconnecter.',
createVideo: 'Générer une vidéo (coûte {price} de solde)',
ok: 'Confirmer',
rechartNotice: 'Instructions de recharge',
rechargeNotice1: '1. La recharge est créditée tout de suite. Si rien ne change, actualisez la page.',
rechargeNotice2: '2. Les offres limitées sont des promos ; vous pouvez recharger plusieurs fois.',
rechargeNotice3: '3. Le solde rechargé n’a pas de limite de durée.',
rechargeNotice4: 'Vérifiez l’offre avant d’acheter ; pas de remboursement.',
rechargeLeft: 'Solde',
dollor: 'USD',
isDevelop: 'En cours de développement.',
copySuccessfully: 'Copié',
copyLink: 'Copier le lien',
goPay: 'Aller au paiement',
filePreview: 'Aperçu',
doSame: 'Créer un équivalent',
saveQRCode: 'Enregistrer le QR code',
invitationCodePlaceholder: 'Saisissez le code d\'invitation',
testRecharge: 'Recharge test',
orderNo: 'Numéro de commande',
orderNoP: 'Le numéro de commande est obligatoire',
emailNotExists: 'Cet email n’est pas inscrit',
gearNotExists: 'L’offre choisie n’existe pas ; choisissez une autre.',
myProduct: 'Mes œuvres',
permissionError: 'Votre total de recharges est {total}, insuffisant pour la prévisualisation.',
createTagFailed: 'Échec de génération ; actualisez et réessayez.',
loadingText: 'Chargement...',
hasMore: 'Tirez pour charger plus',
noMore: 'Plus rien',
giftAmount: 'Montant crédité',
cardNo: 'Numéro de carte',
cardName: 'Nom sur la carte',
cardNoRequired: 'Saisissez le numéro de carte',
cardNameRequired: 'Saisissez le nom sur la carte',
rechargeFailed: 'Recharge échouée',
vmCardInfo: 'Informations de carte',
cardNumber: 'Numéro de carte',
cardNumberPlaceholder: 'Saisissez le numéro de carte',
cardNumberRequired: 'Saisissez le numéro de carte',
cardNumberInvalid: 'Le numéro doit contenir 13 à 19 chiffres',
cvc: 'CVC',
cvcPlaceholder: 'Saisissez le CVC',
cvcRequired: 'Saisissez le CVC',
cvcInvalid: 'Le CVC doit faire 3 chiffres',
expYear: 'Année d\'expiration',
expYearPlaceholder: 'Saisissez l’année (ex. 2027)',
expYearRequired: 'Saisissez l’année d\'expiration',
expYearInvalid: 'L’année doit faire 4 chiffres',
expMonth: 'Mois d\'expiration',
expMonthPlaceholder: 'Choisissez le mois',
expMonthRequired: 'Choisissez le mois',
email: 'Email',
emailPlaceholder: 'Saisissez l’email',
emailRequired: 'Saisissez l’email',
emailInvalid: 'Format d’email invalide',
emailMaxLength: 'L’email ne doit pas dépasser 30 caractères',
firstName: 'Prénom',
firstNamePlaceholder: 'Saisissez le prénom',
firstNameRequired: 'Saisissez le prénom',
firstNameMaxLength: 'Le prénom ne doit pas dépasser 30 caractères',
lastName: 'Nom de famille',
lastNamePlaceholder: 'Saisissez le nom de famille',
lastNameRequired: 'Saisissez le nom de famille',
lastNameMaxLength: 'Le nom ne doit pas dépasser 30 caractères',
country: 'Pays',
countryPlaceholder: 'Choisissez le pays',
countryRequired: 'Choisissez le pays',
submit: 'Envoyer',
cancel: 'Annuler'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'Accueil',
imageToImage: 'Déshabiller en un clic',
imageToImage2: 'Image vers Image 2',
changeFace: 'Changer le visage',
changeFaceVideo: 'Changer le visage en vidéo',
fastImage: 'Générer une image',
fastVideo: 'Générer une vidéo',
recharge: 'Recharge rapide',
help: 'Centre d\'aide',
moneyInvite: 'Invitation avec récompense',
assetGroupManage: 'Gestion des groupes d\'actifs',
assetManage: 'Gestion des actifs'
}

View File

@ -0,0 +1,179 @@
export default {
image1: 'एक क्लिक में कपड़े उतारें',
image2: 'इमेज टू इमेज 2',
uploadImage: 'छवि अपलोड करें',
uploadImageTip: 'PNG/JPG, अधिकतम 10MB',
uploadPlaceholder: 'अपलोड करने के लिए क्लिक करें',
selectImageSource: 'छवि स्रोत चुनें',
selectTemplate: 'टेम्पलेट चुनें',
reselectTemplate: 'टेम्पलेट दोबारा चुनें',
noTemplates: 'कोई टेम्पलेट नहीं',
tag1: 'टैग प्रकार 1',
tag2: 'टैग प्रकार 2',
tag3: 'टैग प्रकार 3',
generateImage: 'अभी जनरेट करें ({score} बैलेंस खर्च)',
generateImageNow: 'अभी जनरेट करें',
generateTip: 'सबमिट के बाद "मेरी रचनाएं" में देखें',
generateVideo: 'वीडियो जनरेट करें',
imageFace: 'छवि में चेहरा बदलें',
videoFace: 'वीडियो में चेहरा बदलें',
uploadImageFace: 'चेहरे की छवि अपलोड करने के लिए क्लिक करें',
uploadTemplate: 'कस्टम टेम्पलेट अपलोड करने के लिए क्लिक करें',
textPlaceholder: 'वह छवि बताएं जो आप बनाना चाहते हैं',
uploadImageError: 'कृपया एक छवि अपलोड करें',
replaceImage: 'छवि बदलें',
uploadFaceImageError: 'कृपया चेहरे की छवि अपलोड करें',
uploadTemplateError: 'कृपया कस्टम टेम्पलेट अपलोड करें',
textError: 'कृपया प्रॉम्प्ट दर्ज करें',
textVideoPlaceholder: 'वह वीडियो बताएं जो आप बनाना चाहते हैं',
uploadFirstPlaceholder: 'पहला फ्रेम अपलोड करने के लिए क्लिक करें',
uploadLastPlaceholder: 'आखिरी फ्रेम अपलोड करने के लिए क्लिक करें',
uploadFirstImageError: 'कृपया पहले फ्रेम की छवि अपलोड करें',
uploadWaitImageError: 'अपलोड पूरा होने तक प्रतीक्षा करें',
saveVideo: 'वीडियो डाउनलोड करें',
videoLoadingText: 'वीडियो बन रहा है...',
viewVideo: 'वीडियो देखें',
changeFacePrompt: 'दूसरी छवि का चेहरा लेकर पहली छवि के चेहरे से बदलें',
rechartTip1: 'वॉलेट ट्रांसफर से रिचार्ज करते समय वॉलेट का नेटवर्क जांचें।',
rechartTip2: 'रिचार्ज में देरी हो सकती है; 3-5 मिनट बाद रिफ्रेश करें।',
walletAddr: 'वॉलेट पता:',
fbTitle: 'चेतावनी! यह साइट केवल वयस्कों के लिए है।',
fbContent: 'प्रवेश करके मैं पुष्टि करता/करती हूं कि मैं 18 वर्ष या उससे अधिक का हूं',
fbCancel: '18 से कम',
fbOK: 'मैं 18+ हूं',
sorry: 'क्षमा करें!',
useLess: 'आप इस साइट का उपयोग नहीं कर सकते...',
loginAccount: 'लॉगिन',
logout: 'लॉगआउट',
userEmailPlaceholder: 'ईमेल या यूजरनेम दर्ज करें',
passwordPlaceholder: 'पासवर्ड दर्ज करें',
forgetPassword: 'पासवर्ड भूल गए?',
register: 'रजिस्टर',
login: 'लॉगिन',
forgotPassword: 'पासवर्ड भूल गए',
registerAccount: 'अकाउंट बनाएं',
usernamePlaceholder: 'यूजरनेम दर्ज करें',
codePlaceholder: 'वेरिफिकेशन कोड दर्ज करें',
confirmPasswordPlaceholder: 'पासवर्ड की पुष्टि करें',
backToLogin: 'लॉगिन पर वापस',
send: 'भेजें',
emailValidPlaceholder: 'वैध ईमेल दर्ज करें',
confirmPwdValidMsg: 'पासवर्ड मेल नहीं खाते',
editPassword: 'पासवर्ड बदलें',
recharge: 'रिचार्ज',
myAccount: 'मेरा अकाउंट',
moneyInvite: 'इनाम निमंत्रण',
rechargeRecord: 'रिचार्ज रिकॉर्ड',
resumeRecord: 'खर्च रिकॉर्ड',
inviteRecord: 'निमंत्रण रिकॉर्ड',
username: 'यूजरनेम',
userId: 'यूजर आईडी',
accountAmount: 'बैलेंस',
editUserInfo: 'जानकारी संपादित करें',
contact: 'संपर्क करें',
backToUser: 'वापस',
moneyTips: 'जब आप दोस्त को रजिस्टर करवाते हैं, उसके रिचार्ज का {rate} हर बार मिलता है।',
inviteCode: 'इनवाइट कोड',
inviteLink: 'इनवाइट लिंक',
saveImage: 'छवि सहेजें',
totalAmount: 'कुल रिचार्ज',
amount: 'राशि',
sendAmount: 'उपहार राशि',
rechargeType: 'रिचार्ज तरीका',
rechargeTime: 'रिचार्ज समय',
emptyText: 'कोई डेटा नहीं',
product: 'रचना',
resumeAmount: 'खर्च राशि',
productType: 'रचना प्रकार',
productTime: 'बनाने का समय',
totalReward: 'कुल इनाम बैलेंस',
rewardAmount: 'इनाम राशि',
rewardTime: 'इनाम समय',
reSend: 'दोबारा भेजें',
registerSuccessfully: 'रजिस्ट्रेशन सफल',
loginSuccessfully: 'लॉगिन सफल',
passwordResetSuccessfully: 'पासवर्ड रीसेट सफल',
rechargeSuccessfully: 'रिचार्ज सफल',
avatar: 'अवतार',
input: 'दर्ज करें',
save: 'सहेजें',
editEmail: 'ईमेल बदलें',
editEmailSuccessfully: 'ईमेल अपडेट हो गया',
updateAvatarSuccessfully: 'अवतार अपडेट हो गया',
balenceLow: 'बैलेंस कम है, रिचार्ज करें',
confirm: 'पुष्टि करें',
createFailed: 'जनरेशन विफल, बैलेंस वापस',
notice: 'सूचना',
oldPasswordPlaceholder: 'पुराना पासवर्ड दर्ज करें',
newpasswordPlaceholder: 'नया पासवर्ड दर्ज करें',
switchPageTip: 'वीडियो बन रहा है; निकलकर खर्च रिकॉर्ड देखें।',
loginless: 'लॉगिन समाप्त! दोबारा लॉगिन करें।',
createVideo: 'वीडियो जनरेट करें ({price} बैलेंस खर्च)',
ok: 'पुष्टि करें',
rechartNotice: 'रिचार्ज निर्देश',
rechargeNotice1: '1. रिचार्ज तुरंत जमा होता है। बदलाव न दिखे तो रिफ्रेश करें।',
rechargeNotice2: '2. लिमिटेड पैक प्रचार हैं; आप कई बार रिचार्ज कर सकते हैं।',
rechargeNotice3: '3. रिचार्ज की गई राशि की कोई समय सीमा नहीं।',
rechargeNotice4: 'खरीदने से पहले पैक देखें; वापसी नहीं होती।',
rechargeLeft: 'बैलेंस',
dollor: 'USD',
isDevelop: 'विकास में है।',
copySuccessfully: 'कॉपी हुआ',
copyLink: 'लिंक कॉपी करें',
goPay: 'भुगतान पर जाएं',
filePreview: 'पूर्वावलोकन',
doSame: 'इसी तरह बनाएं',
saveQRCode: 'QR कोड सहेजें',
invitationCodePlaceholder: 'इनवाइट कोड दर्ज करें',
testRecharge: 'टेस्ट रिचार्ज',
orderNo: 'ऑर्डर नंबर',
orderNoP: 'ऑर्डर नंबर खाली नहीं हो सकता',
emailNotExists: 'यह ईमेल रजिस्टर नहीं है',
gearNotExists: 'चुना गया रिचार्ज प्लान मौजूद नहीं; दूसरा चुनें।',
myProduct: 'मेरी रचनाएं',
permissionError: 'आपका कुल रिचार्ज {total} है, प्रीव्यू के लिए पर्याप्त नहीं।',
createTagFailed: 'जनरेशन विफल; रिफ्रेश करके दोबारा कोशिश करें।',
loadingText: 'लोड हो रहा है...',
hasMore: 'ऊपर खींचें और अधिक लोड करें',
noMore: 'और नहीं',
giftAmount: 'जमा राशि',
cardNo: 'कार्ड नंबर',
cardName: 'कार्डधारक का नाम',
cardNoRequired: 'कार्ड नंबर दर्ज करें',
cardNameRequired: 'कार्डधारक का नाम दर्ज करें',
rechargeFailed: 'रिचार्ज विफल',
vmCardInfo: 'कार्ड जानकारी',
cardNumber: 'कार्ड नंबर',
cardNumberPlaceholder: 'कार्ड नंबर दर्ज करें',
cardNumberRequired: 'कार्ड नंबर दर्ज करें',
cardNumberInvalid: 'कार्ड नंबर 13-19 अंकों का होना चाहिए',
cvc: 'CVC',
cvcPlaceholder: 'CVC दर्ज करें',
cvcRequired: 'CVC दर्ज करें',
cvcInvalid: 'CVC 3 अंकों का होना चाहिए',
expYear: 'समाप्ति वर्ष',
expYearPlaceholder: 'वर्ष दर्ज करें (जैसे 2027)',
expYearRequired: 'समाप्ति वर्ष दर्ज करें',
expYearInvalid: 'वर्ष 4 अंकों का होना चाहिए',
expMonth: 'समाप्ति माह',
expMonthPlaceholder: 'माह चुनें',
expMonthRequired: 'माह चुनें',
email: 'ईमेल',
emailPlaceholder: 'ईमेल दर्ज करें',
emailRequired: 'ईमेल दर्ज करें',
emailInvalid: 'ईमेल फॉर्मेट गलत',
emailMaxLength: 'ईमेल 30 अक्षर से अधिक नहीं',
firstName: 'पहला नाम',
firstNamePlaceholder: 'पहला नाम दर्ज करें',
firstNameRequired: 'पहला नाम दर्ज करें',
firstNameMaxLength: 'पहला नाम 30 अक्षर से अधिक नहीं',
lastName: 'उपनाम',
lastNamePlaceholder: 'उपनाम दर्ज करें',
lastNameRequired: 'उपनाम दर्ज करें',
lastNameMaxLength: 'उपनाम 30 अक्षर से अधिक नहीं',
country: 'देश',
countryPlaceholder: 'देश चुनें',
countryRequired: 'देश चुनें',
submit: 'भेजें',
cancel: 'रद्द करें'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'होम',
imageToImage: 'एक क्लिक में कपड़े उतारें',
imageToImage2: 'इमेज टू इमेज 2',
changeFace: 'चेहरा बदलें',
changeFaceVideo: 'वीडियो में चेहरा बदलें',
fastImage: 'छवि जनरेट करें',
fastVideo: 'वीडियो जनरेट करें',
recharge: 'त्वरित रिचार्ज',
help: 'सहायता केंद्र',
moneyInvite: 'इनाम निमंत्रण',
assetGroupManage: 'एसेट समूह प्रबंधन',
assetManage: 'एसेट प्रबंधन'
}

View File

@ -1,26 +1,28 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import zh_HK from '@/lang/zh_HK/index.js' import zh_HK from '@/lang/zh_HK/index.js'
/**
import en_US from '@/lang/en_US/index.js' import en_US from '@/lang/en_US/index.js'
import es_ES from '@/lang/es_ES/index.js' mport es_ES from '@/lang/es_ES/index.js'
import pt_BR from '@/lang/pt_BR/index.js' import pt_BR from '@/lang/pt_BR/index.js'
import hi_IN from '@/lang/hi_IN/index.js' import hi_IN from '@/lang/hi_IN/index.js'
import ru_RU from '@/lang/ru_RU/index.js' import ru_RU from '@/lang/ru_RU/index.js'
import ar_SA from '@/lang/ar_SA/index.js' import ar_SA from '@/lang/ar_SA/index.js'
import fr_FR from '@/lang/fr_FR/index.js' import fr_FR from '@/lang/fr_FR/index.js'**/
let locale = Cookies.get('language') || 'en_US' // 多语言切换已禁用:全站固定繁体中文
let locale = 'zh_HK'
/** 各语言在界面上的显示名称 */ /** 各语言在界面上的显示名称 */
export const LOCALE_NAMES = { export const LOCALE_NAMES = {
zh_HK: '繁体中文', zh_HK: '繁体中文',
en_US: 'English', /** en_US: 'English',
es_ES: 'Español', es_ES: 'Español',
pt_BR: 'Português', pt_BR: 'Português',
hi_IN: 'हिन्दी', hi_IN: 'हिन्दी',
ru_RU: 'Русский', ru_RU: 'Русский',
ar_SA: 'العربية', ar_SA: 'العربية',
fr_FR: 'Français' fr_FR: 'Français'**/
} }
const i18n = createI18n({ const i18n = createI18n({
@ -28,13 +30,13 @@ const i18n = createI18n({
locale, locale,
messages: { messages: {
zh_HK, zh_HK,
en_US, /** en_US,
es_ES, es_ES,
pt_BR, pt_BR,
hi_IN, hi_IN,
ru_RU, ru_RU,
ar_SA, ar_SA,
fr_FR fr_FR**/
} }
}) })

View File

@ -1,7 +1,7 @@
import en_USLocale from './en/index' //import en_USLocale from './en/index'
import zh_HKLocale from './zh_HK/index' import zh_HKLocale from './zh_HK/index'
export default { export default {
en_US: en_USLocale, //en_US: en_USLocale,
zh_HK: zh_HKLocale zh_HK: zh_HKLocale
} }

View File

@ -0,0 +1,179 @@
export default {
image1: 'Despir em um clique',
image2: 'Imagem para Imagem 2',
uploadImage: 'Enviar imagem',
uploadImageTip: 'PNG/JPG, máx. 10MB',
uploadPlaceholder: 'Clique para enviar imagem',
selectImageSource: 'Selecionar origem da imagem',
selectTemplate: 'Selecionar modelo',
reselectTemplate: 'Selecionar outro modelo',
noTemplates: 'Sem modelos',
tag1: 'Tipo de tag 1',
tag2: 'Tipo de tag 2',
tag3: 'Tipo de tag 3',
generateImage: 'Gerar agora (usa {score} de saldo)',
generateImageNow: 'Gerar agora',
generateTip: 'Após enviar, veja em "Minhas obras"',
generateVideo: 'Gerar vídeo',
imageFace: 'Trocar rosto em imagem',
videoFace: 'Trocar rosto em vídeo',
uploadImageFace: 'Clique para enviar imagem do rosto',
uploadTemplate: 'Clique para enviar modelo personalizado',
textPlaceholder: 'Descreva a imagem que deseja gerar',
uploadImageError: 'Envie uma imagem',
replaceImage: 'Substituir imagem',
uploadFaceImageError: 'Envie uma imagem do rosto',
uploadTemplateError: 'Envie um modelo personalizado',
textError: 'Digite o texto de indicação',
textVideoPlaceholder: 'Descreva o vídeo que deseja gerar',
uploadFirstPlaceholder: 'Clique para enviar primeiro frame',
uploadLastPlaceholder: 'Clique para enviar último frame',
uploadFirstImageError: 'Envie a imagem do primeiro frame',
uploadWaitImageError: 'Aguarde o envio terminar',
saveVideo: 'Baixar vídeo',
videoLoadingText: 'Gerando vídeo...',
viewVideo: 'Ver vídeo',
changeFacePrompt: 'Extrair o rosto da segunda imagem e substituir na primeira',
rechartTip1: 'Ao recarregar por carteira, confirme a rede do carteira.',
rechartTip2: 'A recarga pode atrasar; aguarde 3-5 minutos antes de atualizar.',
walletAddr: 'Endereço da carteira:',
fbTitle: 'Aviso: este site é apenas para maiores de 18 anos.',
fbContent: 'Ao entrar confirmo que tenho 18 anos ou mais',
fbCancel: 'Menor de 18',
fbOK: 'Tenho 18 ou mais',
sorry: 'Desculpe!',
useLess: 'Você não pode usar este site...',
loginAccount: 'Entrar',
logout: 'Sair',
userEmailPlaceholder: 'Digite e-mail ou usuário',
passwordPlaceholder: 'Digite a senha',
forgetPassword: 'Esqueceu a senha?',
register: 'Cadastrar',
login: 'Entrar',
forgotPassword: 'Esqueci a senha',
registerAccount: 'Criar conta',
usernamePlaceholder: 'Digite o usuário',
codePlaceholder: 'Digite o código de verificação',
confirmPasswordPlaceholder: 'Confirme a senha',
backToLogin: 'Voltar ao login',
send: 'Enviar',
emailValidPlaceholder: 'Digite um e-mail válido',
confirmPwdValidMsg: 'As senhas não coincidem',
editPassword: 'Alterar senha',
recharge: 'Recarregar',
myAccount: 'Minha conta',
moneyInvite: 'Convite com recompensa',
rechargeRecord: 'Histórico de recargas',
resumeRecord: 'Histórico de consumo',
inviteRecord: 'Histórico de convites',
username: 'Usuário',
userId: 'ID do usuário',
accountAmount: 'Saldo',
editUserInfo: 'Editar dados',
contact: 'Contato',
backToUser: 'Voltar',
moneyTips: 'Ao convidar um amigo, você ganha {rate} do valor que ele recarregar.',
inviteCode: 'Código de convite',
inviteLink: 'Link de convite',
saveImage: 'Salvar imagem',
totalAmount: 'Total recarregado',
amount: 'Valor',
sendAmount: 'Valor de brinde',
rechargeType: 'Forma de recarga',
rechargeTime: 'Data da recarga',
emptyText: 'Sem dados',
product: 'Obra',
resumeAmount: 'Valor consumido',
productType: 'Tipo de obra',
productTime: 'Data de criação',
totalReward: 'Saldo de recompensas',
rewardAmount: 'Valor da recompensa',
rewardTime: 'Data da recompensa',
reSend: 'Reenviar',
registerSuccessfully: 'Cadastro concluído',
loginSuccessfully: 'Login realizado',
passwordResetSuccessfully: 'Senha redefinida',
rechargeSuccessfully: 'Recarga concluída',
avatar: 'Avatar',
input: 'Digite',
save: 'Salvar',
editEmail: 'Alterar e-mail',
editEmailSuccessfully: 'E-mail atualizado',
updateAvatarSuccessfully: 'Avatar atualizado',
balenceLow: 'Saldo insuficiente, recarregue',
confirm: 'Confirmar',
createFailed: 'Falha na geração, saldo devolvido',
notice: 'Aviso',
oldPasswordPlaceholder: 'Digite a senha antiga',
newpasswordPlaceholder: 'Digite a nova senha',
switchPageTip: 'O vídeo está sendo gerado; saia e veja o histórico de consumo.',
loginless: 'Sessão expirada; faça login novamente.',
createVideo: 'Gerar vídeo (usa {price} de saldo)',
ok: 'Confirmar',
rechartNotice: 'Instruções de recarga',
rechargeNotice1: '1. A recarga é creditada na hora. Se não mudar, atualize a página.',
rechargeNotice2: '2. Pacotes por tempo limitado são promoções; você pode recarregar várias vezes.',
rechargeNotice3: '3. O saldo recarregado não tem prazo.',
rechargeNotice4: 'Confira o pacote antes de comprar; não há reembolso.',
rechargeLeft: 'Saldo',
dollor: 'USD',
isDevelop: 'Em desenvolvimento.',
copySuccessfully: 'Copiado',
copyLink: 'Copiar link',
goPay: 'Ir para pagamento',
filePreview: 'Visualizar',
doSame: 'Fazer igual',
saveQRCode: 'Salvar código QR',
invitationCodePlaceholder: 'Digite o código de convite',
testRecharge: 'Recarga de teste',
orderNo: 'Número do pedido',
orderNoP: 'O número do pedido é obrigatório',
emailNotExists: 'O e-mail não está cadastrado',
gearNotExists: 'O plano de recarga não existe; escolha outro.',
myProduct: 'Minhas obras',
permissionError: 'Sua recarga acumulada é {total}, não atende para visualizar.',
createTagFailed: 'Falha na geração; atualize e tente de novo.',
loadingText: 'Carregando...',
hasMore: 'Deslize para mais',
noMore: 'Não há mais',
giftAmount: 'Valor creditado',
cardNo: 'Número do cartão',
cardName: 'Nome no cartão',
cardNoRequired: 'Digite o número do cartão',
cardNameRequired: 'Digite o nome no cartão',
rechargeFailed: 'Recarga falhou',
vmCardInfo: 'Dados do cartão',
cardNumber: 'Número do cartão',
cardNumberPlaceholder: 'Digite o número do cartão',
cardNumberRequired: 'Digite o número do cartão',
cardNumberInvalid: 'O número deve ter 13-19 dígitos',
cvc: 'CVC',
cvcPlaceholder: 'Digite o CVC',
cvcRequired: 'Digite o CVC',
cvcInvalid: 'CVC deve ter 3 dígitos',
expYear: 'Ano de validade',
expYearPlaceholder: 'Digite o ano (ex.: 2027)',
expYearRequired: 'Digite o ano de validade',
expYearInvalid: 'O ano deve ter 4 dígitos',
expMonth: 'Mês de validade',
expMonthPlaceholder: 'Selecione o mês',
expMonthRequired: 'Selecione o mês',
email: 'E-mail',
emailPlaceholder: 'Digite o e-mail',
emailRequired: 'Digite o e-mail',
emailInvalid: 'Formato de e-mail inválido',
emailMaxLength: 'Máx. 30 caracteres',
firstName: 'Nome',
firstNamePlaceholder: 'Digite o nome',
firstNameRequired: 'Digite o nome',
firstNameMaxLength: 'Máx. 30 caracteres',
lastName: 'Sobrenome',
lastNamePlaceholder: 'Digite o sobrenome',
lastNameRequired: 'Digite o sobrenome',
lastNameMaxLength: 'Máx. 30 caracteres',
country: 'País',
countryPlaceholder: 'Selecione o país',
countryRequired: 'Selecione o país',
submit: 'Enviar',
cancel: 'Cancelar'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'Início',
imageToImage: 'Despir em um clique',
imageToImage2: 'Imagem para Imagem 2',
changeFace: 'Trocar rosto',
changeFaceVideo: 'Trocar rosto em vídeo',
fastImage: 'Gerar imagem',
fastVideo: 'Gerar vídeo',
recharge: 'Recarga rápida',
help: 'Central de ajuda',
moneyInvite: 'Convite com recompensa',
assetGroupManage: 'Gerenciamento de grupos de ativos',
assetManage: 'Gerenciamento de ativos'
}

View File

@ -0,0 +1,179 @@
export default {
image1: 'Раздеть в один клик',
image2: 'Изображение в изображение 2',
uploadImage: 'Загрузить изображение',
uploadImageTip: 'PNG/JPG, макс. 10 МБ',
uploadPlaceholder: 'Нажмите, чтобы загрузить',
selectImageSource: 'Выберите источник изображения',
selectTemplate: 'Выберите шаблон',
reselectTemplate: 'Выбрать шаблон заново',
noTemplates: 'Нет шаблонов',
tag1: 'Тип метки 1',
tag2: 'Тип метки 2',
tag3: 'Тип метки 3',
generateImage: 'Создать сейчас (списание {score} с баланса)',
generateImageNow: 'Создать сейчас',
generateTip: 'После отправки смотрите в «Мои работы»',
generateVideo: 'Создать видео',
imageFace: 'Заменить лицо на изображении',
videoFace: 'Заменить лицо в видео',
uploadImageFace: 'Нажмите, чтобы загрузить фото лица',
uploadTemplate: 'Нажмите, чтобы загрузить свой шаблон',
textPlaceholder: 'Опишите изображение, которое хотите получить',
uploadImageError: 'Загрузите изображение',
replaceImage: 'Заменить изображение',
uploadFaceImageError: 'Загрузите фото лица',
uploadTemplateError: 'Загрузите свой шаблон',
textError: 'Введите текст подсказки',
textVideoPlaceholder: 'Опишите видео, которое хотите получить',
uploadFirstPlaceholder: 'Нажмите, чтобы загрузить первый кадр',
uploadLastPlaceholder: 'Нажмите, чтобы загрузить последний кадр',
uploadFirstImageError: 'Загрузите изображение первого кадра',
uploadWaitImageError: 'Дождитесь окончания загрузки',
saveVideo: 'Скачать видео',
videoLoadingText: 'Создание видео...',
viewVideo: 'Смотреть видео',
changeFacePrompt: 'Взять лицо со второй картинки и заменить им лицо на первой',
rechartTip1: 'При пополнении через кошелёк проверьте сеть кошелька.',
rechartTip2: 'Зачисление может задерживаться; подождите 3–5 минут и обновите.',
walletAddr: 'Адрес кошелька:',
fbTitle: 'Внимание! Сайт только для взрослых!',
fbContent: 'Входя, я подтверждаю, что мне 18 лет или больше',
fbCancel: 'Мне нет 18',
fbOK: 'Мне есть 18',
sorry: 'Извините!',
useLess: 'Вам запрещено пользоваться этим сайтом...',
loginAccount: 'Вход',
logout: 'Выйти',
userEmailPlaceholder: 'Введите email или логин',
passwordPlaceholder: 'Введите пароль',
forgetPassword: 'Забыли пароль?',
register: 'Регистрация',
login: 'Войти',
forgotPassword: 'Забыл пароль',
registerAccount: 'Создать аккаунт',
usernamePlaceholder: 'Введите имя пользователя',
codePlaceholder: 'Введите код подтверждения',
confirmPasswordPlaceholder: 'Подтвердите пароль',
backToLogin: 'Вернуться к входу',
send: 'Отправить',
emailValidPlaceholder: 'Введите корректный email',
confirmPwdValidMsg: 'Пароли не совпадают',
editPassword: 'Изменить пароль',
recharge: 'Пополнить',
myAccount: 'Мой аккаунт',
moneyInvite: 'Приглашение с наградой',
rechargeRecord: 'История пополнений',
resumeRecord: 'История списаний',
inviteRecord: 'История приглашений',
username: 'Имя пользователя',
userId: 'ID пользователя',
accountAmount: 'Баланс',
editUserInfo: 'Редактировать данные',
contact: 'Связаться с нами',
backToUser: 'Назад',
moneyTips: 'При регистрации по вашей ссылке вы получаете {rate} от каждой его пополнения.',
inviteCode: 'Код приглашения',
inviteLink: 'Ссылка приглашения',
saveImage: 'Сохранить изображение',
totalAmount: 'Всего пополнено',
amount: 'Сумма',
sendAmount: 'Сумма бонуса',
rechargeType: 'Способ пополнения',
rechargeTime: 'Время пополнения',
emptyText: 'Нет данных',
product: 'Работа',
resumeAmount: 'Списано',
productType: 'Тип работы',
productTime: 'Дата создания',
totalReward: 'Баланс наград',
rewardAmount: 'Сумма награды',
rewardTime: 'Время награды',
reSend: 'Отправить снова',
registerSuccessfully: 'Регистрация выполнена',
loginSuccessfully: 'Вход выполнен',
passwordResetSuccessfully: 'Пароль сброшен',
rechargeSuccessfully: 'Пополнение выполнено',
avatar: 'Аватар',
input: 'Введите',
save: 'Сохранить',
editEmail: 'Изменить email',
editEmailSuccessfully: 'Email обновлён',
updateAvatarSuccessfully: 'Аватар обновлён',
balenceLow: 'Недостаточно средств, пополните баланс',
confirm: 'Подтвердить',
createFailed: 'Ошибка создания, средства возвращены',
notice: 'Уведомление',
oldPasswordPlaceholder: 'Введите старый пароль',
newpasswordPlaceholder: 'Введите новый пароль',
switchPageTip: 'Видео создаётся; выйдите и проверьте историю списаний.',
loginless: 'Сессия истекла. Войдите снова.',
createVideo: 'Создать видео (списание {price} с баланса)',
ok: 'Подтвердить',
rechartNotice: 'Правила пополнения',
rechargeNotice1: '1. Средства зачисляются сразу. Если не изменилось — обновите страницу.',
rechargeNotice2: '2. Акционные пакеты проводятся периодически; можно пополнять несколько раз.',
rechargeNotice3: '3. Пополненный баланс не имеет срока действия.',
rechargeNotice4: 'Перед покупкой ознакомьтесь с пакетом; возврат не предусмотрен.',
rechargeLeft: 'Баланс',
dollor: 'USD',
isDevelop: 'В разработке.',
copySuccessfully: 'Скопировано',
copyLink: 'Копировать ссылку',
goPay: 'Перейти к оплате',
filePreview: 'Предпросмотр',
doSame: 'Сделать похожее',
saveQRCode: 'Сохранить QR-код',
invitationCodePlaceholder: 'Введите код приглашения',
testRecharge: 'Тестовое пополнение',
orderNo: 'Номер заказа',
orderNoP: 'Номер заказа обязателен',
emailNotExists: 'Этот email не зарегистрирован',
gearNotExists: 'Выбранный тариф отсутствует; выберите другой.',
myProduct: 'Мои работы',
permissionError: 'Ваше суммарное пополнение {total}, недостаточно для просмотра.',
createTagFailed: 'Ошибка создания; обновите страницу и повторите.',
loadingText: 'Загрузка...',
hasMore: 'Потяните вверх для подгрузки',
noMore: 'Больше нет',
giftAmount: 'Зачисленная сумма',
cardNo: 'Номер карты',
cardName: 'Имя владельца',
cardNoRequired: 'Введите номер карты',
cardNameRequired: 'Введите имя владельца',
rechargeFailed: 'Ошибка пополнения',
vmCardInfo: 'Данные карты',
cardNumber: 'Номер карты',
cardNumberPlaceholder: 'Введите номер карты',
cardNumberRequired: 'Введите номер карты',
cardNumberInvalid: 'Номер карты должен быть из 13–19 цифр',
cvc: 'CVC',
cvcPlaceholder: 'Введите CVC',
cvcRequired: 'Введите CVC',
cvcInvalid: 'CVC должен быть из 3 цифр',
expYear: 'Год срока действия',
expYearPlaceholder: 'Введите год (например 2027)',
expYearRequired: 'Введите год',
expYearInvalid: 'Год должен быть из 4 цифр',
expMonth: 'Месяц срока действия',
expMonthPlaceholder: 'Выберите месяц',
expMonthRequired: 'Выберите месяц',
email: 'Email',
emailPlaceholder: 'Введите email',
emailRequired: 'Введите email',
emailInvalid: 'Некорректный email',
emailMaxLength: 'Email не более 30 символов',
firstName: 'Имя',
firstNamePlaceholder: 'Введите имя',
firstNameRequired: 'Введите имя',
firstNameMaxLength: 'Имя не более 30 символов',
lastName: 'Фамилия',
lastNamePlaceholder: 'Введите фамилию',
lastNameRequired: 'Введите фамилию',
lastNameMaxLength: 'Фамилия не более 30 символов',
country: 'Страна',
countryPlaceholder: 'Выберите страну',
countryRequired: 'Выберите страну',
submit: 'Отправить',
cancel: 'Отмена'
}

View File

@ -0,0 +1,7 @@
import route from './route'
import common from './common.js'
export default {
route,
common
}

View File

@ -0,0 +1,14 @@
export default {
index: 'Главная',
imageToImage: 'Раздеть в один клик',
imageToImage2: 'Изображение в изображение 2',
changeFace: 'Заменить лицо',
changeFaceVideo: 'Заменить лицо в видео',
fastImage: 'Создать изображение',
fastVideo: 'Создать видео',
recharge: 'Быстрая пополнение',
help: 'Центр помощи',
moneyInvite: 'Приглашение с наградой',
assetGroupManage: 'Управление группами ресурсов',
assetManage: 'Управление ресурсами'
}

View File

@ -145,6 +145,14 @@ export default {
cardNoRequired: '請輸入銀行卡號', cardNoRequired: '請輸入銀行卡號',
cardNameRequired: '請輸入銀行卡姓名', cardNameRequired: '請輸入銀行卡姓名',
rechargeFailed: '充值失敗', rechargeFailed: '充值失敗',
// 模型选择
selectModel: '選擇模型',
seedance20: 'Seedance 2.0',
seedance20Fast: 'Seedance 2.0 Fast',
// 富文本编辑器
insertImage: '插入圖片',
mentionImage: '引用圖片',
noImageToMention: '暫無可引用的圖片',
// VM支付相關 // VM支付相關
vmCardInfo: '信用卡信息', vmCardInfo: '信用卡信息',
cardNumber: '信用卡卡號', cardNumber: '信用卡卡號',
@ -178,6 +186,9 @@ export default {
country: '國家', country: '國家',
countryPlaceholder: '請選擇國家', countryPlaceholder: '請選擇國家',
countryRequired: '請選擇國家', countryRequired: '請選擇國家',
videoGen: '視頻生成',
uploadFirstImage: '上傳首圖',
insertImage: '插入圖片',
submit: '提交', submit: '提交',
cancel: '取消' cancel: '取消'
} }

View File

@ -6,7 +6,11 @@ export default {
changeFaceVideo: '視頻換臉', changeFaceVideo: '視頻換臉',
fastImage: '快捷生圖', fastImage: '快捷生圖',
fastVideo: '快捷生視頻', fastVideo: '快捷生視頻',
videoGen: '視頻生成',
recharge: '快速充值', recharge: '快速充值',
help: '幫助中心', help: '幫助中心',
moneyInvite: '有獎邀請' moneyInvite: '有獎邀請',
assetGroupManage: '資源組管理',
assetManage: '素材管理',
generatedAssets: '作品库'
} }

View File

@ -188,16 +188,19 @@ export default {
<style lang="less"> <style lang="less">
.change-password-dialog { .change-password-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
background-color: transparent !important; background-color: transparent !important;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 500px; width: 500px;
top: 45% !important; top: 45% !important;
transform: translateY(-45%) !important; transform: translateY(-45%) !important;

View File

@ -235,16 +235,19 @@ export default {
<style lang="less"> <style lang="less">
.forgot-dialog { .forgot-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
background-color: transparent !important; background-color: transparent !important;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 500px; width: 500px;
top: 45% !important; top: 45% !important;
transform: translateY(-45%) !important; transform: translateY(-45%) !important;

View File

@ -44,20 +44,7 @@
inputType="password" inputType="password"
:placeholder="`${$t('common.passwordPlaceholder')}`" /> :placeholder="`${$t('common.passwordPlaceholder')}`" />
<div class="login-link">
<mf-button
class="grey"
type="text"
@click="showForgot">
{{ $t('common.forgotPassword') }}?
</mf-button>
</div>
<div class="login-submit"> <div class="login-submit">
<mf-button
size="large"
@click="showRegister">
{{ $t('common.register') }}
</mf-button>
<mf-button <mf-button
size="large" size="large"
type="primary" type="primary"
@ -67,25 +54,10 @@
</mf-button> </mf-button>
</div> </div>
</mf-dialog> </mf-dialog>
<Forgot
v-if="forgotVisible"
:visible="forgotVisible"
@open="forgotVisible = true"
@back="back"
@cancel="forgotVisible = false" />
<Register
v-if="registerVisible"
:visible="registerVisible"
@cancel="registerVisible = false"
@back="backFormRegister"
@open="registerVisible = true" />
</div> </div>
</template> </template>
<script> <script>
import Forgot from './Forgot.vue'
import Register from './Register.vue'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import i18n from '@/lang/i18n' import i18n from '@/lang/i18n'
@ -93,22 +65,16 @@ export default {
data() { data() {
return { return {
email: '', email: '',
forgotVisible: false,
emailVisible: false, emailVisible: false,
registerVisible: false,
loading: false, loading: false,
username: '', username: '',
password: '' password: ''
} }
}, },
props: { props: {
visible: Boolean, visible: Boolean
register: Boolean
},
components: {
Forgot,
Register
}, },
components: {},
computed: { computed: {
...mapGetters(['lang']) ...mapGetters(['lang'])
}, },
@ -121,10 +87,6 @@ export default {
// } // }
this.username = "" this.username = ""
this.password = "" this.password = ""
let { inviteCode } = this.$route.query || {}
if (inviteCode) {
this.showRegister()
}
}, },
methods: { methods: {
cancel() { cancel() {
@ -132,24 +94,12 @@ export default {
this.password = '' this.password = ''
this.$emit('cancel') this.$emit('cancel')
}, },
showRegister() {
this.registerVisible = true
this.$emit('cancel')
},
changeLang(value) { changeLang(value) {
if (value != this.lang) { if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value) this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value i18n.global.locale = value
} }
}, },
back() {
this.forgotVisible = false
this.$emit('open')
},
backFormRegister() {
this.registerVisible = false
this.$emit('open')
},
login() { login() {
if (!this.username) { if (!this.username) {
this.$message.error(this.$t('common.userEmailPlaceholder')) this.$message.error(this.$t('common.userEmailPlaceholder'))
@ -181,6 +131,7 @@ export default {
}) })
this.username = "" this.username = ""
this.password = "" this.password = ""
this.$router.replace({ name: 'video-gen' })
this.$emit('cancel') this.$emit('cancel')
}) })
} else { } else {
@ -190,10 +141,6 @@ export default {
.catch((_) => { .catch((_) => {
this.loading = false this.loading = false
}) })
},
showForgot() {
this.forgotVisible = true
this.$emit('cancel')
} }
} }
} }
@ -201,15 +148,18 @@ export default {
<style lang="less"> <style lang="less">
.login-dialog { .login-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 500px; width: 500px;
height: 320px; height: 320px;
top: 45% !important; top: 45% !important;
@ -323,36 +273,8 @@ export default {
justify-content: center; justify-content: center;
margin-top: 30px; margin-top: 30px;
.mf-button { .mf-button {
width: 160px; width: 220px;
border-radius: 10px; border-radius: 10px;
margin: 0 14px;
&:first-child {
color: #ffffff;
background-color: #1a1a1a;
&:hover {
background-color: #262626;
}
&:active {
background-color: #0d0d0d;
}
}
}
}
.login-link {
display: flex;
justify-content: flex-end;
color: #999;
margin-top: 10px;
.mf-button {
padding: 0;
color: #fff;
font-size: 12px;
&:hover {
background-color: transparent;
}
} }
} }
} }

View File

@ -22,15 +22,11 @@
<template v-else> <template v-else>
<a-menu-item :key="item.key"> <a-menu-item :key="item.key">
<template #icon> <template #icon>
<a-image <span
:width="24" class="menu-svg-icon"
:preview="false" :class="{ active: selectedKeys.indexOf(item.key) > -1 }"
:height="24" v-html="getMenuSvg(item.key)"
:src="`/images/nav/${ ></span>
selectedKeys.indexOf(item.key) > -1
? item.icon + '-a'
: item.icon
}.png`" />
</template> </template>
<div class="menu-item"> <div class="menu-item">
{{ item.label }} {{ item.label }}
@ -50,6 +46,9 @@ import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index' import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n' import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由(name 与 router/index.js 一致) */
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage', 'generatedAssets']
defineProps({ defineProps({
collapsed: Boolean collapsed: Boolean
}) })
@ -62,9 +61,14 @@ const $base = inject('$base')
const $message = inject('$message') const $message = inject('$message')
const menuItems = computed(() => { const menuItems = computed(() => {
return constantRoutes const root = constantRoutes.find((d) => d.path === '/')
.find((d) => d.path == '/') const children = root?.children ?? []
.children.map((route) => ({ return children
.filter(
(r) =>
r.meta?.menuItem !== false && SIDEBAR_ONLY_ROUTE_NAMES.includes(r.name)
)
.map((route) => ({
key: route.name, key: route.name,
label: generateTitle(route.meta?.title), label: generateTitle(route.meta?.title),
meta: route.meta, meta: route.meta,
@ -140,6 +144,19 @@ const count = (system) => {
? servicePolicyCount.value ? servicePolicyCount.value
: agencyPolicyCount.value : agencyPolicyCount.value
} }
const getMenuSvg = (key) => {
if (key === 'video-gen') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18Zm-1.5 5.2a.9.9 0 0 1 1.35-.78l4.7 2.7a.9.9 0 0 1 0 1.56l-4.7 2.7a.9.9 0 0 1-1.35-.78V8.2Z"/></svg>'
}
if (key === 'asset-group-manage') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l1.7 2H18.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-10Z"/></svg>'
}
if (key === 'asset-manage') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1.8 11.5h10.4L14 11.4l-2.6 3.1-1.7-2.1-2.9 3.1Zm2.7-6.1a1.6 1.6 0 1 0 0-3.2a1.6 1.6 0 0 0 0 3.2Z"/></svg>'
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>'
}
</script> </script>
<style lang="less"> <style lang="less">
.arco-tooltip-content { .arco-tooltip-content {
@ -166,4 +183,25 @@ const count = (system) => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.menu-svg-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.74);
transition: color 0.2s ease;
}
.menu-svg-icon:deep(svg) {
width: 22px;
height: 22px;
display: block;
fill: currentColor;
}
.menu-svg-icon.active {
color: #ffffff;
}
</style> </style>

View File

@ -102,7 +102,7 @@ export default {
&-save { &-save {
width: 410px; width: 410px;
height: 40px; height: 40px;
background: #e6217a; background: #4d89ff;
border-radius: 10px; border-radius: 10px;
margin-top: 30px; margin-top: 30px;
} }

View File

@ -263,16 +263,19 @@ export default {
<style lang="less"> <style lang="less">
.register-dialog { .register-dialog {
border-radius: 20px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
0deg, 160deg,
rgba(39, 20, 51, 0.7) 0%, rgba(18, 24, 38, 0.98) 0%,
rgba(230, 33, 122, 0.7) 49% rgba(26, 31, 46, 0.98) 50%,
rgba(37, 43, 58, 0.96) 100%
); );
background-color: transparent !important; background-color: transparent !important;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.35);
width: 500px; width: 500px;
top: 45% !important; top: 45% !important;
transform: translateY(-45%) !important; transform: translateY(-45%) !important;

View File

@ -24,60 +24,36 @@
:title="$t('common.myAccount')"> :title="$t('common.myAccount')">
<UserAccount /> <UserAccount />
</a-tab-pane> </a-tab-pane>
<a-tab-pane <!-- 隐藏有奖邀请 -->
key="money" <!-- <a-tab-pane key="money" :title="$t('common.moneyInvite')"><Money /></a-tab-pane> -->
:title="$t('common.moneyInvite')"> <!-- 隐藏快速充值入口 -->
<Money /> <!-- <a-tab-pane
</a-tab-pane>
<a-tab-pane
key="recharge" key="recharge"
:title="$t('common.rechargeRecord')"> :title="$t('common.rechargeRecord')">
<RechargeRecord /> <RechargeRecord />
</a-tab-pane> </a-tab-pane> -->
<a-tab-pane <a-tab-pane
key="resume" key="resume"
:title="$t('common.myProduct')"> :title="$t('common.myProduct')">
<ResumeRecord /> <ResumeRecord />
</a-tab-pane> </a-tab-pane>
<a-tab-pane <!-- 隐藏邀请消费记录 -->
key="invite" <!-- <a-tab-pane key="invite" :title="$t('common.inviteRecord')"><RewardRecord /></a-tab-pane> -->
:title="$t('common.inviteRecord')">
<RewardRecord />
</a-tab-pane>
</a-tabs> </a-tabs>
</div> </div>
</mf-dialog> </mf-dialog>
<Forgot
:visible="forgotVisible"
@open="forgotVisible = true"
@back="back"
@cancel="forgotVisible = false" />
<Register
:visible="registerVisible"
@cancel="registerVisible = false"
@back="backFormRegister"
@open="registerVisible = true" />
</div> </div>
</template> </template>
<script> <script>
import Forgot from './Forgot.vue'
import Register from './Register.vue'
import { mapGetters } from 'vuex'
import i18n from '@/lang/i18n'
import Money from './Money.vue'
import UserAccount from './UserAccount.vue' import UserAccount from './UserAccount.vue'
import RechargeRecord from './RechargeRecord.vue'
import ResumeRecord from './ResumeRecord.vue' import ResumeRecord from './ResumeRecord.vue'
import RewardRecord from './RewardRecord.vue'
export default { export default {
data() { data() {
return { return {
email: '', email: '',
forgotVisible: false,
emailVisible: false, emailVisible: false,
registerVisible: false,
loading: false, loading: false,
username: '', username: '',
password: '', password: '',
@ -85,28 +61,13 @@ export default {
} }
}, },
props: { props: {
visible: Boolean, visible: Boolean
register: Boolean
}, },
components: { components: {
Forgot,
Register,
Money,
UserAccount, UserAccount,
RechargeRecord, ResumeRecord
ResumeRecord,
RewardRecord
},
computed: {
...mapGetters(['lang'])
}, },
mounted() { mounted() {
if (this.register) {
this.showRegister()
setTimeout((_) => {
this.$emit('cancel')
}, 300)
}
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
if (event.data.type === 'google-login') { if (event.data.type === 'google-login') {
const { token } = event.data const { token } = event.data
@ -120,24 +81,6 @@ export default {
}) })
}, },
methods: { methods: {
showRegister() {
this.registerVisible = true
this.$emit('cancel')
},
changeLang(value) {
if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value
}
},
back() {
this.forgotVisible = false
this.$emit('open')
},
backFormRegister() {
this.registerVisible = false
this.$emit('open')
},
backEmail() { backEmail() {
this.emailVisible = false this.emailVisible = false
this.$emit('open') this.$emit('open')
@ -170,10 +113,6 @@ export default {
.catch((_) => { .catch((_) => {
this.loading = false this.loading = false
}) })
},
showForgot() {
this.forgotVisible = true
this.$emit('cancel')
} }
} }
} }
@ -182,10 +121,12 @@ export default {
<style lang="less"> <style lang="less">
.user-dialog { .user-dialog {
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #1a1f2e;
border-radius: 10px; border-radius: 12px;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.3);
width: 500px; width: 500px;
min-height: 520px; min-height: 520px;
top: 45% !important; top: 45% !important;
@ -227,11 +168,12 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&-active { &-active {
background: #e6217a; background: #4d89ff;
box-shadow: 0 0 12px rgba(77, 137, 255, 0.35);
} }
&:hover { &:hover:not(.arco-tabs-tab-active) {
background: #e6217a; background: rgba(77, 137, 255, 0.16);
.arco-tabs-tab-title { .arco-tabs-tab-title {
&:before { &:before {

View File

@ -281,10 +281,12 @@ export default {
<style lang="less"> <style lang="less">
.edit-user-dialog { .edit-user-dialog {
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #1a1f2e;
border-radius: 10px; border-radius: 12px;
border-radius: 20px; border: none;
border: 2px solid #e6217a; box-shadow:
inset 0 0 0 1px rgba(77, 137, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.3);
width: 500px; width: 500px;
min-height: 400px; min-height: 400px;
top: 45% !important; top: 45% !important;

View File

@ -4,13 +4,17 @@
class="app-main-wrap" class="app-main-wrap"
:style="style"> :style="style">
<section class="app-main"> <section class="app-main">
<keep-alive <transition
:include="cachedViews" name="portal-page"
:max="100"> mode="out-in">
<component <keep-alive
:is="Component" :include="cachedViews"
:key="key" /> :max="100">
</keep-alive> <component
:is="Component"
:key="key" />
</keep-alive>
</transition>
</section> </section>
</div> </div>
<a-back-top <a-back-top
@ -24,6 +28,8 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import Breadcrumb from './Breadcrumb.vue' import Breadcrumb from './Breadcrumb.vue'
const LAYOUT_MOBILE_MAX = 768
export default { export default {
name: 'app-main', name: 'app-main',
props: { props: {
@ -33,6 +39,21 @@ export default {
} }
}, },
components: { Breadcrumb }, components: { Breadcrumb },
data() {
return {
layoutWidth:
typeof window !== 'undefined' ? window.innerWidth : LAYOUT_MOBILE_MAX + 1
}
},
mounted() {
this._layoutOnResize = () => {
this.layoutWidth = window.innerWidth
}
window.addEventListener('resize', this._layoutOnResize)
},
beforeUnmount() {
window.removeEventListener('resize', this._layoutOnResize)
},
computed: { computed: {
...mapGetters(['cachedViews', 'sidebar']), ...mapGetters(['cachedViews', 'sidebar']),
// 相同路由,不同参数缓存问题 // 相同路由,不同参数缓存问题
@ -43,7 +64,10 @@ export default {
return !this.sidebar.opened return !this.sidebar.opened
}, },
style() { style() {
let sidebarWidth = this.isCollapsed ? '50px' : this.sidebar.width if (this.layoutWidth <= LAYOUT_MOBILE_MAX) {
return { width: '100%' }
}
const sidebarWidth = this.isCollapsed ? '56px' : this.sidebar.width
return { return {
width: `calc(100% - ${sidebarWidth})` width: `calc(100% - ${sidebarWidth})`
} }
@ -64,13 +88,15 @@ export default {
.app-main { .app-main {
position: relative; position: relative;
height: 100%; height: 100%;
overflow-y: auto;
overflow-x: hidden;
&-wrap { &-wrap {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
} }
@media (max-width: 576px) { @media (max-width: 768px) {
.app-main { .app-main {
&-wrap { &-wrap {
width: 100% !important; width: 100% !important;

View File

@ -1,11 +1,23 @@
<template> <template>
<div class="navbar"> <div class="navbar">
<div class="left-menu"> <div class="left-menu">
<mf-icon <button
:class="['left-collapse', { isCollapse: isCollapse }]" type="button"
cursor="pointer" class="sidebar-trigger"
@click="$store.dispatch('main/toggleSideBar')" :aria-expanded="sidebar.opened"
value="icon-shrink" /> aria-label="展开或收起侧栏"
@click="$store.dispatch('main/toggleSideBar')">
<mf-icon
class="sidebar-trigger__icon sidebar-trigger__icon--desktop"
:class="{ isCollapse: isCollapse }"
cursor="pointer"
value="icon-shrink" />
<span class="sidebar-trigger__icon sidebar-trigger__icon--mobile" aria-hidden="true">
<span class="hamburger-line" />
<span class="hamburger-line" />
<span class="hamburger-line" />
</span>
</button>
<div class="logo"> <div class="logo">
<div <div
class="logo-wrap" class="logo-wrap"
@ -31,20 +43,9 @@
</mf-button> </mf-button>
</div> </div>
<div class="right-menu-item language"> <div class="right-menu-item language">
<a-dropdown @select="handleSelect"> <mf-button type="text" disabled class="language-display">
<mf-button type="text"> {{ localeName }}
{{ localeName }} </mf-button>
<icon-down />
</mf-button>
<template #content>
<a-doption
v-for="(name, code) in localeNames"
:key="code"
:value="code">
{{ name }}
</a-doption>
</template>
</a-dropdown>
</div> </div>
<div <div
class="right-menu-item user" class="right-menu-item user"
@ -56,7 +57,7 @@
:preview="false" :preview="false"
:width="16" :width="16"
fit="cover" fit="cover"
src="/images/nav/img_money@2x.png" /> src="https://img.icons8.com/fluency/48/wallet.png" />
{{ userInfo.balance || '0.00' }} {{ userInfo.balance || '0.00' }}
</span> </span>
<mf-avatar <mf-avatar
@ -77,7 +78,6 @@
</div> </div>
<Login <Login
:register="!$datas.isEmpty(inviteCode)"
:visible="showLogin" :visible="showLogin"
@open="openLogin" @open="openLogin"
@cancel="cancelLogin" /> @cancel="cancelLogin" />
@ -97,8 +97,9 @@ import { mapGetters, mapState } from 'vuex'
import cloneDeep from 'lodash-es/cloneDeep' import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index.js' import { constantRoutes } from '@/router/index.js'
import Login from './Login.vue' import Login from './Login.vue'
import i18n, { LOCALE_NAMES } from '@/lang/i18n' import { LOCALE_NAMES } from '@/lang/i18n'
import User from './User.vue' import User from './User.vue'
import { getToken } from '@/utils/auth'
export default { export default {
name: 'nav-bar', name: 'nav-bar',
@ -116,7 +117,6 @@ export default {
} }
], ],
publishVisible: false, publishVisible: false,
inviteCode: this.$route.query?.inviteCode,
userVisible: false, userVisible: false,
logoUrl: null logoUrl: null
} }
@ -126,9 +126,6 @@ export default {
demoEnv() { demoEnv() {
return import.meta.env.MODE === 'demo' return import.meta.env.MODE === 'demo'
}, },
localeNames() {
return LOCALE_NAMES
},
localeName() { localeName() {
return LOCALE_NAMES[this.lang] || 'English' return LOCALE_NAMES[this.lang] || 'English'
}, },
@ -180,10 +177,10 @@ export default {
} }
}, },
mounted() { mounted() {
if (this.inviteCode) {
this.openLogin()
}
this.getLogo() this.getLogo()
if (getToken()) {
this.$store.dispatch('user/getInfo').catch(() => {})
}
}, },
methods: { methods: {
openLogin() { openLogin() {
@ -219,12 +216,6 @@ export default {
this.openLogin() this.openLogin()
} }
}, },
handleSelect(value) {
if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value
}
},
logout() { logout() {
this.$store.dispatch('user/logout2').then((_) => { this.$store.dispatch('user/logout2').then((_) => {
this.$router.replace('/') this.$router.replace('/')
@ -246,39 +237,73 @@ export default {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-sizing: content-box; box-sizing: content-box;
padding-left: 30px; padding-left: 20px;
padding-right: 30px; padding-right: 30px;
background: #1a1f2e;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
.left { .left-menu {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
padding-left: 4px;
height: 60px;
gap: 8px;
min-width: 0;
&-collapse { .sidebar-trigger {
display: none; display: inline-flex;
transition: 0.25s; align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 8px;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 10px;
color: rgba(255, 255, 255, 0.92);
&.isCollapse { &:hover {
transform: rotate(-180deg); background: rgba(255, 255, 255, 0.08);
}
&__icon--desktop {
display: flex;
transition: transform 0.25s;
&.isCollapse {
transform: rotate(-180deg);
}
}
&__icon--mobile {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 22px;
padding: 2px 0;
} }
} }
&-menu { .hamburger-line {
padding-left: 8px; display: block;
height: 2px;
width: 100%;
border-radius: 1px;
background: currentColor;
}
.logo {
display: flex; display: flex;
align-items: center; align-items: flex-end;
height: 60px; cursor: pointer;
color: var(--color-text-1);
.logo { &-wrap {
display: flex; display: flex;
align-items: flex-end; align-items: center;
cursor: pointer;
color: var(--color-text-1);
&-wrap {
display: flex;
align-items: center;
}
} }
} }
} }
@ -303,12 +328,18 @@ export default {
&.language { &.language {
.mf-button { .mf-button {
font-size: 14px; font-size: 14px;
color: #999999; color: #9ca3af;
background-color: transparent; background-color: transparent;
&:hover { &:hover {
background-color: transparent; background-color: transparent;
} }
} }
.language-display[disabled] {
color: #9ca3af !important;
opacity: 0.85;
cursor: not-allowed;
}
} }
&.logout { &.logout {
@ -330,10 +361,9 @@ export default {
&.user { &.user {
width: 150px; width: 150px;
color: rgb(var(--primary-6)); color: rgb(var(--primary-6));
border: 2px solid transparent;
height: 32px; height: 32px;
border: 1px solid #5c5d68; border: 1px solid rgba(77, 137, 255, 0.2);
background: #26272e; background: #252b3a;
border-radius: 16px; border-radius: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
@ -410,23 +440,23 @@ export default {
} }
} }
@media (max-width: 576px) { @media (max-width: 768px) {
.navbar { .navbar {
padding: 0 10px; padding: 0 12px;
.left {
&-collapse { .left-menu {
display: block; .sidebar-trigger {
font-size: 18px; &__icon--desktop {
display: none !important;
}
&__icon--mobile {
display: flex !important;
}
} }
&-menu { .logo {
display: flex; display: none;
.logo {
display: none;
}
.mf-icon {
color: #fff;
}
} }
} }

View File

@ -112,19 +112,19 @@ export default {
position: relative; position: relative;
transition: width 0.3s; transition: width 0.3s;
z-index: 12; z-index: 12;
background-color: #000; background-color: #121826;
border-right: 1px solid; /* 宽度随你调整 */ border-right: 1px solid transparent;
border-image: linear-gradient( border-image: linear-gradient(
to bottom, to bottom,
#0f0f12 0%, #121826 0%,
rgba(255,255,255, 0.7) 40%, rgba(77, 137, 255, 0.35) 45%,
rgba(255,255,255, 0.7) 60%, rgba(157, 107, 255, 0.25) 55%,
#0f0f12 100% #121826 100%
) )
1 100%; 1 100%;
&.collapsed { &.collapsed {
width: 50px !important; width: 56px !important;
overflow: hidden; overflow: hidden;
:deep(.arco-menu) { :deep(.arco-menu) {
@ -134,19 +134,36 @@ export default {
:deep(.arco-menu) { :deep(.arco-menu) {
background-color: transparent; background-color: transparent;
padding-left: 16px; padding: 8px 12px 12px;
&-item { &-item {
width: 180px; min-height: 48px;
line-height: 1.4;
font-size: 15px;
background-color: transparent; background-color: transparent;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
border-radius: 10px; border-radius: 10px;
margin-bottom: 12px; margin-bottom: 10px;
&.arco-menu-selected,
&:hover { &:hover {
background-color: rgb(var(--primary-6)); background-color: rgba(77, 137, 255, 0.12);
color: #e5e7eb;
} }
&.arco-menu-selected {
background-color: rgba(77, 137, 255, 0.2);
color: #e5e7eb;
box-shadow: inset 0 0 0 1px rgba(77, 137, 255, 0.12);
}
&.arco-menu-selected:hover {
background-color: rgba(77, 137, 255, 0.26);
}
}
&:not(.arco-menu-collapsed) .arco-menu-item {
width: calc(100% - 8px);
max-width: 100%;
} }
} }
@ -172,18 +189,22 @@ export default {
} }
} }
@media (max-width: 576px) { @media (max-width: 768px) {
.sidebar-container { .sidebar-container {
position: fixed; position: fixed;
left: 0px; left: 0px;
top: 60px; top: 60px;
z-index: 10; z-index: 100;
height: calc(100% - 60px); height: calc(100% - 60px);
overflow: hidden; overflow: hidden;
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.35);
&.collapsed { &.collapsed {
width: 0px !important; width: 0px !important;
min-width: 0 !important;
border-right: 0; border-right: 0;
box-shadow: none;
pointer-events: none;
} }
} }
} }

View File

@ -1,5 +1,10 @@
<template> <template>
<div class="app-wrapper"> <div class="app-wrapper">
<div
v-show="showSidebar && mobileDrawerOpen"
class="sidebar-backdrop"
aria-hidden="true"
@click="closeMobileDrawer" />
<div class="fixed-header"> <div class="fixed-header">
<nav-bar class="nav-bar" /> <nav-bar class="nav-bar" />
</div> </div>
@ -18,6 +23,8 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { sideBar, appMain, navBar, appFooter } from './components' import { sideBar, appMain, navBar, appFooter } from './components'
const LAYOUT_MOBILE_MAX = 768
export default { export default {
name: 'layout', name: 'layout',
components: { components: {
@ -26,16 +33,44 @@ export default {
navBar, navBar,
appFooter appFooter
}, },
data() {
return {
layoutWidth:
typeof window !== 'undefined' ? window.innerWidth : LAYOUT_MOBILE_MAX + 1
}
},
computed: { computed: {
...mapGetters(['sidebar']), ...mapGetters(['sidebar']),
isCollapsed() { isCollapsed() {
return !this.sidebar.opened return !this.sidebar.opened
}, },
isMobileLayout() {
return this.layoutWidth <= LAYOUT_MOBILE_MAX
},
mobileDrawerOpen() {
return this.isMobileLayout && this.sidebar.opened
},
// 是否显示菜单 // 是否显示菜单
showSidebar() { showSidebar() {
let { menu } = this.$route.query || {} let { menu } = this.$route.query || {}
return menu != 0 return menu != 0
} }
},
mounted() {
this._layoutOnResize = () => {
this.layoutWidth = window.innerWidth
}
window.addEventListener('resize', this._layoutOnResize)
},
beforeUnmount() {
window.removeEventListener('resize', this._layoutOnResize)
},
methods: {
closeMobileDrawer() {
if (this.isMobileLayout && this.sidebar.opened) {
this.$store.dispatch('main/closeSideBar')
}
}
} }
} }
</script> </script>
@ -47,7 +82,11 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #121826;
.sidebar-backdrop {
display: none;
}
.fixed-header { .fixed-header {
z-index: 100; z-index: 100;
@ -59,6 +98,18 @@ export default {
.main-wrapper { .main-wrapper {
display: flex; display: flex;
height: calc(100% - 60px); height: calc(100% - 60px);
position: relative;
}
}
@media (max-width: 768px) {
.app-wrapper .sidebar-backdrop {
display: block;
position: fixed;
inset: 60px 0 0 0;
z-index: 99;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
} }
} }
</style> </style>

View File

@ -9,9 +9,11 @@ import router from './router'
import '@/assets/styles/base.less' import '@/assets/styles/base.less'
import '@/assets/styles/quill.bubble.css' // Quill 样式 - 临时注释避免 source map 文件缺失导致的构建错误
import '@/assets/styles/quill.core.css' // 如果后续需要使用 Quill 富文本编辑器,可取消注释
import '@/assets/styles/quill.snow.css' // import '@/assets/styles/quill.bubble.css'
// import '@/assets/styles/quill.core.css'
// import '@/assets/styles/quill.snow.css'
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)

View File

@ -47,7 +47,7 @@ export const constantRoutes = [{
path: '/', path: '/',
component: Layout, component: Layout,
redirect: { redirect: {
name: 'index' name: 'video-gen'
}, },
children: [{ children: [{
path: 'index', path: 'index',
@ -69,49 +69,47 @@ export const constantRoutes = [{
permission: "pass", permission: "pass",
icon: 'btn_tst' icon: 'btn_tst'
} }
}, }, {
// { path: 'image-to-image?type=2',
// path: 'image-to-image?type=2', name: 'image-to-image2',
// name: 'image-to-image2', component: () => import('@/views/Image.vue'),
// component: () => import('@/views/Image.vue'), meta: {
// meta: { title: 'imageToImage2',
// title: 'imageToImage2', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_tst'
// icon: 'btn_tst' }
// } }, {
// }, { path: 'change-face',
// path: 'change-face', name: 'change-face',
// name: 'change-face', component: () => import('@/views/ChangeFace.vue'),
// component: () => import('@/views/ChangeFace.vue'), meta: {
// meta: { title: 'changeFace',
// title: 'changeFace', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_yjhl'
// icon: 'btn_yjhl' }
// } }, {
// }, { path: 'change-face-video',
// path: 'change-face-video', name: 'change-face-video',
// name: 'change-face-video', component: () => import('@/views/ChangeFace.vue'),
// component: () => import('@/views/ChangeFace.vue'), meta: {
// meta: { title: 'changeFaceVideo',
// title: 'changeFaceVideo', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_yjhl'
// icon: 'btn_yjhl' }
// } }, {
// }, { path: 'fast-image',
// path: 'fast-image', name: 'fast-image',
// name: 'fast-image', component: () => import('@/views/FastImage.vue'),
// component: () => import('@/views/FastImage.vue'), meta: {
// meta: { title: 'fastImage',
// title: 'fastImage', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_kjst'
// icon: 'btn_kjst' }
// } }, {
// },
{
path: 'fast-video', path: 'fast-video',
name: 'fast-video', name: 'fast-video',
component: () => import('@/views/FastVideo.vue'), component: () => import('@/views/FastVideo.vue'),
@ -122,14 +120,44 @@ export const constantRoutes = [{
icon: 'btn_kjsp' icon: 'btn_kjsp'
} }
}, { }, {
path: 'recharge', path: 'video-gen',
name: 'recharge', name: 'video-gen',
component: () => import('@/views/Recharge.vue'), component: () => import('@/views/VideoGen.vue'),
meta: { meta: {
title: 'recharge', title: 'videoGen',
menuItem: true, menuItem: true,
permission: "pass", permission: "pass",
icon: 'btn_kscz' icon: 'btn_video'
}
}, {
path: 'asset-group-manage',
name: 'asset-group-manage',
component: () => import('@/views/AssetGroupManage.vue'),
meta: {
title: 'assetGroupManage',
menuItem: true,
permission: "pass",
icon: 'btn_video'
}
}, {
path: 'asset-manage',
name: 'asset-manage',
component: () => import('@/views/AssetManage.vue'),
meta: {
title: 'assetManage',
menuItem: true,
permission: "pass",
icon: 'btn_video'
}
}, {
path: 'generated-assets',
name: 'generatedAssets',
component: () => import('@/views/GeneratedAssets.vue'),
meta: {
title: 'generatedAssets',
menuItem: true,
permission: "pass",
icon: 'btn_video'
} }
}, { }, {
path: 'help', path: 'help',

View File

@ -16,11 +16,13 @@ const state = {
sidebar: { sidebar: {
opened: isMobile() ? false : true, opened: isMobile() ? false : true,
withoutAnimation: false, withoutAnimation: false,
width: Cookies.get('sidebarWidth') || '230px' // 左侧导航默认适中宽度(用户若需更窄/宽可本地调小范围会写 cookie)
width: Cookies.get('sidebarWidth') || '268px'
}, },
topMenu: Cookies.get('topMenu'), topMenu: Cookies.get('topMenu'),
// 当前语言 // 当前语言
language: Cookies.get('language') || 'en_US', // 多语言切换已禁用:全站固定繁体中文(zh_HK)
language: 'zh_HK',
// 主题 '':亮色 / dark:暗黑 // 主题 '':亮色 / dark:暗黑
theme: $storage.get('theme') || '', theme: $storage.get('theme') || '',
// 系统端 // 系统端
@ -39,8 +41,8 @@ const state = {
showMessage: false, showMessage: false,
messageData: {}, messageData: {},
messageCount: 0, messageCount: 0,
// 是否显示18禁弹窗 // 是否显示18禁弹窗:默认不拦截,直接进入首页等主页面
showForbidden: true, showForbidden: false,
// 阻止页面切换 // 阻止页面切换
showPrevent: false, showPrevent: false,
showLogin: false showLogin: false

View File

@ -27,9 +27,12 @@ export const isFirefox = (_) => {
} }
} }
export const isMobile = _=> { export const isMobile = (_) => {
const userAgent = navigator.userAgent; const userAgent = navigator.userAgent
const isMobile = /iPad|Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent); const uaMobile =
const isSmallScreen = window.innerWidth <= 576; /iPad|Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
return isMobile || isSmallScreen; userAgent
)
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth <= 768
return uaMobile || isSmallScreen
} }

View File

@ -0,0 +1,18 @@
/**
* 火山素材相关接口 data 层字段兼容:
* Spring/Jackson 序列化为 camelCase(items、totalCount),
* 旧前端曾按 PascalCase(Items、TotalCount)取值,此处统一解析。
*/
export function byteApiItems(data) {
if (!data) return []
if (Array.isArray(data.items)) return data.items
if (Array.isArray(data.Items)) return data.Items
return []
}
export function byteApiTotalCount(data) {
if (!data) return 0
const raw = data.totalCount ?? data.TotalCount
const n = Number(raw)
return Number.isFinite(n) ? n : 0
}

View File

@ -130,6 +130,23 @@ export const convertBase64ToUrl = (base64) => {
) )
} }
/**
* 从若依 AjaxResult / 兼容仅返回顶层 url 的上传响应中解析访问地址
*/
export const extractUploadUrlFromResponse = (res) => {
if (!res) return ''
if (typeof res.data === 'string' && res.data.trim()) return res.data.trim()
if (res.data && typeof res.data === 'object') {
const u = res.data.url || res.data.path
if (typeof u === 'string' && u.trim()) return u.trim()
}
if (typeof res.url === 'string' && res.url.trim()) return res.url.trim()
return ''
}
/** 门户统一腾讯云 COS:后端 FileController `/api/file/upload`,与 mf-image-upload 一致 */
export const PORTAL_TENCENT_COS_UPLOAD_URL = '/api/file/upload'
/** /**
* 文件上传 * 文件上传
* @param {Object} {url, file,name} 文件上传地址 file 文件 name 文件名参数 * @param {Object} {url, file,name} 文件上传地址 file 文件 name 文件名参数
@ -148,12 +165,10 @@ export const uploadFile = ({
formData.append(key, data[key]) formData.append(key, data[key])
}) })
} }
// 不要手动设置 Content-Type,否则缺少 boundary,服务端无法解析 multipart,文件参数字段为空
return request({ return request({
url: url, url: url,
method: 'POST', method: 'POST',
data: formData, data: formData
headers: {
'Content-Type': 'multipart/form-data'
}
}) })
} }

View File

@ -21,7 +21,7 @@ const messages = i18n.global.messages[lang];
* @desc 创建axios实例 * @desc 创建axios实例
*/ */
const service = axios.create({ const service = axios.create({
// baseURL: process.env.VUE_APP_BASE_API, baseURL: process.env.VUE_APP_BASE_API,
withCredentials: import.meta.env.MODE === 'development', withCredentials: import.meta.env.MODE === 'development',
timeout: 600 * 1000 timeout: 600 * 1000
}) })

View File

@ -0,0 +1,465 @@
<template>
<div class="asset-group-page">
<section class="ag-panel">
<div class="ag-head">
<h3 class="ag-title">资源组管理</h3>
<a-button type="primary" @click="openCreateDialog">新增资源组</a-button>
</div>
<div class="ag-filter">
<div class="ag-field">
<label>名称</label>
<a-input v-model="filters.name" placeholder="按名称过滤" />
</div>
<div class="ag-field">
<label>资源组编号</label>
<a-input v-model="filters.groupIdsText" placeholder="多个编号用英文逗号分隔" />
</div>
<div class="ag-field">
<label>资源组类型</label>
<a-select v-model="filters.groupType">
<a-option value="AIGC">AIGC(生成类)</a-option>
</a-select>
</div>
<div class="ag-field">
<label>排序字段</label>
<a-select v-model="filters.sortBy">
<a-option value="CreateTime">创建时间</a-option>
<a-option value="UpdateTime">更新时间</a-option>
</a-select>
</div>
<div class="ag-field">
<label>排序方向</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">从新到旧</a-option>
<a-option value="Asc">从旧到新</a-option>
</a-select>
</div>
<div class="ag-actions">
<a-button type="primary" :loading="listLoading" @click="search(1)">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</div>
</div>
<a-spin :loading="listLoading">
<div class="ag-total">总数:{{ totalCount }}</div>
<div class="ag-table-wrap">
<table class="ag-table">
<colgroup>
<col class="ag-col-id" />
<col class="ag-col-name" />
<col class="ag-col-desc" />
<col class="ag-col-type" />
<col class="ag-col-project" />
<col class="ag-col-time" />
<col class="ag-col-time" />
<col class="ag-col-action" />
</colgroup>
<thead>
<tr>
<th>编号</th>
<th>名称</th>
<th>描述</th>
<th>类型</th>
<th>项目名称</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.Id || item.id">
<td>{{ item.Id || item.id }}</td>
<td>{{ item.Name || item.name }}</td>
<td>{{ item.Description || item.description || '-' }}</td>
<td>{{ formatGroupTypeLabel(item) }}</td>
<td>{{ item.ProjectName || item.projectName || '-' }}</td>
<td>{{ item.CreateTime || item.createTime || '-' }}</td>
<td>{{ item.UpdateTime || item.updateTime || '-' }}</td>
<td>
<a-button
size="mini"
type="outline"
:loading="detailLoadingId === (item.Id || item.id)"
@click="getDetail(item)">
详情
</a-button>
</td>
</tr>
<tr v-if="!items.length">
<td colspan="8" class="ag-empty">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="ag-pagination">
<a-pagination
:total="totalCount"
:current="filters.pageNumber"
:page-size="filters.pageSize"
show-total
show-jumper
show-page-size
:page-size-options="listPageSizeOptions"
@change="onGroupPageChange"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
</section>
<a-modal v-model:visible="detailVisible" title="资源组详情" :footer="false" width="680px">
<pre class="ag-detail">{{ prettyDetail }}</pre>
</a-modal>
<a-modal
v-model:visible="createVisible"
title="新增资源组"
width="520px"
:confirm-loading="createLoading"
@ok="createGroup">
<!-- 弹层 teleported 到 body,使用 FormItem 保证标签与 dark 样式生效 -->
<a-form :model="createForm" layout="horizontal" auto-label-width class="ag-create-modal-form">
<a-form-item label="名称" required>
<a-input
v-model="createForm.name"
placeholder="请输入资源组名称(≤64 字符)"
:max-length="64"
allow-clear />
</a-form-item>
<a-form-item label="描述">
<a-textarea
v-model="createForm.description"
:max-length="300"
show-word-limit
:auto-size="{ minRows: 3, maxRows: 8 }"
placeholder="请输入描述(≤300 字符)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
const LIST_PAGE_NUMBER_MAX = 100
const LIST_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
export default {
name: 'AssetGroupManage',
data() {
return {
createLoading: false,
createVisible: false,
listLoading: false,
detailLoadingId: '',
detailVisible: false,
detailData: null,
createForm: {
name: '',
description: '',
groupType: 'AIGC'
},
filters: {
name: '',
groupIdsText: '',
groupType: 'AIGC',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
},
totalCount: 0,
items: [],
listPageSizeOptions: LIST_PAGE_SIZE_OPTIONS
}
},
computed: {
prettyDetail() {
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
}
},
mounted() {
this.search(1)
},
methods: {
formatGroupTypeLabel(item) {
const t = String(item?.GroupType || item?.groupType || '').trim()
if (!t) return '-'
if (t === 'AIGC') return 'AIGC(生成类)'
return t
},
clampGroupPage(n) {
const page = Number(n) || 1
const size = Number(this.filters.pageSize) || 10
let maxPage = LIST_PAGE_NUMBER_MAX
if (this.totalCount > 0) {
const totalPages = Math.ceil(this.totalCount / size)
maxPage = Math.min(LIST_PAGE_NUMBER_MAX, Math.max(1, totalPages))
}
return Math.min(Math.max(1, page), maxPage)
},
onGroupPageChange(page) {
this.search(this.clampGroupPage(page))
},
openCreateDialog() {
this.createVisible = true
},
buildGroupIds() {
return String(this.filters.groupIdsText || '')
.split(',')
.map((x) => x.trim())
.filter((x) => !!x)
},
async createGroup() {
const name = String(this.createForm.name || '').trim()
if (!name) {
this.$message.error('请填写名称')
return
}
this.createLoading = true
try {
const res = await this.$axios({
url: 'api/byteAssetGroup/createAssetGroup',
method: 'POST',
data: {
name,
description: String(this.createForm.description || '').trim()
}
})
if (res.code === 200) {
this.$message.success('新增成功')
this.createForm.name = ''
this.createForm.description = ''
this.createVisible = false
this.search(1)
} else {
this.$message.error(res.msg || '新增失败')
}
} catch (e) {
this.$message.error(e?.message || '新增失败')
} finally {
this.createLoading = false
}
},
async search(page = this.filters.pageNumber) {
this.filters.pageNumber = this.clampGroupPage(page)
this.listLoading = true
try {
const payload = {
filter: {
groupType: this.filters.groupType || 'AIGC'
},
pageNumber: this.filters.pageNumber,
pageSize: this.filters.pageSize,
sortBy: this.filters.sortBy,
sortOrder: this.filters.sortOrder
}
const name = String(this.filters.name || '').trim()
if (name) payload.filter.name = name
const ids = this.buildGroupIds()
if (ids.length) payload.filter.groupIds = ids
const res = await this.$axios({
url: 'api/byteAssetGroup/listAssetGroups',
method: 'POST',
data: payload
})
const data = res.data || {}
this.totalCount = byteApiTotalCount(data)
this.items = byteApiItems(data)
if (res.code !== 200) {
this.$message.error(res.msg || '查询失败')
}
} catch (e) {
this.$message.error(e?.message || '查询失败')
} finally {
this.listLoading = false
}
},
onPageSizeChange(size) {
const s = Number(size) || 10
this.filters.pageSize = LIST_PAGE_SIZE_OPTIONS.includes(s) ? s : 10
this.search(1)
},
resetFilters() {
this.filters = {
name: '',
groupIdsText: '',
groupType: 'AIGC',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
this.search(1)
},
async getDetail(item) {
const id = item?.Id || item?.id
if (!id) return
this.detailLoadingId = id
try {
const res = await this.$axios({
url: 'api/byteAssetGroup/getAssetGroup',
method: 'POST',
data: { id: id }
})
if (res.code === 200) {
this.detailData = res.data || {}
this.detailVisible = true
} else {
this.$message.error(res.msg || '查询详情失败')
}
} catch (e) {
this.$message.error(e?.message || '查询详情失败')
} finally {
this.detailLoadingId = ''
}
}
}
}
</script>
<style scoped lang="less">
.asset-group-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
background: #121826;
color: rgba(255, 255, 255, 0.88);
min-height: 100%;
}
.ag-panel {
background: rgba(22, 24, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
}
.ag-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.ag-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.ag-form,
.ag-filter {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
}
.ag-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.ag-field label {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.ag-actions {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ag-total {
margin: 6px 0 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.ag-table-wrap {
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.ag-table {
width: 100%;
max-width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 12px;
}
/* 列宽按百分比分配,随容器(100% 宽)随分辨率伸缩 */
.ag-col-id {
width: 10%;
}
.ag-col-name {
width: 12%;
}
.ag-col-desc {
width: 24%;
}
.ag-col-type {
width: 8%;
}
.ag-col-project {
width: 12%;
}
.ag-col-time {
width: 11%;
}
.ag-col-action {
width: 12%;
}
.ag-table th,
.ag-table td {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
word-break: break-word;
overflow-wrap: anywhere;
}
.ag-table th {
color: rgba(255, 255, 255, 0.7);
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
.ag-empty {
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.ag-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.ag-detail {
margin: 0;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
color: #d8f4f7;
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 960px) {
.ag-form,
.ag-filter {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,932 @@
<template>
<div class="asset-manage-page">
<section class="asset-left">
<div class="asset-left-head">
<div class="panel-title">素材组树</div>
</div>
<div class="group-tree">
<div
v-for="g in groups"
:key="g.Id || g.id"
:class="['group-node', { active: selectedGroupId === (g.Id || g.id) }]"
@click="selectGroup(g)">
<span class="folder-icon">📁</span>
<div class="group-meta">
<div class="group-name">{{ g.Name || g.name || (g.Id || g.id) }}</div>
<div class="group-id">{{ g.Id || g.id }}</div>
</div>
</div>
<div v-if="!groups.length" class="empty-tip">暂无素材组,请先在“资源组管理”创建</div>
</div>
</section>
<section class="asset-right">
<div class="asset-left-head">
<div class="panel-title">素材管理</div>
<div class="field actions">
<a-button type="primary" size="mini" @click="openCreateAssetDialog">新增素材</a-button>
</div>
</div>
<div class="panel-title mtop">查询素材</div>
<div class="form-grid">
<div class="field">
<label>素材组</label>
<a-select
v-model="filters.groupId"
placeholder="请选择素材组"
allow-clear
:loading="groupLoading"
@change="onFilterGroupChange">
<a-option v-for="g in groups" :key="g.Id || g.id" :value="String(g.Id || g.id)">
{{ (g.Name || g.name || g.Id || g.id) + ' · ' + (g.Id || g.id) }}
</a-option>
</a-select>
</div>
<div class="field">
<label>素材名称</label>
<a-input v-model="filters.name" placeholder="按名称筛选" />
</div>
<div class="field">
<label>状态</label>
<a-select v-model="filters.status" placeholder="全部状态">
<a-option value="">全部</a-option>
<a-option value="Active">可用</a-option>
<a-option value="Processing">处理中</a-option>
<a-option value="Failed">失败</a-option>
</a-select>
</div>
<div class="field">
<label>排序字段</label>
<a-select v-model="filters.sortBy">
<a-option value="CreateTime">创建时间</a-option>
<a-option value="UpdateTime">更新时间</a-option>
<a-option value="GroupId">素材组</a-option>
</a-select>
</div>
<div class="field">
<label>排序方向</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">从新到旧</a-option>
<a-option value="Asc">从旧到新</a-option>
</a-select>
</div>
<div class="field actions">
<a-button type="primary" :loading="listLoading" @click="searchAssets(1)">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</div>
</div>
<a-spin :loading="listLoading">
<div class="total-line">总数:{{ totalCount }}</div>
<div class="table-wrap">
<table class="asset-table">
<thead>
<tr>
<th>素材编号</th>
<th>名称</th>
<th>访问地址</th>
<th>所属素材组</th>
<th>类型</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="it in items" :key="it.Id || it.id">
<td class="asset-td-id">{{ it.Id || it.id }}</td>
<td>{{ it.Name || it.name || '-' }}</td>
<td class="asset-url-cell">
<template v-if="assetUrlPreviewKind(it) === 'image' && !thumbError[it.Id || it.id]">
<a
:href="assetUrl(it)"
target="_blank"
rel="noreferrer"
class="asset-thumb-link">
<img
class="asset-url-thumb"
:src="assetUrl(it)"
alt=""
loading="lazy"
@error="onAssetThumbError(it)" />
</a>
</template>
<template v-else-if="assetUrlPreviewKind(it) === 'video'">
<video
class="asset-url-video"
:src="assetUrl(it)"
muted
playsinline
preload="metadata"
controls />
</template>
<template v-else-if="assetUrl(it)">
<a :href="assetUrl(it)" target="_blank" rel="noreferrer" class="asset-url-plain">{{
assetUrl(it)
}}</a>
</template>
<span v-else class="asset-url-empty">-</span>
<div
v-if="assetUrlCaptionVisible(it)"
class="asset-url-caption"
:title="assetUrl(it)">
{{ assetUrl(it) }}
</div>
</td>
<td>{{ it.GroupId || it.groupId || '-' }}</td>
<td>{{ formatAssetTypeLabel(it) }}</td>
<td>{{ formatAssetStatusLabel(it) }}</td>
<td>{{ it.CreateTime || it.createTime || '-' }}</td>
<td>
<a-button size="mini" type="outline" @click="getAsset(it)">详情</a-button>
<a-button size="mini" status="danger" @click="deleteAsset(it)">删除</a-button>
</td>
</tr>
<tr v-if="!items.length">
<td colspan="8" class="empty-tip">暂无素材</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<a-pagination
:total="totalCount"
:current="filters.pageNumber"
:page-size="filters.pageSize"
show-total
show-jumper
show-page-size
:page-size-options="listPageSizeOptions"
@change="onAssetPageChange"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
</section>
<a-modal v-model:visible="detailVisible" title="素材详情" :footer="false" width="700px">
<pre class="detail-box">{{ detailText }}</pre>
</a-modal>
<a-modal
v-model:visible="createAssetVisible"
title="新增素材"
width="520px"
:confirm-loading="createLoading"
@ok="createAsset">
<a-form :model="createForm" layout="horizontal" auto-label-width class="asset-create-modal-form">
<a-form-item label="素材组" required>
<a-select v-model="createForm.groupId" placeholder="请选择素材组" allow-clear>
<a-option v-for="g in groups" :key="g.Id || g.id" :value="String(g.Id || g.id)">
{{ g.Name || g.name || (g.Id || g.id) }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="上传文件" required>
<div
class="asset-file-dropzone"
:class="{ 'asset-file-dropzone--dragover': createUploadDragOver }"
@click="triggerCreateFilePicker"
@dragenter.prevent="onCreateUploadDragEnter"
@dragover.prevent="onCreateUploadDragOver"
@dragleave.prevent="onCreateUploadDragLeave"
@drop.prevent="onCreateUploadDrop">
<input
ref="createAssetFileInput"
class="asset-file-input"
type="file"
style="display: none"
@change="onFileChange" />
<div class="asset-file-dropzone-content">
<div class="asset-file-dropzone-icon">+</div>
<div class="asset-file-dropzone-text">点击选择或拖拽上传</div>
</div>
</div>
<div v-if="createForm.previewUrl && createForm.assetType === 'Image'" class="asset-file-preview">
<img :src="createForm.previewUrl" alt="" />
</div>
<div class="asset-file-hint">{{ createForm.fileName || '未选择文件' }}</div>
</a-form-item>
<a-form-item label="素材名称">
<a-input v-model="createForm.name" placeholder="素材名称(可选)" allow-clear />
</a-form-item>
<a-form-item label="类型">
<a-select v-model="createForm.assetType">
<a-option value="Image">图片</a-option>
<a-option value="Video">视频</a-option>
<a-option value="Audio">音频</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
import { extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL, uploadFile } from '@/utils/file'
/** 素材树与筛选区素材组列表统一请求体 */
const GROUP_LIST_REQUEST_BODY = {
filter: { groupType: 'AIGC' },
pageNumber: 1,
pageSize: 100,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
const GROUP_LIST_API = 'api/byteAssetGroup/listAssetGroups'
const ASSET_CREATE_API = 'api/byteAsset/createAsset'
const ASSET_LIST_API = 'api/byteAsset/listAssets'
const ASSET_GET_API = 'api/byteAsset/getAsset'
const ASSET_DELETE_API = 'api/byteAsset/deleteAsset'
/** 列表页码上限(与接口分页一致);每页条数可选项 */
const LIST_PAGE_NUMBER_MAX = 100
const LIST_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
export default {
name: 'AssetManage',
data() {
return {
groupLoading: false,
createLoading: false,
listLoading: false,
createAssetVisible: false,
groups: [],
selectedGroupId: '',
createForm: {
groupId: '',
file: null,
fileName: '',
previewUrl: '',
localPreviewUrl: '',
name: '',
assetType: 'Image'
},
createUploadDragOver: false,
filters: {
groupId: '',
name: '',
status: '',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
},
totalCount: 0,
items: [],
detailVisible: false,
detailData: null,
listPageSizeOptions: LIST_PAGE_SIZE_OPTIONS,
thumbError: {}
}
},
computed: {
detailText() {
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
}
},
mounted() {
this.loadGroups()
},
watch: {
createAssetVisible(val) {
if (!val) this.clearCreateUploadPreview()
}
},
methods: {
clearCreateUploadPreview() {
if (this.createForm?.localPreviewUrl) {
try {
URL.revokeObjectURL(this.createForm.localPreviewUrl)
} catch (_) {}
}
this.createForm.previewUrl = ''
this.createForm.localPreviewUrl = ''
},
setCreateFormFile(file) {
this.createForm.file = file || null
this.createForm.fileName = file?.name || ''
const mime = String(file?.type || '').toLowerCase()
// 根据文件类型自动设置素材类型,避免与文件不匹配
if (mime.startsWith('image/')) this.createForm.assetType = 'Image'
else if (mime.startsWith('video/')) this.createForm.assetType = 'Video'
else if (mime.startsWith('audio/')) this.createForm.assetType = 'Audio'
// 图片预览
this.clearCreateUploadPreview()
if (mime.startsWith('image/')) {
try {
this.createForm.localPreviewUrl = URL.createObjectURL(file)
this.createForm.previewUrl = this.createForm.localPreviewUrl
} catch (_) {}
}
},
formatAssetTypeLabel(it) {
const t = String(it?.AssetType || it?.assetType || '').trim()
if (!t) return '-'
const map = {
Image: '图片',
Video: '视频',
Audio: '音频'
}
return map[t] || t
},
formatAssetStatusLabel(it) {
const s = String(it?.Status || it?.status || '').trim()
if (!s) return '-'
const map = {
Active: '可用',
Processing: '处理中',
Failed: '失败'
}
return map[s] || s
},
assetUrl(it) {
return String(it?.URL || it?.url || '').trim()
},
/** image | video | link */
assetUrlPreviewKind(it) {
const u = this.assetUrl(it)
if (!u || !/^https?:\/\//i.test(u)) return 'link'
const t = String(it?.AssetType || it?.assetType || '')
if (/image/i.test(t)) return 'image'
if (/video/i.test(t)) return 'video'
if (/audio/i.test(t)) return 'link'
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(u)) return 'image'
if (/\.(mp4|webm|mov|m4v|ogg)(\?|#|$)/i.test(u)) return 'video'
return 'link'
},
onAssetThumbError(it) {
const id = it?.Id || it?.id
if (id) this.thumbError = { ...this.thumbError, [id]: true }
},
assetUrlCaptionVisible(it) {
const u = this.assetUrl(it)
if (!u) return false
const k = this.assetUrlPreviewKind(it)
if (k === 'video') return true
if (k === 'image' && !this.thumbError[it?.Id || it?.id]) return true
return false
},
clampAssetPage(n) {
const page = Number(n) || 1
const size = Number(this.filters.pageSize) || 10
let maxPage = LIST_PAGE_NUMBER_MAX
if (this.totalCount > 0) {
const totalPages = Math.ceil(this.totalCount / size)
maxPage = Math.min(LIST_PAGE_NUMBER_MAX, Math.max(1, totalPages))
}
return Math.min(Math.max(1, page), maxPage)
},
onAssetPageChange(page) {
this.searchAssets(this.clampAssetPage(page))
},
async openCreateAssetDialog() {
await this.loadGroups()
this.createForm.groupId = this.selectedGroupId || this.createForm.groupId || ''
this.createForm.file = null
this.createForm.fileName = ''
this.createForm.name = ''
this.createForm.assetType = 'Image'
this.clearCreateUploadPreview()
this.createAssetVisible = true
},
async loadGroups() {
this.groupLoading = true
try {
const res = await this.$axios({
url: GROUP_LIST_API,
method: 'POST',
data: { ...GROUP_LIST_REQUEST_BODY }
})
this.groups = byteApiItems(res?.data)
if (!this.selectedGroupId && this.groups.length) {
const gid = String(this.groups[0].Id || this.groups[0].id || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
}
} catch (e) {
this.$message.error(e?.message || '加载分组失败')
} finally {
this.groupLoading = false
}
},
selectGroup(g) {
const gid = String(g?.Id || g?.id || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
},
onFilterGroupChange(val) {
const gid = val != null && val !== '' ? String(val) : ''
this.selectedGroupId = gid
this.createForm.groupId = gid
this.searchAssets(1)
},
triggerCreateFilePicker() {
const el = this.$refs.createAssetFileInput
if (el && typeof el.click === 'function') el.click()
},
onCreateUploadDragEnter() {
this.createUploadDragOver = true
},
onCreateUploadDragOver() {
this.createUploadDragOver = true
},
onCreateUploadDragLeave() {
this.createUploadDragOver = false
},
onCreateUploadDrop(e) {
this.createUploadDragOver = false
const file = e?.dataTransfer?.files?.[0]
if (!file) return
this.setCreateFormFile(file)
},
onFileChange(e) {
const file = e?.target?.files?.[0]
this.setCreateFormFile(file)
},
async createAsset() {
const groupId = String(this.createForm.groupId || '').trim()
if (!groupId) return this.$message.error('请选择素材组')
if (!this.createForm.file) return this.$message.error('请选择上传文件')
this.createLoading = true
try {
// 1) 先走 /api/file/upload 拿到上传后的 url
const uploadRes = await uploadFile({
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file: this.createForm.file,
name: 'file'
})
const uploadUrl = extractUploadUrlFromResponse(uploadRes)
if (!uploadUrl) throw new Error(uploadRes?.msg || '上传后未返回文件地址')
// 图片上传成功后,在上传组件展示远程预览(替换本地预览)
if (this.createForm.assetType === 'Image') {
this.clearCreateUploadPreview()
this.createForm.previewUrl = uploadUrl
}
// 2) 再通过 /api/byteAsset/createAsset(JSON) 用 url 创建素材
const res = await this.$axios({
url: ASSET_CREATE_API,
method: 'POST',
data: {
URL: uploadUrl,
groupId,
assetType: this.createForm.assetType,
name: String(this.createForm.name || '').trim()
}
})
if (res.code === 200) {
this.$message.success(this.createForm.assetType === 'Image' ? '上传图片成功' : '上传成功')
this.createForm.file = null
this.createForm.name = ''
const isImage = this.createForm.assetType === 'Image'
if (isImage) {
// 图片模式下延迟关闭,避免用户来不及看到上传组件缩略图
setTimeout(() => {
this.createAssetVisible = false
}, 500)
} else {
this.createAssetVisible = false
}
this.searchAssets(1)
} else {
this.$message.error(res.msg || '新增素材失败')
}
} catch (e) {
this.$message.error(e?.message || '新增素材失败')
} finally {
this.createLoading = false
}
},
buildListPayload() {
const filter = {
groupType: 'AIGC'
}
const gid = String(this.filters.groupId || '').trim()
if (gid) filter.groupIds = [gid]
const name = String(this.filters.name || '').trim()
if (name) filter.name = name
if (this.filters.status) filter.statuses = [this.filters.status]
return {
filter,
pageNumber: this.filters.pageNumber,
pageSize: this.filters.pageSize,
sortBy: this.filters.sortBy,
sortOrder: this.filters.sortOrder
}
},
async searchAssets(page = this.filters.pageNumber) {
this.filters.pageNumber = this.clampAssetPage(page)
this.listLoading = true
this.thumbError = {}
try {
const res = await this.$axios({
url: ASSET_LIST_API,
method: 'POST',
data: this.buildListPayload()
})
this.totalCount = byteApiTotalCount(res?.data)
this.items = byteApiItems(res?.data)
if (res.code !== 200) {
this.$message.error(res.msg || '查询素材失败')
}
} catch (e) {
this.$message.error(e?.message || '查询素材失败')
} finally {
this.listLoading = false
}
},
onPageSizeChange(size) {
const s = Number(size) || 10
this.filters.pageSize = LIST_PAGE_SIZE_OPTIONS.includes(s) ? s : 10
this.searchAssets(1)
},
resetFilters() {
const gid = this.groups.length
? String(this.groups[0].Id || this.groups[0].id || '')
: String(this.selectedGroupId || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters = {
groupId: gid,
name: '',
status: '',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
this.searchAssets(1)
},
async getAsset(it) {
const id = it?.Id || it?.id
if (!id) return
try {
const res = await this.$axios({
url: ASSET_GET_API,
method: 'POST',
data: { id: id }
})
if (res.code === 200) {
this.detailData = res.data || {}
this.detailVisible = true
} else {
this.$message.error(res.msg || '查询详情失败')
}
} catch (e) {
this.$message.error(e?.message || '查询详情失败')
}
},
deleteAsset(it) {
const id = it?.Id || it?.id
if (!id) return
this.$confirm({
title: '删除素材',
content: `确认删除素材 ${id} 吗?`,
onOk: async () => {
try {
const res = await this.$axios({
url: ASSET_DELETE_API,
method: 'POST',
data: { id: id }
})
if (res.code === 200) {
this.$message.success('删除成功')
this.searchAssets(this.filters.pageNumber)
} else {
this.$message.error(res.msg || '删除失败')
}
} catch (e) {
this.$message.error(e?.message || '删除失败')
}
}
})
}
}
}
</script>
<style scoped lang="less">
.asset-manage-page {
display: grid;
grid-template-columns: 300px 1fr;
gap: 14px;
padding: 16px;
min-height: 100%;
background: #121826;
color: rgba(255, 255, 255, 0.9);
}
.asset-left,
.asset-right {
background: rgba(22, 24, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 12px;
}
.asset-right {
min-width: 0;
width: 100%;
}
.panel-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.asset-left-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.group-tree {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 72vh;
overflow: auto;
}
.group-node {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.group-node:hover {
background: rgba(255, 255, 255, 0.04);
}
.group-node.active {
border-color: rgba(0, 202, 224, 0.45);
background: rgba(0, 202, 224, 0.12);
}
.folder-icon {
font-size: 14px;
line-height: 1.6;
}
.group-name {
font-size: 13px;
}
.group-id {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
word-break: break-all;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.field.actions {
align-self: end;
flex-direction: row;
align-items: center;
}
.mtop {
margin-top: 14px;
}
.total-line {
margin: 10px 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.table-wrap {
overflow: auto;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.asset-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 12px;
}
.asset-table th:nth-child(1),
.asset-table td:nth-child(1) {
width: 88px;
word-break: break-all;
}
.asset-table th:nth-child(2),
.asset-table td:nth-child(2) {
width: 14%;
word-break: break-word;
}
.asset-table th:nth-child(3),
.asset-table td:nth-child(3) {
width: 30%;
min-width: 160px;
}
.asset-table th:nth-child(4),
.asset-table td:nth-child(4) {
width: 10%;
word-break: break-all;
}
.asset-table th:nth-child(5),
.asset-table td:nth-child(5) {
width: 9%;
}
.asset-table th:nth-child(6),
.asset-table td:nth-child(6) {
width: 9%;
}
.asset-table th:nth-child(7),
.asset-table td:nth-child(7) {
width: 14%;
word-break: break-word;
}
.asset-table th:nth-child(8),
.asset-table td:nth-child(8) {
width: 16%;
}
.asset-table th,
.asset-table td {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
.asset-table th {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.02);
}
.asset-url-cell {
min-width: 0;
word-break: break-word;
}
.asset-thumb-link {
display: inline-block;
max-width: 100%;
vertical-align: top;
}
.asset-url-thumb {
display: block;
max-width: 100%;
max-height: 120px;
width: auto;
height: auto;
object-fit: contain;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
}
.asset-url-video {
display: block;
max-width: 100%;
max-height: 160px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: #000;
}
.asset-url-plain {
color: rgba(0, 202, 224, 0.95);
word-break: break-all;
}
.asset-url-empty {
color: rgba(255, 255, 255, 0.35);
}
.asset-url-caption {
margin-top: 6px;
font-size: 11px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.45);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.empty-tip {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
text-align: center;
padding: 10px 0;
}
.detail-box {
margin: 0;
padding: 10px;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
max-height: 420px;
overflow: auto;
color: #d8f4f7;
}
@media (max-width: 980px) {
.asset-manage-page {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
<style lang="less">
/* 弹层挂载到 body,与深色页搭配的标签可读性 */
.asset-create-modal-form.arco-form .arco-form-item-label-col .arco-form-item-label,
.ag-create-modal-form.arco-form .arco-form-item-label-col .arco-form-item-label {
color: var(--color-text-2, rgba(255, 255, 255, 0.78));
font-weight: 500;
}
.asset-file-input {
display: block;
width: 100%;
max-width: 100%;
padding: 4px 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.asset-file-dropzone {
width: 100%;
max-width: 100%;
min-height: 38px;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease-in-out;
box-sizing: border-box;
}
.asset-file-dropzone--dragover {
border-color: rgba(0, 202, 224, 0.65);
background: rgba(0, 202, 224, 0.06);
}
.asset-file-dropzone-content {
display: flex;
align-items: center;
gap: 10px;
}
.asset-file-dropzone-icon {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(0, 202, 224, 0.35);
background: rgba(0, 202, 224, 0.08);
color: rgba(0, 202, 224, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.asset-file-dropzone-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.asset-file-preview {
margin-top: 10px;
width: 100%;
max-width: 160px;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.25);
}
.asset-file-preview img {
display: block;
width: 100%;
height: auto;
}
.asset-file-hint {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
word-break: break-all;
}
</style>

Some files were not shown because too many files have changed in this diff Show More