Compare commits

...

95 Commits

Author SHA1 Message Date
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
184 changed files with 16636 additions and 798 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$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/python" />
<excludePattern pattern="*.zip" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@ -5,4 +5,7 @@ VUE_APP_TITLE = 管理系统
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'
# 若依管理系统/测试环境
VUE_APP_BASE_API = '/api'
VUE_APP_BASE_API = 'http://101.96.201.225:8011'

1
admin-ui/.gitignore vendored
View File

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

Binary file not shown.

View File

@ -0,0 +1,46 @@
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'
})
}

View File

@ -81,3 +81,12 @@ export function updatePassword(id, newPassword) {
data: data
})
}
/** 分配归属部门deptId 可省略或 null 表示清空 */
export function assignAiUserDept(data) {
return request({
url: '/ai/user/dept',
method: 'put',
data
})
}

View File

@ -0,0 +1,443 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="部门名称" prop="deptName">
<el-input
v-model="queryParams.deptName"
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
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="Byte API Key">
<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 / 模型 IDep-…)"
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"
export default {
name: "AiDept",
dicts: ['sys_normal_disable'],
components: { Treeselect },
data() {
return {
loading: true,
showSearch: true,
deptList: [],
deptOptions: [],
title: "",
open: false,
isExpandAll: true,
refreshTable: true,
queryParams: {
deptName: undefined,
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.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
listDept(this.queryParams).then(response => {
this.deptList = this.handleTree(response.data, "deptId")
this.loading = false
})
},
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

@ -74,11 +74,6 @@
<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="warning"
plain
@ -87,23 +82,23 @@
@click="handleExport"
v-hasPermi="['ai:order:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</el-form-item>
</el-form>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<el-table v-loading="loading" :data="orderList" @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="id" width="60" />
<!-- <el-table-column label="备注" align="center" prop="remark" /> -->
<el-table-column label="订单编号" align="center" prop="orderNum" />
<el-table-column label="用户ID" align="center" prop="uuid" />
<el-table-column label="操作类型" align="center" prop="type">
<el-table-column label="订单编号" align="center" prop="orderNum" width="150"/>
<el-table-column label="用户ID" align="center" prop="uuid" width="100" />
<el-table-column label="操作类型" align="center" prop="type" width="90" >
<template slot-scope="scope">
<dict-tag :options="dict.type.ai_function_type" :value="scope.row.type" />
</template>
</el-table-column>
<el-table-column label="金额" align="center" prop="amount" />
<el-table-column label="生成结果" align="center">
<el-table-column label="金额" align="center" prop="amount" width="100" />
<el-table-column label="生成结果" align="center" width="200">
<template slot-scope="scope">
<!-- 判断是否为链接 -->
<template v-if="isUrl(scope.row.result)">
@ -123,7 +118,43 @@
@click="playVideo(scope.row.result)"
/>
</template>
<!-- 非链接内容 -->
<!-- 视频任务失败等场景result VideoTaskError JSON表格内简短悬停看全文 -->
<template v-else-if="parseVolcTaskErrorJson(scope.row.result)">
<span
v-for="err in [parseVolcTaskErrorJson(scope.row.result)]"
:key="'oe-' + scope.row.id"
class="order-result-error-wrap"
>
<el-tooltip
placement="top-start"
effect="dark"
:open-delay="200"
popper-class="order-result-error-tooltip-popper"
>
<div slot="content" class="order-result-error-tooltip-body">
<div class="order-result-error-tooltip-row">
<span class="order-result-error-tooltip-k">错误编号</span>
<span class="order-result-error-tooltip-v">{{ err.code || '—' }}</span>
</div>
<div
v-if="volcTaskErrorHintForCode(err.code)"
class="order-result-error-tooltip-row"
>
<span class="order-result-error-tooltip-k">中文说明</span>
<span class="order-result-error-tooltip-v">{{
volcTaskErrorHintForCode(err.code)
}}</span>
</div>
<div class="order-result-error-tooltip-row order-result-error-tooltip-row--msg">
<span class="order-result-error-tooltip-k">信息</span>
<span class="order-result-error-tooltip-v">{{ err.message || '—' }}</span>
</div>
</div>
<span class="order-result-error-compact">{{ volcTaskErrorCellSummary(err) }}</span>
</el-tooltip>
</span>
</template>
<!-- 非链接内容如任务 id可点击尝试拉取视频 -->
<span v-else @click="handleOtherEvent(scope.row)">{{ scope.row.result }}</span>
</template>
</el-table-column>
@ -145,7 +176,7 @@
>{{ scope.row.text}}</div>
</template>
</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">
<el-switch
v-model="scope.row.isTop"
@ -155,9 +186,9 @@
></el-switch>
</template>
</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-column label="创建时间" align="center" prop="createTime" width="150"/>
<el-table-column label="状态" align="center" prop="status" :formatter="formatStatus" width="70"/>
<el-table-column label="来源" align="center" prop="source" width="70"/>
</el-table>
<pagination
@ -304,23 +335,70 @@ export default {
row.isTop = row.isTop === "Y" ? "N" : "Y";
});
},
// URL
// http(s) URL portal-ui
isUrl(str) {
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
return urlRegex.test(str);
const value = String(str || "").trim();
return /^https?:\/\//i.test(value);
},
//
/** 已知错误码对应中文说明(与门户 VideoGen 一致) */
volcTaskErrorHintForCode(code) {
const c = String(code || "").trim();
const hints = {
OutputVideoSensitiveContentDetected: "输出视频可能包含敏感信息",
InvalidParameter: "请求参数无效"
};
return hints[c] || "";
},
/** 表格内摘要:已知码只显示中文,未知码显示 code */
volcTaskErrorCellSummary(err) {
if (!err) return "—";
const code = String(err.code || "").trim();
const hint = this.volcTaskErrorHintForCode(code);
if (hint) return hint;
if (code) return code;
const msg = String(err.message || "").trim();
return msg ? msg.slice(0, 32) + (msg.length > 32 ? "…" : "") : "—";
},
/** 已知火山错误码后附中文说明(括号),导出等场景可用 */
volcFailureCodeWithHint(code) {
const c = String(code || "").trim();
if (!c) return "";
const hint = this.volcTaskErrorHintForCode(c);
return hint ? `${c}${hint}` : c;
},
/** result 为火山回调失败写入的 VideoTaskError JSON 时解析为 { code, message } */
parseVolcTaskErrorJson(str) {
const s = String(str || "").trim();
if (!s || s[0] !== "{") return null;
try {
const o = JSON.parse(s);
if (
o &&
typeof o === "object" &&
!Array.isArray(o) &&
("code" in o || "message" in o)
) {
return {
code: o.code != null ? String(o.code) : "",
message: o.message != null ? String(o.message) : ""
};
}
} catch (_) {
/* ignore */
}
return null;
},
// portal-ui GeneratedAssets
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);
const value = String(url || "").trim();
if (!value) return false;
return /\.(jpeg|jpg|png|gif|webp|bmp)(\?.*)?$/i.test(value);
},
//
// portal-ui VideoGen/GeneratedAssets
isVideo(url) {
const videoExtensions = /\.(mp4|avi|mov|wmv|flv|webm)$/i;
return videoExtensions.test(url);
const value = String(url || "").trim();
if (!value) return false;
return /\.(mp4|mov|webm|ogg|m4v|avi|mkv)(\?.*)?$/i.test(value);
},
//
viewImage(url) {
@ -334,6 +412,7 @@ export default {
},
//
async handleOtherEvent(row) {
if (this.parseVolcTaskErrorJson(row.result)) return;
//
if (row.isDownloading) return;
const originalResult = row.result;
@ -510,6 +589,56 @@ export default {
height: auto;
}
.order-result-error-wrap {
display: inline-block;
}
.order-result-error-compact {
display: inline-block;
max-width: 200px;
margin: 0 auto;
font-size: 12px;
line-height: 1.35;
color: #c45656;
cursor: help;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.order-result-error-tooltip-body {
max-width: 420px;
text-align: left;
line-height: 1.5;
font-size: 12px;
}
.order-result-error-tooltip-row {
margin-bottom: 6px;
}
.order-result-error-tooltip-row:last-child {
margin-bottom: 0;
}
.order-result-error-tooltip-row--msg .order-result-error-tooltip-v {
display: block;
margin-top: 2px;
white-space: pre-wrap;
word-break: break-word;
}
.order-result-error-tooltip-k {
font-weight: 600;
color: #fde2e2;
}
.order-result-error-tooltip-v {
color: #fff;
}
.text-ellipsis-two-lines {
/* 必须设置宽度(继承表格列宽,也可手动指定) */
width: 100%;

View File

@ -50,6 +50,16 @@
/>
</el-select>
</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-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>
@ -57,6 +67,27 @@
</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:user:add']"
>新增</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:user:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
@ -78,6 +109,7 @@
<el-table-column label="上级ID" align="center" prop="superiorUuid" />
<el-table-column label="上级账号" align="center" prop="superiorName" />
<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="gender">
<template slot-scope="scope">
@ -103,8 +135,15 @@
</el-table-column>
<el-table-column label="余额" align="center" prop="balance" />
<el-table-column label="source" align="center" prop="source" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="310">
<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
size="mini"
type="text"
@ -119,6 +158,13 @@
@click="updateBalance(scope.row)"
v-hasPermi="['ai:user:remove']"
>修改余额</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ai:user:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@ -132,8 +178,24 @@
/>
<!-- 添加或修改ai-用户信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
<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-input v-model="form.nickname" placeholder="请输入用户昵称" />
</el-form-item>
@ -158,10 +220,10 @@
<el-form-item label="余额" prop="balance">
<el-input v-model="form.balance" placeholder="请输入余额" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item>
</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">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
@ -182,6 +244,25 @@
</div>
</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="title" :visible.sync="openUpdateBalance" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
@ -207,12 +288,17 @@ import {
updateUser,
changeBalance,
changeUserStatus,
updatePassword
updatePassword,
assignAiUserDept
} 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 {
name: "User",
dicts: ["sys_normal_disable", "sys_user_sex"],
components: { Treeselect },
data() {
return {
//
@ -235,6 +321,12 @@ export default {
open: false,
openUpdatePassword: false,
openUpdateBalance: false,
assignDeptOpen: false,
deptOptions: [],
assignForm: {
id: null,
deptId: null
},
//
queryParams: {
pageNum: 1,
@ -252,25 +344,73 @@ export default {
paymentUrl: null,
loginTime: null,
balance: null,
superiorName: null
superiorName: null,
deptId: null
},
//
form: {},
//
rules: {
delFlag: [
{ required: true, message: "删除标志不能为空", trigger: "blur" }
],
phone: [
{ required: true, message: "手机号码不能为空", trigger: "blur" }
username: [{ required: true, message: "用户账号不能为空", trigger: "blur" }],
password: [
{
validator: (rule, value, callback) => {
if (this.form.id == null && (!value || !String(value).trim())) {
callback(new Error("密码不能为空"));
} else {
callback();
}
},
trigger: "blur"
}
]
}
};
},
created() {
this.loadDeptTree();
this.getList();
},
methods: {
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) {
switch (command) {
@ -353,6 +493,7 @@ export default {
updateBy: null,
updateTime: null,
remark: null,
username: null,
nickname: null,
gender: null,
avatar: null,
@ -407,13 +548,17 @@ export default {
this.$refs["form"].validate(valid => {
if (valid) {
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.open = false;
this.getList();
});
} else {
addUser(this.form).then(response => {
addUser(this.form).then(() => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
@ -434,11 +579,16 @@ export default {
},
/** 删除按钮操作 */
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
.confirm('是否确认删除ai-用户信息编号为"' + ids + '"的数据项?')
.then(function() {
return delUser(ids);
.confirm('是否确认删除ai-用户信息编号为"' + idParam + '"的数据项?')
.then(() => {
return delUser(idParam);
})
.then(() => {
this.getList();

View File

@ -97,7 +97,7 @@
</el-table>
<!-- 添加或修改部门对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<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">
@ -148,6 +148,67 @@
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isSecondLevelCompanyForm">
<el-col :span="24">
<el-form-item label="Byte API Key">
<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="火山配置项目">
<el-input
v-model="form.project"
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 / 模型 IDep-…)"
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>
@ -157,6 +218,31 @@
</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/system/dept"
import Treeselect from "@riophae/vue-treeselect"
@ -191,6 +277,7 @@ export default {
},
//
form: {},
modelParamRows: [{ label: '', value: '' }],
//
rules: {
parentId: [
@ -222,7 +309,54 @@ export default {
created() {
this.getList()
},
computed: {
/** 二级公司ancestors 为 0,100即上级为根公司 dept_id=100 */
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: {
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)
},
/** 查询部门列表 */
getList() {
this.loading = true
@ -252,13 +386,18 @@ export default {
this.form = {
deptId: undefined,
parentId: undefined,
ancestors: undefined,
deptName: undefined,
orderNum: undefined,
leader: undefined,
phone: undefined,
email: undefined,
byteApiKey: undefined,
project: undefined,
modelParm: undefined,
status: "0"
}
this.modelParamRows = [{ label: '', value: '' }]
this.resetForm("form")
},
/** 搜索按钮操作 */
@ -295,6 +434,7 @@ export default {
this.reset()
getDept(row.deptId).then(response => {
this.form = response.data
this.syncModelRowsFromForm()
this.open = true
this.title = "修改部门"
listDeptExcludeChild(row.deptId).then(response => {
@ -310,6 +450,7 @@ export default {
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
this.buildModelParmPayload()
if (this.form.deptId != undefined) {
updateDept(this.form).then(response => {
this.$modal.msgSuccess("修改成功")

View File

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

View File

@ -1,3 +1,3 @@
# 端口
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/
selenium-debug.log
nodejs/public/uploads
*.zip
# Editor directories and files
.idea
.vscode

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -615,4 +615,4 @@
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
}
/*# sourceMappingURL=quill.snow.css.map*/
/* Quill snow styles - source map removed to fix build error */

View File

@ -8,10 +8,6 @@
fullscreen
:class="`${prefixCls}-wrapper`"
: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}-title`">
{{ $t('common.fbTitle') }}
@ -38,6 +34,8 @@
</template>
<script>
import i18n from '@/lang/i18n'
export default {
name: 'mf-forbidden',
data() {
@ -53,7 +51,13 @@ export default {
this.$router.replace('/403')
},
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' })
})
}
}
}

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: #00cae0;
: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

@ -8,5 +8,7 @@ export default {
fastVideo: 'إنشاء فيديو',
recharge: 'شحن سريع',
help: 'مركز المساعدة',
moneyInvite: 'دعوة مكافآت'
moneyInvite: 'دعوة مكافآت',
assetGroupManage: 'إدارة مجموعات الأصول',
assetManage: 'إدارة الأصول'
}

View File

@ -143,6 +143,14 @@ export default {
cardNoRequired: 'Please enter card number',
cardNameRequired: 'Please enter cardholder name',
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支付相关
vmCardInfo: 'Credit Card Information',
cardNumber: 'Card Number',
@ -175,5 +183,9 @@ export default {
country: 'Country',
countryPlaceholder: '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 {
index: 'Home',
imageToImage: 'One-click undressing',
imageToImage: 'Image-to-Image 1',
imageToImage2: 'Image-to-Image2',
changeFace: 'Swap Face',
changeFaceVideo: 'Swap Video Face',
fastImage: 'Gen Image',
fastVideo: 'Gen Video',
videoGen: 'Video Generation',
recharge: 'Quick Recharge',
help: 'Help Center',
moneyInvite: 'Reward Invitation'
moneyInvite: 'Reward Invitation',
assetGroupManage: 'Asset Group Manage',
assetManage: 'Asset Manage'
}

View File

@ -8,5 +8,7 @@ export default {
fastVideo: 'Generar video',
recharge: 'Recarga rápida',
help: 'Centro de ayuda',
moneyInvite: 'Invitación con recompensa'
moneyInvite: 'Invitación con recompensa',
assetGroupManage: 'Gestión de grupos de activos',
assetManage: 'Gestión de activos'
}

View File

@ -8,5 +8,7 @@ export default {
fastVideo: 'Générer une vidéo',
recharge: 'Recharge rapide',
help: 'Centre d\'aide',
moneyInvite: 'Invitation avec récompense'
moneyInvite: 'Invitation avec récompense',
assetGroupManage: 'Gestion des groupes d\'actifs',
assetManage: 'Gestion des actifs'
}

View File

@ -8,5 +8,7 @@ export default {
fastVideo: 'वीडियो जनरेट करें',
recharge: 'त्वरित रिचार्ज',
help: 'सहायता केंद्र',
moneyInvite: 'इनाम निमंत्रण'
moneyInvite: 'इनाम निमंत्रण',
assetGroupManage: 'एसेट समूह प्रबंधन',
assetManage: 'एसेट प्रबंधन'
}

View File

@ -1,26 +1,28 @@
import { createI18n } from 'vue-i18n'
import Cookies from 'js-cookie'
import zh_HK from '@/lang/zh_HK/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 hi_IN from '@/lang/hi_IN/index.js'
import ru_RU from '@/lang/ru_RU/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 = {
zh_HK: '繁体中文',
en_US: 'English',
/** en_US: 'English',
es_ES: 'Español',
pt_BR: 'Português',
hi_IN: 'हिन्दी',
ru_RU: 'Русский',
ar_SA: 'العربية',
fr_FR: 'Français'
fr_FR: 'Français'**/
}
const i18n = createI18n({
@ -28,13 +30,13 @@ const i18n = createI18n({
locale,
messages: {
zh_HK,
en_US,
/** en_US,
es_ES,
pt_BR,
hi_IN,
ru_RU,
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'
export default {
en_US: en_USLocale,
//en_US: en_USLocale,
zh_HK: zh_HKLocale
}

View File

@ -8,5 +8,7 @@ export default {
fastVideo: 'Gerar vídeo',
recharge: 'Recarga rápida',
help: 'Central de ajuda',
moneyInvite: 'Convite com recompensa'
moneyInvite: 'Convite com recompensa',
assetGroupManage: 'Gerenciamento de grupos de ativos',
assetManage: 'Gerenciamento de ativos'
}

View File

@ -8,5 +8,7 @@ export default {
fastVideo: 'Создать видео',
recharge: 'Быстрая пополнение',
help: 'Центр помощи',
moneyInvite: 'Приглашение с наградой'
moneyInvite: 'Приглашение с наградой',
assetGroupManage: 'Управление группами ресурсов',
assetManage: 'Управление ресурсами'
}

View File

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

View File

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

View File

@ -181,6 +181,7 @@ export default {
})
this.username = ""
this.password = ""
this.$router.replace({ name: 'video-gen' })
this.$emit('cancel')
})
} else {

View File

@ -22,15 +22,11 @@
<template v-else>
<a-menu-item :key="item.key">
<template #icon>
<a-image
:width="24"
:preview="false"
:height="24"
:src="`/images/nav/${
selectedKeys.indexOf(item.key) > -1
? item.icon + '-a'
: item.icon
}.png`" />
<span
class="menu-svg-icon"
:class="{ active: selectedKeys.indexOf(item.key) > -1 }"
v-html="getMenuSvg(item.key)"
></span>
</template>
<div class="menu-item">
{{ item.label }}
@ -50,6 +46,9 @@ import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由name 与 router/index.js 一致) */
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage', 'generatedAssets']
defineProps({
collapsed: Boolean
})
@ -62,9 +61,14 @@ const $base = inject('$base')
const $message = inject('$message')
const menuItems = computed(() => {
return constantRoutes
.find((d) => d.path == '/')
.children.map((route) => ({
const root = constantRoutes.find((d) => d.path === '/')
const children = root?.children ?? []
return children
.filter(
(r) =>
r.meta?.menuItem !== false && SIDEBAR_ONLY_ROUTE_NAMES.includes(r.name)
)
.map((route) => ({
key: route.name,
label: generateTitle(route.meta?.title),
meta: route.meta,
@ -140,6 +144,19 @@ const count = (system) => {
? servicePolicyCount.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>
<style lang="less">
.arco-tooltip-content {
@ -166,4 +183,25 @@ const count = (system) => {
justify-content: space-between;
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>

View File

@ -24,26 +24,21 @@
:title="$t('common.myAccount')">
<UserAccount />
</a-tab-pane>
<a-tab-pane
key="money"
:title="$t('common.moneyInvite')">
<Money />
</a-tab-pane>
<a-tab-pane
<!-- 隐藏有奖邀请 -->
<!-- <a-tab-pane key="money" :title="$t('common.moneyInvite')"><Money /></a-tab-pane> -->
<!-- 隐藏快速充值入口 -->
<!-- <a-tab-pane
key="recharge"
:title="$t('common.rechargeRecord')">
<RechargeRecord />
</a-tab-pane>
</a-tab-pane> -->
<a-tab-pane
key="resume"
:title="$t('common.myProduct')">
<ResumeRecord />
</a-tab-pane>
<a-tab-pane
key="invite"
:title="$t('common.inviteRecord')">
<RewardRecord />
</a-tab-pane>
<!-- 隐藏邀请消费记录 -->
<!-- <a-tab-pane key="invite" :title="$t('common.inviteRecord')"><RewardRecord /></a-tab-pane> -->
</a-tabs>
</div>
</mf-dialog>
@ -63,13 +58,8 @@
<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 RechargeRecord from './RechargeRecord.vue'
import ResumeRecord from './ResumeRecord.vue'
import RewardRecord from './RewardRecord.vue'
export default {
data() {
@ -91,14 +81,8 @@ export default {
components: {
Forgot,
Register,
Money,
UserAccount,
RechargeRecord,
ResumeRecord,
RewardRecord
},
computed: {
...mapGetters(['lang'])
ResumeRecord
},
mounted() {
if (this.register) {
@ -124,12 +108,6 @@ export default {
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')

View File

@ -24,6 +24,8 @@
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from './Breadcrumb.vue'
const LAYOUT_MOBILE_MAX = 768
export default {
name: 'app-main',
props: {
@ -33,6 +35,21 @@ export default {
}
},
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: {
...mapGetters(['cachedViews', 'sidebar']),
//
@ -43,7 +60,10 @@ export default {
return !this.sidebar.opened
},
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 {
width: `calc(100% - ${sidebarWidth})`
}
@ -64,13 +84,15 @@ export default {
.app-main {
position: relative;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
&-wrap {
height: 100%;
overflow: hidden;
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.app-main {
&-wrap {
width: 100% !important;

View File

@ -1,11 +1,23 @@
<template>
<div class="navbar">
<div class="left-menu">
<mf-icon
:class="['left-collapse', { isCollapse: isCollapse }]"
cursor="pointer"
@click="$store.dispatch('main/toggleSideBar')"
value="icon-shrink" />
<button
type="button"
class="sidebar-trigger"
:aria-expanded="sidebar.opened"
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-wrap"
@ -31,20 +43,9 @@
</mf-button>
</div>
<div class="right-menu-item language">
<a-dropdown @select="handleSelect">
<mf-button type="text">
{{ localeName }}
<icon-down />
</mf-button>
<template #content>
<a-doption
v-for="(name, code) in localeNames"
:key="code"
:value="code">
{{ name }}
</a-doption>
</template>
</a-dropdown>
<mf-button type="text" disabled class="language-display">
{{ localeName }}
</mf-button>
</div>
<div
class="right-menu-item user"
@ -97,8 +98,9 @@ import { mapGetters, mapState } from 'vuex'
import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index.js'
import Login from './Login.vue'
import i18n, { LOCALE_NAMES } from '@/lang/i18n'
import { LOCALE_NAMES } from '@/lang/i18n'
import User from './User.vue'
import { getToken } from '@/utils/auth'
export default {
name: 'nav-bar',
@ -126,9 +128,6 @@ export default {
demoEnv() {
return import.meta.env.MODE === 'demo'
},
localeNames() {
return LOCALE_NAMES
},
localeName() {
return LOCALE_NAMES[this.lang] || 'English'
},
@ -184,6 +183,9 @@ export default {
this.openLogin()
}
this.getLogo()
if (getToken()) {
this.$store.dispatch('user/getInfo').catch(() => {})
}
},
methods: {
openLogin() {
@ -219,12 +221,6 @@ export default {
this.openLogin()
}
},
handleSelect(value) {
if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value
}
},
logout() {
this.$store.dispatch('user/logout2').then((_) => {
this.$router.replace('/')
@ -246,39 +242,71 @@ export default {
align-items: center;
justify-content: space-between;
box-sizing: content-box;
padding-left: 30px;
padding-left: 20px;
padding-right: 30px;
.left {
.left-menu {
display: flex;
align-items: center;
flex: 1;
padding-left: 4px;
height: 60px;
gap: 8px;
min-width: 0;
&-collapse {
display: none;
transition: 0.25s;
.sidebar-trigger {
display: inline-flex;
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 {
transform: rotate(-180deg);
&:hover {
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 {
padding-left: 8px;
.hamburger-line {
display: block;
height: 2px;
width: 100%;
border-radius: 1px;
background: currentColor;
}
.logo {
display: flex;
align-items: center;
height: 60px;
align-items: flex-end;
cursor: pointer;
color: var(--color-text-1);
.logo {
&-wrap {
display: flex;
align-items: flex-end;
cursor: pointer;
color: var(--color-text-1);
&-wrap {
display: flex;
align-items: center;
}
align-items: center;
}
}
}
@ -309,6 +337,12 @@ export default {
background-color: transparent;
}
}
.language-display[disabled] {
color: #999999 !important;
opacity: 0.85;
cursor: not-allowed;
}
}
&.logout {
@ -410,23 +444,23 @@ export default {
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.navbar {
padding: 0 10px;
.left {
&-collapse {
display: block;
font-size: 18px;
padding: 0 12px;
.left-menu {
.sidebar-trigger {
&__icon--desktop {
display: none !important;
}
&__icon--mobile {
display: flex !important;
}
}
&-menu {
display: flex;
.logo {
display: none;
}
.mf-icon {
color: #fff;
}
.logo {
display: none;
}
}

View File

@ -124,7 +124,7 @@ export default {
1 100%;
&.collapsed {
width: 50px !important;
width: 56px !important;
overflow: hidden;
:deep(.arco-menu) {
@ -134,20 +134,27 @@ export default {
:deep(.arco-menu) {
background-color: transparent;
padding-left: 16px;
padding: 8px 12px 12px;
&-item {
width: 180px;
min-height: 48px;
line-height: 1.4;
font-size: 15px;
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border-radius: 10px;
margin-bottom: 12px;
margin-bottom: 10px;
&.arco-menu-selected,
&:hover {
background-color: rgb(var(--primary-6));
}
}
&:not(.arco-menu-collapsed) .arco-menu-item {
width: calc(100% - 8px);
max-width: 100%;
}
}
.toogle-menu {
@ -172,18 +179,22 @@ export default {
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.sidebar-container {
position: fixed;
left: 0px;
top: 60px;
z-index: 10;
z-index: 100;
height: calc(100% - 60px);
overflow: hidden;
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.35);
&.collapsed {
width: 0px !important;
min-width: 0 !important;
border-right: 0;
box-shadow: none;
pointer-events: none;
}
}
}

View File

@ -1,5 +1,10 @@
<template>
<div class="app-wrapper">
<div
v-show="showSidebar && mobileDrawerOpen"
class="sidebar-backdrop"
aria-hidden="true"
@click="closeMobileDrawer" />
<div class="fixed-header">
<nav-bar class="nav-bar" />
</div>
@ -18,6 +23,8 @@
import { mapGetters } from 'vuex'
import { sideBar, appMain, navBar, appFooter } from './components'
const LAYOUT_MOBILE_MAX = 768
export default {
name: 'layout',
components: {
@ -26,16 +33,44 @@ export default {
navBar,
appFooter
},
data() {
return {
layoutWidth:
typeof window !== 'undefined' ? window.innerWidth : LAYOUT_MOBILE_MAX + 1
}
},
computed: {
...mapGetters(['sidebar']),
isCollapsed() {
return !this.sidebar.opened
},
isMobileLayout() {
return this.layoutWidth <= LAYOUT_MOBILE_MAX
},
mobileDrawerOpen() {
return this.isMobileLayout && this.sidebar.opened
},
//
showSidebar() {
let { menu } = this.$route.query || {}
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>
@ -49,6 +84,10 @@ export default {
overflow: hidden;
background: #0f0f12;
.sidebar-backdrop {
display: none;
}
.fixed-header {
z-index: 100;
.navbar {
@ -59,6 +98,18 @@ export default {
.main-wrapper {
display: flex;
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>

View File

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

View File

@ -47,7 +47,7 @@ export const constantRoutes = [{
path: '/',
component: Layout,
redirect: {
name: 'index'
name: 'video-gen'
},
children: [{
path: 'index',
@ -69,49 +69,47 @@ export const constantRoutes = [{
permission: "pass",
icon: 'btn_tst'
}
},
// {
// path: 'image-to-image?type=2',
// name: 'image-to-image2',
// component: () => import('@/views/Image.vue'),
// meta: {
// title: 'imageToImage2',
// menuItem: true,
// permission: "pass",
// icon: 'btn_tst'
// }
// }, {
// path: 'change-face',
// name: 'change-face',
// component: () => import('@/views/ChangeFace.vue'),
// meta: {
// title: 'changeFace',
// menuItem: true,
// permission: "pass",
// icon: 'btn_yjhl'
// }
// }, {
// path: 'change-face-video',
// name: 'change-face-video',
// component: () => import('@/views/ChangeFace.vue'),
// meta: {
// title: 'changeFaceVideo',
// menuItem: true,
// permission: "pass",
// icon: 'btn_yjhl'
// }
// }, {
// path: 'fast-image',
// name: 'fast-image',
// component: () => import('@/views/FastImage.vue'),
// meta: {
// title: 'fastImage',
// menuItem: true,
// permission: "pass",
// icon: 'btn_kjst'
// }
// },
{
}, {
path: 'image-to-image?type=2',
name: 'image-to-image2',
component: () => import('@/views/Image.vue'),
meta: {
title: 'imageToImage2',
menuItem: true,
permission: "pass",
icon: 'btn_tst'
}
}, {
path: 'change-face',
name: 'change-face',
component: () => import('@/views/ChangeFace.vue'),
meta: {
title: 'changeFace',
menuItem: true,
permission: "pass",
icon: 'btn_yjhl'
}
}, {
path: 'change-face-video',
name: 'change-face-video',
component: () => import('@/views/ChangeFace.vue'),
meta: {
title: 'changeFaceVideo',
menuItem: true,
permission: "pass",
icon: 'btn_yjhl'
}
}, {
path: 'fast-image',
name: 'fast-image',
component: () => import('@/views/FastImage.vue'),
meta: {
title: 'fastImage',
menuItem: true,
permission: "pass",
icon: 'btn_kjst'
}
}, {
path: 'fast-video',
name: 'fast-video',
component: () => import('@/views/FastVideo.vue'),
@ -121,6 +119,46 @@ export const constantRoutes = [{
permission: "pass",
icon: 'btn_kjsp'
}
}, {
path: 'video-gen',
name: 'video-gen',
component: () => import('@/views/VideoGen.vue'),
meta: {
title: 'videoGen',
menuItem: true,
permission: "pass",
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: 'recharge',
name: 'recharge',

View File

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

View File

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

View File

@ -0,0 +1,18 @@
/**
* 火山素材相关接口 data 层字段兼容
* Spring/Jackson 序列化为 camelCaseitemstotalCount
* 旧前端曾按 PascalCaseItemsTotalCount取值此处统一解析
*/
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 文件名参数
@ -148,12 +165,10 @@ export const uploadFile = ({
formData.append(key, data[key])
})
}
// 不要手动设置 Content-Type否则缺少 boundary服务端无法解析 multipart文件参数字段为空
return request({
url: url,
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
data: formData
})
}

View File

@ -21,7 +21,7 @@ const messages = i18n.global.messages[lang];
* @desc 创建axios实例
*/
const service = axios.create({
// baseURL: process.env.VUE_APP_BASE_API,
baseURL: process.env.VUE_APP_BASE_API,
withCredentials: import.meta.env.MODE === 'development',
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: #0a0b0d;
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: #0a0b0d;
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>

View File

@ -68,11 +68,28 @@
</a-radio>
</a-radio-group>
</div>
<!-- <div class="text">
<a-textarea
v-model="text"
:placeholder="$t('common.textVideoPlaceholder')" />
</div> -->
<!-- 模型选择 -->
<div class="model-select">
<div class="model-title">{{ $t('common.selectModel') || '选择模型' }}</div>
<a-select v-model="selectedModel" style="width: 100%;">
<a-option
v-for="option in modelOptions"
:key="option.value"
:value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<!-- 富文本编辑器 -->
<RichTextEditor
v-model="editorContent"
:placeholder="$t('common.textVideoPlaceholder') || '请输入文本生成视频...'"
:uploaded-images="uploadedImages"
@text-change="handleTextChange"
@image-upload="handleImageUpload"
/>
<mf-button
class="submit"
type="primary"
@ -175,6 +192,8 @@
<script>
import { mapGetters } from 'vuex'
import RichTextEditor from '@/components/RichTextEditor.vue'
export default {
data() {
return {
@ -182,6 +201,7 @@ export default {
firstUrl: '',
lastUrl: '',
text: '',
editorContent: '',
interval: null,
videoUrl: null,
videoLoading: false,
@ -202,9 +222,20 @@ export default {
templateLoading: false,
selectedTemplate: null,
selectedTemplatePreview: '', // URL
maxPollAttempts: 40 // 3 * 40 = 120
maxPollAttempts: 40, // 3 * 40 = 120
//
modelOptions: [
{ label: 'Seedance 2.0', value: 'ep-20260326165811-dlkth' },
{ label: 'Seedance 2.0 Fast', value: 'ep-20260326170056-dkj9m' }
],
selectedModel: 'ep-20260326165811-dlkth',
// @
uploadedImages: []
}
},
components: {
RichTextEditor
},
beforeUnmount() {
this.destroyInterval()
},
@ -316,8 +347,25 @@ export default {
// chineseContent englishContent
if (this.lang === 'zh_HK') {
return template.chineseContent || template.name || ''
}else{
return template.englishContent || template.name || ''
}
},
//
handleTextChange(content) {
this.editorContent = content
// text
this.text = content
},
// @
handleImageUpload(imageInfo) {
if (imageInfo && imageInfo.url) {
this.uploadedImages.push({
url: imageInfo.url,
name: imageInfo.name || 'image'
})
}
return template.englishContent || template.name || ''
},
//
handleConfirmTemplate() {
@ -477,9 +525,10 @@ export default {
this.generateLoading = true
let params = {
text: this.text,
text: this.editorContent || this.text,
firstUrl: firstImageUrl,
functionType: '21'
functionType: '21',
model: this.selectedModel //
}
if (this.lastUrl && this.lastUrl.url) {
params.lastUrl = this.lastUrl.url
@ -601,8 +650,76 @@ export default {
}
}
.upload {
margin-bottom: 20px;
.upload {
margin-bottom: 20px;
.model-select {
margin-bottom: 20px;
.model-title {
color: #fff;
margin-bottom: 8px;
font-size: 14px;
}
:deep(.arco-select) {
background-color: #1a1b20;
border-color: rgba(255,255,255,0.1);
}
}
/* 富文本编辑器样式 */
.rich-editor-root {
margin-bottom: 20px;
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding: 8px;
background-color: #1a1b20;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
.tool-btn {
padding: 6px 12px;
background-color: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover {
background-color: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
}
}
}
.rich-editor {
min-height: 160px;
padding: 16px;
background-color: #1a1b20;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 14px;
line-height: 1.6;
outline: none;
resize: none;
&:focus {
border-color: rgb(var(--primary-6));
}
&[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: #5c5d68;
pointer-events: none;
}
}
}
&-title {
display: flex;
align-items: center;

View File

@ -0,0 +1,983 @@
<template>
<div class="generated-assets-page">
<div class="page-header">
<div class="panel-title">作品库</div>
</div>
<div class="view-tabs">
<a-tabs v-model="activeTab" @change="handleTabChange" type="card">
<a-tab-pane key="personal" title="个人" />
<a-tab-pane key="department" title="部门" />
</a-tabs>
</div>
<!-- 查询区域 -->
<div class="query-section">
<div class="form-grid">
<div class="field">
<label>收藏状态</label>
<a-select v-model="filters.isTop" clearable placeholder="全部">
<a-option :value="null">全部</a-option>
<a-option value="Y">已收藏</a-option>
<a-option value="N">未收藏</a-option>
</a-select>
</div>
<div class="field">
<label>开始时间</label>
<a-date-picker v-model="filters.beginTime" placeholder="选择开始时间" style="width: 100%" />
</div>
<div class="field">
<label>结束时间</label>
<a-date-picker v-model="filters.endTime" placeholder="选择结束时间" style="width: 100%" />
</div>
<div class="field actions">
<a-button type="primary" :loading="loading" @click="search(1)">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</div>
</div>
</div>
<!-- 表格区域 -->
<a-spin :loading="loading">
<div class="total-line">总数{{ pagination.total }}</div>
<div class="table-wrap">
<table class="asset-table">
<thead>
<tr>
<th>ID</th>
<th>订单编号</th>
<th>类型</th>
<th>状态</th>
<th>模型参数</th>
<th>生成结果</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in dataList" :key="item.id">
<td class="td-id">{{ item.id }}</td>
<td>{{ item.orderNum || '-' }}</td>
<td>{{ formatType(item.type) }}</td>
<td>
<a-tag :color="getStatusColor(item.status)">
{{ formatStatus(item.status) }}
</a-tag>
</td>
<td>
<div class="params-cell">
<div v-if="item.model" class="param-tag">模型: {{ item.model }}</div>
<div v-if="item.duration" class="param-tag">时长: {{ item.duration }}s</div>
<div v-if="item.resolution" class="param-tag">分辨率: {{ item.resolution }}</div>
<div v-if="item.ratio" class="param-tag">比例: {{ item.ratio }}</div>
<div v-if="item.mode" class="param-tag">模式: {{ formatMode(item.mode) }}</div>
<div v-if="!item.model && !item.duration && !item.resolution && !item.ratio && !item.mode" class="param-empty">-</div>
</div>
</td>
<td class="result-cell">
<div v-if="isVideoResult(item.result)" class="media-preview">
<video
class="video-thumb"
:src="item.result"
controls
preload="metadata"
@click.stop="openPreview(item.result, 'video')" />
</div>
<div v-else-if="isImageResult(item.result)" class="media-preview">
<img
class="image-thumb"
:src="item.result"
@click.stop="openPreview(item.result, 'image')" />
</div>
<div v-else-if="item.result && item.result.startsWith('cgt-')" class="task-id">
{{ item.result }}
</div>
<div v-else class="result-empty">-</div>
</td>
<td>{{ item.createTime || '-' }}</td>
<td>
<a-button
size="mini"
:type="item.isTop === 'Y' ? 'primary' : 'outline'"
:status="item.isTop === 'Y' ? 'success' : 'default'"
@click="toggleFavorite(item)">
<template v-if="item.isTop === 'Y'">
<a-icon name="star-fill" /> 已收藏
</template>
<template v-else>
<a-icon name="star" /> 收藏
</template>
</a-button>
<a-button
size="mini"
type="outline"
@click="viewDetail(item)">
详情
</a-button>
</td>
</tr>
<tr v-if="!dataList.length">
<td colspan="8" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<a-pagination
:total="pagination.total"
:current="pagination.pageNum"
:page-size="pagination.pageSize"
show-total
show-jumper
show-page-size
:page-size-options="pagination.pageSizes"
@change="changePage"
@page-size-change="changePageSize" />
</div>
</a-spin>
<!-- 详情弹窗 -->
<a-modal v-model:visible="detailVisible" title="订单详情" :footer="false" width="850px">
<div class="detail-content" v-if="detailData">
<div class="detail-form">
<!-- 基本信息 -->
<div class="detail-group">
<div class="group-title">基本信息</div>
<div class="detail-row">
<div class="label">订单编号</div>
<div class="value">{{ detailData.orderNum || '-' }}</div>
</div>
<div class="detail-row">
<div class="label">功能类型</div>
<div class="value">{{ formatType(detailData.type) }}</div>
</div>
<div class="detail-row">
<div class="label">扣除积分</div>
<div class="value">{{ detailData.amount ? detailData.amount + ' 积分' : '-' }}</div>
</div>
<div class="detail-row">
<div class="label">订单状态</div>
<div class="value">
<a-tag :color="getStatusColor(detailData.status)">
{{ formatStatus(detailData.status) }}
</a-tag>
</div>
</div>
<div class="detail-row">
<div class="label">执行状态</div>
<div class="value">{{ formatExtStatus(detailData.extStatus) }}</div>
</div>
<div class="detail-row">
<div class="label">收藏状态</div>
<div class="value">
<a-tag v-if="detailData.isTop === 'Y'" color="orange">已收藏</a-tag>
<span v-else class="text-muted">未收藏</span>
</div>
</div>
<div class="detail-row">
<div class="label">请求来源</div>
<div class="value">{{ detailData.source || '-' }}</div>
</div>
</div>
<!-- 生成参数 -->
<div class="detail-group">
<div class="group-title">生成参数</div>
<div class="detail-row">
<div class="label">提示词</div>
<div class="value long-text">{{ detailData.text || '-' }}</div>
</div>
<div class="detail-row">
<div class="label">生成模式</div>
<div class="value">{{ formatMode(detailData.mode) }}</div>
</div>
<div class="detail-row">
<div class="label">使用模型</div>
<div class="value">{{ detailData.model || '-' }}</div>
</div>
<div class="detail-row">
<div class="label">视频时长</div>
<div class="value">{{ detailData.duration ? detailData.duration + ' 秒' : '-' }}</div>
</div>
<div class="detail-row">
<div class="label">分辨率</div>
<div class="value">{{ detailData.resolution || '-' }}</div>
</div>
<div class="detail-row">
<div class="label">画面比例</div>
<div class="value">{{ detailData.ratio || '-' }}</div>
</div>
</div>
<!-- 生成结果 -->
<div class="detail-group">
<div class="group-title">生成结果</div>
<div class="detail-row">
<div class="label">生成内容</div>
<div class="value media-preview">
<video
v-if="isVideoResult(detailData.result)"
class="detail-video"
:src="detailData.result"
controls
preload="metadata" />
<img
v-else-if="isImageResult(detailData.result)"
class="detail-image"
:src="detailData.result"
@click="viewImageFull(detailData.result)" />
<div v-else class="result-text">{{ detailData.result || '无结果' }}</div>
</div>
</div>
<div class="detail-row" v-if="detailData.img1">
<div class="label">首帧图片</div>
<div class="value media-preview">
<img class="detail-image small" :src="detailData.img1" @click="viewImageFull(detailData.img1)" />
</div>
</div>
<div class="detail-row" v-if="detailData.img2">
<div class="label">尾帧图片</div>
<div class="value media-preview">
<img class="detail-image small" :src="detailData.img2" @click="viewImageFull(detailData.img2)" />
</div>
</div>
</div>
<!-- 完整参数 -->
<div class="detail-group" v-if="detailData.videoParams">
<div class="group-title">完整参数</div>
<div class="detail-row">
<div class="label">JSON参数</div>
<div class="value">
<pre class="json-block">{{ formatVideoParams(detailData.videoParams) }}</pre>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="detail-group">
<div class="group-title">时间信息</div>
<div class="detail-row">
<div class="label">创建时间</div>
<div class="value">{{ detailData.createTime || '-' }}</div>
</div>
<div class="detail-row">
<div class="label">更新时间</div>
<div class="value">{{ detailData.updateTime || '-' }}</div>
</div>
</div>
</div>
</div>
</a-modal>
<!-- 预览弹窗 -->
<a-modal
v-model:visible="previewVisible"
:title="previewType === 'video' ? '视频预览' : '图片预览'"
:footer="false"
width="800px"
@cancel="closePreview">
<div class="preview-content">
<video
v-if="previewType === 'video' && previewUrl"
class="preview-video"
:src="previewUrl"
controls
autoplay />
<img
v-else-if="previewType === 'image' && previewUrl"
class="preview-image"
:src="previewUrl" />
</div>
</a-modal>
</div>
</template>
<script>
export default {
name: 'GeneratedAssets',
data() {
return {
loading: false,
dataList: [],
filters: {
isTop: null,
beginTime: '',
endTime: ''
},
activeTab: 'personal', // 'personal', 'department'
pagination: {
total: 0,
pageNum: 1,
pageSize: 10,
pageSizes: [10, 20, 50, 100]
},
detailVisible: false,
detailData: null,
previewVisible: false,
previewUrl: '',
previewType: 'video'
}
},
mounted() {
this.search()
},
methods: {
//
search(page = 1) {
this.pagination.pageNum = page
this.loading = true
const params = {
pageNum: this.pagination.pageNum,
pageSize: this.pagination.pageSize
}
console.log("this.activeTab", this.activeTab)
// personal = , department = dept=true
if (this.activeTab === 'department') {
params.dept = true
}
// - 使 is_top
if (this.filters.isTop != null && String(this.filters.isTop) !== '') {
// isTopnull
if (this.filters.isTop === null || this.filters.isTop === '' || this.filters.isTop === '全部') {
delete params.is_top
} else {
params.is_top = String(this.filters.isTop)
}
}
if (this.filters.beginTime) {
params.beginTime = this.formatDate(this.filters.beginTime)
}
if (this.filters.endTime) {
params.endTime = this.formatDate(this.filters.endTime)
}
this.$axios({
url: 'api/portal/assets/list',
method: 'GET',
data: params
})
.then((res) => {
this.loading = false
if (res.code === 200) {
this.dataList = res.rows || []
this.pagination.total = res.total || 0
} else {
this.$message.error(res.msg || '查询失败')
}
})
.catch((err) => {
this.loading = false
this.$message.error(err?.message || '查询失败')
})
},
//
resetFilters() {
this.filters.isTop = null
this.filters.beginTime = ''
this.filters.endTime = ''
this.search(1)
},
// Tabs personal/
handleTabChange(tabKey) {
if (tabKey) {
this.activeTab = tabKey
}
this.pagination.pageNum = 1
this.search(1)
},
//
changePage(page) {
this.pagination.pageNum = page
this.search(page)
},
//
changePageSize(pageSize) {
this.pagination.pageSize = pageSize
this.pagination.pageNum = 1
this.search(1)
},
//
formatDate(date) {
if (!date) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
//
formatType(type) {
const typeMap = {
'11': '图生图',
'12': '图生图2',
'13': '换脸',
'1': '快速生图',
'21': '快速生视频'
}
return typeMap[String(type)] || type || '-'
},
//
formatStatus(status) {
const statusMap = {
0: '进行中',
1: '已完成',
2: '失败'
}
return statusMap[status] || status || '-'
},
//
formatExtStatus(extStatus) {
const statusMap = {
'running': '执行中',
'queued': '队列中',
'succeeded': '已完成',
'failed': '失败',
'cancelled': '已取消',
'expired': '超时'
}
return statusMap[extStatus] || '未知'
},
//
getStatusColor(status) {
const colorMap = {
0: 'blue',
1: 'green',
2: 'red'
}
return colorMap[status] || 'default'
},
//
formatMode(mode) {
const modeMap = {
'text-to-video': '文生视频',
'image-first-frame': '图生视频·首帧',
'image-first-last-frame': '图生视频·首尾帧',
'image-reference': '图生视频·参考图'
}
return modeMap[mode] || mode || '-'
},
// videoParams
formatVideoParams(params) {
if (!params) return ''
try {
const obj = typeof params === 'string' ? JSON.parse(params) : params
return JSON.stringify(obj, null, 2)
} catch (e) {
return params
}
},
//
isVideoResult(url) {
if (!url) return false
return /\.(mp4|mov|webm|ogg|m4v|avi|mkv)(\?.*)?$/i.test(url)
},
//
isImageResult(url) {
if (!url) return false
return /\.(jpeg|jpg|png|gif|webp|bmp)(\?.*)?$/i.test(url)
},
//
openPreview(url, type) {
this.previewUrl = url
this.previewType = type
this.previewVisible = true
},
//
closePreview() {
this.previewVisible = false
this.previewUrl = ''
},
//
viewImageFull(url) {
this.$viewerApi({
options: {
initialViewIndex: 0,
toolbar: true
},
images: [url]
})
},
//
viewDetail(item) {
this.detailData = item
this.detailVisible = true
},
//
toggleFavorite(item) {
const newIsTop = item.isTop === 'Y' ? 'N' : 'Y'
this.$axios({
url: '/api/portal/assets/favorite',
method: 'POST',
data: {
id: item.id,
isTop: newIsTop
}
})
.then((res) => {
if (res.code === 200) {
item.isTop = newIsTop
this.$message.success(newIsTop === 'Y' ? '收藏成功' : '取消收藏成功')
} else {
this.$message.error(res.msg || '操作失败')
}
})
.catch((err) => {
this.$message.error(err?.message || '操作失败')
})
}
}
}
</script>
<style scoped lang="less">
.generated-assets-page {
padding: 16px;
min-height: 100%;
background: #0a0b0d;
color: rgba(255, 255, 255, 0.9);
}
.page-header {
margin-bottom: 16px;
}
.panel-title {
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.query-section {
background: rgba(22, 24, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 12px;
@media (max-width: 1200px) {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
@media (max-width: 900px) {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
&.actions {
flex-direction: row;
align-items: flex-end;
gap: 8px;
}
}
.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;
th, td {
padding: 12px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
th {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.02);
font-weight: 500;
}
//
th:nth-child(1), td:nth-child(1) { width: 60px; }
th:nth-child(2), td:nth-child(2) { width: 120px; }
th:nth-child(3), td:nth-child(3) { width: 80px; }
th:nth-child(4), td:nth-child(4) { width: 80px; }
th:nth-child(5), td:nth-child(5) { width: 180px; }
th:nth-child(6), td:nth-child(6) { width: 200px; }
th:nth-child(7), td:nth-child(7) { width: 140px; }
th:nth-child(8), td:nth-child(8) { width: 140px; }
//
td:nth-child(8) {
.arco-btn + .arco-btn {
margin-left: 12px;
}
}
}
.td-id {
word-break: break-all;
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
}
.params-cell {
display: flex;
flex-direction: column;
gap: 4px;
.param-tag {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
display: inline-block;
}
.param-empty {
color: rgba(255, 255, 255, 0.35);
}
}
.result-cell {
.media-preview {
.video-thumb {
width: 160px;
height: 90px;
object-fit: cover;
border-radius: 6px;
background: #000;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
}
.image-thumb {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 6px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
}
}
.task-id {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
word-break: break-all;
}
.result-empty {
color: rgba(255, 255, 255, 0.35);
}
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
//
::v-deep(.arco-pagination-total) {
color: rgba(255, 255, 255, 0.9) !important;
}
//
::v-deep(.arco-pagination) {
color: rgba(255, 255, 255, 0.9);
}
//
::v-deep(.arco-pagination-jumper) {
color: rgba(255, 255, 255, 0.9);
}
}
.empty-tip {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
text-align: center;
padding: 40px 0;
}
/* 详情弹窗 - label:value 形式(深色背景白字) */
.detail-content {
padding: 20px;
font-size: 14px;
background: #1a1f2e;
color: rgba(255, 255, 255, 0.95);
min-height: 100%;
.detail-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-group {
background: rgba(22, 24, 30, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
}
.group-title {
font-size: 15px;
font-weight: 600;
color: #00cae0;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 202, 224, 0.2);
}
.detail-row {
display: flex;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
&:last-child {
border-bottom: none;
}
}
.label {
width: 100px;
flex-shrink: 0;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
padding-top: 2px;
}
.value {
flex: 1;
color: rgba(255, 255, 255, 0.95);
word-break: break-all;
line-height: 1.5;
&.long-text {
max-height: 110px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 6px;
font-size: 13px;
}
.text-muted {
color: rgba(255, 255, 255, 0.45);
}
}
.media-preview {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-video {
width: 100%;
max-width: 420px;
border-radius: 8px;
background: #000;
}
.detail-image {
max-width: 100%;
max-height: 260px;
border-radius: 8px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s ease;
&:hover {
transform: scale(1.03);
box-shadow: 0 0 0 3px rgba(0, 202, 224, 0.3);
}
&.small {
max-height: 160px;
}
}
.result-text {
color: rgba(255, 255, 255, 0.5);
font-style: italic;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.json-block {
background: #1a1f2e;
color: #a5d6ff;
padding: 14px;
border-radius: 8px;
font-size: 12px;
max-height: 220px;
overflow: auto;
white-space: pre;
font-family: 'Consolas', monospace;
line-height: 1.4;
border: 1px solid rgba(165, 214, 255, 0.1);
}
}
//
// Tabs - Arco Design Vue + UI
.view-tabs {
margin-bottom: 20px;
padding: 0 4px;
::v-deep(.arco-tabs) {
.arco-tabs-nav {
background: rgba(22, 24, 30, 0.98) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: 12px !important;
padding: 6px !important;
margin-bottom: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.arco-tabs-tab {
color: rgba(255, 255, 255, 0.85) !important;
margin: 0 4px !important;
padding: 10px 28px !important;
border-radius: 8px !important;
transition: all 0.2s ease !important;
font-size: 14px;
font-weight: 500;
border: 1px solid transparent !important;
}
.arco-tabs-tab:hover {
color: white !important;
background: rgba(0, 102, 204, 0.25) !important;
border-color: rgba(0, 102, 204, 0.3) !important;
}
.arco-tabs-tab-active {
color: white !important;
background: #0066cc !important;
border-color: #0066cc !important;
font-weight: 600 !important;
box-shadow: 0 4px 15px rgba(0, 102, 204, 0.5) !important;
}
.arco-tabs-ink-bar {
display: none !important;
}
// tabs
.arco-tabs-content {
display: none !important;
}
}
}
.tabs-field {
grid-column: span 2;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
.preview-video {
width: 100%;
max-height: 500px;
border-radius: 8px;
}
.preview-image {
max-width: 100%;
max-height: 500px;
border-radius: 8px;
}
}
/* 作品详情弹窗深色底样式 - 严格符合 GeneratedAssets.vue 深色主题(适配 Arco Design */
::v-deep(.arco-modal) {
.arco-modal-content {
background-color: #1a1f2e !important;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.arco-modal-header {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 20px 24px 16px;
}
.arco-modal-title {
color: rgba(255, 255, 255, 0.95) !important;
font-size: 17px;
font-weight: 600;
}
.arco-modal-close {
color: rgba(255, 255, 255, 0.65) !important;
top: 18px;
right: 20px;
&:hover {
color: #fff !important;
background: rgba(255, 255, 255, 0.1);
}
}
.arco-modal-body {
padding: 0 !important;
background: transparent;
color: rgba(255, 255, 255, 0.9);
}
// detail-content
.detail-content {
background: #1a1f2e !important;
color: rgba(255, 255, 255, 0.95) !important;
padding: 20px !important;
min-height: 200px;
}
}
</style>

View File

@ -61,13 +61,28 @@
</a-radio>
</a-radio-group>
</div>
<!-- <div
class="text"
v-if="current == 2">
<a-textarea
v-model="text"
:placeholder="$t('common.textPlaceholder')" />
</div> -->
<!-- 模型选择 -->
<div class="model-select">
<div class="model-title">{{ $t('common.selectModel') || '选择模型' }}</div>
<a-select v-model="selectedModel" style="width: 100%;">
<a-option
v-for="option in modelOptions"
:key="option.value"
:value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<!-- 富文本编辑器 -->
<RichTextEditor
v-model="editorContent"
:placeholder="$t('common.textPlaceholder') || '请输入文本生成图片...'"
:uploaded-images="uploadedImages"
@text-change="handleTextChange"
@image-upload="handleImageUpload"
/>
<mf-button
class="submit"
type="primary"
@ -157,6 +172,7 @@
<script>
import { mapGetters } from 'vuex'
import RichTextEditor from '@/components/RichTextEditor.vue'
export default {
data() {
@ -179,9 +195,20 @@ export default {
templateList: [],
templateLoading: false,
selectedTemplate: null,
selectedTemplatePreview: '' // URL
selectedTemplatePreview: '', // URL
// - Seedance 2.0
modelOptions: [
{ label: 'Seedance 2.0', value: 'ep-20260326165811-dlkth' },
{ label: 'Seedance 2.0 Fast', value: 'ep-20260326170056-dkj9m' }
],
selectedModel: 'ep-20260326165811-dlkth',
// @
uploadedImages: []
}
},
components: {
RichTextEditor
},
computed: {
...mapGetters(['lang']),
},
@ -333,6 +360,21 @@ export default {
this.templateDialogVisible = false
// selectedTemplate便
},
//
handleTextChange(content) {
this.editorContent = content
// text
this.text = content
},
// @
handleImageUpload(imageInfo) {
if (imageInfo && imageInfo.url) {
this.uploadedImages.push({
url: imageInfo.url,
name: imageInfo.name || 'image'
})
}
},
close() {
this.showResult = false
},
@ -396,10 +438,11 @@ export default {
url: 'api/ai/imgToImg',
method: 'POST',
data: {
text: this.text,
text: this.editorContent || this.text,
firstUrl: this.firstUrl.url,
functionType: this.current == 1 ? '11' : '12',
tags: tags.join(',')
tags: tags.join(','),
model: this.selectedModel //
}
})
.then((res) => {
@ -493,9 +536,77 @@ export default {
}
}
.upload {
.upload {
// margin-top: 20px;
margin-bottom: 20px;
.model-select {
margin-bottom: 20px;
.model-title {
color: #fff;
margin-bottom: 8px;
font-size: 14px;
}
:deep(.arco-select) {
background-color: #1a1b20;
border-color: rgba(255,255,255,0.1);
}
}
/* 富文本编辑器样式 */
.rich-editor-root {
margin-bottom: 20px;
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding: 8px;
background-color: #1a1b20;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
.tool-btn {
padding: 6px 12px;
background-color: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover {
background-color: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
}
}
}
.rich-editor {
min-height: 160px;
padding: 16px;
background-color: #1a1b20;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 14px;
line-height: 1.6;
outline: none;
resize: none;
&:focus {
border-color: rgb(var(--primary-6));
}
&[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: #5c5d68;
pointer-events: none;
}
}
}
&-title {
display: flex;
align-items: center;

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

2
web-api/.gitignore vendored
View File

@ -4,7 +4,7 @@
.gradle
/build/
!gradle/wrapper/gradle-wrapper.jar
*.sql
target/
!.mvn/wrapper/maven-wrapper.jar

View File

@ -0,0 +1,64 @@
server {
listen 80;
listen 443 ssl;
http2 on;
server_name undressing.top www.undressing.top;
index index.html index.htm;
ssl_certificate ssl/undressing.top.crt;
ssl_certificate_key ssl/undressing.top.key;
ssl_session_timeout 5m;
# 优化加密套件配置,移除不安全的算法
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!3DES;
# 移除不安全的 TLSv1.1,只保留 TLSv1.2 和 TLSv1.3
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# 启用 SSL session 缓存,提高性能
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
root /data/web/client_web;
autoindex off; # 禁用目录列表
# 安全头部
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* (\.git|\.env|composer\.json|\.log|\.sql)$ {
deny all;
}
location ^~ /api/ {
proxy_pass http://10.0.0.167:8110;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 缓冲配置,提高性能
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
location / {
root /data/web/client_web;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}

View File

@ -51,6 +51,9 @@ public class AiManagerApiController extends BaseController {
@Anonymous
public AjaxResult selectInfo(String aiType) {
AiManager aiManager = aiManagerService.selectAiManagerByType(aiType);
if (aiManager == null) {
return AjaxResult.error("该功能未配置或已停用");
}
aiManager.setPrompt(null);
return AjaxResult.success(aiManager);
}

View File

@ -2,12 +2,9 @@ package com.ruoyi.api;
import com.ruoyi.ai.domain.AiSampleAmount;
import com.ruoyi.ai.domain.AiSampleAmountRecord;
import com.ruoyi.ai.domain.AiStatistics;
import com.ruoyi.ai.domain.enums.AiConfigEnum;
import com.ruoyi.ai.mapper.AiSampleAmountMapper;
import com.ruoyi.ai.service.EmailVerifyService;
import com.ruoyi.ai.service.IAiSampleAmountRecordService;
import com.ruoyi.ai.service.IAiStatisticsService;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.constant.BalanceChangerConstants;
@ -22,10 +19,8 @@ import com.ruoyi.common.core.domain.model.RegisterAiUserBody;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.job.TaskException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.IpCountryQueryByApi;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.quartz.domain.SysJob;
import com.ruoyi.quartz.service.ISysJobService;
@ -40,7 +35,10 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.mail.MessagingException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
/**
@ -104,8 +102,11 @@ public class AiUserApiController extends BaseController {
// 查询启用状态体验金活动
AiSampleAmount aiSampleAmount = aiSampleAmountMapper.getSampleAmount();
if (aiSampleAmount != null) {
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
String orderNo = dateTime + uuid;
// 余额变更记录
aiUserService.addUserBalance(aiUser.getId(), aiSampleAmount.getAmount(), BalanceChangerConstants.EXPERIENCE_GOLD_GIFT);
aiUserService.addUserBalance(orderNo, aiUser.getId(), aiSampleAmount.getAmount(), BalanceChangerConstants.EXPERIENCE_GOLD_GIFT);
// 新增体验金记录
AiSampleAmountRecord aiSampleAmountRecord = new AiSampleAmountRecord();
aiSampleAmountRecord.setUserId(aiUser.getId());
@ -218,6 +219,7 @@ public class AiUserApiController extends BaseController {
/**
* 查询返佣比例配置
*
* @return
*/
@GetMapping("/getRebateConfig")

View File

@ -1,29 +1,36 @@
package com.ruoyi.api;
import com.ruoyi.ai.domain.*;
import com.ruoyi.ai.service.IAiManagerService;
import com.ruoyi.ai.service.IAiOrderService;
import com.ruoyi.ai.service.IAiTagService;
import com.ruoyi.ai.service.IByteService;
import com.ruoyi.ai.service.*;
import com.ruoyi.api.request.ByteApiRequest;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginAiUser;
import com.ruoyi.common.utils.AwsS3Util;
import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest;
import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse;
import com.ruoyi.common.core.response.video.dto.VideoTaskError;
import com.ruoyi.common.enums.AiOrderStatusType;
import com.ruoyi.common.enums.VideoTaskStatusType;
import com.ruoyi.common.utils.JsonUtils;
import com.ruoyi.common.utils.RandomStringUtil;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import com.ruoyi.common.utils.ip.IpUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
@ -34,14 +41,22 @@ import java.util.regex.Pattern;
@Api(tags = "生成内容")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class ByteApiController extends BaseController {
// 回调时分布式锁的key前缀
private static final String VOLC_CALLBACK_LOCK_KEY_PREFIX = "volc:callback:lock:";
// 锁参数
private static final int LOCK_WAIT_SECONDS = 10;
private static final int LOCK_LEASE_SECONDS = 20;
private final IByteService byteService;
private final AwsS3Util awsS3Util;
private final TencentCosUtil tencentCosUtil;
private final IAiOrderService aiOrderService;
private final IAiManagerService managerService;
private final IAiTagService aiTagService;
@Value("${byteapi.callBackUrl}")
private String url;
private final RedissonClient redissonClient;
@Value("${volcengine.ark.callbackUrl}")
private String volcCallbackUrl;
private final IByteDeptApiKeyService byteDeptApiKeyService;
@PostMapping("/promptToImg")
@ApiOperation("文生图")
@ -51,9 +66,12 @@ public class ByteApiController extends BaseController {
return AjaxResult.error("functionType is null");
}
String mode = request.getMode() != null ? request.getMode() : "image-to-video";
AiManager aiManager = managerService.selectAiManagerByType(functionType);
String tags = request.getTags();
String text = "";
String text = request.getText();
// 如果使用标签系统生成prompt
if (StringUtils.isNotEmpty(tags)) {
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort());
List<String> tagPrompts = new ArrayList<>();
@ -70,22 +88,24 @@ public class ByteApiController extends BaseController {
tagPrompts.add(p);
}
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
} else {
text = aiManager.getPrompt();
}
if (StringUtils.isEmpty(text)) {
return AjaxResult.error("text is null");
}
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
try {
if (aiOrder == null) {
return AjaxResult.error(-1, "You have a low balance, please recharge");
}
aiOrder.setText(text);
aiOrder.setMode(mode); // 记录生成模式
ByteBodyReq byteBodyReq = new ByteBodyReq();
byteBodyReq.setModel("ep-20251104104536-2gpgz");
// model由前端传入默认为Seedance 2.0
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
request.getModel() : "ep-20260326165811-dlkth");
byteBodyReq.setPrompt(text);
byteBodyReq.setSequential_image_generation("disabled");
byteBodyReq.setResponse_format("url");
@ -96,7 +116,7 @@ public class ByteApiController extends BaseController {
List<ByteDataRes> data = byteBodyRes.getData();
ByteDataRes byteDataRes = data.get(0);
String url = byteDataRes.getUrl();
url = awsS3Util.uploadFileByUrl(url);
url = tencentCosUtil.uploadFileByUrl(url);
if (url == null) {
// 判断生成失败退回金额逻辑
aiOrderService.orderFailure(aiOrder);
@ -163,7 +183,8 @@ public class ByteApiController extends BaseController {
aiOrder.setImg1(firstUrl.toString());
ByteBodyReq byteBodyReq = new ByteBodyReq();
byteBodyReq.setModel("ep-20251104104536-2gpgz");
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
request.getModel() : "ep-20260326165811-dlkth");
byteBodyReq.setPrompt(text);
byteBodyReq.setImage(firstUrl);
byteBodyReq.setSequential_image_generation("disabled");
@ -175,7 +196,7 @@ public class ByteApiController extends BaseController {
List<ByteDataRes> data = byteBodyRes.getData();
ByteDataRes byteDataRes = data.get(0);
String url = byteDataRes.getUrl();
url = awsS3Util.uploadFileByUrl(url);
url = tencentCosUtil.uploadFileByUrl(url);
if (url == null) {
// 判断生成失败退回金额逻辑
aiOrderService.orderFailure(aiOrder);
@ -191,7 +212,7 @@ public class ByteApiController extends BaseController {
}
@PostMapping("/imgToVideo")
@ApiOperation("图生视频")
@ApiOperation("图生视频 (Seedance 2.0)")
public AjaxResult imgToVideo(@RequestBody ByteApiRequest request) throws Exception {
String functionType = request.getFunctionType();
if (null == functionType) {
@ -235,51 +256,56 @@ public class ByteApiController extends BaseController {
return AjaxResult.error(-1, "You have a low balance, please recharge");
}
try {
// String text = request.getText();
// if (StringUtils.isBlank(text)) {
// return AjaxResult.error("text is null");
// }
// String tags = request.getTags();
// if (StringUtils.isNotBlank(tags)) {
// text = "(优先考虑以下关键词:" + tags + ")";
// }
aiOrder.setText(text);
aiOrder.setImg1(firstUrl.toString());
Integer duration = request.getDuration();
Integer duration = request.getDuration() != null ? request.getDuration() : 4;
ByteBodyReq byteBodyReq = new ByteBodyReq();
byteBodyReq.setModel("ep-20251113072240-cfxlz");
byteBodyReq.setCallback_url(url + "/api/ai/callBack");
List<ContentItem> content = new ArrayList<>();
ContentItem contentItem = new ContentItem();
contentItem.setType("text");
contentItem.setText(text + " --dur " + duration + " --fps 24 --rs 720p --wm false --cf false");
content.add(contentItem);
// model由前端传入默认为Seedance2.0
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
request.getModel() : "ep-20260326165811-dlkth");
byteBodyReq.setCallback_url(volcCallbackUrl);
ContentItem contentItem1 = new ContentItem();
contentItem1.setType("image_url");
contentItem1.setRole("first_frame");
ImageUrl imageUrl1 = new ImageUrl();
imageUrl1.setUrl(firstUrl.toString());
contentItem1.setImageUrl(imageUrl1);
content.add(contentItem1);
// 构建符合火山引擎格式的content
List<ContentItem> contentList = new ArrayList<>();
// 文本提示词
ContentItem textItem = new ContentItem();
textItem.setType("text");
textItem.setText(text);
contentList.add(textItem);
// 首帧图片
ContentItem firstFrameItem = new ContentItem();
firstFrameItem.setType("image_url");
firstFrameItem.setRole("first_frame");
ImageUrl firstImageUrl = new ImageUrl();
firstImageUrl.setUrl(firstUrl.toString());
firstFrameItem.setImageUrl(firstImageUrl);
contentList.add(firstFrameItem);
// 如果有尾帧
String lastUrl = request.getLastUrl();
if (StringUtils.isNotBlank(lastUrl)) {
ContentItem contentItem2 = new ContentItem();
contentItem2.setType("image_url");
contentItem2.setRole("last_frame");
ImageUrl imageUrl2 = new ImageUrl();
imageUrl2.setUrl(lastUrl);
contentItem2.setImageUrl(imageUrl2);
content.add(contentItem2);
ContentItem lastFrameItem = new ContentItem();
lastFrameItem.setType("image_url");
lastFrameItem.setRole("last_frame");
ImageUrl lastImageUrl = new ImageUrl();
lastImageUrl.setUrl(lastUrl);
lastFrameItem.setImageUrl(lastImageUrl);
contentList.add(lastFrameItem);
aiOrder.setImg2(lastUrl);
}
byteBodyReq.setContent(content);
byteBodyReq.setContent(contentList);
byteBodyReq.setDuration(duration);
byteBodyReq.setResolution("720p");
byteBodyReq.setRatio("3:4");
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq);
String id = byteBodyRes.getId();
if (id == null) {
// 判断生成失败退回金额逻辑
aiOrderService.orderFailure(aiOrder);
return AjaxResult.error(-2, "generation failed, balance has been refunded");
}
@ -299,7 +325,7 @@ public class ByteApiController extends BaseController {
if ("succeeded".equals(byteBodyRes.getStatus())) {
content content = byteBodyRes.getContent();
String videoUrl = content.getVideo_url();
videoUrl = awsS3Util.uploadFileByUrl(videoUrl);
videoUrl = tencentCosUtil.uploadFileByUrl(videoUrl);
content.setVideo_url(videoUrl);
AiOrder aiOrderByResult = aiOrderService.getAiOrderByResult(id);
AiOrder aiOrder = new AiOrder();
@ -311,24 +337,212 @@ public class ByteApiController extends BaseController {
return AjaxResult.success(byteBodyRes);
}
@GetMapping(value = "/callBack")
@ApiOperation("视频下载回调")
@PostMapping(value = "/volcCallback")
@ApiOperation("火山引擎视频回调")
@Anonymous
public AjaxResult callBack(@PathVariable("id") ByteBodyRes byteBodyRes) throws Exception {
if ("succeeded".equals(byteBodyRes.getStatus())) {
String id = byteBodyRes.getId();
content content = byteBodyRes.getContent();
String videoUrl = content.getVideo_url();
videoUrl = awsS3Util.uploadFileByUrl(videoUrl);
content.setVideo_url(videoUrl);
AiOrder aiOrderByResult = aiOrderService.getAiOrderByResult(id);
AiOrder aiOrder = new AiOrder();
aiOrder.setId(aiOrderByResult.getId());
aiOrder.setResult(videoUrl);
// aiOrder.setUpdateBy(SecurityUtils.getLoginAiUser().getUsername());
aiOrderService.updateAiOrder(aiOrder);
public AjaxResult volcCallback(@RequestBody VideoTaskCallBackRequest request, HttpServletRequest httpRequest) throws Exception {
logger.info("volcCallback 收到回调数据: clientIp = {}, host = {}, request = {}", IpUtils.getIpAddr(httpRequest),
httpRequest.getHeader("Host"), request);
// 1基础参数校验
AjaxResult result = volcCallbackBaseCheck(request);
if (result != null) {
return result;
}
// 2查询订单
String taskId = request.getId();
AiOrder order = aiOrderService.selectOneByThirdPartyOrderNum(taskId);
if (order == null) {
// 可能是其他环境生成的但回调地址配置成正式的
logger.warn("volcCallback aiorder is not exist! third party order num = {}", taskId);
return AjaxResult.success();
}
// 3从官方获取任务数据
// 根据订单用户ID查询使用的Key
// 严格来讲按逻辑这块是应放在锁内但这是调外部接口如果接口超时整个服务可能会当机所以不放锁内即不做强一致
String apiKey = byteDeptApiKeyService.resolveVolcApiKey(order.getUserId());
GetVideoGenerationTaskResponse taskResp = byteService.getVideoGenerationTasks(request.getId(), apiKey);
// 4官方数据校验
result = volcCallbackByteCheck(request, taskResp);
if (result != null) {
return result;
}
// 5查询订单 taskId 串行Redisson 分布式锁步骤 13 已在锁外
String lockKey = VOLC_CALLBACK_LOCK_KEY_PREFIX + taskId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
if (!locked) {
logger.warn("volcCallback skip: concurrent handling for same task, third party order num = {}", taskId);
return AjaxResult.success();
}
// 锁内二次查询防止并发时状态变更
order = aiOrderService.selectOneByThirdPartyOrderNum(taskId);
if (order == null) {
// 可能是其他环境生成的但回调地址配置成正式的
logger.warn("volcCallback aiorder is not exist! third party order num = {}", taskId);
return AjaxResult.success();
}
// 6状态为队列中执行中只更新任务状态
result = volcCallbackRunningTaskProcess(taskResp, order);
if (result != null) {
return result;
}
// 7订单数据校验
result = volcCallbackOrderCheck(taskResp, order);
if (result != null) {
return result;
}
// 8根据状态做不同的处理加事务
String status = taskResp.getStatus().toLowerCase();
if (VideoTaskStatusType.SUCCEEDED.getName().equals(status)) {
// 成功预扣
return aiOrderService.volcCallbackSuccessProcess(request, taskResp, order);
} else {
// 前面已判断过status的合法性并处理了三种非失败的状态所以可以确定是取消失败超时
if (taskResp.getError() != null) {
order.setResult(JsonUtils.toJson(taskResp.getError()));
}
aiOrderService.orderFailure(order);
return AjaxResult.success();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("volcCallback interrupted while waiting for lock, third party order num = {}", taskId, e);
return AjaxResult.error();
} catch (Exception ex) {
logger.error("volcCallback error! third party order num = {}, status = {}",
request.getId(), request.getStatus(), ex);
return AjaxResult.error();
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return AjaxResult.success(byteBodyRes);
}
private AjaxResult volcCallbackOrderCheck(GetVideoGenerationTaskResponse taskResp, AiOrder order) {
// 订单状态如果不为执行中则不做处理
if (order.getStatus() != null && order.getStatus() != AiOrderStatusType.RUNNING.ordinal()) {
logger.warn("volcCallback aiorder's status is not running! third party order num = {}, order status = {}"
, taskResp.getId(), order.getStatus());
return AjaxResult.success();
}
if (order.getIsBackfilled() != null && order.getIsBackfilled() == 1) {
// 已回补过不再回补直接返回成功
logger.warn("volcCallback is back filled! third party order num = {}, order status = {}"
, taskResp.getId(), order.getStatus());
return AjaxResult.success();
}
return null;
}
private AjaxResult volcCallbackByteCheck(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp) {
String requestStatus = request.getStatus().toLowerCase();
String responseStatus = taskResp.getStatus().toLowerCase();
// 请求的状态与字节的状态是否一致
if (!requestStatus.equals(responseStatus)) {
// 如果推送的是队列中执行中但官方任务可能已进到下一步的状态时间差此种情况不处理等待后续推送
if (requestStatus.equals(VideoTaskStatusType.QUEUED.getName())
|| requestStatus.equals(VideoTaskStatusType.RUNNING.getName())) {
logger.warn("volcCallback request's status != official status, no process! order third party order num = {}, request's status = {}, official status = {}",
request.getId(), requestStatus, responseStatus);
// 防止再次推送
return AjaxResult.success();
} else {
logger.error("volcCallback request's status != official status! order third party order num = {}, request's status = {}, official status = {}",
request.getId(), requestStatus, responseStatus);
// 不再让对方二次推送
return AjaxResult.error();
}
}
return null;
}
private AjaxResult volcCallbackBaseCheck(VideoTaskCallBackRequest request) {
// 参数校验
String status = request.getStatus();
if (StringUtils.isEmpty(request.getId()) || StringUtils.isEmpty(status)) {
logger.error("volcCallbackBaseCheck id or status is null! third party order num = {}, status = {}"
, request.getId(), status);
return AjaxResult.error("id or status is null!");
}
// 状态是否正确
if (!VideoTaskStatusType.isValidName(status)) {
logger.error("volcCallbackBaseCheck invalid status! third party order num = {}, status = {}"
, request.getId(), status);
return AjaxResult.error();
}
return null;
}
private AjaxResult volcCallbackRunningTaskProcess(GetVideoGenerationTaskResponse taskResp, AiOrder order) {
// 执行中状态 更新到ext_status字段
Integer extStatus = null;
String status = taskResp.getStatus().toLowerCase();
if (VideoTaskStatusType.QUEUED.getName().equals(status)) {
extStatus = 0;
} else if (VideoTaskStatusType.RUNNING.getName().equals(status)) {
extStatus = 1;
}
if (extStatus != null) {
order.setExtStatus(extStatus);
aiOrderService.updateAiOrder(order);
logger.info("volcCallback order extStatus is updated! third party order num = {}, extStatus = {}"
, taskResp.getId(), order.getExtStatus());
return AjaxResult.success();
}
return null;
}
/**
* 回调体 code 200HTTP/业务状态码清空 resultstatus=2不退款
*/
// private void markVolcCallbackOrderClearResultFailed(String taskId, String reason) {
// AiOrder order = findAiOrderByVolcTaskId(taskId);
// if (order == null) {
// logger.warn("volcCallback code 非 200未找到任务对应订单, taskId={}, {}", taskId, reason);
// return;
// }
// AiOrder upd = new AiOrder();
// upd.setId(order.getId());
// upd.setResult("");
// upd.setStatus(2);
// aiOrderService.updateAiOrder(upd);
// logger.warn("volcCallback code 非 200已清空 result 并 status=2, orderId={}, {}", order.getId(), reason);
// }
// private AiOrder findAiOrderByVolcTaskId(String taskId) {
// AiOrder order = aiOrderService.getAiOrderByPortalVideoTask(taskId);
// if (order != null) {
// return order;
// }
// return aiOrderService.getAiOrderByResult(taskId);
// }
/**
* 回调判定任务失败订单仍持有火山任务 id且非已失败时标记失败并退余额orderFailure
*/
// private void handleVolcCallbackFailure(String taskId, String reason) {
// AiOrder order = aiOrderService.getAiOrderByResult(taskId);
// if (order == null) {
// logger.warn("volcCallback 失败:未找到 result={} 的订单, {}", taskId, reason);
// return;
// }
// if (!taskId.equals(order.getResult())) {
// logger.info("volcCallback 失败处理跳过:订单结果已更新, taskId={}, {}", taskId, reason);
// return;
// }
// if (Integer.valueOf(2).equals(order.getStatus())) {
// return;
// }
// aiOrderService.orderFailure(order);
// logger.warn("volcCallback 任务失败,已标记订单失败并退款, taskId={}, reason={}", taskId, reason);
// }
// @PostMapping(value = "/{id}/cancel")
// @ApiOperation("取消视频生成任务")
// public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception {
// return byteService.cancelVideoTask(id);
// }
}

View File

@ -0,0 +1,87 @@
package com.ruoyi.api;
import com.ruoyi.ai.service.IByteAssetService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.request.asset.*;
import com.ruoyi.common.core.response.asset.GetAssetResponse;
import com.ruoyi.common.core.response.asset.ListAssetsResponse;
import com.ruoyi.common.core.response.asset.UpdateAssetResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 火山 - 素材管理api.docx Assets APICreateAsset 先上传存储桶再调三方接口
*/
@Slf4j
@RestController
@RequestMapping("/api/byteAsset")
@Api(tags = "火山 - 素材管理")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class ByteAssetApiController extends BaseController {
private final IByteAssetService byteAssetService;
@PostMapping("/createAsset")
@ApiOperation("CreateAsset创建素材")
public AjaxResult<ListAssetsResponse> createAsset(@RequestBody CreateAssetRequest request) {
try {
return AjaxResult.success(byteAssetService.createAsset(request));
} catch (Exception e) {
log.error("创建素材时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/listAssets")
@ApiOperation("ListAssets查询素材列表")
public AjaxResult<ListAssetsResponse> listAssets(@RequestBody ListAssetsRequest request) {
try {
return AjaxResult.success(byteAssetService.listAssets(request));
} catch (Exception e) {
log.error("查询素材列表时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/getAsset")
@ApiOperation("GetAsset查询单个素材")
public AjaxResult<GetAssetResponse> getAsset(@RequestBody GetAssetRequest request) {
try {
return AjaxResult.success(byteAssetService.getAsset(request));
} catch (Exception e) {
log.error("查询素材详情时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/updateAsset")
@ApiOperation("UpdateAsset更新素材当前仅名称")
public AjaxResult<UpdateAssetResponse> updateAsset(@RequestBody UpdateAssetRequest request) {
try {
return AjaxResult.success(byteAssetService.updateAsset(request));
} catch (Exception e) {
log.error("更新素材时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/deleteAsset")
@ApiOperation("DeleteAsset删除素材")
public AjaxResult<Void> deleteAsset(@RequestBody DeleteAssetRequest request) {
try {
byteAssetService.deleteAsset(request);
return AjaxResult.success();
} catch (Exception e) {
log.error("删除素材时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
}

View File

@ -0,0 +1,79 @@
package com.ruoyi.api;
import com.ruoyi.ai.service.IByteAssetGroupService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.request.asset.CreateAssetGroupRequest;
import com.ruoyi.common.core.request.asset.GetAssetGroupRequest;
import com.ruoyi.common.core.request.asset.ListAssetsGroupRequest;
import com.ruoyi.common.core.request.asset.UpdateAssetGroupRequest;
import com.ruoyi.common.core.response.asset.CreateAssetGroupResponse;
import com.ruoyi.common.core.response.asset.GetAssetGroupResponse;
import com.ruoyi.common.core.response.asset.ListAssetsGroupResponse;
import com.ruoyi.common.core.response.asset.UpdateAssetGroupResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 火山 - 素材组管理 Controller
*/
@Slf4j
@RestController
@RequestMapping("/api/byteAssetGroup")
@Api(tags = "火山 - 素材组管理")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class ByteAssetGroupApiController extends BaseController {
private final IByteAssetGroupService groupService;
@PostMapping("/listAssetGroups")
@ApiOperation("ListAssetGroups查询素材资产组合列表")
public AjaxResult<ListAssetsGroupResponse> listAssetGroups(@RequestBody ListAssetsGroupRequest request) {
try {
return AjaxResult.success(groupService.listAssetGroups(request));
} catch (Exception e) {
log.error("查询素材组列表时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/createAssetGroup")
@ApiOperation("CreateAssetGroup创建素材资产组合")
public AjaxResult<CreateAssetGroupResponse> createAssetGroup(@RequestBody CreateAssetGroupRequest request) {
try {
return AjaxResult.success(groupService.createAssetGroup(request));
} catch (Exception e) {
log.error("创建素材组时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/getAssetGroup")
@ApiOperation("GetAssetGroup查询单个素材资产组合")
public AjaxResult<GetAssetGroupResponse> getAssetGroup(@RequestBody GetAssetGroupRequest request) {
try {
return AjaxResult.success(groupService.getAssetGroup(request));
} catch (Exception e) {
log.error("查询素材组详情时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
@PostMapping("/updateAssetGroup")
@ApiOperation("UpdateAssetGroup更新素材资产组合")
public AjaxResult<UpdateAssetGroupResponse> updateAssetGroup(@RequestBody UpdateAssetGroupRequest request) {
try {
return AjaxResult.success(groupService.updateAssetGroup(request));
} catch (Exception e) {
log.error("更新素材组时发生异常", e);
return AjaxResult.error(e.getMessage());
}
}
}

View File

@ -0,0 +1,44 @@
package com.ruoyi.api;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* COS 上传兼容接口
*/
@RestController
@RequestMapping("/api/cos")
@Api(tags = "COS文件上传")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class CosController {
private final TencentCosUtil tencentCosUtil;
@ApiOperation("COS上传接口")
@PostMapping("/upload")
public AjaxResult upload(
@ApiParam(name = "file", value = "文件", required = true)
@RequestParam("file") MultipartFile file,
@ApiParam(name = "prefix", value = "路径前缀如asset", required = false)
@RequestParam(value = "prefix", required = false) String prefix) throws Exception {
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true, prefix);
AjaxResult ajax = AjaxResult.success(uploadUrl);
ajax.put("url", uploadUrl);
ajax.put("oldName", file.getOriginalFilename());
if (StringUtils.isNotBlank(prefix)) {
ajax.put("prefix", prefix);
}
return ajax;
}
}

View File

@ -1,7 +1,8 @@
package com.ruoyi.api;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.AwsS3Util;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
@ -18,20 +19,28 @@ import org.springframework.web.multipart.MultipartFile;
@Api(tags = "文件上传")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class FileController {
private final AwsS3Util awsS3Util;
private final TencentCosUtil tencentCosUtil;
/**
* 文件上传
* 文件上传支持prefix路径前缀
*/
@ApiOperation(value = "文件上传")
@PostMapping("/upload")
public AjaxResult upload(
@ApiParam(name = "file", value = "文件", required = true)
@RequestParam("file") MultipartFile file) throws Exception {
@RequestParam("file") MultipartFile file,
@ApiParam(name = "prefix", value = "路径前缀如asset、generated等", required = false)
@RequestParam(value = "prefix", required = false) String prefix) throws Exception {
AjaxResult ajax = AjaxResult.success();
String uploadUrl = awsS3Util.uploadMultipartFile(file, true);
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true, prefix);
ajax.put("url", uploadUrl);
ajax.put("oldName", file.getOriginalFilename());
if (StringUtils.isNotBlank(prefix)) {
ajax.put("prefix", prefix);
}
return ajax;
}

View File

@ -69,8 +69,8 @@ public class KadaPaymentJava {
map.put("pm", "QRPH");
map.put("ref", orderNo);
map.put("payer", payer);
map.put("redirect", "www.google.com/recharge");
map.put("callbackUrl", "www.google.com/api/pay/kada-callBack");
map.put("redirect", "https://undressing.top/recharge");
map.put("callbackUrl", "https://undressing.top/api/pay/kada-callBack");
return JSON.toJSONString(map);
}
@ -84,7 +84,9 @@ public class KadaPaymentJava {
// 转16进制
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) sb.append(String.format("%02x", b));
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

View File

@ -5,8 +5,10 @@ import com.ruoyi.ai.service.IAiRechargeService;
import com.ruoyi.ai.service.IJinShaService;
import com.ruoyi.ai.service.IKaDaService;
import com.ruoyi.ai.service.IYuZhouService;
import com.ruoyi.ai.service.IVmService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.ip.IpUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
@ -25,6 +27,7 @@ public class PayController {
private final IKaDaService kaDaService;
private final IAiRechargeService aiRechargeService;
private final IYuZhouService yuZhouService;
private final IVmService vmService;
/**
* jinsha请求支付
@ -85,4 +88,109 @@ public class PayController {
return s;
}
/**
* VM请求支付
*/
@PostMapping("/vm-pay")
@ApiOperation("VM请求支付")
public AjaxResult vmPay(@RequestBody VmPayReq req, HttpServletRequest request) throws Exception {
// 获取用户真实IP地址
String clientIp = IpUtils.getIpAddr(request);
PayResVO payResVO = vmService.vmPay(req.getGearId(), req.getVmCardInfo(), clientIp);
return AjaxResult.success(payResVO);
}
@PostMapping("/vm-callBack")
@ApiOperation("VM支付回调")
@Anonymous
public String vmCallBack(HttpServletRequest request) {
// 记录请求的Content-Type
String contentType = request.getContentType();
// 从请求中解析表单参数
VmCallBackReq req = new VmCallBackReq();
// 记录所有请求参数用于调试
java.util.Enumeration<String> paramNames = request.getParameterNames();
java.util.Map<String, String> allParams = new java.util.HashMap<>();
while (paramNames.hasMoreElements()) {
String paramName = paramNames.nextElement();
String paramValue = request.getParameter(paramName);
allParams.put(paramName, paramValue);
}
org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(PayController.class);
log.info("VM支付回调请求参数: {}, Content-Type: {}", allParams, contentType);
req.setMchNo(request.getParameter("mchNo"));
req.setAppId(request.getParameter("appId"));
req.setMchOrderNo(request.getParameter("mchOrderNo"));
req.setPayOrderId(request.getParameter("payOrderId"));
req.setWayCode(request.getParameter("wayCode"));
// 解析整数类型字段
String amountStr = request.getParameter("amount");
if (amountStr != null && !amountStr.isEmpty()) {
try {
req.setAmount(Integer.parseInt(amountStr));
} catch (NumberFormatException e) {
log.warn("VM支付回调amount解析失败: {}", amountStr);
}
}
req.setCurrency(request.getParameter("currency"));
req.setIfCode(request.getParameter("ifCode"));
req.setSubject(request.getParameter("subject"));
req.setBody(request.getParameter("body"));
req.setClientIp(request.getParameter("clientIp"));
// 解析订单状态文档字段名state
String stateStr = request.getParameter("state");
if (stateStr != null && !stateStr.isEmpty()) {
try {
req.setState(Integer.parseInt(stateStr));
} catch (NumberFormatException e) {
log.warn("VM支付回调state解析失败: {}", stateStr);
}
}
req.setChannelOrderNo(request.getParameter("channelOrderNo"));
req.setErrCode(request.getParameter("errCode"));
req.setErrMsg(request.getParameter("errMsg"));
req.setExtParam(request.getParameter("extParam"));
// 解析时间戳createdAtsuccessTimereqTime文档字段名
String createdAtStr = request.getParameter("createdAt");
if (createdAtStr != null && !createdAtStr.isEmpty()) {
try {
req.setCreatedAt(Long.parseLong(createdAtStr));
} catch (NumberFormatException e) {
log.warn("VM支付回调createdAt解析失败: {}", createdAtStr);
}
}
String successTimeStr = request.getParameter("successTime");
if (successTimeStr != null && !successTimeStr.isEmpty()) {
try {
req.setSuccessTime(Long.parseLong(successTimeStr));
} catch (NumberFormatException e) {
log.warn("VM支付回调successTime解析失败: {}", successTimeStr);
}
}
String reqTimeStr = request.getParameter("reqTime");
if (reqTimeStr != null && !reqTimeStr.isEmpty()) {
try {
req.setReqTime(Long.parseLong(reqTimeStr));
} catch (NumberFormatException e) {
log.warn("VM支付回调reqTime解析失败: {}", reqTimeStr);
}
}
req.setSign(request.getParameter("sign"));
req.setSignType(request.getParameter("signType"));
log.info("VM支付回调解析后的对象: mchOrderNo={}, state={}, sign={}",
req.getMchOrderNo(), req.getState(), req.getSign());
return vmService.vmCallBack(req);
}
}

View File

@ -0,0 +1,146 @@
package com.ruoyi.api;
import com.ruoyi.ai.domain.AiOrder;
import com.ruoyi.ai.service.IAiOrderService;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AiUser;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 门户-生成资产管理展示当前用户的AI订单记录支持收藏功能
*/
@Api(tags = "门户-生成资产")
@RestController
@RequestMapping("/api/portal/assets")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class PortalAssetsController extends BaseController {
private final IAiOrderService aiOrderService;
private final IAiUserService aiUserService;
/**
* 查询当前用户的生成资产列表
* 支持dept=true参数查询当前用户所在部门的所有作品
*/
@GetMapping("/list")
@ApiOperation("查询生成资产列表支持收藏筛选、时间范围、dept部门查询")
public TableDataInfo list(
@RequestParam(required = false) String is_top,
@RequestParam(required = false) String beginTime,
@RequestParam(required = false) String endTime,
@RequestParam(required = false, defaultValue = "false") Boolean dept) {
AiOrder query = new AiOrder();
Long currentUserId = SecurityUtils.getAiUserId();
if (Boolean.TRUE.equals(dept)) {
// 查询当前用户所在部门的所有作品包括自己
AiUser currentUser = aiUserService.selectAiUserById(currentUserId);
if (currentUser != null && currentUser.getDeptId() != null) {
// 通过部门查询所有用户在XML中通过IN子查询实现
query.getParams().put("deptId", currentUser.getDeptId());
System.out.println("=== 查询部门作品deptId=" + currentUser.getDeptId() + " ===");
} else {
// 兜底只查个人
query.setUserId(currentUserId);
System.out.println("=== 部门ID为空兜底查询个人作品 ===");
}
} else {
// 默认只查询当前用户的数据
query.setUserId(currentUserId);
System.out.println("=== 查询个人作品 userId=" + currentUserId + " ===");
}
System.out.println("=== PortalAssetsController DEBUG START ===");
System.out.println("收到参数: is_top=" + is_top + ", beginTime=" + beginTime + ", endTime=" + endTime + ", dept=" + dept);
// 收藏状态筛选 - is_top: Y=已收藏, N=未收藏
if (is_top != null && !is_top.trim().isEmpty()) {
String trimmedIsTop = is_top.trim();
query.setIsTop(trimmedIsTop);
System.out.println("✓ 设置 isTop = [" + trimmedIsTop + "]");
System.out.println(" query.getIsTop() = " + query.getIsTop());
} else {
System.out.println("✗ is_top 参数为空或未提供,不设置筛选条件");
}
// 时间范围筛选
if (beginTime != null && !beginTime.isEmpty()) {
query.getParams().put("beginTime", beginTime);
System.out.println("✓ 设置 beginTime = " + beginTime);
}
if (endTime != null && !endTime.isEmpty()) {
query.getParams().put("endTime", endTime);
System.out.println("✓ 设置 endTime = " + endTime);
}
System.out.println("查询对象状态: isTop=" + query.getIsTop() + ", userId=" + query.getUserId() + ", params=" + query.getParams());
System.out.println("=== PortalAssetsController DEBUG END ===");
startPage();
List<AiOrder> list = aiOrderService.selectAiOrderList(query);
System.out.println("PortalAssetsController - 查询返回 " + (list != null ? list.size() : 0) + " 条记录");
return getDataTable(list);
}
/**
* 收藏/取消收藏
*/
@PostMapping("/favorite")
@ApiOperation("收藏或取消收藏生成资产")
public AjaxResult favorite(@RequestBody FavoriteRequest request) {
Long userId = SecurityUtils.getAiUserId();
// 验证订单归属
AiOrder order = aiOrderService.selectAiOrderById(request.getId());
if (order == null) {
return AjaxResult.error("订单不存在");
}
if (!userId.equals(order.getUserId())) {
return AjaxResult.error("无权操作该订单");
}
// 更新收藏状态
AiOrder update = new AiOrder();
update.setId(request.getId());
update.setIsTop(request.getIsTop());
int result = aiOrderService.updateAiOrder(update);
return toAjax(result);
}
/**
* 收藏请求体
*/
public static class FavoriteRequest {
private Long id;
private String isTop;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getIsTop() {
return isTop;
}
public void setIsTop(String isTop) {
this.isTop = isTop;
}
}
}

View File

@ -0,0 +1,655 @@
package com.ruoyi.api;
import cn.hutool.core.util.NumberUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ruoyi.ai.domain.AiOrder;
import com.ruoyi.ai.domain.ByteBodyReq;
import com.ruoyi.ai.domain.ByteBodyRes;
import com.ruoyi.ai.domain.ContentItem;
import com.ruoyi.ai.domain.ImageUrl;
import com.ruoyi.ai.domain.content;
import com.ruoyi.ai.service.IAiOrderService;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.ai.service.IByteDeptApiKeyService;
import com.ruoyi.ai.service.IByteService;
import com.ruoyi.api.request.PortalVideoGenRequest;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import com.ruoyi.config.PortalVideoProperties;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 门户视频生成按用户二级部门 byte_api_key 调用火山任务列表含库表与火山过滤列表
*/
@Api(tags = "门户-视频生成")
@RestController
@RequestMapping("/api/portal/video")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class PortalVideoController extends BaseController {
private final IByteService byteService;
private final IByteDeptApiKeyService byteDeptApiKeyService;
private final IAiOrderService aiOrderService;
private final TencentCosUtil tencentCosUtil;
private final PortalVideoProperties portalVideoProperties;
private final IAiUserService aiUserService;
@Value("${volcengine.ark.callbackUrl:}")
private String volcCallbackUrl;
private static final ObjectMapper OM = new ObjectMapper();
private String apiKey() {
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
}
/** 与 ai_manager.type、portal.video.function-type 对齐,用于扣费 */
private String resolveFunctionType(PortalVideoGenRequest req) {
if (StringUtils.isNotEmpty(req.getFunctionType())) {
return req.getFunctionType();
}
if (StringUtils.isNotEmpty(portalVideoProperties.getFunctionType())) {
return portalVideoProperties.getFunctionType();
}
return "21";
}
private List<PortalVideoProperties.ModelOption> loadModelOptionsForAiUser(Long aiUserId, Long secondDeptId) {
String json = byteDeptApiKeyService.resolveSecondLevelModelParm(aiUserId);
List<PortalVideoProperties.ModelOption> fromDb = parseModelParmJson(json);
if (fromDb != null && !fromDb.isEmpty()) {
return fromDb;
}
return portalVideoProperties.resolveModelsForSecondDept(secondDeptId);
}
private List<PortalVideoProperties.ModelOption> parseModelParmJson(String json) {
if (StringUtils.isEmpty(json)) {
return null;
}
try {
List<PortalVideoProperties.ModelOption> list = OM.readValue(json.trim(),
new TypeReference<List<PortalVideoProperties.ModelOption>>() { });
if (list == null || list.isEmpty()) {
return null;
}
List<PortalVideoProperties.ModelOption> out = new ArrayList<>();
for (PortalVideoProperties.ModelOption o : list) {
if (o != null && StringUtils.isNotEmpty(o.getLabel()) && StringUtils.isNotEmpty(o.getValue())) {
out.add(o);
}
}
return out.isEmpty() ? null : out;
} catch (Exception e) {
logger.warn("解析二级部门 model_parm 失败: {}", e.getMessage());
return null;
}
}
private static boolean isModelInOptions(String modelId, List<PortalVideoProperties.ModelOption> options) {
if (modelId == null || modelId.isEmpty() || options == null) {
return false;
}
for (PortalVideoProperties.ModelOption o : options) {
if (modelId.equals(o.getValue())) {
return true;
}
}
return false;
}
private static void alignDefaultsModelToOptions(PortalVideoProperties.Defaults defs, List<PortalVideoProperties.ModelOption> models) {
if (defs == null || models == null || models.isEmpty()) {
return;
}
String m = defs.getModel();
if (m == null || m.isEmpty()) {
defs.setModel(models.get(0).getValue());
return;
}
if (!isModelInOptions(m, models)) {
defs.setModel(models.get(0).getValue());
}
}
private String resolveDefaultModelForAiUser(Long aiUserId, Long secondDeptId, List<PortalVideoProperties.ModelOption> allowed) {
PortalVideoProperties.Defaults d = portalVideoProperties.resolveEffectiveDefaults(secondDeptId);
if (d.getModel() != null && !d.getModel().isEmpty() && isModelInOptions(d.getModel(), allowed)) {
return d.getModel();
}
if (!allowed.isEmpty() && StringUtils.isNotEmpty(allowed.get(0).getValue())) {
return allowed.get(0).getValue();
}
return null;
}
private void applyOptionalParams(ByteBodyReq body, PortalVideoGenRequest req, Long secondDeptId) {
PortalVideoProperties.Defaults d = portalVideoProperties.resolveEffectiveDefaults(secondDeptId);
body.setDuration(req.getDuration() != null ? req.getDuration() : d.getDuration());
body.setResolution(StringUtils.isNotEmpty(req.getResolution()) ? req.getResolution() : d.getResolution());
body.setRatio(StringUtils.isNotEmpty(req.getRatio()) ? req.getRatio() : d.getRatio());
if (StringUtils.isNotEmpty(volcCallbackUrl)) {
body.setCallback_url(volcCallbackUrl);
}
}
private ByteBodyReq newVideoBody(PortalVideoGenRequest req, List<ContentItem> content) {
Long uid = SecurityUtils.getAiUserId();
Long secondDeptId = byteDeptApiKeyService.resolveSecondLevelDeptId(uid);
List<PortalVideoProperties.ModelOption> allowed = loadModelOptionsForAiUser(uid, secondDeptId);
String modelId = StringUtils.isNotEmpty(req.getModel()) ? req.getModel() : resolveDefaultModelForAiUser(uid, secondDeptId, allowed);
if (StringUtils.isEmpty(modelId)) {
throw new ServiceException("未配置门户视频模型:请在后台为该用户所属二级部门配置 model_parm或在 portal.video.models 中配置全局列表");
}
if (StringUtils.isNotEmpty(req.getModel()) && !isModelInOptions(modelId, allowed)) {
throw new ServiceException("所选模型对当前部门不可用");
}
ByteBodyReq body = new ByteBodyReq();
body.setModel(modelId);
body.setContent(content);
applyOptionalParams(body, req, secondDeptId);
return body;
}
private void applyOrderImages(AiOrder aiOrder, PortalVideoGenRequest req) {
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
aiOrder.setImg1(req.getFirstUrl());
}
if (StringUtils.isNotEmpty(req.getLastUrl())) {
aiOrder.setImg2(req.getLastUrl());
}
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
aiOrder.setImg1(req.getReferenceUrl());
}
}
/**
* 写入订单提示词生成模式图床 URL以及模型/时长/分辨率/比例与完整 JSON 参数便于对账与审计
*/
private void fillVideoOrderRecord(AiOrder aiOrder, PortalVideoGenRequest req, String mode, ByteBodyReq body, String functionTypeResolved) {
aiOrder.setText(req.getText());
aiOrder.setMode(mode);
aiOrder.setIsBackfilled(0);
applyOrderImages(aiOrder, req);
aiOrder.setExtStatus(0);
if (req.getDuration() != null) {
aiOrder.setDuration(req.getDuration());
} else if (body.getDuration() != null) {
aiOrder.setDuration(body.getDuration());
}
if (StringUtils.isNotEmpty(body.getResolution())) {
aiOrder.setResolution(body.getResolution());
}
if (StringUtils.isNotEmpty(body.getRatio())) {
aiOrder.setRatio(body.getRatio());
}
if (StringUtils.isNotEmpty(body.getModel())) {
aiOrder.setModel(body.getModel());
}
try {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("generationMode", mode);
snap.put("prompt", req.getText());
snap.put("functionType", functionTypeResolved);
snap.put("model", body.getModel());
snap.put("duration", body.getDuration());
snap.put("resolution", body.getResolution());
snap.put("ratio", body.getRatio());
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
snap.put("firstUrl", req.getFirstUrl());
}
if (StringUtils.isNotEmpty(req.getLastUrl())) {
snap.put("lastUrl", req.getLastUrl());
}
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
snap.put("referenceUrl", req.getReferenceUrl());
}
if (req.getContent() != null && !req.getContent().isEmpty()) {
snap.put("content", req.getContent());
}
aiOrder.setVideoParams(OM.writeValueAsString(snap));
} catch (Exception e) {
aiOrder.setVideoParams("{\"error\":\"video_params_serialize_failed\"}");
}
}
/** 写入 video_params.volcTaskId任务成功后 result 会改为成品 URL仍应用此 id 校验归属与轮询 */
private void mergeVolcTaskIdIntoVideoParams(AiOrder aiOrder, String volcTaskId) {
if (StringUtils.isEmpty(volcTaskId)) {
return;
}
try {
ObjectNode node;
if (StringUtils.isNotEmpty(aiOrder.getVideoParams())) {
JsonNode existing = OM.readTree(aiOrder.getVideoParams());
if (existing instanceof ObjectNode) {
node = (ObjectNode) existing;
} else {
node = OM.createObjectNode();
node.set("snapshotBeforeVolcId", existing);
}
} else {
node = OM.createObjectNode();
}
node.put("volcTaskId", volcTaskId);
aiOrder.setVideoParams(OM.writeValueAsString(node));
} catch (Exception e) {
aiOrder.setVideoParams("{\"volcTaskId\":\"" + volcTaskId.replace("\"", "") + "\"}");
}
}
private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) {
String functionType = resolveFunctionType(req);
// 判断余额是否足够aimanager里配置了最低限额创建订单
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
if (aiOrder == null) {
return AjaxResult.error(-1, "You have a low balance, please recharge");
}
try {
fillVideoOrderRecord(aiOrder, req, mode, byteBodyReq, functionType);
aiOrderService.updateAiOrder(aiOrder);
String key = apiKey();
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq, key);
String thirdPartyOrderNumId = byteBodyRes.getId();
if (thirdPartyOrderNumId == null) {
aiOrderService.orderFailure(aiOrder);
return AjaxResult.error(-2, "generation failed, balance has been refunded");
}
mergeVolcTaskIdIntoVideoParams(aiOrder, thirdPartyOrderNumId);
aiOrder.setResult(thirdPartyOrderNumId);
// 字节订单号与请求ID
aiOrder.setThirdPartyOrderNum(thirdPartyOrderNumId);
aiOrder.setVideoGenRequestId(byteBodyRes.getRequestId());
// aiOrderService.orderSuccess(aiOrder);
aiOrderService.updateAiOrder(aiOrder);
// 逻辑暂时停用先不预扣 start !!!!!
// 查询任务详情按字节返回的预扣数量扣减
// GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(thirdPartyOrderNumId, key);
// if (task == null || task.getUsage() == null || task.getUsage().getTotalTokens() == null) {
// return AjaxResult.error(-2, "generation failed, byte task's usage is null");
// }
// if (task.getUsage().getTotalTokens() <= 0) {
// return AjaxResult.error(-2, "generation failed, byte task's totalTokens <= 0");
// }
// BigDecimal totalTokens = new BigDecimal(task.getUsage().getTotalTokens());
// // 同步设置aiOrder以防在抛异常时数值没变
// aiOrder.setPreDeductAmount(totalTokens);
// aiOrder.setAmount(totalTokens);
// 设置订单信息
// AiOrder updAiOrder = new AiOrder();
// updAiOrder.setId(aiOrder.getId());
// updAiOrder.setOrderNum(aiOrder.getOrderNum());
// updAiOrder.setPreDeductAmount(totalTokens);
// 先设置成预扣数量等收到回调再改过来这样后续报表会比较准确
// updAiOrder.setAmount(totalTokens);
// aiOrderService.updateAiOrder(updAiOrder);
// 扣减余额
// aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId()
// , NumberUtil.mul(-1, totalTokens), aiOrderService.getChangerType(functionType));
// 逻辑暂时停用先不预扣 start !!!!!
return AjaxResult.success(byteBodyRes);
} catch (Exception e) {
aiOrderService.orderFailure(aiOrder);
throw new RuntimeException(e);
}
}
@PostMapping("/text-to-video")
@ApiOperation("文生视频")
public AjaxResult textToVideo(@RequestBody PortalVideoGenRequest request) {
List<ContentItem> contentList;
if (request.getContent() != null && !request.getContent().isEmpty()) {
contentList = request.getContent();
} else {
if (StringUtils.isEmpty(request.getText())) {
return AjaxResult.error("请输入视频描述文本");
}
contentList = new ArrayList<>();
ContentItem textItem = new ContentItem();
textItem.setType("text");
textItem.setText(request.getText());
contentList.add(textItem);
}
ByteBodyReq body = newVideoBody(request, contentList);
return submitOrderAndCreate(request, "text-to-video", body);
}
@PostMapping("/image-first-frame")
@ApiOperation("图生视频-基于首帧")
public AjaxResult imageFirstFrame(@RequestBody PortalVideoGenRequest request) {
if (StringUtils.isEmpty(request.getFirstUrl())) {
return AjaxResult.error("请上传首帧图片");
}
if (StringUtils.isEmpty(request.getText())) {
return AjaxResult.error("请输入视频描述文本");
}
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
ByteBodyReq body = newVideoBody(request, contentList);
return submitOrderAndCreate(request, "image-first-frame", body);
}
@PostMapping("/image-first-last-frame")
@ApiOperation("图生视频-基于首尾帧")
public AjaxResult imageFirstLastFrame(@RequestBody PortalVideoGenRequest request) {
if (StringUtils.isEmpty(request.getFirstUrl()) || StringUtils.isEmpty(request.getLastUrl())) {
return AjaxResult.error("请同时上传首帧与尾帧图片");
}
if (StringUtils.isEmpty(request.getText())) {
return AjaxResult.error("请输入视频描述文本");
}
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
ContentItem lastFrameItem = new ContentItem();
lastFrameItem.setType("image_url");
lastFrameItem.setRole("last_frame");
ImageUrl lastImageUrl = new ImageUrl();
lastImageUrl.setUrl(request.getLastUrl());
lastFrameItem.setImageUrl(lastImageUrl);
contentList.add(lastFrameItem);
ByteBodyReq body = newVideoBody(request, contentList);
return submitOrderAndCreate(request, "image-first-last-frame", body);
}
@PostMapping("/image-reference")
@ApiOperation("图生视频-基于参考图")
public AjaxResult imageReference(@RequestBody PortalVideoGenRequest request) {
List<ContentItem> contentList;
if (request.getContent() != null && !request.getContent().isEmpty()) {
contentList = new ArrayList<>(request.getContent());
ContentItem head = contentList.get(0);
if (head == null || !"text".equals(head.getType()) || StringUtils.isEmpty(head.getText())) {
return AjaxResult.error("请输入视频描述文本(首条须为 type=text可含 [图片n]/[视频n]/[音频n] 占位)");
}
// 保留 text + 合法 reference_*//视频允许只有 text 没有参考素材
List<ContentItem> filtered = new ArrayList<>();
filtered.add(head);
for (int i = 1; i < contentList.size(); i++) {
ContentItem it = contentList.get(i);
if (isReferenceImageContentItem(it)
|| isReferenceAudioContentItem(it)
|| isReferenceVideoContentItem(it)) {
filtered.add(it);
}
}
contentList = filtered;
String firstRef = contentList.stream()
.skip(1)
.map(PortalVideoController::firstReferenceUrlFromItem)
.filter(StringUtils::isNotEmpty)
.findFirst()
.orElse(null);
// 无参考图也允许 text 提示词
if (StringUtils.isNotEmpty(firstRef) && StringUtils.isEmpty(request.getReferenceUrl())) {
request.setReferenceUrl(firstRef);
}
} else {
if (StringUtils.isEmpty(request.getReferenceUrl())) {
return AjaxResult.error("请上传参考图");
}
if (StringUtils.isEmpty(request.getText())) {
return AjaxResult.error("请输入视频描述文本");
}
contentList = new ArrayList<>();
ContentItem textItem = new ContentItem();
textItem.setType("text");
textItem.setText(request.getText());
contentList.add(textItem);
ContentItem refItem = new ContentItem();
refItem.setType("image_url");
refItem.setRole("reference_image");
ImageUrl refUrl = new ImageUrl();
refUrl.setUrl(request.getReferenceUrl());
refItem.setImageUrl(refUrl);
contentList.add(refItem);
}
ByteBodyReq body = newVideoBody(request, contentList);
return submitOrderAndCreate(request, "image-reference", body);
}
private static String firstReferenceUrlFromItem(ContentItem item) {
if (isReferenceImageContentItem(item)) {
return item.getImageUrl().getUrl();
}
if (isReferenceVideoContentItem(item)) {
return item.getVideoUrl().getUrl();
}
if (isReferenceAudioContentItem(item)) {
return item.getAudioUrl().getUrl();
}
return null;
}
private static boolean isValidReferenceAssetOrHttpUrl(String raw) {
if (StringUtils.isEmpty(raw)) {
return false;
}
String url = raw.trim().toLowerCase();
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("asset://");
}
private static boolean isReferenceImageContentItem(ContentItem item) {
if (item == null || !"image_url".equals(item.getType())) {
return false;
}
if (!"reference_image".equals(item.getRole())) {
return false;
}
ImageUrl iu = item.getImageUrl();
if (iu == null || StringUtils.isEmpty(iu.getUrl())) {
return false;
}
return isValidReferenceAssetOrHttpUrl(iu.getUrl());
}
private static boolean isReferenceVideoContentItem(ContentItem item) {
if (item == null || !"video_url".equals(item.getType())) {
return false;
}
if (!"reference_video".equals(item.getRole())) {
return false;
}
ImageUrl vu = item.getVideoUrl();
if (vu == null || StringUtils.isEmpty(vu.getUrl())) {
return false;
}
return isValidReferenceAssetOrHttpUrl(vu.getUrl());
}
private static boolean isReferenceAudioContentItem(ContentItem item) {
if (item == null || !"audio_url".equals(item.getType())) {
return false;
}
if (!"reference_audio".equals(item.getRole())) {
return false;
}
ImageUrl au = item.getAudioUrl();
if (au == null || StringUtils.isEmpty(au.getUrl())) {
return false;
}
return isValidReferenceAssetOrHttpUrl(au.getUrl());
}
private List<ContentItem> buildTextAndFirstFrame(String text, String firstUrl) {
List<ContentItem> contentList = new ArrayList<>();
ContentItem textItem = new ContentItem();
textItem.setType("text");
textItem.setText(text);
contentList.add(textItem);
ContentItem firstFrameItem = new ContentItem();
firstFrameItem.setType("image_url");
firstFrameItem.setRole("first_frame");
ImageUrl firstImageUrl = new ImageUrl();
firstImageUrl.setUrl(firstUrl);
firstFrameItem.setImageUrl(firstImageUrl);
contentList.add(firstFrameItem);
return contentList;
}
@GetMapping("/options")
@ApiOperation("门户视频生成可选参数(模型/比例/时长等,来自配置)")
public AjaxResult videoParamOptions() {
Long uid = SecurityUtils.getAiUserId();
Long secondDeptId = byteDeptApiKeyService.resolveSecondLevelDeptId(uid);
List<PortalVideoProperties.ModelOption> models = loadModelOptionsForAiUser(uid, secondDeptId);
PortalVideoProperties.Defaults defs = portalVideoProperties.resolveEffectiveDefaults(secondDeptId);
alignDefaultsModelToOptions(defs, models);
Map<String, Object> data = new LinkedHashMap<>();
data.put("defaults", defs);
data.put("models", models);
data.put("ratios", portalVideoProperties.getRatios());
data.put("durations", portalVideoProperties.getDurations());
data.put("resolutions", portalVideoProperties.getResolutions());
return AjaxResult.success(data);
}
@GetMapping("/tasks")
@ApiOperation("查询视频生成任务列表(本用户库表分页)")
public TableDataInfo listMyVideoTasks(AiOrder aiOrder) {
aiOrder.setUserId(SecurityUtils.getAiUserId());
aiOrder.setType("21");
startPage();
List<AiOrder> list = aiOrderService.selectAiOrderList(aiOrder);
return getDataTable(list);
}
@GetMapping("/volc-tasks")
@ApiOperation("查询视频生成任务列表(火山平台,按本用户在库中的任务 id 过滤)")
public AjaxResult listVolcTasks(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) throws Exception {
Long uid = SecurityUtils.getAiUserId();
String key = apiKey();
String raw = byteService.listVideoGenerationTasks(pageNum, pageSize, key);
String filtered = filterVolcTasksJsonForUser(raw, uid);
return AjaxResult.success(OM.readTree(filtered));
}
private String filterVolcTasksJsonForUser(String raw, Long userId) throws Exception {
AiOrder query = new AiOrder();
query.setUserId(userId);
query.setType("21");
List<AiOrder> mine = aiOrderService.selectAiOrderList(query);
Set<String> allowed = mine.stream()
.map(AiOrder::getResult)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toSet());
JsonNode root = OM.readTree(raw);
if (!(root instanceof ObjectNode)) {
return raw;
}
ArrayNode arr = null;
String arrayKey = null;
if (root.has("items") && root.get("items").isArray()) {
arr = (ArrayNode) root.get("items");
arrayKey = "items";
} else if (root.has("data") && root.get("data").isArray()) {
arr = (ArrayNode) root.get("data");
arrayKey = "data";
}
if (arr == null) {
return raw;
}
ArrayNode out = OM.createArrayNode();
for (JsonNode n : arr) {
String tid = n.has("id") ? n.get("id").asText() : null;
if (tid != null && allowed.contains(tid)) {
out.add(n);
}
}
ObjectNode result = ((ObjectNode) root).deepCopy();
result.set(arrayKey, out);
result.put("filtered_total", out.size());
return OM.writeValueAsString(result);
}
@GetMapping("/tasks/{taskId}")
@ApiOperation("查询单个视频生成任务(火山)")
public AjaxResult getVolcTask(@PathVariable String taskId) throws Exception {
Long uid = SecurityUtils.getAiUserId();
AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId);
if (owned == null || !uid.equals(owned.getUserId())) {
return AjaxResult.error("无权查看该任务");
}
String key = apiKey();
ByteBodyRes byteBodyRes;
try {
byteBodyRes = byteService.uploadVideo(taskId, key);
} catch (Exception e) {
String msg = e.getMessage();
// ByteService HTTP 2xx 时抛出以此前缀开头的异常仅此种情况清空订单避免网络抖动等误伤
if (msg != null && msg.startsWith("uploadVideo error")) {
logger.warn("查询火山任务 HTTP 非成功,已清空订单 result 并标记失败, orderId={}, taskId={}",
owned.getId(), taskId, e);
AiOrder failedOrder = new AiOrder();
failedOrder.setId(owned.getId());
failedOrder.setResult("");
failedOrder.setStatus(2);
aiOrderService.updateAiOrder(failedOrder);
return AjaxResult.error(msg);
}
throw e;
}
if ("succeeded".equals(byteBodyRes.getStatus())) {
content contentObj = byteBodyRes.getContent();
if (contentObj != null && StringUtils.isNotEmpty(contentObj.getVideo_url())) {
String videoUrl = tencentCosUtil.uploadFileByUrl(contentObj.getVideo_url());
if (videoUrl != null) {
contentObj.setVideo_url(videoUrl);
AiOrder aiOrder = new AiOrder();
aiOrder.setId(owned.getId());
aiOrder.setResult(videoUrl);
aiOrderService.updateAiOrder(aiOrder);
}
}
}
return AjaxResult.success(byteBodyRes);
}
// @DeleteMapping("/tasks/{taskId}")
// @ApiOperation("删除或取消视频生成任务")
// public AjaxResult deleteOrCancelTask(@PathVariable String taskId) throws Exception {
// Long uid = SecurityUtils.getAiUserId();
// AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId);
// if (owned == null || !uid.equals(owned.getUserId())) {
// return AjaxResult.error("无权操作该任务");
// }
// String key = apiKey();
// AjaxResult cancelRes = byteService.cancelVideoTask(taskId, key);
// if (cancelRes.isSuccess() && owned.getStatus() != null && owned.getStatus() == 0) {
// aiOrderService.orderFailure(owned);
// }
// return cancelRes;
// }
}

View File

@ -31,6 +31,11 @@ public class ByteApiRequest {
@ApiModelProperty(name = "标签字符串")
private String tags;
@ApiModelProperty(name = "使用的模型")
private String model;
@ApiModelProperty(name = "生成模式text-to-video 或 image-to-video")
private String mode = "text-to-video";
}

View File

@ -0,0 +1,38 @@
package com.ruoyi.api.request;
import com.ruoyi.ai.domain.ContentItem;
import lombok.Data;
import java.util.List;
/**
* 门户视频生成火山 Seedance请求体
*/
@Data
public class PortalVideoGenRequest {
private String text;
/**
* 文生视频时可选多段文本 + 参考图与火山 content 一致不传则仅使用 text 单行
*/
private List<ContentItem> content;
/** 默认与后台配置的视频计费类型一致 */
private String functionType = "21";
private String model;
private Integer duration;
private String resolution;
private String ratio;
private String firstUrl;
private String lastUrl;
/** 图生视频-参考图模式 */
private String referenceUrl;
}

View File

@ -0,0 +1,245 @@
package com.ruoyi.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 门户视频生成参数模型比例时长分辨率等从配置读取不在代码中写死业务默认值
*/
@Component
@ConfigurationProperties(prefix = "portal.video")
public class PortalVideoProperties {
private Defaults defaults = new Defaults();
private List<ModelOption> models = new ArrayList<>();
/**
* 按二级部门 {@code dept_id}字符串 {@code "101"}配置专属模型列表未命中时使用全局 {@link #models}
*/
private Map<String, List<ModelOption>> modelsBySecondDept = new LinkedHashMap<>();
/**
* 按二级部门覆盖 {@link #defaults} 中的字段仅配置需要覆盖的项即可
*/
private Map<String, Defaults> defaultsBySecondDept = new LinkedHashMap<>();
private List<String> ratios = new ArrayList<>();
private List<Integer> durations = new ArrayList<>();
private List<String> resolutions = new ArrayList<>();
/**
* ai_manager.type 一致用于扣费与订单库中需存在对应记录且 status=0del_flag=0
*/
private String functionType = "21";
public String getFunctionType() {
return functionType;
}
public void setFunctionType(String functionType) {
this.functionType = functionType != null ? functionType : "21";
}
public Defaults getDefaults() {
return defaults;
}
public void setDefaults(Defaults defaults) {
if (defaults != null) {
this.defaults = defaults;
}
}
public List<ModelOption> getModels() {
return models;
}
public void setModels(List<ModelOption> models) {
this.models = models != null ? models : new ArrayList<>();
}
public Map<String, List<ModelOption>> getModelsBySecondDept() {
return modelsBySecondDept;
}
public void setModelsBySecondDept(Map<String, List<ModelOption>> modelsBySecondDept) {
this.modelsBySecondDept = modelsBySecondDept != null ? modelsBySecondDept : new LinkedHashMap<>();
}
public Map<String, Defaults> getDefaultsBySecondDept() {
return defaultsBySecondDept;
}
public void setDefaultsBySecondDept(Map<String, Defaults> defaultsBySecondDept) {
this.defaultsBySecondDept = defaultsBySecondDept != null ? defaultsBySecondDept : new LinkedHashMap<>();
}
public List<String> getRatios() {
return ratios;
}
public void setRatios(List<String> ratios) {
this.ratios = ratios != null ? ratios : new ArrayList<>();
}
public List<Integer> getDurations() {
return durations;
}
public void setDurations(List<Integer> durations) {
this.durations = durations != null ? durations : new ArrayList<>();
}
public List<String> getResolutions() {
return resolutions;
}
public void setResolutions(List<String> resolutions) {
this.resolutions = resolutions != null ? resolutions : new ArrayList<>();
}
/**
* defaults.model 为空时 models 第一项的 value
*/
public String resolveDefaultModelId() {
if (defaults.getModel() != null && !defaults.getModel().isEmpty()) {
return defaults.getModel();
}
if (!models.isEmpty() && models.get(0).getValue() != null) {
return models.get(0).getValue();
}
return null;
}
public List<ModelOption> resolveModelsForSecondDept(Long secondDeptId) {
if (secondDeptId != null) {
List<ModelOption> byDept = modelsBySecondDept.get(String.valueOf(secondDeptId));
if (byDept != null && !byDept.isEmpty()) {
return byDept;
}
}
return models;
}
public Defaults resolveEffectiveDefaults(Long secondDeptId) {
Defaults out = new Defaults();
Defaults g = this.defaults;
out.setModel(g.getModel());
out.setDuration(g.getDuration());
out.setResolution(g.getResolution());
out.setRatio(g.getRatio());
if (secondDeptId != null) {
Defaults o = defaultsBySecondDept.get(String.valueOf(secondDeptId));
if (o != null) {
if (o.getModel() != null && !o.getModel().isEmpty()) {
out.setModel(o.getModel());
}
if (o.getDuration() != null) {
out.setDuration(o.getDuration());
}
if (o.getResolution() != null && !o.getResolution().isEmpty()) {
out.setResolution(o.getResolution());
}
if (o.getRatio() != null && !o.getRatio().isEmpty()) {
out.setRatio(o.getRatio());
}
}
}
return out;
}
/**
* 在给定二级部门下解析默认模型先看合并后的 defaults.model否则取该部门 models 列表首项
*/
public String resolveDefaultModelIdForSecondDept(Long secondDeptId) {
Defaults d = resolveEffectiveDefaults(secondDeptId);
if (d.getModel() != null && !d.getModel().isEmpty()) {
return d.getModel();
}
List<ModelOption> m = resolveModelsForSecondDept(secondDeptId);
if (!m.isEmpty() && m.get(0).getValue() != null && !m.get(0).getValue().isEmpty()) {
return m.get(0).getValue();
}
return null;
}
public boolean isModelAllowedForSecondDept(String modelId, Long secondDeptId) {
if (modelId == null || modelId.isEmpty()) {
return true;
}
for (ModelOption opt : resolveModelsForSecondDept(secondDeptId)) {
if (modelId.equals(opt.getValue())) {
return true;
}
}
return false;
}
public static class Defaults {
private String model;
private Integer duration;
private String resolution;
private String ratio;
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Integer getDuration() {
return duration;
}
public void setDuration(Integer duration) {
this.duration = duration;
}
public String getResolution() {
return resolution;
}
public void setResolution(String resolution) {
this.resolution = resolution;
}
public String getRatio() {
return ratio;
}
public void setRatio(String ratio) {
this.ratio = ratio;
}
}
public static class ModelOption {
private String label;
private String value;
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
}

View File

@ -0,0 +1,120 @@
package com.ruoyi.web.controller.ai;
import java.util.List;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.constant.UserConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysDeptService;
/**
* AI 业务侧部门管理数据源与系统部门 sys_dept 一致权限独立
*/
@RestController
@RequestMapping("/ai/dept")
public class AiDeptController extends BaseController
{
@Autowired
private ISysDeptService deptService;
@Autowired
private IAiUserService aiUserService;
@PreAuthorize("@ss.hasPermi('ai:dept:list')")
@GetMapping("/list")
public AjaxResult list(SysDept dept)
{
List<SysDept> depts = deptService.selectDeptList(dept);
return success(depts);
}
@PreAuthorize("@ss.hasPermi('ai:dept:list')")
@GetMapping("/list/exclude/{deptId}")
public AjaxResult excludeChild(@PathVariable(value = "deptId", required = false) Long deptId)
{
List<SysDept> depts = deptService.selectDeptList(new SysDept());
depts.removeIf(d -> d.getDeptId().intValue() == deptId || ArrayUtils.contains(StringUtils.split(d.getAncestors(), ","), deptId + ""));
return success(depts);
}
@PreAuthorize("@ss.hasPermi('ai:dept:query')")
@GetMapping(value = "/{deptId}")
public AjaxResult getInfo(@PathVariable Long deptId)
{
deptService.checkDeptDataScope(deptId);
return success(deptService.selectDeptById(deptId));
}
@PreAuthorize("@ss.hasPermi('ai:dept:add')")
@Log(title = "AI部门管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysDept dept)
{
if (!deptService.checkDeptNameUnique(dept))
{
return error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在");
}
dept.setCreateBy(getUsername());
return toAjax(deptService.insertDept(dept));
}
@PreAuthorize("@ss.hasPermi('ai:dept:edit')")
@Log(title = "AI部门管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysDept dept)
{
Long deptId = dept.getDeptId();
deptService.checkDeptDataScope(deptId);
if (!deptService.checkDeptNameUnique(dept))
{
return error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在");
}
else if (dept.getParentId().equals(deptId))
{
return error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己");
}
else if (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus()) && deptService.selectNormalChildrenDeptById(deptId) > 0)
{
return error("该部门包含未停用的子部门!");
}
dept.setUpdateBy(getUsername());
return toAjax(deptService.updateDept(dept));
}
@PreAuthorize("@ss.hasPermi('ai:dept:remove')")
@Log(title = "AI部门管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{deptId}")
public AjaxResult remove(@PathVariable Long deptId)
{
if (deptService.hasChildByDeptId(deptId))
{
return warn("存在下级部门,不允许删除");
}
if (deptService.checkDeptExistUser(deptId))
{
return warn("部门存在系统用户,不允许删除");
}
if (aiUserService.countAiUserByDeptId(deptId) > 0)
{
return warn("部门存在 AI 用户,不允许删除");
}
deptService.checkDeptDataScope(deptId);
return toAjax(deptService.deleteDeptById(deptId));
}
}

View File

@ -70,7 +70,7 @@ public class AiManagerController extends BaseController
// 查询所有标签
AiTag aiTag = new AiTag();
aiTag.setAiId(data.getId());
aiTag.setParentId(0l);
aiTag.setParentId(0L);
aiTag.setStatus(0);
List<AiTag> tagList = aiTagService.selectAiTagList(aiTag);
AjaxResult ajaxResult = success();

View File

@ -113,6 +113,6 @@ public class AiRechargeGiftController extends BaseController
@Anonymous
@GetMapping("/getGifStatus")
public boolean getGifStatus() {
return aiRechargeGiftService.isActivityAvailable(1l);
return aiRechargeGiftService.isActivityAvailable(1L);
}
}

View File

@ -97,7 +97,7 @@ public class AiTagController extends BaseController
public AjaxResult edit(@RequestBody AiTag aiTag)
{
int rows = aiTagService.updateAiTag(aiTag);
if (aiTag.getStatus() == 1 && aiTag.getParentId() != 0l) {
if (aiTag.getStatus() == 1 && aiTag.getParentId() != 0L) {
// 查询是否父级是否开启并且还有其他子级开启数量
boolean b = aiTagService.checkStatus(aiTag.getParentId());
if (!b) {

View File

@ -1,4 +1,4 @@
package com.ruoyi.ai.controller;
package com.ruoyi.web.controller.ai;
import java.util.List;
import javax.servlet.http.HttpServletResponse;

View File

@ -1,33 +1,27 @@
package com.ruoyi.web.controller.ai;
import cn.hutool.core.util.NumberUtil;
import com.ruoyi.ai.service.EmailVerifyService;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.constant.BalanceChangerConstants;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AiUser;
import com.ruoyi.common.core.domain.model.RegisterAiUserBody;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.mail.MessagingException;
import javax.servlet.http.HttpServletResponse;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.UUID;
/**
* ai-用户信息Controller
@ -77,7 +71,6 @@ public class AiUserController extends BaseController {
}
/**
* 新增ai-用户信息
*/
@ -100,6 +93,20 @@ public class AiUserController extends BaseController {
return toAjax(aiUserService.updateAiUser(aiUser));
}
/**
* 分配归属部门deptId 为空表示不归属任何部门
*/
@ApiOperation("分配AI用户归属部门")
@PreAuthorize("@ss.hasPermi('ai:user:edit')")
@Log(title = "ai-用户信息", businessType = BusinessType.UPDATE)
@PutMapping("/dept")
public AjaxResult assignDept(@RequestBody AiUser aiUser) {
if (aiUser.getId() == null) {
return error("用户主键不能为空");
}
return toAjax(aiUserService.updateAiUserDept(aiUser.getId(), aiUser.getDeptId()));
}
/**
* 状态修改
*/
@ -123,9 +130,12 @@ public class AiUserController extends BaseController {
aiUser.setUpdateBy(getUsername());
// 获取原余额
AiUser u = aiUserService.selectAiUserById(aiUser.getId());
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
String orderNo = dateTime + uuid;
// 计算变更余额
BigDecimal amount = NumberUtil.sub(aiUser.getBalance(), u.getBalance());
aiUserService.addUserBalance(aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION);
aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION);
return toAjax(1);
}
@ -151,8 +161,6 @@ public class AiUserController extends BaseController {
}
// @GetMapping("/addUserBalance")
// public AjaxResult addUserBalance(BigDecimal amount) {
// aiUserService.addUserBalance(SecurityUtils.getAiUserId(), amount, BalanceChangerConstants.RECHARGE);

View File

@ -4,7 +4,6 @@ import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.AwsS3Util;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import com.ruoyi.common.utils.file.FileUploadUtils;
@ -36,7 +35,6 @@ public class CommonController {
private static final Logger log = LoggerFactory.getLogger(CommonController.class);
private final ServerConfig serverConfig;
private final TencentCosUtil tencentCosUtil;
private final AwsS3Util awsS3Util;
/**
* 通用下载请求
@ -144,21 +142,25 @@ public class CommonController {
}
@PostMapping("/cos/upload")
public AjaxResult uploadCover(MultipartFile file) {
public AjaxResult uploadCover(MultipartFile file,
@RequestParam(value = "prefix", required = false) String prefix) {
AjaxResult ajax = AjaxResult.success();
String uploadUrl;
try {
uploadUrl = tencentCosUtil.upload(file);
uploadUrl = tencentCosUtil.uploadMultipartFile(file, true, prefix);
} catch (Exception e) {
throw new BaseException("上传失败");
}
ajax.put("url", uploadUrl);
ajax.put("oldName", file.getOriginalFilename());
if (StringUtils.isNotBlank(prefix)) {
ajax.put("prefix", prefix);
}
return ajax;
}
/**
* AWS上传请求单个
* 腾讯云COS上传请求单个
*/
@ApiOperation("图片上传接口")
@PostMapping("/aws/upload")
@ -166,7 +168,7 @@ public class CommonController {
AjaxResult ajax = AjaxResult.success();
String uploadUrl;
try {
uploadUrl = awsS3Util.uploadMultipartFile(file, true);
uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
} catch (Exception e) {
return AjaxResult.error(e.getMessage());
}

View File

@ -99,7 +99,7 @@ public class SysConfigController extends BaseController
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysConfig config)
{
if (config.getConfigId().equals(100l)) {
if (config.getConfigId().equals(100L)) {
if (!isStringLongInteger(config.getConfigValue())) {
return error(config.getConfigName() + "参数值只能输入整数");
}

View File

@ -5,11 +5,10 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://xxxxx:xxxxx/byteai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://database-1.cvs822qoc391.ap-southeast-1.rds.amazonaws.com:3306/byteai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: xxxxxx
password: mkMReisAKl6I7rVqEY90
driverClassName: com.mysql.cj.jdbc.Driver
# 初始连接数
initialSize: 5
# 最小连接池数量
@ -41,8 +40,8 @@ spring:
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username:
login-password:
login-username: ruoyi
login-password: cvs822qoc391
filter:
stat:
enabled: true
@ -53,4 +52,3 @@ spring:
wall:
config:
multi-statement-allow: true

View File

@ -47,12 +47,16 @@ user:
# Spring配置
spring:
# 邮件发送配置
# 邮件发送配置
mail:
host: smtp.qq.com # QQ邮箱SMTP服务器163邮箱smtp.163.comGmailsmtp.gmail.com
# host: mailcow-self.undressing.name # QQ邮箱SMTP服务器163邮箱smtp.163.comGmailsmtp.gmail.com
# port: 465 # SMTP端口SSL加密端口465非加密587优先用587
# username: undressing@mail.undressing.name # 你的邮箱地址
# password: aRtHEN39 # 邮箱授权码(不是登录密码!)
host: smtpdm-ap-southeast-1.aliyun.com # QQ邮箱SMTP服务器163邮箱smtp.163.comGmailsmtp.gmail.com
port: 465 # SMTP端口SSL加密端口465非加密587优先用587
username: # 你的邮箱地址
password: # 邮箱授权码(不是登录密码!)
username: undressing@undressing.name # 你的邮箱地址
password: 1284GOvkho # 邮箱授权码(不是登录密码!)
default-encoding: UTF-8 # 编码格式
protocol: smtp
# SSL/TLS配置可选根据邮箱要求
@ -89,15 +93,17 @@ spring:
# redis 配置
redis:
# 地址
host: localhost
host: master.redis.chguac.apse1.cache.amazonaws.com
# 端口默认为6379
port: 6379
# 数据库索引
database: 2
# 密码
password:
user: root
password: mkMReisAKl6I7rVqEY90
# 连接超时时间
timeout: 10s
ssl: true
lettuce:
pool:
# 连接池中的最小空闲连接
@ -108,7 +114,6 @@ spring:
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 自定义验证码配置
verify:
code:
@ -162,7 +167,8 @@ mybatis-plus:
map-underscore-to-camel-case: true
cache-enabled: true
# 这个配置会将执行的sql打印出来在开发或测试的时候可以用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
# PageHelper分页插件
pagehelper:
@ -204,42 +210,118 @@ google:
redirect-uri:
tencentCos:
accessKey:
secretKey:
endpoint:
bucketName:
domain:
accessKey: AKIDBE3dzBdLsHYfZLwKVSFArLchZDerrfHf
secretKey: EDyUmsnX2IJ5f0oRn1QdeQ0TmrtqgQ1c
endpoint: ap-guangzhou
bucketName: seedance-1331490964
domain: https://seedance-1331490964.cos.ap-guangzhou.myqcloud.com
aws:
accessKey: AKIAYVMHEVDDZQGE3HVX
secretKey: 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
endpoint: ap-southeast-1
bucketName: di-image
domain: https://images.iqyjsnwv.com/
# aws配置已替换为腾讯云COS请在环境变量或配置文件中设置腾讯云凭证
# aws:
# accessKey: AKIAYVMHEVDDZQGE3HVX
# secretKey: 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
# endpoint: ap-southeast-1
# bucketName: di-image
# domain: https://images.iqyjsnwv.com/
byteapi:
url: https://ark.ap-southeast.bytepluses.com/api/v3
apiKey: 327d2815-2516-44c2-9e32-2dc50bf7afd7
callBackUrl: www.google.com
url:
apiKey:
callBackUrl:
volcengine:
region: cn-beijing
# 素材库等 ark 控制面 OpenAPI与 byteapi 数据面推理地址无关)
# URL 请加引号;代码会去掉 https:// 再交给 SDKSDK 自己会拼 https://,避免双 scheme
openApiEndpoint: "https://ark.cn-beijing.volces.com"
ark:
baseUrl: "https://ark.cn-beijing.volces.com"
openApiVersion: "2024-01-01"
apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97
callbackUrl: http://47.86.170.114:8011/api/ai/volcCallback
ak: AKLTNmYyN2VhZTcyMDcxNDNlNzg3OGVlMDVmZjRhNWQwY2M
sk: Tm1ZeU1UTmlORFk1WmpKa05HUmpaRGcxTWpjMFpqUmpOVE01TUdJME5URQ==
projectAesKeyBase64: "gJajABVfQJ9xA94Q9IvQi68fqqhSIkfcKlG7pjGFt2U="
# 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举
portal:
video:
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist请插入对应 type 或改此处与库一致
function-type: "21"
defaults:
model: ep-20260326165811-dlkth
duration: 4
resolution: 720p
ratio: "3:4"
models:
- label: Seedance 2.0
value: ep-20260326165811-dlkth
- label: Seedance 2.0 Fast
value: ep-20260326170056-dkj9m
# 可选:按二级部门 dept_id 覆盖 YAML仅当库表 sys_dept.model_parm 为空时才会用到)
# models-by-second-dept / defaults-by-second-dept 见 PortalVideoProperties 说明
ratios:
- "16:9"
- "9:16"
- "3:4"
- "1:1"
- "4:3"
durations:
- 4
- 5
- 6
- 8
- 10
- 11
- 12
- 13
- 14
- 15
resolutions:
- "480p"
- "720p"
jinsha:
url: https://api.jinshapay.xyz
appId: 1763617360
secret: a201e7969af5045dcd62d203b26121ae
notifyUrl: www.google.com
returnUrl: www.google.com
notifyUrl: https://undressing.top
returnUrl: https://undressing.top
kada:
url: https://rapi.openkada.xyz
appId: c70f1719017e290354017d1c101d0cc288d06ceb
secret: ME2VRe6tWH6weK/NAUJA5lhmewHkB23rA6CdWlrHrAs+/E/E3j3eG3io/GCHbQKqMMurfTNrBj/R4Yy84UziM5YJheiKFKbsWQc5xRoE46E3/0EYy4ZjbK9jhwGyHS+C
notifyUrl: www.google.com
returnUrl: www.google.com
appId: d1743d48fb8fc24f38b7268015cf800e3b49f0fd
secret: b0CH/+tVEsz+1j2mfBzd9Kgu6UylJxr0056TwTbkKfHWw9UW/6TaQyQHv+teBnGbqWy5ObaLUMvnrs9adpymebEqjI3ipNpJa7YPQbMYm0VGuYUEgeM+fjakhWuYx2XEVzmjdIvvfhNsfr2YHTmDUzwIKPbp/OJvfG9KhSPMzpw=
notifyUrl: https://undressing.top
returnUrl: https://undressing.top
yuzhou:
url: https://pay.joinus6688.cc
url: https://api.fast-vip.store
appId: PM20251211091945
publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiTl8fg6eM6uUJqxAjGtzskt+ESIgferomy6uUtjRx4yhu6I4cVBgaw9ErJq9KMNQpMVl44GEese6PRDmNPdvXBktI/skpCfyNvT+1LqYm69Hh+rFre2Ve+0XIVoln0H3EGNUHL/KPOCm2tYXLXlZ3r02z+AQeS3rxNhE4jr32oQIDAQAB
secretKey: MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAL7U/f7yb9Z9j56dauCUb0B/I0ONAcZDK+TtOnAgLEjV4qrirYHYuCumxYbPFvt6qYggjbBpphFhihbWzf8IPS7iD6VXoSX1T1iAlWFL1ZaBscqQSvPxFGtgpTBiRFS3KkZV70WswcCL770OCkY8+DJpdyk9OkD6vBa0TYU1wxJFAgMBAAECgYBpF+t5iBJHUYbSl2bQn25VWq8U+IbNpRh7TposPculoQTfj052f9+NSp7liw7hF8Bdk2/0g3pNgCYIRevUU7k9MEIKqHCiOWkyavtsfqGYI37PZ4/0uMzB5eibTqKTEkcyskSJ9GxrL4uGKgTGNc213i3VOcZZ4xEfvuDQCHF8gQJBAPATuhQeFNNAIE9TkGiESHFGChSZZgzp1xfGrAt8BwidBSe+r9duAcGJSeNJatxneeu0w6NuwQ6iq9ztnqtoG90CQQDLfSHrOTloHSa+DdBc2SFQWa/P4K2Tznb8Y5ng8L/t+a9sYvGjWOln0R3Bq9TrImm0AjWnq/saaMg2nYD1wr2JAkA36XA5xTO2a0XbE6wbG0u/zb8FQyCIO2GTsPpahl0g/Wi48+kB9CXGjBHANFYF1LeJVIUHqACgRvRdtJ1ycAGlAkEAqASVWiTw2p+fWrQLRG7gS/kR6uIIUI/cvT78UrhWsYdFqof0Hz0N0/PdzwkzkEbk4oYkiWK+viqgjj/0uHfoiQJAQhPYVVLHD7xiJApc/Aga6g0OFF5O7zy8KTsq+KTXqRlJREBH5nirSponHwYalEbUvtQrVs+Z4BCBEGCU8m2GEw==
redirectUrl: www.google.com
callbackUrl: www.google.com
secretKey: MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOyqpe98vNWLL/1ja4WnUkCGiHAVnNJtlw7c9bdacCc7xgGSlXIq1bRQWXMjLZeSC31BUFcrIMk5eMr1AWUTYnVOmAJ4IsLZCUMQOBU9S6BJdqnaus4K6Kul35lrb/Zt6ju6fY1acL7q3NWJGdeRtmYaGXZbF+RH6UbA6aH4vkHBAgMBAAECgYAEh+8EbveOYZCuGdpil82w9KUVpe5Hj5JQANOMpU+RdWPLiX7xDX2jqv2kFmEeiPeNbXk7AAABJjzoKtO84lz/n0Vp8hwqIgeMdmLZBBTsJhXgOfgnvwjb/1Ev2Yjg3KGp7nKvkyPpoU9e0W8+PR5yTclylV0bCYqsP260BFLnIQJBAPmqUzQLQN739dKoO3uVpB9qU3exZOv+zlTQZj2C0cXt3/+JzJs78DBieN9+b3ZLq307namt8QAUVV+QwRoNPxECQQDyq+QoAakpi1tQoGfYUecucDcZf8GDPdEkCGhGoIq/FkzL0gGub45Rz0oXzAzBME6DcobfVVippcUB54pueTexAkBxDTtX2bCqRkW9+gfVUlFbGF5rWJyGcH8l0Kg7Oj2bDrfbkp5fvKhqgGyTZ0E5o9InhxNBfk4e5xYxi+6kyVLBAkBcPahA6LizOOxhzkcKu78jMLZQ8/XLfCDWEHgKeJWkosZYJyBKfM7dG+zu9LnYaRM+9bZ8h8Vm3sLuwWMmMN9RAkBdOSqC71e9248Bmw70zX7x6ZmszGQ64toA6mhhcdYb4wAlDtmm0OJjJRmpyXdhgdIHF3i7vNe7sXpPfNKFc1Yf
redirectUrl: https://undressing.top
callbackUrl: https://undressing.top
vm:
url: http://payment-api.togame.top
mchNo: M1768983012
appId: 697089e4f41a4f456f159408
secret: 120tzr4snoq11yus8la9gx7cbutw1uore4pervckvqmsswrt1hl9qkd0ug5r6twwv94jex03ajpsmsky2za4x1kghd2l54z4nn7t5fcy4gewsvwjjxrce5q1f7u2yeqj
notifyUrl: https://undressing.top
# 支付方式固定为BUZHI_VM国际VM卡支付
wayCode: BUZHI_VM
# 货币代码默认USD
currency: USD
# 汇率服务配置
exchange-rate:
enabled: true
apikey: 0bed27315c87475f8dd6a0792a632cc5
api-url: https://api.currencyfreaks.com/v2.0/rates/latest
# base 货币API返回的基准货币通常为USD
base-currency: USD
# 备用汇率当API调用失败时使用
fallback-rate-jinsha: 90
fallback-rate-kada: 60
# PHP比索的备用汇率当API调用失败时使用默认使用JinSha的汇率
fallback-rate-php: 90

View File

@ -22,6 +22,8 @@ user.mobile.phone.number.not.valid=手机号格式错误
user.login.success=登录成功
user.register.success=注册成功
user.notfound=请重新登录
## 门户 AI 用户与后台「用户管理」开关一致0 启用1 停用);库表亦可能为 2 表示禁用
ai.user.login.disabled=账号已停用,无法登录
user.forcelogout=管理员强制退出,请重新登录
user.unknown.error=未知错误,请重新登录
@ -36,3 +38,8 @@ no.update.permission=您没有修改数据的权限,请联系管理员添加
no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]
# video generation
order.number.generation.failed=订单号 {0} 生成视频任务失败。
order.number.generation.submit=订单号 {0} 生成视频任务已提交!预扣金额。
order.number.generation.successbackfill=订单号 {0} 生成视频任务成功!金额已回补!

View File

@ -30,4 +30,9 @@ email.verification.code.error=Verification code is incorrect, please try again.
# User not found
user.not.found=User not found.
user.password.incorrect=Password is incorrect, please try again.
order.number.generation.failed=Order number {0} generation failed, please try again later.
ai.user.login.disabled=This account has been disabled and cannot log in.
# video generation
order.number.generation.failed=Order number {0} generation failed.
order.number.generation.submit=Order number {0} generation task submitted!
order.number.generation.successbackfill=Order number {0} sucessed! Amount is back filled!

View File

@ -28,5 +28,9 @@ email.verification.code.content=你的電子郵件驗證碼為:{0},有效期
email.verification.code.error=驗證碼錯誤,請重新輸入。
user.not.found=用戶不存在。
user.password.incorrect=密碼錯誤,請重新輸入。
ai.user.login.disabled=帳號已停用,無法登入。
order.number.generation.failed=訂單號 {0} 生成失敗,請稍後重試。
# video generation
order.number.generation.failed=訂單號 {0} 生成視頻任務失敗。
order.number.generation.submit=訂單號 {0} 生成視頻任務已提交!預扣金額。
order.number.generation.successbackfill=訂單號 {0} 生成視頻任務成功!金額已回補!

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 日志存放路径 -->
<property name="log.path" value=".logs" />
<!-- 日志输出格式 -->
<!-- 日志目录:与 jar 同目录下的 logs/(按日归档,重启以追加方式打开当前文件,不会整文件覆盖) -->
<property name="log.path" value="logs" />
<!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
<!-- 控制台输出 -->
@ -14,80 +14,75 @@
<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<file>${log.path}/sys-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<file>${log.path}/sys-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 用户访问日志输出 -->
<appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
<appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-user.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 按天回滚 daily -->
<fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
</appender>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
</appender>
<!--
全量业务日志:当前写入 logs/byteai-api.log按天切分为 logs/byteai-api.yyyy-MM-dd.log
(若仍用 shell 重定向覆盖根目录 byteai-api.log可去掉重定向仅依赖本 appender
-->
<appender name="byteai_api" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/byteai-api.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/byteai-api.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
</appender>
<!-- 系统模块日志级别控制 -->
<logger name="com.ruoyi" level="info" />
<!-- Spring日志级别控制 -->
<logger name="org.springframework" level="warn" />
<root level="info">
<appender-ref ref="console" />
<appender-ref ref="file_info" />
<appender-ref ref="file_error" />
<appender-ref ref="byteai_api" />
</root>
<!--系统操作日志-->
<root level="info">
<appender-ref ref="file_info" />
<appender-ref ref="file_error" />
</root>
<!--系统用户操作日志-->
<logger name="sys-user" level="info">
<appender-ref ref="sys-user"/>
</logger>
<logger name="sys-user" level="info">
<appender-ref ref="sys-user" />
</logger>
</configuration>

View File

@ -71,6 +71,12 @@
<artifactId>commons-io</artifactId>
</dependency>
<!-- Apache Commons Codec for Base64 encoding/decoding -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!-- excel工具 -->
<dependency>
<groupId>org.apache.poi</groupId>
@ -142,10 +148,6 @@
<version>1.6.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>

View File

@ -8,7 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
@ -54,8 +54,8 @@ public class AiUser extends BaseEntity {
@Excel(name = "手机号码")
private String phone;
/** 密码 */
@JsonIgnore
/** 密码(仅接受入参反序列化,响应 Json 不输出,避免与 @JsonIgnore 导致入参丢失) */
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Excel(name = "密码")
private String password;
@ -117,12 +117,19 @@ public class AiUser extends BaseEntity {
*/
private String country;
/** 归属部门IDsys_dept.dept_id */
private Long deptId;
/**
* 上级用户昵称
*/
@TableField(exist = false)
private String superiorName;
/** 归属部门名称 */
@TableField(exist = false)
private String deptName;
/**
* 上级用户ID
*/

View File

@ -52,6 +52,15 @@ public class SysDept extends BaseEntity
/** 父部门名称 */
private String parentName;
/** Byte API Key */
private String byteApiKey;
/** 门户视频模型列表 JSON[{"label":"…","value":"ep-…"},…] */
private String modelParm;
/** Byte project */
private String project;
/** 子部门 */
private List<SysDept> children = new ArrayList<SysDept>();
@ -171,6 +180,36 @@ public class SysDept extends BaseEntity
this.parentName = parentName;
}
public String getByteApiKey()
{
return byteApiKey;
}
public void setByteApiKey(String byteApiKey)
{
this.byteApiKey = byteApiKey;
}
public String getModelParm()
{
return modelParm;
}
public void setModelParm(String modelParm)
{
this.modelParm = modelParm;
}
public String getProject()
{
return project;
}
public void setProject(String project)
{
this.project = project;
}
public List<SysDept> getChildren()
{
return children;
@ -192,6 +231,9 @@ public class SysDept extends BaseEntity
.append("leader", getLeader())
.append("phone", getPhone())
.append("email", getEmail())
.append("byteApiKey", getByteApiKey())
.append("modelParm", getModelParm())
.append("project", getProject())
.append("status", getStatus())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())

View File

@ -0,0 +1,32 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* CreateAssetGroup POST /open/CreateAssetGroupapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "创建 Asset Group素材资产组合。首次创建需在控制台签署授权函。")
public class CreateAssetGroupRequest {
@ApiModelProperty(value = "Asset Group素材资产组合的名称上限为 64 字符。", required = true)
private String name;
@ApiModelProperty(value = "Asset Group素材资产组合的描述上限为 300 字符。")
private String description;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "Asset Group素材资产组合的类型。可选值AIGC虚拟人像。当前仅支持 AIGC 类型。")
private String groupType = "AIGC";
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "资源所属的项目名称,默认值为 default。若资源不在默认项目中需填写正确的项目名称。")
private String projectName;
}

View File

@ -0,0 +1,34 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* CreateAsset POST /open/CreateAsset 请求体api.docx由服务端在上传存储桶后组装 URL
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "CreateAsset 第三方接口请求体")
public class CreateAssetRequest {
@ApiModelProperty(value = "Asset素材资产所属的 Asset Group素材资产组合的 id。", required = true)
private String groupId;
@JsonProperty("URL")
@ApiModelProperty(value = "传入的 Asset素材资产的公共可访问地址。", required = true)
private String url;
@ApiModelProperty(value = "Asset素材资产的名称上限为 64 个字符。仅用于 ListAssets 模糊搜索,不参与模型推理。")
private String name;
@ApiModelProperty(allowableValues = "Image,Video,Audio", value = "Asset素材资产的类型。可选值Image、Video、Audio。", required = true)
private String assetType;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "资源所属的项目名称,默认 default。需与所属 Asset Group 的 ProjectName 一致。")
private String projectName;
}

View File

@ -0,0 +1,24 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DeleteAsset POST /open/DeleteAssetapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "删除单个 Asset素材资产")
public class DeleteAssetRequest {
@ApiModelProperty(value = "需要删除的 Asset素材资产的 id。", required = true)
private String id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "需要删除的 Asset素材资产所属的项目名称默认 default。")
private String projectName;
}

View File

@ -0,0 +1,24 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* GetAssetGroup POST /open/GetAssetGroupapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "获取单个 Asset Group素材资产组合信息")
public class GetAssetGroupRequest {
@ApiModelProperty(value = "Asset Group素材资产组合的 id。", required = true)
private String id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "需要查询的 Asset Group素材资产组合所属的项目名称默认值为 default。若资源不在默认项目中需填写正确的项目名称。")
private String projectName;
}

View File

@ -0,0 +1,24 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* GetAsset POST /open/GetAssetapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "获取单个 Asset素材资产信息")
public class GetAssetRequest {
@ApiModelProperty(value = "Asset素材资产的 id。", required = true)
private String id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "需要查询的 Asset 所属的项目名称,默认 default。")
private String projectName;
}

View File

@ -0,0 +1,37 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.common.core.request.asset.dto.ListAssetsGroupFilter;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* ListAssetGroups POST /open/ListAssetGroupsapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "查询符合筛选条件的 Asset Groups素材资产组合列表")
public class ListAssetsGroupRequest {
@ApiModelProperty(value = "搜索的过滤条件。", required = true)
private ListAssetsGroupFilter filter;
@ApiModelProperty(value = "搜索页码,可用于列表分页功能,从 1 开始。", required = true)
private int pageNumber;
@ApiModelProperty(value = "每页搜索结果的数量,上限为 100。", required = true)
private int pageSize;
@ApiModelProperty(allowableValues = "CreateTime,UpdateTime", value = "用于排序的字段名称,默认值 createTime。支持CreateTime创建时间、UpdateTime更新时间")
private String sortBy;
@ApiModelProperty(allowableValues = "Desc,Asc", value = "排序顺序,默认值 Desc。可选值Desc降序、Asc升序")
private String sortOrder;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "资源所属的项目名称,默认值为 default。若资源不在默认项目中需填写正确的项目名称。")
private String projectName;
}

View File

@ -0,0 +1,38 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.common.core.request.asset.dto.ListAssetsFilter;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* ListAssets POST /open/ListAssetsapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "查询符合筛选条件的 Assets素材资产列表")
public class ListAssetsRequest {
@ApiModelProperty(value = "搜索的过滤条件。", required = true)
private ListAssetsFilter filter;
@ApiModelProperty(value = "搜索页码,从 1 开始。", required = true)
private int pageNumber;
@ApiModelProperty(value = "每页搜索结果的数量,上限为 100。", required = true)
private int pageSize;
@ApiModelProperty(value = "排序字段,默认 createTime。支持CreateTime、UpdateTime、GroupId。")
private String sortBy;
@ApiModelProperty(value = "排序顺序,默认 Desc。可选Desc、Asc。")
private String sortOrder;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "资源所属的项目名称,默认 default。")
private String projectName;
}

View File

@ -0,0 +1,30 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* UpdateAssetGroup POST /open/UpdateAssetGroupapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "更新单个 Asset Group素材资产组合信息。当前仅支持更新 Name 和 Description。")
public class UpdateAssetGroupRequest {
@ApiModelProperty(value = "需要更新的 Asset Group素材资产组合的 id。", required = true)
private String id;
@ApiModelProperty(value = "需要更新的 Asset Group素材资产组合的新名称上限为 64 个字符。")
private String name;
@ApiModelProperty(value = "需要更新的 Asset Group素材资产组合的新描述上限为 300 字符。")
private String description;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "需要更新的 Asset Group素材资产组合所属的项目名称默认值为 default。若资源不在默认项目中需填写正确的项目名称。")
private String projectName;
}

View File

@ -0,0 +1,27 @@
package com.ruoyi.common.core.request.asset;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* UpdateAsset POST /open/UpdateAssetapi.docx
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "更新单个 Asset素材资产信息当前仅支持更新 Name。")
public class UpdateAssetRequest {
@ApiModelProperty(value = "需要更新的 Asset素材资产的 id。", required = true)
private String id;
@ApiModelProperty(value = "需要更新的 Asset素材资产的新名称上限为 64 个字符。")
private String name;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@ApiModelProperty(hidden = true, value = "需要更新的 Asset素材资产所属的项目名称默认 default。")
private String projectName;
}

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