新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。
This commit is contained in:
@@ -131,7 +131,7 @@ export const generateMemoryByAppId = (params) => {
|
||||
url: Api.generateMemoryByAppId+'?variables='+ params.variables + '&memoryId='+ params.memoryId,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: 5 * 60 * 1000,
|
||||
timeout: 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
isTransformResponse: false,
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
initChartData(params.appId);
|
||||
} else {
|
||||
initChartData();
|
||||
appData.value.metadata = { izDraw: '1', defaultSelect: '0' }
|
||||
quickCommandData.value = [
|
||||
{ name: '请介绍一下JeecgBoot', descr: "请介绍一下JeecgBoot" },
|
||||
{ name: 'JEECG有哪些优势?', descr: "JEECG有哪些优势?" },
|
||||
|
||||
@@ -360,8 +360,8 @@
|
||||
const isThinking = ref<boolean>(false);
|
||||
//是否开启网络搜索
|
||||
const enableSearch = ref<boolean>(false);
|
||||
//是否显示网络搜索按钮(只有千问模型支持)
|
||||
const showWebSearch = ref<boolean>(false);
|
||||
//是否显示网络搜索按钮(默认显示)
|
||||
const showWebSearch = ref<boolean>(true);
|
||||
//模型provider信息
|
||||
const modelProvider = ref<string>('');
|
||||
//是否显示深度思考( 只有deepsee-reason支持 )
|
||||
@@ -565,7 +565,7 @@
|
||||
|
||||
// 停止响应
|
||||
const handleStop = () => {
|
||||
console.log('ai 聊天:::---停止响应');
|
||||
console.log('ai 聊天:::---停止响应, 当前loading:', loading.value, ', 调用栈:', new Error().stack?.split('\n').slice(1,4).join(' <- '));
|
||||
if (loading.value) {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -666,7 +666,7 @@
|
||||
params: param,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: 5 * 60 * 1000,
|
||||
timeout: 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
isTransformResponse: false,
|
||||
@@ -1037,59 +1037,34 @@
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-03-12---for:【QQYUN-11555】聊天时要流式显示消息---
|
||||
let result = decoder.decode(value, { stream: true });
|
||||
result = buffer + result;
|
||||
const lines = result.split('\n\n');
|
||||
for (let line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
let content = line.replace('data:', '').trim();
|
||||
if(!content){
|
||||
continue;
|
||||
}
|
||||
if(!content.endsWith('}')){
|
||||
buffer = buffer + content;
|
||||
continue;
|
||||
}
|
||||
buffer = "";
|
||||
try {
|
||||
//update-begin---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
if(content.indexOf(":::card:::") !== -1){
|
||||
content = content.replace(/\s+/g, '');
|
||||
}
|
||||
let parse = JSON.parse(content);
|
||||
await renderText(parse,conversationId,text,options).then((res)=>{
|
||||
text = res.returnText;
|
||||
conversationId = res.conversationId;
|
||||
});
|
||||
//update-end---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
} catch (error) {
|
||||
console.log('Error parsing update:', error);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-12---for:【QQYUN-11555】聊天时要流式显示消息---
|
||||
}else{
|
||||
if(!line){
|
||||
continue;
|
||||
}
|
||||
if(!line.endsWith('}')){
|
||||
buffer = buffer + line;
|
||||
continue;
|
||||
}
|
||||
buffer = "";
|
||||
//update-begin---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
try {
|
||||
if(line.indexOf(":::card:::") !== -1){
|
||||
line = line.replace(/\s+/g, '');
|
||||
}
|
||||
let parse = JSON.parse(line);
|
||||
await renderText(parse, conversationId, text, options).then((res) => {
|
||||
text = res.returnText;
|
||||
conversationId = res.conversationId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Error parsing update:', error);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
buffer += result;
|
||||
// 按SSE协议用 \n\n 分割完整事件,最后一个元素可能不完整需保留在buffer中
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
for (let part of parts) {
|
||||
if (!part || !part.trim()) {
|
||||
continue;
|
||||
}
|
||||
let content = part.startsWith('data:') ? part.replace('data:', '').trim() : part.trim();
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
try {
|
||||
if(content.indexOf(":::card:::") !== -1){
|
||||
content = content.replace(/\s+/g, '');
|
||||
}
|
||||
let parse = JSON.parse(content);
|
||||
await renderText(parse, conversationId, text, options).then((res) => {
|
||||
text = res.returnText;
|
||||
conversationId = res.conversationId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败, content长度:', content.length, ', error:', error);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-13---for:【QQYUN-11572】发布到线上不能实时动态,内容不能加载出来,得刷新才能看到全部回答---
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-12---for:【QQYUN-11555】聊天时要流式显示消息---
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-11-05---for: 如果是断线重连并且文本为空,需要移出前面两条会话---
|
||||
if(!text && isReConnect && chatData.value.length >1){
|
||||
@@ -1118,7 +1093,7 @@
|
||||
const result = await defHttp.get({ url: '/airag/chat/receive/' + requestId ,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: 5 * 60 * 1000
|
||||
timeout: 60 * 60 * 1000
|
||||
}, { isTransformResponse: false }).catch(async (err)=>{
|
||||
loading.value = false;
|
||||
localStorage.removeItem('chat_requestId_' + uuid.value);
|
||||
@@ -1240,29 +1215,28 @@
|
||||
|
||||
//是否显示绘图工具
|
||||
showDraw.value = metadata.izDraw === '1';
|
||||
//是否选中生成图片
|
||||
enableDraw.value = metadata.izDraw === '1';
|
||||
//是否选中生成图片(defaultSelect 为 0 时默认不选中)
|
||||
const defaultSelect = metadata.defaultSelect || metadata.izDraw;
|
||||
enableDraw.value = defaultSelect === '1';
|
||||
|
||||
drawModelId.value = metadata.drawModelId;
|
||||
|
||||
if (metadata && metadata.modelInfo) {
|
||||
modelProvider.value = metadata.modelInfo.provider || '';
|
||||
modelName.value = metadata.modelInfo.modelName || '';
|
||||
// 只有千问模型支持网络搜索
|
||||
showWebSearch.value = modelProvider.value === 'QWEN';
|
||||
showThink.value = modelName.value === 'deepseek-reasoner';
|
||||
} else {
|
||||
showWebSearch.value = false;
|
||||
showThink.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析模型信息失败', e);
|
||||
showWebSearch.value = false;
|
||||
showThink.value = false;
|
||||
enableDraw.value = false;
|
||||
}
|
||||
} else {
|
||||
showWebSearch.value = false;
|
||||
showThink.value = false;
|
||||
showDraw.value = false;
|
||||
enableDraw.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="chat" :class="[inversion === 'user' ? 'self' : 'chatgpt']" v-if="getText || (props.presetQuestion && props.presetQuestion.length>0)">
|
||||
<div class="chat" :class="[inversion === 'user' ? 'self' : 'chatgpt']" v-if="getText || props.error || (props.presetQuestion && props.presetQuestion.length>0)">
|
||||
<div class="avatar" v-if="showAvatar !== 'no'">
|
||||
<img v-if="inversion === 'user'" :src="avatar()" />
|
||||
<img v-else :src="getAiImg()" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="parsedText != ''" class="textWrap" :class="[inversion === 'user' ? 'self' : (isOnlyImage ? 'chatgpt-image' : 'chatgpt')]" ref="textRef">
|
||||
<div v-if="parsedText != '' || error" class="textWrap" :class="[inversion === 'user' ? 'self' : (isOnlyImage ? 'chatgpt-image' : 'chatgpt')]" ref="textRef">
|
||||
<div v-if="inversion != 'user'" :style="{ width: getIsMobile? screenWidth : 'auto' }">
|
||||
<div ref="markdownBodyRef" class="markdown-body" :class="{ 'markdown-body-generate': loading }" v-html="parsedText" />
|
||||
<template v-if="showRefKnow">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
sqlPageExecute = '/airag/mcp/database/sqlPageExecute',
|
||||
sqlExportXls = '/airag/mcp/database/sqlExportXls',
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页执行 SQL 查询
|
||||
*/
|
||||
export function sqlPageExecute(params: { sql: string; dbSource?: string; pageNo: number; pageSize: number }) {
|
||||
return defHttp.post<Recordable>(
|
||||
{
|
||||
url: Api.sqlPageExecute,
|
||||
params: {
|
||||
sql: params.sql,
|
||||
dbSourceKey: params.dbSource || '',
|
||||
pageNo: params.pageNo,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出图表原始数据为 Excel
|
||||
*/
|
||||
export function sqlExportXls(params: { sql: string; dbSource?: string; columns?: Recordable }) {
|
||||
return defHttp.post(
|
||||
{
|
||||
url: Api.sqlExportXls,
|
||||
params: {
|
||||
sql: params.sql,
|
||||
dbSourceKey: params.dbSource || '',
|
||||
columns: params.columns || {},
|
||||
},
|
||||
responseType: 'blob',
|
||||
timeout: 5 * 60 * 1000,
|
||||
},
|
||||
{ isTransformResponse: false, isReturnNativeResponse: true }
|
||||
);
|
||||
}
|
||||
@@ -9,32 +9,42 @@
|
||||
<span v-else>模型返回的图表渲染格式不正确,请优化提示词或重新尝试。</span>
|
||||
</div>
|
||||
<div v-else class="ai-chat-chart__body">
|
||||
<!-- 折线图 -->
|
||||
<LineMulti v-if="resolvedType === 'line'" v-bind="lineProps"/>
|
||||
<!-- 柱状图 -->
|
||||
<BarMulti v-else-if="resolvedType === 'bar'" v-bind="barProps"/>
|
||||
<!-- 饼图 -->
|
||||
<Pie v-else-if="resolvedType === 'pie'" v-bind="pieProps"/>
|
||||
<!-- 多列柱状图 -->
|
||||
<BarMulti v-else-if="resolvedType === 'multibar'" v-bind="multiBarProps"/>
|
||||
<!-- 多行折线图 -->
|
||||
<LineMulti v-else-if="resolvedType === 'multiline'" v-bind="multiLineProps"/>
|
||||
<!-- 折柱图 -->
|
||||
<BarAndLine v-else-if="resolvedType === 'barline'" v-bind="barLineProps"/>
|
||||
<!-- 面积图 -->
|
||||
<SingleLine v-else-if="resolvedType === 'area'" v-bind="areaLineProps"/>
|
||||
<!-- 雷达图 -->
|
||||
<Radar v-else-if="resolvedType === 'radar'" v-bind="radarProps"/>
|
||||
<!-- 仪表盘 -->
|
||||
<Gauge v-else-if="resolvedType === 'gauge'" v-bind="gaugeProps"/>
|
||||
|
||||
<Tabs v-model:activeKey="activeTab" size="small" class="ai-chat-chart__tabs">
|
||||
<!-- 主图表 Tab -->
|
||||
<TabPane :key="resolvedType" :tab="getChartLabel(resolvedType)">
|
||||
<ChartBody :chartType="resolvedType" />
|
||||
</TabPane>
|
||||
<!-- 可替代图表 Tabs -->
|
||||
<TabPane v-for="alt in validAltTypes" :key="alt" :tab="getChartLabel(alt)">
|
||||
<ChartBody :chartType="alt" />
|
||||
</TabPane>
|
||||
<!-- 数据 Tab -->
|
||||
<TabPane v-if="hasSql" key="__data__" tab="数据">
|
||||
<div class="ai-chat-chart__data-toolbar">
|
||||
<a-button size="small" :loading="exportLoading" @click="handleExport">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
导出
|
||||
</a-button>
|
||||
</div>
|
||||
<BasicTable
|
||||
v-bind="tableBindings"
|
||||
:loading="tableLoading"
|
||||
size="small"
|
||||
class="ai-chat-chart__table"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartType } from './types';
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import type { BasicColumn } from '/@/components/Table';
|
||||
import { computed, defineComponent, h, ref, watch, watchEffect } from 'vue';
|
||||
import { Tabs, TabPane, message } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { BasicTable, useTable } from '/@/components/Table';
|
||||
import LineMulti from '/@/components/chart/LineMulti.vue';
|
||||
import BarMulti from '/@/components/chart/BarMulti.vue';
|
||||
import Pie from '/@/components/chart/Pie.vue';
|
||||
@@ -42,11 +52,12 @@ import Radar from '/@/components/chart/Radar.vue';
|
||||
import Gauge from '/@/components/chart/Gauge.vue';
|
||||
import BarAndLine from '/@/components/chart/BarAndLine.vue';
|
||||
import SingleLine from '/@/components/chart/SingleLine.vue';
|
||||
import { sqlPageExecute, sqlExportXls } from './ChartRender.api';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 图表配置字符串,示例:
|
||||
* {"type":"bar","data":[{"x":"数据项1","y":100},{"x":"数据项2","y":80}]}
|
||||
* {"type":"bar","altTypes":["line","pie"],"data":[...],"sql":"SELECT ...","dbSource":"","columns":{"field1":"标题1"}}
|
||||
*/
|
||||
data: {
|
||||
type: String,
|
||||
@@ -58,6 +69,39 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
/** 图表类型中文名映射 */
|
||||
const chartTypeLabels: Record<string, string> = {
|
||||
bar: '柱状图',
|
||||
line: '折线图',
|
||||
pie: '饼图',
|
||||
radar: '雷达图',
|
||||
gauge: '仪表盘',
|
||||
barline: '折柱图',
|
||||
multibar: '多列柱状图',
|
||||
multiline: '多行折线图',
|
||||
area: '面积图',
|
||||
};
|
||||
|
||||
/** 获取图表类型的中文标签 */
|
||||
function getChartLabel(type: string): string {
|
||||
return chartTypeLabels[type] || type;
|
||||
}
|
||||
|
||||
/** 当前激活的 Tab */
|
||||
const activeTab = ref<string>('');
|
||||
/** 表格加载状态 */
|
||||
const tableLoading = ref(false);
|
||||
/** 导出加载状态 */
|
||||
const exportLoading = ref(false);
|
||||
/** 表格数据 */
|
||||
const tableData = ref<Recordable[]>([]);
|
||||
/** 表格列定义 */
|
||||
const tableColumns = ref<BasicColumn[]>([]);
|
||||
/** 数据总条数 */
|
||||
const tableTotal = ref(0);
|
||||
/** 是否已首次加载过数据 */
|
||||
const dataLoaded = ref(false);
|
||||
|
||||
/**
|
||||
* 解析失败或类型错误的提示文本。
|
||||
*/
|
||||
@@ -85,7 +129,6 @@ const parsedConfig = computed<Recordable>(() => {
|
||||
/**
|
||||
* 支持的图表类型集合。
|
||||
*/
|
||||
// 支持的类型覆盖常见图表,名称与提示词保持宽松映射
|
||||
const supportedTypes: ChartType[] = ['bar', 'line', 'pie', 'radar', 'gauge', 'barline', 'multibar', 'multiline', 'area'];
|
||||
|
||||
/**
|
||||
@@ -115,6 +158,25 @@ const resolvedType = computed<ChartType>(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
/** 解析可替代图表类型列表(过滤掉无效类型和主类型重复) */
|
||||
const validAltTypes = computed<ChartType[]>(() => {
|
||||
const altTypes = (parsedConfig.value as any).altTypes;
|
||||
if (!Array.isArray(altTypes)) {
|
||||
return [];
|
||||
}
|
||||
const primary = resolvedType.value;
|
||||
return altTypes
|
||||
.map((t: string) => String(t).toLowerCase() as ChartType)
|
||||
.filter((t: ChartType) => t !== primary && supportedTypes.includes(t));
|
||||
});
|
||||
|
||||
/** 初始化 activeTab 为主图表类型 */
|
||||
watchEffect(() => {
|
||||
if (resolvedType.value && !activeTab.value) {
|
||||
activeTab.value = resolvedType.value;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 当类型不被支持时,给出错误提示。
|
||||
*/
|
||||
@@ -149,6 +211,171 @@ const hasData = computed<boolean>(() => {
|
||||
return Array.isArray(rawData.value) && rawData.value.length > 0;
|
||||
});
|
||||
|
||||
/** 是否包含可查询的 SQL */
|
||||
const hasSql = computed<boolean>(() => {
|
||||
const sql = (parsedConfig.value as any).sql;
|
||||
return typeof sql === 'string' && sql.trim().length > 0;
|
||||
});
|
||||
|
||||
/** 从配置中提取 SQL */
|
||||
const chartSql = computed<string>(() => {
|
||||
return ((parsedConfig.value as any).sql || '').trim();
|
||||
});
|
||||
|
||||
/** 从配置中提取数据源 */
|
||||
const chartDbSource = computed<string>(() => {
|
||||
return ((parsedConfig.value as any).dbSource || '').trim();
|
||||
});
|
||||
|
||||
/** 从配置中提取列标题映射 */
|
||||
const chartColumns = computed<Recordable>(() => {
|
||||
return (parsedConfig.value as any).columns || {};
|
||||
});
|
||||
|
||||
/**
|
||||
* 使用 BasicTable 组件
|
||||
*/
|
||||
const [registerTable, { setPagination }] = useTable({
|
||||
columns: tableColumns,
|
||||
dataSource: tableData,
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
current: 1,
|
||||
total: 0,
|
||||
},
|
||||
showIndexColumn: false,
|
||||
canResize: false,
|
||||
bordered: true,
|
||||
onChange: handleTableChange,
|
||||
});
|
||||
|
||||
/** 表格绑定属性 */
|
||||
const tableBindings = computed(() => ({
|
||||
onRegister: registerTable,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 加载表格数据
|
||||
*/
|
||||
async function loadTableData(pageNo = 1, pageSize = 10) {
|
||||
if (!chartSql.value) {
|
||||
return;
|
||||
}
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const res = await sqlPageExecute({
|
||||
sql: chartSql.value,
|
||||
dbSource: chartDbSource.value,
|
||||
pageNo,
|
||||
pageSize,
|
||||
});
|
||||
if (res.success && res.result) {
|
||||
const { records = [], total = 0 } = res.result;
|
||||
tableData.value = records;
|
||||
tableTotal.value = total;
|
||||
// 根据返回数据动态生成列(使用 columns 映射中文标题)
|
||||
if (records.length > 0) {
|
||||
tableColumns.value = buildColumns(records[0]);
|
||||
}
|
||||
setPagination({
|
||||
current: pageNo,
|
||||
pageSize,
|
||||
total,
|
||||
});
|
||||
} else {
|
||||
tableData.value = [];
|
||||
tableTotal.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载图表原始数据失败', e);
|
||||
tableData.value = [];
|
||||
tableTotal.value = 0;
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据数据行的 key 动态生成 BasicTable 列定义,优先使用 columns 映射的中文标题
|
||||
*/
|
||||
function buildColumns(row: Recordable): BasicColumn[] {
|
||||
const columnMap = chartColumns.value;
|
||||
return Object.keys(row).map((key) => ({
|
||||
title: columnMap[key] || key,
|
||||
dataIndex: key,
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格分页变更处理
|
||||
*/
|
||||
function handleTableChange(pagination: any) {
|
||||
loadTableData(pagination.current, pagination.pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出全部数据
|
||||
*/
|
||||
async function handleExport() {
|
||||
if (!chartSql.value) {
|
||||
return;
|
||||
}
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const response = await sqlExportXls({
|
||||
sql: chartSql.value,
|
||||
dbSource: chartDbSource.value,
|
||||
columns: chartColumns.value,
|
||||
});
|
||||
if (!response || !response.data) {
|
||||
message.warning('导出失败');
|
||||
return;
|
||||
}
|
||||
const data = response.data;
|
||||
// 检查是否为错误响应(JSON 格式)
|
||||
if (data.type && data.type.indexOf('json') !== -1) {
|
||||
const text = await data.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (!json.success) {
|
||||
message.warning('导出失败:' + (json.message || '未知错误'));
|
||||
return;
|
||||
}
|
||||
} catch (_e) {
|
||||
// 非 JSON,继续下载
|
||||
}
|
||||
}
|
||||
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/vnd.ms-excel' }));
|
||||
const link = document.createElement('a');
|
||||
link.style.display = 'none';
|
||||
link.href = url;
|
||||
link.setAttribute('download', '数据导出.xls');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('导出失败', e);
|
||||
message.error('导出失败');
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到数据 Tab 时首次加载
|
||||
*/
|
||||
watch(activeTab, (val) => {
|
||||
if (val === '__data__' && !dataLoaded.value && hasSql.value) {
|
||||
dataLoaded.value = true;
|
||||
loadTableData();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================== 图表数据转换 ========================
|
||||
|
||||
/**
|
||||
* 将原始数据标准化为多序列图表所需的结构。
|
||||
*/
|
||||
@@ -201,7 +428,7 @@ function buildMultiSeriesItem(item: Recordable): Recordable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析系列名称,优先使用 series,其次使用 type,最后回退为“数据”。
|
||||
* 解析系列名称,优先使用 series,其次使用 type,最后回退为"数据"。
|
||||
*/
|
||||
function resolveSeriesName(item: Recordable): string {
|
||||
if (item && item.series !== undefined) {
|
||||
@@ -295,114 +522,84 @@ const barLineSeriesData = computed<Recordable[]>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 折线图的渲染属性。
|
||||
*/
|
||||
const lineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
};
|
||||
});
|
||||
// ======================== 根据类型获取图表 props ========================
|
||||
|
||||
/**
|
||||
* 柱状图的渲染属性。
|
||||
* 根据图表类型返回对应的渲染属性
|
||||
*/
|
||||
const barProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
};
|
||||
});
|
||||
function getChartPropsForType(type: ChartType): Recordable {
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return { type: 'line', height: '360px', width: '100%', chartData: multiSeriesData.value };
|
||||
case 'bar':
|
||||
return { height: '360px', width: '100%', chartData: multiSeriesData.value };
|
||||
case 'pie':
|
||||
return { height: '360px', width: '100%', chartData: pieSeriesData.value };
|
||||
case 'multibar':
|
||||
return { height: '360px', width: '100%', chartData: multiSeriesData.value, option: parsedConfig.value.option || {} };
|
||||
case 'multiline':
|
||||
return { type: 'line', height: '360px', width: '100%', chartData: multiSeriesData.value, option: parsedConfig.value.option || {} };
|
||||
case 'barline':
|
||||
return { height: '360px', width: '100%', chartData: barLineSeriesData.value, customColor: (parsedConfig.value as any).colors || [], option: parsedConfig.value.option || {} };
|
||||
case 'area':
|
||||
return { type: 'line', height: '360px', width: '100%', chartData: areaSeriesData.value, option: { ...(parsedConfig.value.option || {}), areaStyle: {} } };
|
||||
case 'radar':
|
||||
return { height: '420px', width: '100%', chartData: radarSeriesData.value, option: parsedConfig.value.option || {} };
|
||||
case 'gauge':
|
||||
return { height: '360px', width: '100%', chartData: gaugeData.value, option: parsedConfig.value.option || {}, seriesColor: (parsedConfig.value as any).seriesColor || undefined };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 饼图的渲染属性。
|
||||
* 根据图表类型返回对应的组件
|
||||
*/
|
||||
const pieProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: pieSeriesData.value,
|
||||
};
|
||||
});
|
||||
function getChartComponentForType(type: ChartType) {
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return LineMulti;
|
||||
case 'bar':
|
||||
return BarMulti;
|
||||
case 'pie':
|
||||
return Pie;
|
||||
case 'multibar':
|
||||
return BarMulti;
|
||||
case 'multiline':
|
||||
return LineMulti;
|
||||
case 'barline':
|
||||
return BarAndLine;
|
||||
case 'area':
|
||||
return SingleLine;
|
||||
case 'radar':
|
||||
return Radar;
|
||||
case 'gauge':
|
||||
return Gauge;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多列柱状图配置,与 bar 相同但允许区分类型前缀。
|
||||
* 内部图表渲染组件,根据 chartType 动态渲染对应图表
|
||||
*/
|
||||
const multiBarProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 多行折线图配置。
|
||||
*/
|
||||
const multiLineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 面积折线图配置,开启面积样式。
|
||||
*/
|
||||
const areaLineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: areaSeriesData.value,
|
||||
option: { ...(parsedConfig.value.option || {}), areaStyle: {} },
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 雷达图配置。
|
||||
*/
|
||||
const radarProps = computed(() => {
|
||||
return {
|
||||
height: '420px',
|
||||
width: '100%',
|
||||
chartData: radarSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 仪表盘配置。
|
||||
*/
|
||||
const gaugeProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: gaugeData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
seriesColor: (parsedConfig.value as any).seriesColor || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 折柱图配置,支持自定义颜色。
|
||||
*/
|
||||
const barLineProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: barLineSeriesData.value,
|
||||
customColor: (parsedConfig.value as any).colors || [],
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
const ChartBody = defineComponent({
|
||||
props: {
|
||||
chartType: {
|
||||
type: String as () => ChartType,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(bodyProps) {
|
||||
return () => {
|
||||
const comp = getChartComponentForType(bodyProps.chartType as ChartType);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
const chartProps = getChartPropsForType(bodyProps.chartType as ChartType);
|
||||
return h(comp, chartProps);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -410,7 +607,7 @@ const barLineProps = computed(() => {
|
||||
|
||||
.ai-chat-chart {
|
||||
width: 100%;
|
||||
min-width: 360px;
|
||||
min-width: 420px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
@@ -424,4 +621,22 @@ const barLineProps = computed(() => {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-chart__tabs {
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-chart__data-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-chart__table {
|
||||
:deep(.ant-table-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -232,6 +232,7 @@ function formatData(value: unknown): string {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.tool-exec-loading,
|
||||
@@ -371,6 +372,7 @@ function formatData(value: unknown): string {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,13 @@
|
||||
prologue: '你好,我是 AI绘图智能体。',
|
||||
presetQuestion: '[{"key":1,"descr":"请生成一张具有日本风格的动漫成年女孩。","update":true}, {"key":2,"descr":"请生成一幅中国神话故事中,手持武器的哪吒形象。","update":true}]',
|
||||
metadata:"{\"izDraw\":\"1\"}"
|
||||
},
|
||||
{
|
||||
id: '1993651187913981953',
|
||||
name: '商品导购',
|
||||
icon: 'https://minio.jeecg.com/otatest/shoppingGuide_1769754188966.png',
|
||||
prologue: '向要了解哪一款产品,我可以给你介绍。',
|
||||
presetQuestion: '[{"key":1,"sort":1,"descr":"HIP 0603T Series","update":false},{"key":2,"sort":2,"descr":"CHIP 1206HC Series","update":false},{"key":3,"sort":3,"descr":"BRICK 1032ST Series","update":true}]',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
resetField();
|
||||
await getPromptList();
|
||||
setModalProps({
|
||||
title: '选择提示词',
|
||||
height: 600,
|
||||
bodyStyle: { padding: '24px' },
|
||||
});
|
||||
@@ -200,6 +201,8 @@
|
||||
*/
|
||||
async function handleOk() {
|
||||
if (selectedPrompt.value) {
|
||||
// select 传递完整对象,供调用方按需取用 id/name/content
|
||||
emit('select', selectedPrompt.value);
|
||||
emit('ok', selectedPrompt.value.content);
|
||||
} else {
|
||||
emit('ok');
|
||||
|
||||
@@ -428,6 +428,20 @@
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-row>
|
||||
<a-row>
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol">
|
||||
<div style="display: flex;margin-top: 10px">
|
||||
<div style="margin-left: 2px">显示工具调用过程:</div>
|
||||
<a-switch
|
||||
v-model:checked="showToolProcessChecked"
|
||||
:disabled="isRelease"
|
||||
checked-children="开"
|
||||
un-checked-children="关"
|
||||
@change="handleShowToolProcessChange"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-row>
|
||||
<a-row v-if="izDrawChecked" class="mt-10">
|
||||
<a-col :span="24">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" v-bind="validateInfos.drawModelId">
|
||||
@@ -622,6 +636,8 @@
|
||||
const multiSessionChecked = ref<boolean>(true);
|
||||
//开启会话能力
|
||||
const izDrawChecked = ref<boolean>(false);
|
||||
//显示工具调用过程
|
||||
const showToolProcessChecked = ref<boolean>(true);
|
||||
// 是否已发布
|
||||
const isRelease = ref<boolean>(false);
|
||||
//对话设置弹窗ref
|
||||
@@ -1208,6 +1224,11 @@
|
||||
}else{
|
||||
izDrawChecked.value = false;
|
||||
}
|
||||
if(metadata.value?.showToolProcess != null){
|
||||
showToolProcessChecked.value = metadata.value.showToolProcess === '1';
|
||||
}else{
|
||||
showToolProcessChecked.value = true;
|
||||
}
|
||||
if(metadata.value?.drawModelId){
|
||||
formState.drawModelId = metadata.value.drawModelId;
|
||||
}
|
||||
@@ -1611,6 +1632,20 @@
|
||||
}
|
||||
//================================================ end 开启绘画 ========================================================
|
||||
|
||||
//================================================ begin 显示工具调用过程 =========================================================
|
||||
/**
|
||||
* 显示工具调用过程开关回调
|
||||
*/
|
||||
function handleShowToolProcessChange(checked){
|
||||
if(checked){
|
||||
metadata.value.showToolProcess = "1";
|
||||
}else{
|
||||
metadata.value.showToolProcess = "0";
|
||||
}
|
||||
formState.metadata = JSON.stringify(metadata.value);
|
||||
}
|
||||
//================================================ end 显示工具调用过程 ========================================================
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
@@ -1705,6 +1740,8 @@
|
||||
izDrawChecked,
|
||||
handleDrawChange,
|
||||
handleDrawModelChange,
|
||||
showToolProcessChecked,
|
||||
handleShowToolProcessChange,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
import { Icon } from '/src/components/Icon';
|
||||
import { Button, Checkbox, Switch, Popconfirm } from 'ant-design-vue';
|
||||
import { JVxeTypes, JVxeColumn, JVxeTableInstance } from '/src/components/jeecg/JVxeTable/types';
|
||||
import { JVxeTable } from '/src/components/jeecg/JVxeTable';
|
||||
import { useMessage } from '/src/hooks/web/useMessage';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -47,7 +46,6 @@
|
||||
components: {
|
||||
BasicModal,
|
||||
Icon,
|
||||
JVxeTable,
|
||||
AButton: Button,
|
||||
ACheckbox: Checkbox,
|
||||
ASwitch: Switch,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
/**
|
||||
* AI换衣 - 生成图片表单
|
||||
*/
|
||||
export const clothImageFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: ['1、需要选择已激活的图像模型', '2、当前推荐通义万象模型 (wan2.5-i2i-preview)', '3、建议上传清晰的模特图和服装图以获得最佳效果'],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'IMAGE' and activate_flag = 1,name,id",
|
||||
placeholder: '请选择图像模型',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'modelImage',
|
||||
label: '模特图片',
|
||||
component: 'JImageUpload',
|
||||
required: true,
|
||||
componentProps: {
|
||||
fileMax: 1,
|
||||
text: '上传模特',
|
||||
},
|
||||
helpMessage: ['上传模特图片,建议使用全身照,正面清晰'],
|
||||
},
|
||||
|
||||
{
|
||||
field: 'clothUpload',
|
||||
label: '服装',
|
||||
slot: 'clothUpload',
|
||||
component: 'Input',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
field: 'userPrompt',
|
||||
label: '提示词',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '在此输入你的提示词,或使用示例快速填充',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* AI换衣 - 生成视频表单
|
||||
*/
|
||||
export const clothVideoFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: ['1、需要选择已激活的视频模型', '2、建议选择支持图生视频的模型'],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'VIDEO' and activate_flag = 1,name,id",
|
||||
placeholder: '请选择视频模型',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'modelImage',
|
||||
label: '模特图片',
|
||||
component: 'JImageUpload',
|
||||
componentProps: {
|
||||
fileMax: 1,
|
||||
text: '上传模特',
|
||||
},
|
||||
helpMessage: ['上传模特图片,建议使用全身照,正面清晰'],
|
||||
},
|
||||
{
|
||||
field: 'clothUpload',
|
||||
label: '',
|
||||
slot: 'clothUpload',
|
||||
component: 'Input',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
field: 'userPrompt',
|
||||
label: '自定义提示词',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '在此输入你的提示词,或使用下方示例快速填充',
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
372
jeecgboot-vue3/src/views/super/airag/aicloth/AiClothChange.less
Normal file
372
jeecgboot-vue3/src/views/super/airag/aicloth/AiClothChange.less
Normal file
@@ -0,0 +1,372 @@
|
||||
.ai-cloth-change-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px 16px 16px;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
// 示例按钮行(单行显示,pill 样式)
|
||||
.examples-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
padding: 8px 0 12px 0;
|
||||
overflow-x: auto; /* 小屏幕时可以横向滚动 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.example-btn {
|
||||
border-radius: 20px !important;
|
||||
border: 1px solid #2b8fff !important;
|
||||
color: #2b8fff !important;
|
||||
background: #fff !important;
|
||||
padding: 6px 14px !important;
|
||||
height: 36px !important;
|
||||
line-height: 22px !important;
|
||||
box-shadow: none !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.example-btn:hover {
|
||||
background: rgba(43, 143, 255, 0.06) !important;
|
||||
border-color: #1a6fe6 !important;
|
||||
color: #1a6fe6 !important;
|
||||
}
|
||||
|
||||
//顶部标题区
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
padding: 16px 16px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.cloth-change-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
//左侧配置面板
|
||||
.config-panel {
|
||||
width: 420px;
|
||||
min-width: 360px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.config-tabs {
|
||||
padding: 16px 20px 0;
|
||||
|
||||
:deep(.ant-tabs-nav::before) {
|
||||
border-bottom: none;
|
||||
}
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 8px 0;
|
||||
margin: 0 24px 0 0;
|
||||
font-size: 15px;
|
||||
&.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #00b96b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
background: #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.form-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 8px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.gen-btn {
|
||||
height: 40px;
|
||||
padding: 0 40px;
|
||||
font-size: 15px;
|
||||
background: #00b96b;
|
||||
border-color: #00b96b;
|
||||
border-radius: 20px;
|
||||
&:hover {
|
||||
background: #00d97e;
|
||||
border-color: #00d97e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//单件服装上传
|
||||
.cloth-upload-area {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border: 1.5px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #fafafa;
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.uploaded-img-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.uploaded-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .img-mask {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//多件服装上传
|
||||
.multi-cloth-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.cloth-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.cloth-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
flex: 1;
|
||||
border: 1.5px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #fafafa;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.uploaded-img-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.uploaded-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .img-mask {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//视频提示
|
||||
.ai-notice {
|
||||
margin-top: 10px;
|
||||
|
||||
:deep(.ant-alert) {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.ant-alert-message) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
//区块
|
||||
.section-block {
|
||||
margin-top: 16px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 结果展示
|
||||
.result-image-wrapper,
|
||||
.result-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.result-image,
|
||||
.result-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hover-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
&:hover .hover-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
//右侧预览面板
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
284
jeecgboot-vue3/src/views/super/airag/aicloth/AiClothChange.vue
Normal file
284
jeecgboot-vue3/src/views/super/airag/aicloth/AiClothChange.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="ai-cloth-change-page">
|
||||
<!-- 顶部标题区 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">AI 换衣</h1>
|
||||
<div class="header-desc">将模特图片和服装图片上传,AI自动生成换衣效果</div>
|
||||
</div>
|
||||
|
||||
<div class="cloth-change-wrapper">
|
||||
<!-- 左侧配置面板 -->
|
||||
<div class="config-panel">
|
||||
<!-- 顶部 Tab:生成图片 / 生成视频 -->
|
||||
<div class="config-tabs">
|
||||
<a-tabs v-model:activeKey="genType" :tabBarStyle="{ margin: 0 }">
|
||||
<a-tab-pane key="image" tab="生成图片" />
|
||||
<a-tab-pane key="video" tab="生成视频" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<div class="form-scroll">
|
||||
<div class="examples-row" role="toolbar" aria-label="示例">
|
||||
<a-button class="example-btn" type="default" @click.prevent="useExample1">示例:换整体服装</a-button>
|
||||
<a-button class="example-btn" type="default" @click.prevent="useExample2">示例:上衣/裤子</a-button>
|
||||
</div>
|
||||
<!-- 模型选择 -->
|
||||
<BasicForm @register="registerForm">
|
||||
<!-- 上传区域(单一上传组件支持多张图片) -->
|
||||
<template #clothUpload>
|
||||
<div class="section-block">
|
||||
<JImageUpload v-model:value="clothUploads" :fileMax="2" text="上传服装" />
|
||||
</div>
|
||||
</template>
|
||||
</BasicForm>
|
||||
|
||||
<!-- 视频提示 -->
|
||||
<div v-if="genType === 'video'" class="ai-notice">
|
||||
<a-alert message="视频生成可能需要较长时间,请耐心等待~" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部生成按钮 -->
|
||||
<div class="action-bar">
|
||||
<a-button type="primary" size="large" block class="gen-btn" :loading="loading" @click="handleGenerate"> 生成 </a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧结果展示 -->
|
||||
<div class="preview-panel">
|
||||
<div class="preview-content">
|
||||
<div v-if="!generatedResult && !loading" class="empty-state">
|
||||
<Icon icon="ant-design:picture-outlined" size="64" color="#ccc" />
|
||||
<p>在左侧配置后点击「生成」</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<a-spin size="large" :tip="`正在${genType === 'image' ? '生成图片' : '生成视频'},请稍候...`" />
|
||||
</div>
|
||||
|
||||
<!-- 图片结果 -->
|
||||
<div v-if="genType === 'image' && generatedResult && !loading" class="result-image-wrapper group">
|
||||
<img :src="generatedResult" class="result-image" alt="换衣结果" />
|
||||
<div class="hover-actions">
|
||||
<a-button type="primary" ghost @click="previewVisible = true"> <Icon icon="ant-design:eye-outlined" /> 预览 </a-button>
|
||||
<a-button type="primary" ghost @click="handleDownload"> <Icon icon="ant-design:download-outlined" /> 下载 </a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频结果 -->
|
||||
<div v-if="genType === 'video' && generatedResult && !loading" class="result-video-wrapper group">
|
||||
<video ref="videoRef" :src="generatedResult" controls class="result-video" />
|
||||
<div class="hover-actions">
|
||||
<a-button type="primary" ghost @click="handleDownload"> <Icon icon="ant-design:download-outlined" /> 下载 </a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageViewer v-if="previewVisible" :imageUrl="generatedResult" @hide="previewVisible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm, JImageUpload } from '@/components/Form';
|
||||
import { clothImageFormSchema, clothVideoFormSchema } from './AiClothChange.data';
|
||||
import ImageViewer from '../aiapp/chat/components/ImageViewer.vue';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
|
||||
const TASK_ID_KEY = 'ai_cloth_task_id';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const { domainUrl } = useGlobSetting();
|
||||
|
||||
// 状态
|
||||
// 生成类型
|
||||
const genType = ref<'image' | 'video'>('image');
|
||||
|
||||
// 上传的服装图片(JImageUpload 返回逗号拼接的路径字符串)
|
||||
const clothUploads = ref<string>('');
|
||||
const loading = ref(false);
|
||||
const generatedResult = ref('');
|
||||
const previewVisible = ref(false);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 表单
|
||||
const [registerForm, { validate, resetSchema, getFieldsValue, setFieldsValue }] = useForm({
|
||||
schemas: clothImageFormSchema,
|
||||
showActionButtonGroup: false,
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
// 不再区分单件/多件,提示词可由用户自定义
|
||||
|
||||
watch(genType, (val) => {
|
||||
generatedResult.value = '';
|
||||
resetSchema(val === 'image' ? clothImageFormSchema : clothVideoFormSchema);
|
||||
});
|
||||
|
||||
/**
|
||||
* 构建提示词
|
||||
* @param values
|
||||
*/
|
||||
function buildPrompt(values: any): string {
|
||||
// 若用户自定义了提示词则以用户输入为主(仍会在开头列出图的顺序说明)
|
||||
const userPrompt: string = (values.userPrompt || '').toString().trim();
|
||||
// 如果用户输入了提示词,则把用户提示放在后面,不生成自动 prompt 内容
|
||||
if (userPrompt) {
|
||||
return `用户提示:\n${userPrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
/** 轮询查询任务结果 */
|
||||
function startPolling(taskId: string) {
|
||||
const poll = () => {
|
||||
defHttp
|
||||
.get({ url: `/airag/chat/getAiPosterResult/${taskId}` }, { isTransformResponse: false })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
if (res.result === 'pending' || res.result === null) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
} else {
|
||||
const reg = /#\s*{\s*domainURL\s*}/g;
|
||||
generatedResult.value = (res.result as string).replace(reg, domainUrl + '/sys/common/static');
|
||||
loading.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.success(genType.value === 'image' ? '图片生成成功!' : '视频生成成功!');
|
||||
}
|
||||
} else {
|
||||
loading.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.warning(res.message || '生成失败!');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
}
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
/**
|
||||
* 生成
|
||||
*/
|
||||
async function handleGenerate() {
|
||||
// 校验服装图(从 clothUploads 解析)
|
||||
const validCloth = (clothUploads.value || '').split(',').filter(Boolean);
|
||||
if (validCloth.length === 0) {
|
||||
createMessage.warning('请上传服装图片');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await validate();
|
||||
loading.value = true;
|
||||
generatedResult.value = '';
|
||||
|
||||
// 组装图片 URL(模特 + 服装),按顺序:模特图为图一,后面依次为服装图片(图二/图三)
|
||||
const imgUrls: string[] = [];
|
||||
if (values.modelImage) {
|
||||
const modelFirst = (values.modelImage || '').toString().split(',')[0];
|
||||
if (modelFirst) imgUrls.push(modelFirst);
|
||||
}
|
||||
imgUrls.push(...validCloth);
|
||||
|
||||
if (genType.value === 'image') {
|
||||
values.imageSize = '720*1280';
|
||||
} else {
|
||||
createMessage.info('敬请期待');
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const prompt = buildPrompt(values);
|
||||
|
||||
const params: Record<string, any> = {
|
||||
drawModelId: values.drawModelId,
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
content: prompt,
|
||||
imageUrl: imgUrls.join(','),
|
||||
//update-end---author:wangshuai ---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
type: genType.value === 'image' ? 'cloth_image' : 'cloth_video',
|
||||
imageSize: values.imageSize,
|
||||
};
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
const res = await defHttp.post(
|
||||
{ url: '/airag/chat/genAiPosterAsync', params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
if (res.success && res.result) {
|
||||
const taskId = res.result as string;
|
||||
localStorage.setItem(TASK_ID_KEY, taskId);
|
||||
startPolling(taskId);
|
||||
} else {
|
||||
loading.value = false;
|
||||
createMessage.warning('提交任务失败!');
|
||||
}
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
} catch {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onMounted(() => {
|
||||
const savedTaskId = localStorage.getItem(TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
loading.value = true;
|
||||
generatedResult.value = '';
|
||||
startPolling(savedTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
/**
|
||||
* 下载
|
||||
*/
|
||||
function handleDownload() {
|
||||
if (!generatedResult.value) {
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = generatedResult.value;
|
||||
a.download = `ai-cloth-change-${Date.now()}.${genType.value === 'image' ? 'jpg' : 'mp4'}`;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// 示例提示词操作
|
||||
function useExample1() {
|
||||
const example =
|
||||
'图像映射: 图一=模特; 图二=服装素材。任务: 将图二整体替换到图一身上,保持模特面部与姿态不变,服装贴合自然,光影一致;服装贴合自然,光影一致,风格写实高清。请输出高质量合成图';
|
||||
const modelImage = 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/model_1772695749704.jpg';
|
||||
clothUploads.value = 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/dress_1772700962866.jpg';
|
||||
setFieldsValue({ userPrompt: example, modelImage: modelImage });
|
||||
}
|
||||
|
||||
function useExample2() {
|
||||
const example =
|
||||
'图像映射: 图一=模特; 图二=上衣素材; 图三=下装素材(可选)。任务: 仅将图二的上衣替换到图一上半身(胸部/肩部/袖口),严格不修改面部或下半身; 对齐按肩线/胸围并融合光照; 风格写实高清。请输出高质量合成图';
|
||||
const modelImage = 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/model_1772695749704.jpg';
|
||||
clothUploads.value =
|
||||
'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/jacket_1772701290346.jpg,https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/pants_1772701320192.jpg';
|
||||
setFieldsValue({ userPrompt: example, modelImage: modelImage });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import 'AiClothChange.less';
|
||||
</style>
|
||||
@@ -70,7 +70,98 @@ export const formSchema: FormSchema[] = [
|
||||
type: 'radioButton',
|
||||
},
|
||||
defaultValue: 'knowledge',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '分段策略',
|
||||
field: 'enableSegment',
|
||||
component: 'Switch',
|
||||
defaultValue: false,
|
||||
ifShow: ({ values }) => values.type !== 'memory',
|
||||
componentProps: {
|
||||
checkedChildren: '开启',
|
||||
unCheckedChildren: '关闭',
|
||||
},
|
||||
helpMessage: '开启后,知识库里面的文档默认使用该分段策略;文档也可单独配置自己的分段策略',
|
||||
},
|
||||
{
|
||||
label: '分段模式',
|
||||
field: 'segmentStrategy',
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 'auto',
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '自动分段与清洗', value: 'auto' },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '分段标识符',
|
||||
field: 'separator',
|
||||
component: 'Select',
|
||||
defaultValue: '\\n',
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true && values.segmentStrategy === 'custom',
|
||||
componentProps: {
|
||||
getPopupContainer: () => document.body,
|
||||
options: [
|
||||
{ label: '换行', value: '\\n' },
|
||||
{ label: '2个换行', value: '\\n\\n' },
|
||||
{ label: '中文句号', value: '。' },
|
||||
{ label: '中文叹号', value: '!' },
|
||||
{ label: '中文问号', value: '?' },
|
||||
{ label: '英文句号', value: '.' },
|
||||
{ label: '英文叹号', value: '!' },
|
||||
{ label: '英文问号', value: '?' },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '自定义分隔符',
|
||||
field: 'customSeparator',
|
||||
component: 'Input',
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true && values.separator === 'custom' && values.segmentStrategy !== 'auto',
|
||||
},
|
||||
{
|
||||
label: '分段最大长度',
|
||||
field: 'maxSegment',
|
||||
component: 'InputNumber',
|
||||
defaultValue: 800,
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true,
|
||||
componentProps: {
|
||||
min: 100,
|
||||
max: 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '分段重叠度%',
|
||||
field: 'overlap',
|
||||
component: 'InputNumber',
|
||||
defaultValue: 10,
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true,
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 90,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '文本预处理规则',
|
||||
field: 'textRules',
|
||||
component: 'CheckboxGroup',
|
||||
defaultValue: [],
|
||||
ifShow: ({ values }) => values.type !== 'memory' && values.enableSegment === true && values.segmentStrategy === 'custom',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '替换掉连续的空格、换行符和制表符', value: 'cleanSpaces' },
|
||||
{ label: '删除所有 URL 和电子邮箱地址', value: 'removeUrlsEmails' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
//文档文本表单
|
||||
@@ -135,5 +226,105 @@ export const docTextSchema: FormSchema[] = [
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '网页地址',
|
||||
field: 'website',
|
||||
rules: [
|
||||
{ required: true, message: '请输入网页URL' },
|
||||
{ pattern: /^https?:\/\//, message: '请输入正确的网页地址,以http://或https://开头' },
|
||||
],
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入网页URL,例如:https://help.jeecg.com/',
|
||||
},
|
||||
ifShow:({ values })=>{
|
||||
if(values.type === 'web'){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 分段策略表单
|
||||
*/
|
||||
export const docSegmentSchema: FormSchema[] = [
|
||||
{
|
||||
label: '分段策略',
|
||||
field: 'segmentStrategy',
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 'auto',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '自动分段与清洗', value: 'auto' },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '分段标识符',
|
||||
field: 'separator',
|
||||
component: 'Select',
|
||||
defaultValue: '\\n',
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.segmentStrategy === 'custom',
|
||||
componentProps: {
|
||||
getPopupContainer: () => document.body,
|
||||
options: [
|
||||
{ label: '换行', value: '\\n' },
|
||||
{ label: '2个换行', value: '\\n\\n' },
|
||||
{ label: '中文句号', value: '。' },
|
||||
{ label: '中文叹号', value: '!' },
|
||||
{ label: '中文问号', value: '?' },
|
||||
{ label: '英文句号', value: '.' },
|
||||
{ label: '英文叹号', value: '!' },
|
||||
{ label: '英文问号', value: '?' },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
field: 'customSeparator',
|
||||
component: 'Input',
|
||||
required: true,
|
||||
ifShow: ({ values }) => values.separator === 'custom' && values.segmentStrategy !== 'auto',
|
||||
},
|
||||
{
|
||||
label: '分段最大长度',
|
||||
field: 'maxSegment',
|
||||
component: 'InputNumber',
|
||||
defaultValue: 800,
|
||||
required: true,
|
||||
componentProps: {
|
||||
min: 100,
|
||||
max: 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '分段重叠度%',
|
||||
field: 'overlap',
|
||||
component: 'InputNumber',
|
||||
defaultValue: 10,
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 90,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: '文本预处理规则',
|
||||
field: 'textRules',
|
||||
component: 'CheckboxGroup',
|
||||
defaultValue: [],
|
||||
ifShow: ({ values }) => values.segmentStrategy === 'custom',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '替换掉连续的空格、换行符和制表符', value: 'cleanSpaces' },
|
||||
{ label: '删除所有 URL 和电子邮箱地址', value: 'removeUrlsEmails' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col v-if="knowledgeList && knowledgeList.length>0" :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24" v-for="item in knowledgeList">
|
||||
<a-card class="knowledge-card pointer" @click="handleDocClick(item.id, item.type)">
|
||||
<a-card class="knowledge-card pointer" @click="handleDocClick(item)">
|
||||
<div class="knowledge-header">
|
||||
<div class="flex">
|
||||
<img class="header-img" src="./icon/knowledge.png" />
|
||||
@@ -273,8 +273,10 @@
|
||||
* @param id
|
||||
* @param type
|
||||
*/
|
||||
function handleDocClick(id, type) {
|
||||
openDocModal(true, { id, type });
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
function handleDocClick(item) {
|
||||
openDocModal(true, { id: item.id, type: item.type, knowledgeMetadata: item.metadata });
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
@@ -57,10 +58,27 @@
|
||||
title.value = isUpdate.value ? '编辑知识库' : '创建知识库';
|
||||
if (unref(isUpdate)) {
|
||||
let values = await queryById({ id: data.id });
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
let record = { ...values.result };
|
||||
// 解析 metadata 中的分段策略
|
||||
if (record.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(record.metadata);
|
||||
const hasSegment = !!(meta.enableSegment || meta.segmentStrategy || meta.maxSegment);
|
||||
record.enableSegment = hasSegment;
|
||||
if (hasSegment) {
|
||||
record.segmentStrategy = meta.segmentStrategy || 'auto';
|
||||
record.maxSegment = meta.maxSegment;
|
||||
record.overlap = meta.overlap;
|
||||
record.separator = meta.separator;
|
||||
record.customSeparator = meta.customSeparator;
|
||||
record.textRules = meta.textRules;
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...values.result,
|
||||
});
|
||||
await setFieldsValue(record);
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
@@ -72,10 +90,32 @@
|
||||
try {
|
||||
setModalProps({ confirmLoading: true });
|
||||
let values = await validate();
|
||||
if (!unref(isUpdate)) {
|
||||
await saveKnowledge(values);
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
// 将分段策略字段打包到 metadata
|
||||
const { enableSegment, segmentStrategy, separator, customSeparator, maxSegment, overlap, textRules, ...rest } = values;
|
||||
let params: any = { ...rest };
|
||||
if (enableSegment) {
|
||||
const meta: any = {
|
||||
enableSegment: true,
|
||||
segmentStrategy: segmentStrategy || 'auto',
|
||||
maxSegment,
|
||||
overlap,
|
||||
};
|
||||
if (segmentStrategy === 'custom') {
|
||||
meta.separator = separator;
|
||||
meta.customSeparator = customSeparator;
|
||||
meta.textRules = textRules;
|
||||
}
|
||||
params.metadata = JSON.stringify(meta);
|
||||
} else {
|
||||
await editKnowledge(values);
|
||||
params.metadata = null;
|
||||
}
|
||||
|
||||
if (!unref(isUpdate)) {
|
||||
await saveKnowledge(params);
|
||||
} else {
|
||||
await editKnowledge(params);
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
<div class="add-knowledge-doc" @click="handleCreateUpload">
|
||||
<Icon icon="ant-design:cloud-upload-outlined" size="13"></Icon><span>文件上传</span>
|
||||
</div>
|
||||
<div class="add-knowledge-doc" @click="handleCreateWeb">
|
||||
<Icon icon="ant-design:global-outlined" size="13"></Icon><span>网页录入</span>
|
||||
</div>
|
||||
<div class="add-knowledge-doc">
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
@@ -98,6 +101,7 @@
|
||||
<div class="knowledge-header">
|
||||
<div class="header-text flex">
|
||||
<Icon v-if="item.type==='text'" icon="ant-design:file-text-outlined" size="32" color="#00a7d0"></Icon>
|
||||
<Icon v-if="item.type==='web'" icon="ant-design:global-outlined" size="32" color="#1890ff"></Icon>
|
||||
<Icon v-if="item.type==='file' && getFileSuffix(item.metadata) === 'pdf'" icon="ant-design:file-pdf-outlined" size="32" color="rgb(211, 47, 47)"></Icon>
|
||||
<Icon v-if="item.type==='file' && getFileSuffix(item.metadata) === 'docx'" icon="ant-design:file-word-outlined" size="32" color="rgb(68, 138, 255)"></Icon>
|
||||
<Icon v-if="item.type==='file' && getFileSuffix(item.metadata) === 'pptx'" icon="ant-design:file-ppt-outlined" size="32" color="rgb(245, 124, 0)"></Icon>
|
||||
@@ -360,6 +364,8 @@
|
||||
const [docTextRegister, { openModal: docTextOpenModal }] = useModal();
|
||||
const [docTextDescRegister, { openModal: docTextDescOpenModal }] = useModal();
|
||||
const type = ref<string>('');
|
||||
// 知识库的分段策略 metadata
|
||||
const knowledgeMetadata = ref<string>('');
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
knowledgeId.value = data.id;
|
||||
@@ -368,6 +374,7 @@
|
||||
spinning.value = false;
|
||||
notHit.value = false;
|
||||
type.value = data.type;
|
||||
knowledgeMetadata.value = data.knowledgeMetadata || '';
|
||||
await reload();
|
||||
setModalProps({ confirmLoading: false });
|
||||
});
|
||||
@@ -403,22 +410,27 @@
|
||||
* 手工录入文本
|
||||
*/
|
||||
function handleCreateText() {
|
||||
docTextOpenModal(true, { knowledgeId: knowledgeId.value, type: "text" });
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
docTextOpenModal(true, { knowledgeId: knowledgeId.value, type: "text", knowledgeMetadata: knowledgeMetadata.value, knowledgeType: type.value });
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
*/
|
||||
function handleCreateUpload() {
|
||||
console.log("11111111111")
|
||||
docTextOpenModal(true, { knowledgeId: knowledgeId.value, type: "file" });
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
docTextOpenModal(true, { knowledgeId: knowledgeId.value, type: "file", knowledgeMetadata: knowledgeMetadata.value, knowledgeType: type.value });
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
|
||||
/**
|
||||
* web网络地址
|
||||
*/
|
||||
function handleCreateWeb() {
|
||||
createMessage.warning('功能正在完善中....');
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
docTextOpenModal(true, { knowledgeId: knowledgeId.value, type: "web", knowledgeMetadata: knowledgeMetadata.value, knowledgeType: type.value });
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,10 +444,12 @@
|
||||
}
|
||||
|
||||
|
||||
if (record.type === 'text' || record.type === 'file') {
|
||||
if (record.type === 'text' || record.type === 'file' || record.type === 'web') {
|
||||
docTextOpenModal(true, {
|
||||
record,
|
||||
isUpdate: true,
|
||||
knowledgeMetadata: knowledgeMetadata.value,
|
||||
knowledgeType: type.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -719,6 +733,7 @@
|
||||
handleCreateText,
|
||||
beforeUpload,
|
||||
handleCreateUpload,
|
||||
handleCreateWeb,
|
||||
handleSuccess,
|
||||
contentStyle,
|
||||
siderStyle,
|
||||
@@ -890,7 +905,7 @@
|
||||
margin-bottom: 20px;
|
||||
display: inline-flex;
|
||||
font-size: 16px;
|
||||
height: 166px;
|
||||
height: 196px;
|
||||
width: calc(100% - 20px);
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
@@ -906,7 +921,7 @@
|
||||
border-radius: 10px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
height: 166px;
|
||||
height: 196px;
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
|
||||
@@ -2,21 +2,52 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" width="600px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<BasicForm @register="registerForm"></BasicForm>
|
||||
<div v-show="currentStep === 0">
|
||||
<BasicForm @register="registerForm"></BasicForm>
|
||||
<div v-if="showWebContent" class="web-content-preview">
|
||||
<div class="web-content-label">解析内容(只读)</div>
|
||||
<div class="web-content-body">
|
||||
<pre>{{ webContentText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="currentStep === 1">
|
||||
<!-- 知识库有默认分段策略时,显示选择来源 -->
|
||||
<div v-if="knowledgeDefaultSegment" style="margin-bottom: 16px">
|
||||
<div style="margin-bottom: 8px; font-weight: 500; color: rgba(0,0,0,0.85)">分段策略来源</div>
|
||||
<a-radio-group v-model:value="useKnowledgeDefault" button-style="solid">
|
||||
<a-radio-button value="default">使用知识库默认</a-radio-button>
|
||||
<a-radio-button value="custom">自定义</a-radio-button>
|
||||
</a-radio-group>
|
||||
<div style="margin-top: 6px; font-size: 12px; color: rgba(0,0,0,0.45)">
|
||||
<span v-if="useKnowledgeDefault === 'default'">直接保存,文档将使用知识库配置的分段策略</span>
|
||||
<span v-else>忽略知识库默认策略,为该文档单独配置分段参数</span>
|
||||
</div>
|
||||
<!-- 只读展示知识库默认策略 -->
|
||||
<div v-if="useKnowledgeDefault === 'default'" class="default-segment-info">
|
||||
<a-descriptions :column="2" size="small" bordered style="margin-top: 12px">
|
||||
<a-descriptions-item label="分段模式">{{ knowledgeDefaultSegment.segmentStrategy === 'custom' ? '自定义' : '自动分段与清洗' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最大长度">{{ knowledgeDefaultSegment.maxSegment }}</a-descriptions-item>
|
||||
<a-descriptions-item label="重叠度%">{{ knowledgeDefaultSegment.overlap }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="knowledgeDefaultSegment.segmentStrategy === 'custom'" label="分段标识符">{{ knowledgeDefaultSegment.separator === 'custom' ? knowledgeDefaultSegment.customSeparator : knowledgeDefaultSegment.separator }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
<BasicForm v-show="!knowledgeDefaultSegment || useKnowledgeDefault === 'custom'" @register="registerSegmentForm"></BasicForm>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import { ref, unref, computed } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { docTextSchema } from '../AiKnowledgeBase.data';
|
||||
import { knowledgeSaveDoc, queryById } from '../AiKnowledgeBase.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { docSegmentSchema, docTextSchema } from '../AiKnowledgeBase.data';
|
||||
import { knowledgeSaveDoc } from '../AiKnowledgeBase.api';
|
||||
|
||||
export default {
|
||||
name: 'AiragKnowledgeDocModal',
|
||||
@@ -27,11 +58,25 @@
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('创建知识库');
|
||||
const currentStep = ref(0);
|
||||
const step1Values = ref({});
|
||||
//自定义分词的数据
|
||||
const segmentMetadataRef = ref<any>({});
|
||||
// 知识库默认分段策略(有值表示知识库配置了默认分段)
|
||||
const knowledgeDefaultSegment = ref<any>(null);
|
||||
// 分段策略来源:'default' 使用知识库默认,'custom' 自定义
|
||||
const useKnowledgeDefault = ref<'default' | 'custom'>('default');
|
||||
// 知识库类型:'knowledge' | 'memory'
|
||||
const knowledgeType = ref<string>('knowledge');
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
//知识库id
|
||||
const knowledgeId = ref<string>();
|
||||
//网页解析内容(只读展示)
|
||||
const webContentText = ref<string>('');
|
||||
const docType = ref<string>('');
|
||||
const showWebContent = computed(() => docType.value === 'web' && isUpdate.value && webContentText.value);
|
||||
//表单配置
|
||||
const [registerForm, { resetFields, setFieldsValue, validate, clearValidate, updateSchema }] = useForm({
|
||||
schemas: docTextSchema,
|
||||
@@ -40,24 +85,92 @@
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
const [registerSegmentForm, { resetFields: resetSegmentFields, validate: validateSegment, setFieldsValue: setSegmentFieldsValue }] = useForm({
|
||||
schemas: docSegmentSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
setModalProps({ confirmLoading: false });
|
||||
await resetSegmentFields();
|
||||
currentStep.value = 0;
|
||||
webContentText.value = '';
|
||||
docType.value = '';
|
||||
knowledgeType.value = data?.knowledgeType || 'knowledge';
|
||||
setModalProps({ confirmLoading: false, okText: knowledgeType.value === 'memory' ? '保存' : '下一步' });
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
title.value = isUpdate.value ? '编辑文档' : '创建文档';
|
||||
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
// 解析知识库默认分段策略
|
||||
knowledgeDefaultSegment.value = null;
|
||||
useKnowledgeDefault.value = 'default';
|
||||
if (data?.knowledgeMetadata) {
|
||||
try {
|
||||
const kmeta = JSON.parse(data.knowledgeMetadata);
|
||||
if (kmeta.enableSegment) {
|
||||
knowledgeDefaultSegment.value = kmeta;
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
|
||||
if (unref(isUpdate)) {
|
||||
docType.value = data.record.type || '';
|
||||
if(data.record.type === 'file' && data.record.metadata){
|
||||
data.record.filePath = JSON.parse(data.record.metadata).filePath;
|
||||
}
|
||||
if(data.record.type === 'web' && data.record.metadata){
|
||||
data.record.website = JSON.parse(data.record.metadata).website;
|
||||
}
|
||||
if(data.record.type === 'web' && data.record.content){
|
||||
webContentText.value = data.record.content;
|
||||
}
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
|
||||
// 解析metadata并准备给第二步表单
|
||||
if (data.record.metadata) {
|
||||
const meta = JSON.parse(data.record.metadata);
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
// 如果文档保存时使用了知识库默认策略,回显时恢复选项
|
||||
// 兼容老数据:若知识库已不存在默认分段策略,则降级为自定义(展示默认值)
|
||||
if (meta.useKnowledgeDefault && knowledgeDefaultSegment.value) {
|
||||
useKnowledgeDefault.value = 'default';
|
||||
} else {
|
||||
useKnowledgeDefault.value = 'custom';
|
||||
// update-begin--author:wangshuai--date:2026-04-09--for:【issue/9418】AI知识库上传文件太大向量化失败
|
||||
let strategy = meta.segmentStrategy || (meta.separator ? 'custom' : 'auto');
|
||||
// update-end--author:wangshuai--date:2026-04-09--for:【issue/9418】AI知识库上传文件太大向量化失败
|
||||
segmentMetadataRef.value = {
|
||||
segmentStrategy: strategy,
|
||||
maxSegment: meta.maxSegment,
|
||||
overlap: meta.overlap,
|
||||
textRules: meta.textRules,
|
||||
separator: meta.separator,
|
||||
preprocessingRules: meta.preprocessingRules,
|
||||
customSeparator: strategy === 'custom' ? meta.customSeparator : '',
|
||||
};
|
||||
}
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
} else {
|
||||
useKnowledgeDefault.value = knowledgeDefaultSegment.value ? 'default' : 'custom';
|
||||
segmentMetadataRef.value = { segmentStrategy: 'auto', separator: '\\n', maxSegment: 800, overlap: 10 };
|
||||
}
|
||||
} else {
|
||||
knowledgeId.value = data.knowledgeId;
|
||||
await setFieldsValue({ type: data.type })
|
||||
docType.value = data.type || '';
|
||||
// 新建时:有知识库默认策略则默认选中"使用知识库默认"
|
||||
useKnowledgeDefault.value = knowledgeDefaultSegment.value ? 'default' : 'custom';
|
||||
segmentMetadataRef.value = { segmentStrategy: 'auto', separator: '\\n', maxSegment: 800, overlap: 10 };
|
||||
await setFieldsValue({ type: data.type });
|
||||
}
|
||||
setModalProps({ bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
@@ -67,15 +180,62 @@
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
if (currentStep.value === 0 && knowledgeType.value !== 'memory') {
|
||||
step1Values.value = await validate();
|
||||
currentStep.value = 1;
|
||||
setModalProps({ okText: '保存', minHeight: 400 });
|
||||
if (segmentMetadataRef.value) {
|
||||
await setSegmentFieldsValue({ ...segmentMetadataRef.value });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep.value === 0 && knowledgeType.value === 'memory') {
|
||||
step1Values.value = await validate();
|
||||
}
|
||||
|
||||
setModalProps({ confirmLoading: true });
|
||||
let values = await validate();
|
||||
let values: any = { ...step1Values.value };
|
||||
|
||||
let metadata: any = {};
|
||||
|
||||
//update-begin---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
if (knowledgeType.value === 'memory') {
|
||||
// 记忆库不需要分段策略,metadata 留空
|
||||
} else if (useKnowledgeDefault.value === 'default' && knowledgeDefaultSegment.value) {
|
||||
// 使用知识库默认分段策略,标记即可,后端读取知识库 metadata
|
||||
metadata = { useKnowledgeDefault: true };
|
||||
} else {
|
||||
// update-begin--author:wangshuai--date:2026-04-09--for:【issue/9418】AI知识库上传文件太大向量化失败
|
||||
const segmentFormValues = await validateSegment();
|
||||
metadata = {
|
||||
segmentStrategy: segmentFormValues.segmentStrategy,
|
||||
maxSegment: segmentFormValues.maxSegment,
|
||||
overlap: segmentFormValues.overlap,
|
||||
};
|
||||
if (segmentFormValues.segmentStrategy === 'custom') {
|
||||
metadata = {
|
||||
...metadata,
|
||||
separator: segmentFormValues.separator,
|
||||
customSeparator: segmentFormValues.customSeparator,
|
||||
textRules: segmentFormValues.textRules,
|
||||
};
|
||||
}
|
||||
// update-end--author:wangshuai--date:2026-04-09--for:【issue/9418】AI知识库上传文件太大向量化失败
|
||||
}
|
||||
//update-end---wangshuai---date:20260414 for:【QQYUN-14932】创建知识库时,可以创建一个分段策略,知识库里面的文档默认使用知识库的分段策略------------
|
||||
if (!unref(isUpdate)) {
|
||||
values.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
if(values.filePath){
|
||||
values.metadata = JSON.stringify({ filePath: values.filePath });
|
||||
metadata.filePath = values.filePath;
|
||||
delete values.filePath;
|
||||
}
|
||||
if(values.website){
|
||||
metadata.website = values.website;
|
||||
delete values.website;
|
||||
}
|
||||
values.metadata = JSON.stringify(metadata);
|
||||
await knowledgeSaveDoc(values);
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
@@ -96,9 +256,16 @@
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
registerSegmentForm,
|
||||
currentStep,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
showWebContent,
|
||||
webContentText,
|
||||
knowledgeDefaultSegment,
|
||||
useKnowledgeDefault,
|
||||
knowledgeType,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -108,4 +275,28 @@
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.web-content-preview {
|
||||
margin-top: 8px;
|
||||
.web-content-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.web-content-body {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<a-card class="model-card" @click="handleEditClick(item)">
|
||||
<div class="model-header">
|
||||
<div class="flex">
|
||||
<img :src="getImage(item.provider)" class="header-img" />
|
||||
<img :src="getImage(item.provider)" :class="['header-img', item.provider === 'VLLM' ? 'header-img-lg' : '']" />
|
||||
<div class="header-text">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,6 +75,9 @@
|
||||
<Icon icon="ant-design:setting-outlined" size="16"></Icon>
|
||||
<span class="ml-4">模型参数配置</span>
|
||||
</a-menu-item>-->
|
||||
<a-menu-item v-if="item.activateFlag" key="deactivate" @click.prevent.stop="handleDeactivateClick(item)">
|
||||
<Icon icon="ant-design:stop-outlined" size="16"></Icon> 取消激活
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" @click.prevent.stop="handleDeleteClick(item)">
|
||||
<Icon icon="ant-design:delete-outlined" size="16"></Icon> 删除
|
||||
</a-menu-item>
|
||||
@@ -108,7 +111,7 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import AiModelModal from './components/AiModelModal.vue';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import { deleteModel, list } from './model.api';
|
||||
import { deleteModel, list, editModel } from './model.api';
|
||||
import { imageList } from './model.data';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import JInput from '@/components/Form/src/jeecg/components/JInput.vue';
|
||||
@@ -228,6 +231,15 @@
|
||||
await deleteModel({ id: item.id, name: item.name }, reload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消激活模型
|
||||
* @param item
|
||||
*/
|
||||
async function handleDeactivateClick(item) {
|
||||
await editModel({ id: item.id, activateFlag: 0 });
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
@@ -266,6 +278,7 @@
|
||||
handlePageChange,
|
||||
getImage,
|
||||
handleDeleteClick,
|
||||
handleDeactivateClick,
|
||||
searchQuery,
|
||||
searchReset,
|
||||
queryParam,
|
||||
@@ -295,6 +308,11 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.header-img-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.header-text {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<a-card class="model-card" @click="handleClick(item)">
|
||||
<div class="model-header">
|
||||
<div class="flex">
|
||||
<img :src="getImage(item.value)" class="header-img" />
|
||||
<img :src="getImage(item.value)" :class="['header-img', item.value === 'VLLM' ? 'header-img-lg' : '']" />
|
||||
<div class="header-text">{{ item.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,6 +57,21 @@
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<template #extraParams="{ model, field }">
|
||||
<a-input v-model:value="model[field]" readonly placeholder="点击右侧按钮编辑JSON参数">
|
||||
<template #suffix>
|
||||
<FullscreenOutlined style="cursor: pointer;" @click="openExtraParamsModal" />
|
||||
</template>
|
||||
</a-input>
|
||||
<div style="margin-top: 4px; color: #999; font-size: 12px; line-height: 1.5;">
|
||||
目前只支持图片传递,固定格式为 "image_url":"图片地址"(适用于qwen-vl-ocr等视觉模型)<br/>
|
||||
在qwen3.5-plus最新视觉模型中需要额外传递 "incremental_output": true
|
||||
</div>
|
||||
<a-modal v-model:open="extraParamsVisible" title="编辑额外参数" width="600px" @ok="saveExtraParams" destroyOnClose>
|
||||
<JCodeEditor v-model:value="extraParamsTemp" language="javascript" fullScreen height="500px" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<template #modelName="{ model, field }">
|
||||
<AutoComplete v-model:value="model[field]" :options="modelNameAddOption" :filter-option="filterOption">
|
||||
<template #option="{ value, label, descr, type }">
|
||||
@@ -122,7 +137,9 @@
|
||||
import { editModel, queryById, saveModel, testConn } from '../model.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
const {createMessage: $message, createConfirm} = useMessage();
|
||||
import { FullscreenOutlined } from '@ant-design/icons-vue';
|
||||
import AiModelSeniorForm from './AiModelSeniorForm.vue';
|
||||
import JCodeEditor from '/@/components/Form/src/jeecg/components/JCodeEditor.vue';
|
||||
import { cloneDeep } from "lodash-es";
|
||||
export default {
|
||||
name: 'AddModelModal',
|
||||
@@ -131,6 +148,8 @@
|
||||
BasicModal,
|
||||
AiModelSeniorForm,
|
||||
AutoComplete,
|
||||
JCodeEditor,
|
||||
FullscreenOutlined,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
@@ -166,6 +185,41 @@
|
||||
const testLoading = ref<boolean>(false);
|
||||
//模型是否已激活
|
||||
const modelActivate = ref<boolean>(false);
|
||||
//特殊参数
|
||||
const extraParamsVisible = ref<boolean>(false);
|
||||
const extraParamsTemp = ref<string>('');
|
||||
|
||||
/**
|
||||
* 打开特殊参数编辑弹窗
|
||||
*/
|
||||
function openExtraParamsModal() {
|
||||
const formVal = getFieldsValue();
|
||||
let val = formVal.extraParams || '';
|
||||
if (val) {
|
||||
try {
|
||||
val = JSON.stringify(JSON.parse(val), null, 2);
|
||||
} catch (e) {}
|
||||
}
|
||||
extraParamsTemp.value = val;
|
||||
extraParamsVisible.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存特殊参数
|
||||
*/
|
||||
function saveExtraParams() {
|
||||
const val = extraParamsTemp.value;
|
||||
if (val) {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
} catch (e) {
|
||||
$message.error('JSON格式不正确,请检查');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFieldsValue({ extraParams: val });
|
||||
extraParamsVisible.value = false;
|
||||
}
|
||||
|
||||
const getImage = (name) => {
|
||||
return imageList.value[name];
|
||||
@@ -176,11 +230,12 @@
|
||||
}
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { resetFields, setFieldsValue, validate, clearValidate }] = useForm({
|
||||
const [registerForm, { resetFields, setFieldsValue, getFieldsValue, validate, clearValidate }] = useForm({
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
@@ -204,6 +259,9 @@
|
||||
if(credential.apiKey){
|
||||
values.result.apiKey = credential.apiKey;
|
||||
}
|
||||
if (credential.httpVersionOne) {
|
||||
values.result.httpVersionOne = credential.httpVersionOne;
|
||||
}
|
||||
}
|
||||
let provider = values.result.provider;
|
||||
let data = model.data.filter((item) => {
|
||||
@@ -222,7 +280,12 @@
|
||||
modelActivate.value = false;
|
||||
}
|
||||
if(values.result.modelParams){
|
||||
modelParams.value = JSON.parse(values.result.modelParams)
|
||||
let allParams = JSON.parse(values.result.modelParams);
|
||||
if (allParams.extraParams) {
|
||||
values.result.extraParams = JSON.stringify(allParams.extraParams, null, 2);
|
||||
delete allParams.extraParams;
|
||||
}
|
||||
modelParams.value = allParams;
|
||||
}
|
||||
modelTypeDisabled.value = true;
|
||||
//表单赋值
|
||||
@@ -308,14 +371,25 @@
|
||||
let values = await validate();
|
||||
let credential = {
|
||||
apiKey: values.apiKey,
|
||||
secretKey: values.secretKey
|
||||
secretKey: values.secretKey,
|
||||
httpVersionOne: values.httpVersionOne,
|
||||
}
|
||||
let params = {};
|
||||
if(modelParamsRef.value){
|
||||
let modelParams = modelParamsRef.value.emitChange();
|
||||
if(modelParams){
|
||||
values.modelParams = JSON.stringify(modelParams);
|
||||
let seniorParams = modelParamsRef.value.emitChange();
|
||||
if(seniorParams){
|
||||
params = { ...seniorParams };
|
||||
}
|
||||
}
|
||||
if (values.extraParams) {
|
||||
try {
|
||||
params.extraParams = JSON.parse(values.extraParams);
|
||||
} catch(e) {}
|
||||
}
|
||||
if (Object.keys(params).length > 0) {
|
||||
values.modelParams = JSON.stringify(params);
|
||||
}
|
||||
delete values.extraParams;
|
||||
if(modelActivate.value){
|
||||
values.activateFlag = 1
|
||||
}else{
|
||||
@@ -361,13 +435,26 @@
|
||||
let credential = {
|
||||
apiKey: values.apiKey,
|
||||
secretKey: values.secretKey,
|
||||
httpVersionOne: values.httpVersionOne,
|
||||
};
|
||||
let params = {};
|
||||
if (modelParamsRef.value) {
|
||||
let modelParams = modelParamsRef.value.emitChange();
|
||||
if (modelParams) {
|
||||
values.modelParams = JSON.stringify(modelParams);
|
||||
//update-begin---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
|
||||
let seniorParams = modelParamsRef.value.emitChange();
|
||||
if (seniorParams) {
|
||||
params = { ...seniorParams };
|
||||
//update-end---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
|
||||
}
|
||||
}
|
||||
if (values.extraParams) {
|
||||
try {
|
||||
params.extraParams = JSON.parse(values.extraParams);
|
||||
} catch(e) {}
|
||||
}
|
||||
if (Object.keys(params).length > 0) {
|
||||
values.modelParams = JSON.stringify(params);
|
||||
}
|
||||
delete values.extraParams;
|
||||
values.credential = JSON.stringify(credential);
|
||||
if (!values.provider) {
|
||||
values.provider = modelData.value.value;
|
||||
@@ -461,6 +548,10 @@
|
||||
getTitle,
|
||||
test,
|
||||
testLoading,
|
||||
extraParamsVisible,
|
||||
extraParamsTemp,
|
||||
openExtraParamsModal,
|
||||
saveExtraParams,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -492,6 +583,11 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.header-img-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.header-text {
|
||||
width: calc(100% - 80px);
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
"title": "DeepSeek",
|
||||
"value": "DEEPSEEK",
|
||||
"LLM": [
|
||||
{"label": "deepseek-reasoner", "value": "deepseek-reasoner","descr": "【官方模型】深度求索 新推出的推理模型R1满血版\n火便全球。\n支持64k上下文,其中支持8k最大回复。","type": "text"},
|
||||
{"label":"deepseek-chat", "value": "deepseek-chat","descr": "最强开源 MoE 模型 DeepSeek-V3,全球首个在代码、数学能力上与GPT-4-Turbo争锋的模型,在代码、数学的多个榜单上位居全球第二;","type": "text"}
|
||||
{"label": "deepseek-v4-pro", "value": "deepseek-v4-pro","descr": "【官方模型】深度求索 新推出的推理模型R1满血版\n火便全球。\n支持64k上下文,其中支持8k最大回复。","type": "text"},
|
||||
{"label": "deepseek-v4-flash", "value": "deepseek-v4-flash","descr": "【官方模型】深度求索 新推出的推理模型R1满血版\n火便全球。\n支持64k上下文,其中支持8k最大回复。","type": "text"},
|
||||
{"label": "deepseek-reasoner", "value": "deepseek-reasoner","descr": " 2026/07/24下线,【官方模型】深度求索 新推出的推理模型R1满血版\n火便全球。\n支持64k上下文,其中支持8k最大回复。","type": "text"},
|
||||
{"label":"deepseek-chat", "value": "deepseek-chat","descr": "2026/07/24下线,最强开源 MoE 模型 DeepSeek-V3,全球首个在代码、数学能力上与GPT-4-Turbo争锋的模型,在代码、数学的多个榜单上位居全球第二;","type": "text"}
|
||||
],
|
||||
"type": ["LLM"],
|
||||
"baseUrl": "https://api.deepseek.com/v1",
|
||||
"LLMDefaultValue": "deepseek-chat"
|
||||
"LLMDefaultValue": "deepseek-v4-pro"
|
||||
},
|
||||
{
|
||||
"title": "Ollama",
|
||||
@@ -181,6 +183,49 @@
|
||||
"LLMDefaultValue": "glm-4-flash",
|
||||
"EMBEDDefaultValue": "Embedding-2",
|
||||
"IMAGEDefaultValue": "CogView-4"
|
||||
},
|
||||
{
|
||||
"title": "Google Gemini",
|
||||
"value": "GOOGLE",
|
||||
"LLM": [
|
||||
{"label": "gemini-2.5-pro", "value": "gemini-2.5-pro","descr": "【Gemini 2.5系列】Google最新旗舰思维模型,具备强大的推理能力。\n\n支持文本和图像输入,文本输出,拥有1M上下文窗口,在编码、数学和科学推理方面表现卓越。","type": "text,image"},
|
||||
{"label": "gemini-2.5-flash", "value": "gemini-2.5-flash","descr": "【Gemini 2.5系列】Google最新的高效思维模型,速度与性能的最佳平衡。\n\n支持文本和图像输入,文本输出,拥有1M上下文窗口,适合高频交互和大规模部署。","type": "text,image"}
|
||||
],
|
||||
"IMAGE": [
|
||||
{"label": "gemini-3-pro-image-preview", "value": "gemini-3-pro-image-preview","descr": "Google最新Gemini 3 Pro图像生成预览模型,具备卓越的图像生成质量。\n\n支持文本到图像生成,在细节表现、风格多样性和文字渲染方面表现出色。","type": "imageGen"},
|
||||
{"label": "gemini-2.5-flash-image", "value": "gemini-2.5-flash-image","descr": "基于Gemini 2.5 Flash的图像生成模型,支持文本到图像生成。\n\n速度快、成本低,适合高频图像生成场景,支持多种风格。","type": "imageGen"}
|
||||
],
|
||||
"type": ["LLM", "IMAGE"],
|
||||
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"LLMDefaultValue": "gemini-2.5-flash",
|
||||
"IMAGEDefaultValue": "gemini-2.5-flash-image"
|
||||
},
|
||||
{
|
||||
"title": "vLLM",
|
||||
"value": "VLLM",
|
||||
"LLM": [],
|
||||
"EMBED": [],
|
||||
"IMAGE": [],
|
||||
"type": ["LLM", "EMBED", "IMAGE"],
|
||||
"baseUrl": "http://localhost:8000/v1"
|
||||
},
|
||||
{
|
||||
"title": "LM stdio",
|
||||
"value": "LMSTDIO",
|
||||
"LLM": [],
|
||||
"EMBED": [],
|
||||
"IMAGE": [],
|
||||
"type": ["LLM", "EMBED", "IMAGE"],
|
||||
"baseUrl": "http://localhost:1234/v1"
|
||||
},
|
||||
{
|
||||
"title": "Xinference",
|
||||
"value": "XINFERENCE",
|
||||
"LLM": [],
|
||||
"EMBED": [],
|
||||
"IMAGE": [],
|
||||
"type": ["LLM", "EMBED", "IMAGE"],
|
||||
"baseUrl": "http://localhost:9997/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/gemini.png
Normal file
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/gemini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/imstdio.png
Normal file
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/imstdio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/vllm.png
Normal file
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/vllm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg width="173.831879" height="177.308517" viewBox="0 0 173.832 177.309" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<desc>
|
||||
Created with Pixso.
|
||||
</desc>
|
||||
<defs>
|
||||
<linearGradient x1="59.102806" y1="179.252701" x2="146.098419" y2="0.728947" id="paint_linear_0_7_0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E9A85E"/>
|
||||
<stop offset="1.000000" stop-color="#F52B76"/>
|
||||
</linearGradient>
|
||||
<linearGradient x1="18.536041" y1="179.005981" x2="135.938522" y2="129.504761" id="paint_linear_0_8_0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E9A85E"/>
|
||||
<stop offset="1.000000" stop-color="#F52B76"/>
|
||||
</linearGradient>
|
||||
<linearGradient x1="0.000004" y1="49.024223" x2="157.555862" y2="121.868401" id="paint_linear_0_9_0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6A0CF5"/>
|
||||
<stop offset="1.000000" stop-color="#AB66F3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path id="path" d="M59.1 90.81C64.17 96.44 69.93 101.41 76.23 105.61C81.64 109.24 87.41 112.29 93.45 114.72C104.41 104.34 113.49 92.13 120.29 78.63L159.92 0L90.81 54.46C78.08 64.5 67.32 76.82 59.1 90.81Z" fill="url(#paint_linear_0_7_0)" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
<path id="path" d="M76.23 105.61C81.64 109.24 87.41 112.29 93.45 114.72C104.41 104.34 113.49 92.13 120.29 78.63L159.92 0L90.81 54.46C78.08 64.5 67.32 76.82 59.1 90.81C64.17 96.44 69.93 101.41 76.23 105.61Z" stroke="#000000" stroke-opacity="0" stroke-width="1.000000"/>
|
||||
<path id="path" d="M53.15 139.95C48.14 136.62 43.42 133.1 38.95 129.5L14.77 177.3L58.23 143.24C56.53 142.15 54.82 141.07 53.15 139.95Z" fill="url(#paint_linear_0_8_0)" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
<path id="path" d="M38.95 129.5L14.77 177.3L58.23 143.24C56.53 142.15 54.82 141.07 53.15 139.95C48.14 136.62 43.42 133.1 38.95 129.5Z" stroke="#000000" stroke-opacity="0" stroke-width="1.000000"/>
|
||||
<path id="path" d="M141.86 63.63C154.87 80.88 158.67 100.04 149.85 113.23C136.97 132.49 102.06 131.75 71.87 111.58C41.68 91.41 27.65 59.45 40.53 40.19C49.34 27 68.5 23.19 89.42 28.59C53.25 13.23 18.83 14.75 5.48 34.67C-11.27 59.75 11.61 104.48 56.58 134.48C101.54 164.48 151.59 168.55 168.34 143.49C181.68 123.53 169.88 91.16 141.86 63.63Z" fill="url(#paint_linear_0_9_0)" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
<path id="path" d="M149.85 113.23C136.97 132.49 102.06 131.75 71.87 111.58C41.68 91.41 27.65 59.45 40.53 40.19C49.34 27 68.5 23.19 89.42 28.59C53.25 13.23 18.83 14.75 5.48 34.67C-11.27 59.75 11.61 104.48 56.58 134.48C101.54 164.48 151.59 168.55 168.34 143.49C181.68 123.53 169.88 91.16 141.86 63.63C154.87 80.88 158.67 100.04 149.85 113.23Z" stroke="#000000" stroke-opacity="0" stroke-width="1.000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -7,6 +7,10 @@ import OpenAi from './icon/OpenAi.png';
|
||||
import qianfan from './icon/qianfan.png';
|
||||
import qianwen from './icon/qianwen.png';
|
||||
import zhipuai from './icon/zhipuai.png';
|
||||
import xinference from './icon/xinference.svg';
|
||||
import vllm from './icon/vllm.png';
|
||||
import imstdio from './icon/imstdio.png';
|
||||
import gemini from './icon/gemini.png';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
@@ -48,7 +52,7 @@ export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'API Key',
|
||||
field: 'apiKey',
|
||||
required: true,
|
||||
required: ({ values }) => values.provider !== 'XINFERENCE',
|
||||
component: 'InputPassword',
|
||||
componentProps: {
|
||||
autocomplete: 'new-password',
|
||||
@@ -67,12 +71,35 @@ export const formSchema: FormSchema[] = [
|
||||
component: 'InputPassword',
|
||||
ifShow: ({ values }) => {
|
||||
if(values.provider==='DEEPSEEK' || values.provider==="OLLAMA" || values.provider==="OPENAI"
|
||||
|| values.provider==="ZHIPU" || values.provider==="QWEN" || values.provider==="ANTHROPIC"){
|
||||
|| values.provider==="ZHIPU" || values.provider==="QWEN" || values.provider==="ANTHROPIC"
|
||||
|| values.provider==="XINFERENCE" || values.provider==="VLLM" || values.provider === 'LMSTDIO'
|
||||
|| values.provider === "GOOGLE"){
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'HTTP1.1协议',
|
||||
field: 'httpVersionOne',
|
||||
component: 'Switch',
|
||||
defaultValue: 1,
|
||||
helpMessage: '是否使用HTTP1.1协议,在长时间无响应的情况下,可以尝试关闭此开关',
|
||||
componentProps: {
|
||||
checkedValue: 1,
|
||||
unCheckedValue: 0,
|
||||
},
|
||||
ifShow: ({ values }) => {
|
||||
return values.provider === 'VLLM' || values.provider === 'LMSTDIO' || values.provider === 'XINFERENCE';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '额外参数',
|
||||
field: 'extraParams',
|
||||
slot: 'extraParams',
|
||||
component: 'Input',
|
||||
ifShow: ({ values }) => values.modelType === 'LLM',
|
||||
},
|
||||
{
|
||||
label: '供应者',
|
||||
field: 'provider',
|
||||
@@ -94,4 +121,8 @@ export const imageList = ref<any>({
|
||||
QIANFAN: qianfan,
|
||||
QWEN: qianwen,
|
||||
ZHIPU: zhipuai,
|
||||
XINFERENCE: xinference,
|
||||
VLLM: vllm,
|
||||
LMSTDIO: imstdio,
|
||||
GOOGLE: gemini,
|
||||
});
|
||||
|
||||
414
jeecgboot-vue3/src/views/super/airag/aiposter/AiPainting.vue
Normal file
414
jeecgboot-vue3/src/views/super/airag/aiposter/AiPainting.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="content-wrapper">
|
||||
<!-- 中间参数配置 -->
|
||||
<div class="config-panel">
|
||||
<div class="config-tabs">
|
||||
<a-tabs v-model:activeKey="configTab" :tabBarStyle="{ margin: 0 }">
|
||||
<a-tab-pane key="draw" tab="绘图" />
|
||||
<a-tab-pane key="face" tab="换脸" />
|
||||
<a-tab-pane key="mix" tab="混图" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 示例按钮区域 -->
|
||||
<div class="example-buttons" v-if="configTab === 'mix'">
|
||||
<a-tooltip title="工作证制作">
|
||||
<a-button class="example-btn" size="small" @click="applyExample('work_card')">示例一</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="换衣">
|
||||
<a-button class="example-btn" size="small" @click="applyExample('change_clothes')">示例二</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" />
|
||||
|
||||
<div class="instructions" v-if="configTab === 'face'">
|
||||
<div class="title">说明:</div>
|
||||
<p>1 图片都必须包含脸,否则出不来图</p>
|
||||
<p>2 "明星图"可以先用mj绘画制作出来</p>
|
||||
<p>3 "明星图"其实动漫图也行</p>
|
||||
<p>4 "你的头像"建议用一寸个人照</p>
|
||||
</div>
|
||||
|
||||
<div class="instructions" v-if="configTab === 'mix'">
|
||||
<div class="title">说明:</div>
|
||||
<p>1 合成至少2张图片</p>
|
||||
<p>2 最多可传3张图</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="primary" size="large" block @click="handleGenerate" :loading="loading">
|
||||
<Icon icon="ant-design:thunderbolt-outlined" />
|
||||
立即生成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧图片生成结果 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">生成结果</div>
|
||||
<div class="preview-content">
|
||||
<div v-if="!generatedImage && !loading" class="empty-state">
|
||||
<Icon icon="ant-design:picture-outlined" size="64" color="#ccc" />
|
||||
<p>在左侧配置参数并点击生成</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !generatedImage" class="loading-state">
|
||||
<a-spin size="large" tip="正在绘制图片,请稍候..." />
|
||||
</div>
|
||||
|
||||
<div v-if="generatedImage" class="result-image-wrapper group">
|
||||
<img :src="generatedImage" class="result-image" alt="Generated Image" />
|
||||
<div class="image-actions">
|
||||
<a-button type="primary" ghost @click="handlePreview">
|
||||
<Icon icon="ant-design:eye-outlined" />
|
||||
预览
|
||||
</a-button>
|
||||
<a-button type="primary" ghost @click="handleDownload">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageViewer v-if="previewVisible" :imageUrl="generatedImage" @hide="previewVisible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { drawFormSchema, faceSwapFormSchema, mixFormSchema } from './AiPoster.data';
|
||||
import ImageViewer from '../aiapp/chat/components/ImageViewer.vue';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const loading = ref(false);
|
||||
//update-begin---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】AI绘画改为异步轮询,支持切换菜单后继续查询
|
||||
const PAINTING_TASK_ID_KEY = 'ai_painting_task_id';
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
//update-end---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】AI绘画改为异步轮询,支持切换菜单后继续查询
|
||||
const generatedImage = ref('');
|
||||
const previewVisible = ref(false);
|
||||
const configTab = ref('draw');
|
||||
|
||||
const { domainUrl } = useGlobSetting();
|
||||
const [registerForm, { validate, resetSchema, setFieldsValue }] = useForm({
|
||||
schemas: drawFormSchema,
|
||||
labelWidth: 100,
|
||||
actionColOptions: { span: 24 },
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
|
||||
watch(configTab, (val) => {
|
||||
if (val === 'draw') {
|
||||
resetSchema(drawFormSchema);
|
||||
} else if (val === 'face') {
|
||||
resetSchema(faceSwapFormSchema);
|
||||
} else if (val === 'mix') {
|
||||
resetSchema(mixFormSchema);
|
||||
} else {
|
||||
// Default to draw or empty for mix for now
|
||||
resetSchema(drawFormSchema);
|
||||
}
|
||||
});
|
||||
|
||||
//update-begin---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】AI绘画改为异步轮询,支持切换菜单后继续查询
|
||||
/** 轮询查询任务结果 */
|
||||
function startPolling(taskId: string) {
|
||||
const poll = () => {
|
||||
defHttp
|
||||
.get({ url: `/airag/chat/getAiPosterResult/${taskId}` }, { isTransformResponse: false })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
if (res.result === 'pending' || res.result === null) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
} else {
|
||||
let imageUrl = res.result as string;
|
||||
const reg = /#\s*{\s*domainURL\s*}/g;
|
||||
imageUrl = imageUrl.replace(reg, domainUrl + '/sys/common/static');
|
||||
generatedImage.value = imageUrl;
|
||||
loading.value = false;
|
||||
localStorage.removeItem(PAINTING_TASK_ID_KEY);
|
||||
createMessage.success('图片生成成功!');
|
||||
}
|
||||
} else {
|
||||
loading.value = false;
|
||||
localStorage.removeItem(PAINTING_TASK_ID_KEY);
|
||||
createMessage.warning(res.message || '图片生成失败!');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTaskId = localStorage.getItem(PAINTING_TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
loading.value = true;
|
||||
generatedImage.value = '';
|
||||
startPolling(savedTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
//update-end---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】AI绘画改为异步轮询,支持切换菜单后继续查询
|
||||
|
||||
async function handleGenerate() {
|
||||
try {
|
||||
const values = await validate();
|
||||
loading.value = true;
|
||||
generatedImage.value = '';
|
||||
values.type = configTab.value;
|
||||
|
||||
if (configTab.value === 'face') {
|
||||
if (values.sourceImage && values.targetImage) {
|
||||
values.imageUrl = values.sourceImage + ',' + values.targetImage;
|
||||
delete values.sourceImage;
|
||||
delete values.targetImage;
|
||||
}
|
||||
values.content = '将图1的面部特征替换到图2的面部区域,保留图1五官细节,保持图2身体姿态,面部融合自然,高分辨率,写实风格';
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】改为异步提交,获取taskId后开始轮询
|
||||
const res = await defHttp.post(
|
||||
{ url: '/airag/chat/genAiPosterAsync', params: values },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
if (res.success && res.result) {
|
||||
const taskId = res.result as string;
|
||||
localStorage.setItem(PAINTING_TASK_ID_KEY, taskId);
|
||||
startPolling(taskId);
|
||||
} else {
|
||||
loading.value = false;
|
||||
createMessage.warning('提交任务失败!');
|
||||
}
|
||||
//update-end---author:wangshuai---date:2026-04-15---for:【QQYUN-14944】改为异步提交,获取taskId后开始轮询
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
function applyExample(type: string) {
|
||||
if (type === 'work_card') {
|
||||
setFieldsValue({
|
||||
imageUrl:
|
||||
'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/afdad9ea-077f-44a4-9d85-c26cde7aceed_1770703400282.png,https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/4e4d1886-fb3b-4c01-abf6-25e546a1253e_1770703403479.png',
|
||||
content:
|
||||
'以[图1]名片设计稿的构图与磨砂玻璃质感为模板,为[图2]人物生成竖版工作卡。圆角半透明卡面,柔和高光与浅投影;人物胸像置于中上区域;左下排版姓名/职位/公司/电话,极简无衬线字体,留白均衡。右上角放置[图1]人物的可爱3D卡通形象,打破边界半浮出卡片并投下轻影,形成层次与视觉焦点。整体明亮自然光、真实材质细节,不添加多余图案与元素。人物需要清晰,不要模糊。',
|
||||
});
|
||||
} else if (type === 'change_clothes') {
|
||||
setFieldsValue({
|
||||
imageUrl:
|
||||
'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/4e4d1886-fb3b-4c01-abf6-25e546a1253e_1770703403479.png,https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/63706787-4072-4cba-ad88-385e0584b020_1770703456766.png',
|
||||
content: '将【图一】的衣服换到【图二】中。',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片导出
|
||||
*/
|
||||
function handleDownload() {
|
||||
if (!generatedImage.value) {
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = generatedImage.value;
|
||||
a.download = `ai-painting-${Date.now()}.jpg`;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
height: 100%; /* Ensure it takes full height of parent */
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
width: 550px;
|
||||
min-width: 350px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.config-tabs {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.ant-tabs-nav::before) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 8px 0;
|
||||
margin: 0 32px 0 0;
|
||||
font-size: 16px;
|
||||
|
||||
&.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #00b96b;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
background: #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.example-buttons {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
|
||||
.example-btn {
|
||||
color: #666;
|
||||
border-color: #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
color: #00b96b;
|
||||
border-color: #00b96b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.instructions {
|
||||
margin-top: 20px;
|
||||
padding: 0 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-container {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.result-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
&:hover .image-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,264 @@ export const formSchema: FormSchema[] = [
|
||||
field: 'imageSize',
|
||||
label: '图片尺寸',
|
||||
component: 'Select',
|
||||
defaultValue: '1024*1024',
|
||||
defaultValue: '720*1280',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1:1 (1024x1024)', value: '1024*1024' },
|
||||
{ label: '16:9 (1280x720)', value: '1280*720' },
|
||||
{ label: '9:16 (720x1280)', value: '720*1280' },
|
||||
{ label: '4:3 (1024x768)', value: '1024*768' },
|
||||
{ label: '3:4 (768x1024)', value: '768*1024' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 混图表单
|
||||
*/
|
||||
export const mixFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: [
|
||||
'1、需要选择在模型中已有的图像模型',
|
||||
'2、当前支持通义万象模型wan2.5-i2i-preview',
|
||||
],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'IMAGE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageSize',
|
||||
label: '尺寸',
|
||||
component: 'Select',
|
||||
defaultValue: '720*1280',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1:1 (1024x1024)', value: '1024*1024' },
|
||||
{ label: '16:9 (1280x720)', value: '1280*720' },
|
||||
{ label: '9:16 (720x1280)', value: '720*1280' },
|
||||
{ label: '4:3 (1024x768)', value: '1024*768' },
|
||||
{ label: '3:4 (768x1024)', value: '768*1024' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageUrl',
|
||||
label: '上传图像',
|
||||
component: 'JImageUpload',
|
||||
required: true,
|
||||
componentProps: {
|
||||
fileMax: 3,
|
||||
text: '上传图像',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
required: true,
|
||||
validator: async (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.reject('请上传图像');
|
||||
}
|
||||
const images = value.split(',');
|
||||
if (images.length < 2) {
|
||||
return Promise.reject('合成至少2张图片');
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
label: '提示词',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '如将图一的话花瓶放到图二中',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 绘画的表单
|
||||
*/
|
||||
export const drawFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: [
|
||||
'1、需要选择在模型中已有的图像模型',
|
||||
'2、智普语言模型不支持尺寸设置',
|
||||
"3、openAi旧版模型如(dall-e-2)需要选择尺寸,新版模型直接输入'竖版: 9:16即可'",
|
||||
'4、当前只有千问万象模型(wanx2.1-imageedit,wan2.5-i2i-preview)支持图生图',
|
||||
'5、wan2.5-i2i-preview支持多张图片',
|
||||
'6、当前文生图openAi效果最佳',
|
||||
],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'IMAGE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
label: '提示词',
|
||||
component: 'InputTextArea',
|
||||
required: true,
|
||||
componentProps: {
|
||||
rows: 5,
|
||||
placeholder: '请输入提示词,例如:一只可爱的猫咪',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageSize',
|
||||
label: '图片尺寸',
|
||||
component: 'Select',
|
||||
defaultValue: '720*1280',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1:1 (1024x1024)', value: '1024*1024' },
|
||||
{ label: '16:9 (1280x720)', value: '1280*720' },
|
||||
{ label: '9:16 (720x1280)', value: '720*1280' },
|
||||
{ label: '4:3 (1024x768)', value: '1024*768' },
|
||||
{ label: '3:4 (768x1024)', value: '768*1024' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'style',
|
||||
label: '风格',
|
||||
component: 'Select',
|
||||
defaultValue: 'modernOrganic',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '赛博朋克', value: 'cyberpunk' },
|
||||
{ label: '星际', value: 'star' },
|
||||
{ label: '动漫', value: 'anime' },
|
||||
{ label: '日本漫画', value: 'japaneseComicsManga' },
|
||||
{ label: '水墨画风格', value: 'inkWashPaintingStyle' },
|
||||
{ label: '原创', value: 'original' },
|
||||
{ label: '风景画', value: 'landscape' },
|
||||
{ label: '插画', value: 'illustration' },
|
||||
{ label: '漫画', value: 'manga' },
|
||||
{ label: '现代自然', value: 'modernOrganic' },
|
||||
{ label: '创世纪', value: 'genesis' },
|
||||
{ label: '海报风格', value: 'posterstyle' },
|
||||
{ label: '超现实主义', value: 'surrealism' },
|
||||
{ label: '素描', value: 'sketch' },
|
||||
{ label: '写实', value: 'realism' },
|
||||
{ label: '水彩画', value: 'watercolorPainting' },
|
||||
{ label: '立体主义', value: 'cubism' },
|
||||
{ label: '黑白', value: 'blackAndWhite' },
|
||||
{ label: '胶片摄影风格', value: 'fmPhotography' },
|
||||
{ label: '电影化', value: 'cinematic' },
|
||||
{ label: '清晰的面部特征', value: 'clearFacialFeatures' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'visualAngle',
|
||||
label: '视角',
|
||||
component: 'Select',
|
||||
defaultValue: 'frontView',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '宽视角', value: 'wideView' },
|
||||
{ label: '鸟瞰视角', value: 'birdView' },
|
||||
{ label: '顶视角', value: 'topView' },
|
||||
{ label: '仰视角', value: 'upview' },
|
||||
{ label: '正面视角', value: 'frontView' },
|
||||
{ label: '头部特写', value: 'headshot' },
|
||||
{ label: '超广角视角', value: 'ultrawideshot' },
|
||||
{ label: '中景', value: 'mediumShot' },
|
||||
{ label: '远景', value: 'longShot' },
|
||||
{ label: '景深', value: 'depthOfField' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'characterShot',
|
||||
label: '人物镜头',
|
||||
component: 'Select',
|
||||
defaultValue: 'fullLengthShot',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '脸部特写', value: 'faceShot' },
|
||||
{ label: '大特写', value: 'bigCloseUp' },
|
||||
{ label: '特写', value: 'closeUp' },
|
||||
{ label: '腰部以上', value: 'waistShot' },
|
||||
{ label: '膝盖以上', value: 'kneeShot' },
|
||||
{ label: '全身照', value: 'fullLengthShot' },
|
||||
{ label: '极远景', value: 'extraLongShot' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'lighting',
|
||||
label: '灯光',
|
||||
component: 'Select',
|
||||
defaultValue: 'naturalLight',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '冷光', value: 'coldLight' },
|
||||
{ label: '暖光', value: 'warmLight' },
|
||||
{ label: '硬光', value: 'hardLighting' },
|
||||
{ label: '戏剧性光线', value: 'dramaticLight' },
|
||||
{ label: '反射光', value: 'reflectionLight' },
|
||||
{ label: '薄雾', value: 'mistyFoggy' },
|
||||
{ label: '自然光', value: 'naturalLight' },
|
||||
{ label: '阳光', value: 'sunLight' },
|
||||
{ label: '情绪化', value: 'moody' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 换脸表单
|
||||
*/
|
||||
export const faceSwapFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: [
|
||||
'1、需要选择在模型中已有的图像模型',
|
||||
'2、当前只支持通义万象模型(wan2.5-i2i-preview)'
|
||||
],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'IMAGE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sourceImage',
|
||||
label: '你的头像',
|
||||
component: 'JImageUpload',
|
||||
required: true,
|
||||
componentProps: {
|
||||
fileMax: 1,
|
||||
text: '上传头像',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'targetImage',
|
||||
label: '明星图',
|
||||
component: 'JImageUpload',
|
||||
required: true,
|
||||
componentProps: {
|
||||
fileMax: 1,
|
||||
text: '上传明星图',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageSize',
|
||||
label: '图片尺寸',
|
||||
component: 'Select',
|
||||
defaultValue: '720*1280',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1:1 (1024x1024)', value: '1024*1024' },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="ai-poster-page">
|
||||
<div class="page-header">
|
||||
<span class="title">AI 海报生成</span>
|
||||
<span class="subtitle">输入提示词,快速生成精美海报</span>
|
||||
<a-tabs v-model:activeKey="activeKey">
|
||||
<a-tab-pane key="poster" tab="AI 海报" />
|
||||
<a-tab-pane key="painting" tab="AI 绘画" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="content-wrapper" v-show="activeKey === 'poster'">
|
||||
<!-- 左侧模板选择 -->
|
||||
<div class="template-panel">
|
||||
<div class="panel-title">模板选择</div>
|
||||
@@ -47,7 +49,7 @@
|
||||
<p>在左侧配置参数并点击生成</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div v-if="loading && !generatedImage" class="loading-state">
|
||||
<a-spin size="large" tip="正在绘制海报,请稍候..." />
|
||||
</div>
|
||||
|
||||
@@ -68,12 +70,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiPainting v-if="activeKey === 'painting'" />
|
||||
|
||||
<ImageViewer v-if="previewVisible" :imageUrl="generatedImage" @hide="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { formSchema } from './AiPoster.data';
|
||||
import ImageViewer from '../aiapp/chat/components/ImageViewer.vue';
|
||||
@@ -81,6 +85,12 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
import AiPainting from './AiPainting.vue';
|
||||
|
||||
const TASK_ID_KEY = 'ai_poster_task_id';
|
||||
const ACTIVE_TAB_KEY = 'ai_poster_active_tab';
|
||||
const activeKey = ref(localStorage.getItem(ACTIVE_TAB_KEY) || 'poster');
|
||||
watch(activeKey, (val) => localStorage.setItem(ACTIVE_TAB_KEY, val));
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const loading = ref(false);
|
||||
@@ -88,6 +98,7 @@
|
||||
const previewVisible = ref(false);
|
||||
|
||||
const activeTemplateId = ref<number | null>(null);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const templates = [
|
||||
{
|
||||
@@ -96,14 +107,14 @@
|
||||
prompt:
|
||||
'淡雅政务风横版海报,主色调浅蓝 + 米白 + 淡灰,扁平化矢量风格,叠加细腻宣纸纹理;画面核心元素:简约政务办公楼轮廓(线条简洁)、金色钢笔、展开的公文册、淡蓝色祥云纹样、橄榄枝装饰;背景是米白渐变 + 浅蓝竖条肌理,点缀细金色边框;文字设计:居中用黑体写‘政务为民・高效规范’,下方配‘用心服务・务实笃行’浅蓝小字;整体氛围淡雅庄重、专业简洁,层次分明,光影柔和,高清细节,竖版 9:16 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/simple_1767767784521.png',
|
||||
url: 'https://upload.jeecg.com/jeecg/AI/simple.png',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '节日海报',
|
||||
prompt:
|
||||
'国潮中国风春节竖版海报,主色调红金 + 暖橙渐变,国潮插画风格,矢量扁平 + 柔和渐变质感,叠加细腻宣纸纹理;画面层次:前景是红色剪纸风梅花、金色福字贴纸、饱满水饺、红色灯笼串,中景是红墙金瓦的传统民居屋檐、飘带式祥云,远景是淡金色烟花绽放 + 暖红色光晕背景;点缀金色铜钱纹、折纸兔子、如意纹样;画面中央偏上用金色书法字体写‘新春大吉’,下方配\'万事如意\'四字楷书;整体氛围喜庆祥和、团圆温馨,层次分明主次清晰,光影柔和不刺眼,高清细节,竖版 9:16 构图',
|
||||
url: 'https://minio.jeecg.com/otatest/image89444392111_1767844276342.png',
|
||||
url: 'https://upload.jeecg.com/jeecg/AI/image89444392111.png',
|
||||
size: '720*1280',
|
||||
},
|
||||
{
|
||||
@@ -112,7 +123,7 @@
|
||||
prompt:
|
||||
'未来科技感宣传海报,主色调蓝紫渐变 + 银白金属色,冷光霓虹光效,赛博朋克线条质感;画面核心元素:全息投影的地球数据模型、流动的蓝色数据流、发光的电路板纹理、悬浮的芯片与机械齿轮、未来感建筑轮廓;点缀粒子光效、透明全息界面、霓虹光带;文字设计:居中用未来感无衬线字体写‘科技赋能・智启新程’,下方配‘创新驱动・引领未来’小字,字体带轻微发光描边;整体氛围简洁高级、充满未来感,层次分明,光影锐利,高清细节,横版 16:9 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/technology_1767765484936.png',
|
||||
url: 'https://upload.jeecg.com/jeecg/AI/technology.png',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
@@ -120,7 +131,7 @@
|
||||
prompt:
|
||||
'民国风优雅复古竖版海报,主色调米黄 + 豆沙红 + 墨黑,低饱和度胶片质感,叠加老报纸纹理与轻微颗粒感;画面核心元素:穿月白旗袍的女性侧影(盘发配珍珠发簪)、油纸伞、复古留声机、雕花木质窗棂、缠绕珍珠的藤蔓花纹;背景是模糊的老上海石库门建筑轮廓,点缀淡粉色玉兰花、复古字体排版的诗句(‘岁月静好,温婉如初’);文字设计:上方用民国手写体写‘雅致时光’,下方配衬线字体‘复刻民国风雅’,字体带轻微做旧效果;整体氛围温婉知性、静谧典雅,光影柔和(侧光勾勒人物轮廓),层次分明,高清细节,竖版 9:16 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/retro_1767765748402.png',
|
||||
url: 'https://upload.jeecg.com/jeecg/AI/retro.png',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
@@ -128,7 +139,7 @@
|
||||
prompt:
|
||||
'国潮赛博朋克横版海报,主色调中国红 + 深空黑 + 鎏金霓虹,传统纹样与科技元素碰撞,叠加红金渐变光效 + 竹简纹理;画面核心元素:龙形霓虹光带(龙身缠绕电路板)、红墙金瓦的赛博风古建筑(屋檐挂霓虹灯笼)、穿汉服改良款的赛博人物(配发光发簪 / 机械袖)、全息投影的汉字霓虹灯牌(‘江湖’‘未来’);点缀祥云数据流、金属质感的传统回纹、悬浮的鎏金元宝状机械装置;文字设计:上方用金色书法字体写‘赛博江湖’,下方配‘TECH & TRADITION’英文,字体带红金霓虹发光效果;整体氛围大气炫酷、传统与未来交融,光影强烈且富有冲击力,层次分明,高清细节,横版 16:9 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/cyberpunk_1767766076979.png',
|
||||
url: 'https://upload.jeecg.com/jeecg/AI/cyberpunk.png',
|
||||
},
|
||||
];
|
||||
const { domainUrl } = useGlobSetting();
|
||||
@@ -153,35 +164,87 @@
|
||||
createMessage.success(`已应用模板:${template.name}`);
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
/** 轮询查询任务结果 */
|
||||
function startPolling(taskId: string) {
|
||||
const poll = () => {
|
||||
defHttp
|
||||
.get({ url: `/airag/chat/getAiPosterResult/${taskId}` }, { isTransformResponse: false })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
if (res.result === 'pending' || res.result === null) {
|
||||
// 继续轮询
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
} else {
|
||||
// 成功
|
||||
let imageUrl = res.result as string;
|
||||
const reg = /#\s*{\s*domainURL\s*}/g;
|
||||
imageUrl = imageUrl.replace(reg, domainUrl + '/sys/common/static');
|
||||
generatedImage.value = imageUrl;
|
||||
loading.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.success('海报生成成功!');
|
||||
}
|
||||
} else {
|
||||
// 失败
|
||||
loading.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.warning(res.message || '海报生成失败!');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
}
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
async function handleGenerate() {
|
||||
try {
|
||||
const values = await validate();
|
||||
console.log('Generating with values:', values);
|
||||
loading.value = true;
|
||||
generatedImage.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
defHttp
|
||||
.post({ url: '/airag/chat/genAiPoster', params: values, timeout: 5 * 60 * 1000 }, { isTransformResponse: false })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
res.result = res.result.replace(reg, domainUrl + '/sys/common/static');
|
||||
generatedImage.value = res.result;
|
||||
createMessage.success('海报生成成功!');
|
||||
} else {
|
||||
createMessage.warning('海报生成失败!');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}, 2000);
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
values.type = 'poster';
|
||||
const res = await defHttp.post(
|
||||
{ url: '/airag/chat/genAiPosterAsync', params: values },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
if (res.success && res.result) {
|
||||
const taskId = res.result as string;
|
||||
localStorage.setItem(TASK_ID_KEY, taskId);
|
||||
startPolling(taskId);
|
||||
} else {
|
||||
loading.value = false;
|
||||
createMessage.warning('提交任务失败!');
|
||||
}
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
} catch (error) {
|
||||
loading.value = false;
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onMounted(() => {
|
||||
// 切换回菜单时,若有未完成的任务则恢复轮询
|
||||
const savedTaskId = localStorage.getItem(TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
loading.value = true;
|
||||
generatedImage.value = '';
|
||||
startPolling(savedTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
function handlePreview() {
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const { createConfirm } = useMessage();
|
||||
|
||||
enum Api {
|
||||
list = '/airag/prompts/list',
|
||||
queryById = '/airag/prompts/queryById',
|
||||
save='/airag/prompts/add',
|
||||
edit='/airag/prompts/edit',
|
||||
deleteOne = '/airag/prompts/delete',
|
||||
@@ -30,6 +31,13 @@ export const getImportUrl = Api.importExcel;
|
||||
export const list = (params) =>
|
||||
defHttp.get({url: Api.list, params});
|
||||
|
||||
/**
|
||||
* 根据ID查询提示词详情
|
||||
* @param id 提示词ID
|
||||
*/
|
||||
export const queryById = (id: string) =>
|
||||
defHttp.get({url: Api.queryById, params: {id}});
|
||||
|
||||
/**
|
||||
* 删除单个
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BasicColumn } from '/@/components/Table';
|
||||
import { FormSchema } from '/@/components/Table';
|
||||
import {duplicateCheckDelay} from "@/views/system/user/user.api";
|
||||
export const DESFORM_NAME_MAX_LENGTH = 40;
|
||||
import {pinyin} from "pinyin-pro";
|
||||
//列表数据
|
||||
export const columns: BasicColumn[] = [
|
||||
@@ -62,7 +61,7 @@ export const formSchema: FormSchema[] = [
|
||||
componentProps: ({ formModel }) => {
|
||||
return {
|
||||
placeholder: '例如:SQL转换',
|
||||
maxlength: DESFORM_NAME_MAX_LENGTH,
|
||||
maxlength: 40,
|
||||
showCount: true,
|
||||
onChange: (e: ChangeEvent) => {
|
||||
if(formModel.id){
|
||||
|
||||
37
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.api.ts
Normal file
37
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// AI Video API 接口配置
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
submit = '/airag/video/submit',
|
||||
query = '/airag/video/query',
|
||||
listByUser = '/airag/video/listByUser',
|
||||
deleteRecord = '/airag/video/deleteVideoRecord',
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交视频生成任务
|
||||
*/
|
||||
export const submitVideoTask = (params: any) => {
|
||||
return defHttp.post({ url: Api.submit, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询视频生成任务状态
|
||||
*/
|
||||
export const queryVideoTask = (taskId: string) => {
|
||||
return defHttp.get({ url: `${Api.query}/${taskId}` }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据用户id查询视频列表
|
||||
*/
|
||||
export const getVideoListByUser = (params: { userId: string }) => {
|
||||
return defHttp.get({ url: Api.listByUser, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除视频记录
|
||||
*/
|
||||
export const deleteVideoRecord = (params) => {
|
||||
return defHttp.delete({ url: Api.deleteRecord, params }, { isTransformResponse: false, joinParamsToUrl: true });
|
||||
};
|
||||
76
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.data.ts
Normal file
76
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.data.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { h } from 'vue';
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* 视频生成表单配置
|
||||
*/
|
||||
export const videoFormSchemas: FormSchema[] = [
|
||||
// {
|
||||
// label: '模型',
|
||||
// field: 'model',
|
||||
// component: 'JDictSelectTag',
|
||||
// required: true,
|
||||
// defaultValue: 'video-generation-1',
|
||||
// componentProps: {
|
||||
// dictCode: "airag_model where model_type = 'VIDEO' and activate_flag = 1,name,id",
|
||||
// placeholder: '请选择视频生成模型',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
label: '视频尺寸',
|
||||
field: 'size',
|
||||
component: 'Select',
|
||||
defaultValue: '1920x1080',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1280x720 (720P)', value: '1280x720' },
|
||||
{ label: '720x1280', value: '720x1280' },
|
||||
{ label: '1024x1024', value: '1024x1024' },
|
||||
{ label: '1920x1080 (1080P)', value: '1920x1080' },
|
||||
{ label: '1080x1920', value: '1080x1920' },
|
||||
{ label: '2048x1080 (2K)', value: '2048x1080' },
|
||||
{ label: '3840x2160 (4K)', value: '3840x2160' },
|
||||
],
|
||||
placeholder: '请选择视频尺寸',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '视频帧率',
|
||||
field: 'fps',
|
||||
component: 'Select',
|
||||
defaultValue: 30,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '30 FPS', value: 30 },
|
||||
{ label: '60 FPS', value: 60 },
|
||||
],
|
||||
placeholder: '请选择视频帧率',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '视频时长',
|
||||
field: 'duration',
|
||||
component: 'Select',
|
||||
defaultValue: 5,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '5秒', value: 5 },
|
||||
{ label: '10秒', value: 10 },
|
||||
],
|
||||
placeholder: '请选择视频时长',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '是否ai合成音效',
|
||||
field: 'izAiAudio',
|
||||
component: 'Select',
|
||||
defaultValue: 0,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '否', value: 0 },
|
||||
{ label: '是', value: 1 },
|
||||
],
|
||||
},
|
||||
}
|
||||
];
|
||||
375
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.less
Normal file
375
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.less
Normal file
@@ -0,0 +1,375 @@
|
||||
// AI Video 页面样式
|
||||
.ai-video-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8f959e;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
width: 380px;
|
||||
min-width: 320px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.form-item-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
:deep(.ant-input-textarea) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
margin-bottom: 2px;
|
||||
.preset-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preset-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1890ff;
|
||||
background: #fff;
|
||||
color: #1890ff;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-group {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.video-info-section {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 12px;
|
||||
|
||||
.current-video-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
width: 400px;
|
||||
min-width: 280px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.history-list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #b2b8c6;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f9ff;
|
||||
border-color: #d9e8f7;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #1f2329;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 10px;
|
||||
color: #b2b8c6;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
min-width: auto;
|
||||
font-size: 12px;
|
||||
|
||||
&.ant-btn-text {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous.ant-btn-text {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #b2b8c6;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
color: #595959;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.elapsed-time {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-player-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.video-control {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
415
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.vue
Normal file
415
jeecgboot-vue3/src/views/super/airag/aivideo/AiVideo.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="ai-video-page">
|
||||
<!-- 头部区域 -->
|
||||
<div class="page-header">
|
||||
<div class="title">AI视频</div>
|
||||
<div class="subtitle">将文本快速转换为生动的视频内容</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧:视频控制 + 文案 + 常用场景 + 生成按钮 -->
|
||||
<div class="control-panel">
|
||||
<div class="panel-title">视频配置</div>
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" :schemas="leftFormSchemas" />
|
||||
|
||||
<!-- 文案输入框 -->
|
||||
<div class="form-item-group">
|
||||
<label class="form-label">文案</label>
|
||||
<a-textarea v-model:value="formText" :rows="4" :maxlength="500" show-count placeholder="请输入要生成视频的文案内容" />
|
||||
</div>
|
||||
|
||||
<!-- 常用场景 -->
|
||||
<div class="preset-group">
|
||||
<div class="preset-label">常用场景</div>
|
||||
<div class="preset-items">
|
||||
<div v-for="(item, index) in presetTexts" :key="index" class="preset-item" @click.prevent="handleApplyPreset(item.content)">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开始生成按钮 -->
|
||||
<div class="action-btn-group">
|
||||
<a-button type="primary" size="large" block :loading="generating" @click="handleGenerate">
|
||||
<Icon icon="ant-design:video-camera-outlined" />
|
||||
开始生成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:视频预览 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">预览区域</div>
|
||||
<div class="preview-content">
|
||||
<div v-if="!currentVideoUrl && !generating" class="empty-state">
|
||||
<Icon icon="ant-design:video-camera-outlined" size="72" color="#c0c4cc" />
|
||||
<p>填写左侧文案并点击「开始生成」</p>
|
||||
<p class="tip">支持调整视频长度、风格和效果,生成更加个性化的视频内容</p>
|
||||
</div>
|
||||
|
||||
<div v-if="generating" class="loading-state">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">
|
||||
<p>正在生成视频,请耐心等待...</p>
|
||||
<p class="elapsed-time">已等待 {{ elapsedTimeText }}</p>
|
||||
<p class="status-text">{{ statusText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentVideoUrl && !generating" class="video-player-wrapper">
|
||||
<video ref="videoRef" :src="currentVideoUrl" controls class="video-control" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前播放视频信息 -->
|
||||
<div v-if="currentVideoUrl" class="video-info-section">
|
||||
<div class="current-video-info">
|
||||
<span class="info-label">当前视频:</span>
|
||||
<span class="info-text">{{ currentText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧生成历史 - 列表风格 -->
|
||||
<div class="history-panel">
|
||||
<div class="panel-title">生成历史</div>
|
||||
<div class="history-list-wrapper">
|
||||
<div v-if="historyList.length === 0" class="empty-history">
|
||||
<p>暂无生成历史</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="(item, index) in historyList" :key="item.id" class="history-item">
|
||||
<div class="item-header">
|
||||
<span class="item-title" :title="item.content">{{ item.content }}</span>
|
||||
<span class="item-time">{{ formatTime(item) }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-button type="text" size="small" @click="handlePlay(item)">
|
||||
<Icon icon="ant-design:play-circle-outlined" />
|
||||
播放
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleUseText(item)">
|
||||
<Icon icon="ant-design:copy-outlined" />
|
||||
复用文案
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleDownload(item)">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载
|
||||
</a-button>
|
||||
<a-button type="text" size="small" danger @click="handleDelete(item.id)">
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { videoFormSchemas } from './AiVideo.data';
|
||||
import { submitVideoTask, queryVideoTask, getVideoListByUser, deleteVideoRecord } from './AiVideo.api';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const TASK_ID_KEY = 'ai_video_task_id';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 左侧表单:不包含文案
|
||||
const leftFormSchemas = videoFormSchemas.filter((item) => !['text'].includes(item.field));
|
||||
|
||||
const [registerForm, { validate }] = useForm({
|
||||
schemas: leftFormSchemas,
|
||||
showActionButtonGroup: false,
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
const generating = ref(false);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const currentVideoUrl = ref<string>('');
|
||||
const currentText = ref<string>('');
|
||||
const formText = ref<string>('');
|
||||
const historyList = ref<any[]>([]);
|
||||
const isPresetApplying = ref(false);
|
||||
const elapsedSeconds = ref(0);
|
||||
const statusText = ref('任务已提交,排队中...');
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const elapsedTimeText = computed(() => {
|
||||
const minutes = Math.floor(elapsedSeconds.value / 60);
|
||||
const seconds = elapsedSeconds.value % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${seconds}秒`;
|
||||
}
|
||||
return `${seconds}秒`;
|
||||
});
|
||||
|
||||
const presetTexts = [
|
||||
{ title: '沙滩金毛犬', content: '一只金毛犬在金色的沙滩上奔跑,海浪轻轻拍打着岸边,阳光明媚,慢动作镜头' },
|
||||
{ title: '航拍山脉全景', content: '航拍壮丽的山脉全景,云雾缭绕在山峰之间,镜头缓缓推进。' },
|
||||
{ title: '咖啡微距特写', content: '一杯咖啡被缓缓倒入透明玻璃杯中,咖啡与牛奶融合形成美丽的纹理,微距特写。' },
|
||||
{ title: '极光延时摄影', content: '星空下的极光在天空中舞动,色彩绚烂,延时摄影效果。' },
|
||||
{
|
||||
title: '女讲师教学',
|
||||
content:
|
||||
'女讲师,站在 PPT 前手持教鞭指向内容,表情认真亲和,讲解自然流畅,手势得体大方,光线明亮清晰,背景干净整洁,1080P 高清,画面稳定流畅,适合知识讲解、课程教学,风格专业、清晰、有说服力',
|
||||
},
|
||||
{
|
||||
title: '口红带货主播',
|
||||
content:
|
||||
'画面主体: 一位美丽的年轻中国女主播,特写镜头,面对镜头微笑。\n' +
|
||||
'外貌着装: 她穿着浅色职业装,妆容精致无瑕。\n' +
|
||||
'核心动作: 她一只手握着一支高端口红,另一只手优雅地打开盖子,露出丝滑的膏体。她温柔地将口红涂抹在自己的嘴唇上,动作轻柔,目光专注且含笑。\n' +
|
||||
'背景环境: 身后是明亮整洁的直播工作室,有环形灯补光,背景呈柔和的虚化效果。\n' +
|
||||
'画面质感: 高色彩饱和度,电影级布光,4k分辨率,60fps。\n' +
|
||||
'特殊效果: 使用慢镜头捕捉口红涂抹瞬间的丝滑质感。\n'+
|
||||
'语言风格:中文,适合电商直播带货场景,风格专业、清晰、有说服力',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据当前用户id加载视频列表
|
||||
*/
|
||||
async function loadVideoList() {
|
||||
try {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) return;
|
||||
const res = await getVideoListByUser({ userId });
|
||||
if (res && res.result) {
|
||||
const list = Array.isArray(res.result) ? res.result : res.result?.records || [];
|
||||
historyList.value = list.map((item) => ({
|
||||
...item,
|
||||
videoFullUrl: item.videoUrl ? getFileAccessHttpUrl(item.videoUrl) : '',
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取历史列表
|
||||
loadVideoList();
|
||||
|
||||
/**
|
||||
* 应用预设文案
|
||||
*/
|
||||
function handleApplyPreset(text: string) {
|
||||
if (isPresetApplying.value) return;
|
||||
isPresetApplying.value = true;
|
||||
formText.value = text;
|
||||
setTimeout(() => {
|
||||
isPresetApplying.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器
|
||||
*/
|
||||
function clearTimers() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始生成(异步轮询模式)
|
||||
*/
|
||||
async function handleGenerate() {
|
||||
try {
|
||||
const values = await validate();
|
||||
|
||||
if (!formText.value.trim()) {
|
||||
createMessage.warn('请输入文案内容');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
generating.value = true;
|
||||
currentVideoUrl.value = '';
|
||||
elapsedSeconds.value = 0;
|
||||
statusText.value = '任务已提交,排队中...';
|
||||
values.prompt = formText.value.trim();
|
||||
|
||||
// 启动计时器
|
||||
elapsedTimer = setInterval(() => {
|
||||
elapsedSeconds.value++;
|
||||
}, 1000);
|
||||
|
||||
// 提交任务
|
||||
const submitResult = await submitVideoTask(values);
|
||||
if (!submitResult || !submitResult.success || !submitResult.result?.taskId) {
|
||||
createMessage.error(submitResult?.message || '提交任务失败');
|
||||
generating.value = false;
|
||||
clearTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = submitResult.result.taskId;
|
||||
localStorage.setItem(TASK_ID_KEY, taskId);
|
||||
statusText.value = '视频生成中...';
|
||||
|
||||
// 开始轮询
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const queryResult = await queryVideoTask(taskId);
|
||||
if (queryResult?.success && queryResult.result) {
|
||||
const { status, videoUrl, message } = queryResult.result;
|
||||
if (status === 'SUCCESS') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
currentVideoUrl.value = getFileAccessHttpUrl(videoUrl) || '';
|
||||
currentText.value = formText.value;
|
||||
createMessage.success('视频生成成功!');
|
||||
await loadVideoList();
|
||||
} else if (status === 'FAIL') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.error(message || '视频生成失败');
|
||||
}
|
||||
// PROCESSING 状态继续轮询
|
||||
}
|
||||
} catch (e: any) {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
createMessage.error('查询任务状态失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error: any) {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
createMessage.error(error.message || '提交任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从历史记录中播放
|
||||
*/
|
||||
function handlePlay(record: any) {
|
||||
currentVideoUrl.value = record.videoFullUrl || getFileAccessHttpUrl(record.videoUrl) || '';
|
||||
currentText.value = record.content;
|
||||
setTimeout(() => {
|
||||
(videoRef.value as any)?.play?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将历史记录的文案回填到文案输入框
|
||||
*/
|
||||
function handleUseText(record: any) {
|
||||
formText.value = record.content;
|
||||
createMessage.success('已将文案填入输入框');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载视频文件
|
||||
*/
|
||||
function handleDownload(record: any) {
|
||||
const url = record.videoFullUrl || getFileAccessHttpUrl(record.videoUrl);
|
||||
if (!url) {
|
||||
createMessage.error('下载地址不存在');
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = record.fileName || `video-${Date.now()}.mp4`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除历史记录
|
||||
*/
|
||||
async function handleDelete(id: string) {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await deleteVideoRecord({ userId: userId, recordId: id });
|
||||
if (res.success) {
|
||||
createMessage.success('已删除');
|
||||
await loadVideoList();
|
||||
} else {
|
||||
createMessage.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
createMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间展示
|
||||
*/
|
||||
function formatTime(item: any) {
|
||||
return item?.createTime || '';
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onMounted(() => {
|
||||
const savedTaskId = localStorage.getItem(TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
generating.value = true;
|
||||
currentVideoUrl.value = '';
|
||||
elapsedSeconds.value = 0;
|
||||
statusText.value = '任务恢复中,继续等待...';
|
||||
elapsedTimer = setInterval(() => {
|
||||
elapsedSeconds.value++;
|
||||
}, 1000);
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const queryResult = await queryVideoTask(savedTaskId);
|
||||
if (queryResult?.success && queryResult.result) {
|
||||
const { status, videoUrl, message } = queryResult.result;
|
||||
if (status === 'SUCCESS') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
currentVideoUrl.value = getFileAccessHttpUrl(videoUrl) || '';
|
||||
createMessage.success('视频生成成功!');
|
||||
await loadVideoList();
|
||||
} else if (status === 'FAIL') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.error(message || '视频生成失败');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
createMessage.error('查询任务状态失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onUnmounted(() => {
|
||||
clearTimers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import './AiVideo.less';
|
||||
</style>
|
||||
22
jeecgboot-vue3/src/views/super/airag/aivideo2/AiVideo.api.ts
Normal file
22
jeecgboot-vue3/src/views/super/airag/aivideo2/AiVideo.api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
submit = '/airag/video/submit',
|
||||
query = '/airag/video/query',
|
||||
prompts = '/airag/video/prompts',
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交视频生成任务
|
||||
*/
|
||||
export const submitVideoTask = (params: { prompt: string; category?: string }) => defHttp.post({ url: Api.submit, params });
|
||||
|
||||
/**
|
||||
* 查询视频生成任务状态
|
||||
*/
|
||||
export const queryVideoTask = (taskId: string) => defHttp.get({ url: `${Api.query}/${taskId}` });
|
||||
|
||||
/**
|
||||
* 获取预设提示词
|
||||
*/
|
||||
export const getPresetPrompts = () => defHttp.get({ url: Api.prompts });
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FormSchema } from '@/components/Form';
|
||||
|
||||
/**
|
||||
* 视频生成表单Schema
|
||||
*/
|
||||
export const videoFormSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'prompt',
|
||||
label: '视频描述',
|
||||
component: 'InputTextArea',
|
||||
required: true,
|
||||
componentProps: {
|
||||
placeholder: '请描述你想生成的视频内容,例如:一只金毛犬在沙滩上奔跑,海浪拍打岸边,阳光明媚',
|
||||
rows: 5,
|
||||
maxlength: 500,
|
||||
showCount: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 场景分类
|
||||
*/
|
||||
export const categoryList = ['通用演示', '产品营销', '教育培训', '创意设计'];
|
||||
|
||||
/**
|
||||
* 备用预设提示词(当后端API不可用时使用)
|
||||
*/
|
||||
export const fallbackPrompts: Record<string, string[]> = {
|
||||
通用演示: [
|
||||
'一只金毛犬在金色的沙滩上奔跑,海浪轻轻拍打着岸边,阳光明媚,慢动作镜头',
|
||||
'航拍壮丽的山脉全景,云雾缭绕在山峰之间,镜头缓缓推进',
|
||||
'樱花树下,花瓣随风飘落,一条小溪静静流淌,春日午后的宁静氛围',
|
||||
],
|
||||
产品营销: [
|
||||
'一杯咖啡被缓缓倒入透明玻璃杯中,咖啡与牛奶融合形成美丽的纹理,微距特写',
|
||||
'一款高端智能手表在旋转展示台上缓缓旋转,灯光打在表面上反射出金属光泽,黑色背景',
|
||||
'一双运动鞋踩入水洼溅起水花,慢动作特写,动感活力的画面',
|
||||
],
|
||||
教育培训: [
|
||||
'地球从太空视角缓缓旋转,可以看到大气层和云层的细节,星空背景',
|
||||
'一本书的书页被风吹动快速翻动,文字和插图若隐若现,知识流动的意象',
|
||||
'显微镜下的细胞分裂过程,色彩鲜明的科学可视化风格',
|
||||
],
|
||||
创意设计: [
|
||||
'一座未来主义的城市在日落时分,霓虹灯光倒映在雨水的路面上,赛博朋克风格',
|
||||
'水墨在水中缓缓扩散,形成抽象的山水画意境,中国风艺术效果',
|
||||
'星空下的极光在天空中舞动,色彩绚烂,延时摄影效果',
|
||||
],
|
||||
};
|
||||
418
jeecgboot-vue3/src/views/super/airag/aivideo2/AiVideo.vue
Normal file
418
jeecgboot-vue3/src/views/super/airag/aivideo2/AiVideo.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧配置面板 -->
|
||||
<div class="config-panel">
|
||||
<div class="config-tabs">
|
||||
<a-tabs v-model:activeKey="activeCategory" :tabBarStyle="{ margin: 0 }">
|
||||
<a-tab-pane v-for="cat in categoryList" :key="cat" :tab="cat" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 预设提示词 -->
|
||||
<div class="preset-prompts">
|
||||
<div class="preset-label">快捷提示词</div>
|
||||
<div class="preset-list">
|
||||
<a-button
|
||||
v-for="(prompt, index) in currentPrompts"
|
||||
:key="index"
|
||||
class="preset-btn"
|
||||
size="small"
|
||||
@click="applyPrompt(prompt)"
|
||||
>
|
||||
{{ prompt.length > 20 ? prompt.substring(0, 20) + '...' : prompt }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" />
|
||||
</div>
|
||||
|
||||
<div class="action-container">
|
||||
<a-button type="primary" size="large" block @click="handleGenerate" :loading="generating" :disabled="generating">
|
||||
<Icon icon="ant-design:video-camera-outlined" />
|
||||
{{ generating ? '生成中...' : '开始生成' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧预览面板 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">生成结果</div>
|
||||
<div class="preview-content">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!videoUrl && !generating" class="empty-state">
|
||||
<Icon icon="ant-design:video-camera-outlined" size="64" color="#ccc" />
|
||||
<p>在左侧输入视频描述,点击开始生成</p>
|
||||
</div>
|
||||
|
||||
<!-- 生成中 -->
|
||||
<div v-if="generating" class="loading-state">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">
|
||||
<p>正在生成视频,请耐心等待...</p>
|
||||
<p class="elapsed-time">已等待 {{ elapsedTimeText }}</p>
|
||||
<p class="status-text">{{ statusText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成完成 -->
|
||||
<div v-if="videoUrl && !generating" class="result-video-wrapper">
|
||||
<video :src="videoUrl" controls class="result-video" />
|
||||
<div class="video-actions">
|
||||
<a-button type="primary" @click="handleDownload">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载视频
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<Icon icon="ant-design:redo-outlined" />
|
||||
重新生成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成失败 -->
|
||||
<div v-if="errorMessage && !generating" class="error-state">
|
||||
<Icon icon="ant-design:close-circle-outlined" size="64" color="#ff4d4f" />
|
||||
<p class="error-text">{{ errorMessage }}</p>
|
||||
<a-button type="primary" @click="handleReset">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { submitVideoTask, queryVideoTask, getPresetPrompts } from './AiVideo.api';
|
||||
import { videoFormSchema, categoryList, fallbackPrompts } from './AiVideo.data';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const activeCategory = ref('通用演示');
|
||||
const generating = ref(false);
|
||||
const videoUrl = ref('');
|
||||
const errorMessage = ref('');
|
||||
const elapsedSeconds = ref(0);
|
||||
const statusText = ref('任务已提交,排队中...');
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// 预设提示词(优先从后端加载)
|
||||
const promptsMap = ref<Record<string, string[]>>({ ...fallbackPrompts });
|
||||
|
||||
// 加载后端预设提示词
|
||||
getPresetPrompts()
|
||||
.then((data) => {
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
promptsMap.value = data;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 使用备用提示词
|
||||
});
|
||||
|
||||
const currentPrompts = computed(() => {
|
||||
return promptsMap.value[activeCategory.value] || [];
|
||||
});
|
||||
|
||||
const elapsedTimeText = computed(() => {
|
||||
const minutes = Math.floor(elapsedSeconds.value / 60);
|
||||
const seconds = elapsedSeconds.value % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${seconds}秒`;
|
||||
}
|
||||
return `${seconds}秒`;
|
||||
});
|
||||
|
||||
const [registerForm, { validate, setFieldsValue }] = useForm({
|
||||
schemas: videoFormSchema,
|
||||
labelWidth: 100,
|
||||
actionColOptions: { span: 24 },
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
|
||||
function applyPrompt(prompt: string) {
|
||||
setFieldsValue({ prompt });
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
try {
|
||||
const values = await validate();
|
||||
if (!values.prompt || !values.prompt.trim()) {
|
||||
createMessage.warning('请输入视频描述');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
generating.value = true;
|
||||
videoUrl.value = '';
|
||||
errorMessage.value = '';
|
||||
elapsedSeconds.value = 0;
|
||||
statusText.value = '任务已提交,排队中...';
|
||||
|
||||
// 启动计时器
|
||||
elapsedTimer = setInterval(() => {
|
||||
elapsedSeconds.value++;
|
||||
}, 1000);
|
||||
|
||||
// 提交任务
|
||||
const submitResult = await submitVideoTask({
|
||||
prompt: values.prompt.trim(),
|
||||
category: activeCategory.value,
|
||||
});
|
||||
|
||||
if (!submitResult || !submitResult.taskId) {
|
||||
throw new Error(submitResult?.message || '提交任务失败');
|
||||
}
|
||||
|
||||
statusText.value = '视频生成中...';
|
||||
|
||||
// 开始轮询
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const queryResult = await queryVideoTask(submitResult.taskId);
|
||||
if (queryResult.status === 'SUCCESS') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
videoUrl.value = queryResult.videoUrl;
|
||||
createMessage.success('视频生成成功!');
|
||||
} else if (queryResult.status === 'FAIL') {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
errorMessage.value = queryResult.message || '视频生成失败';
|
||||
}
|
||||
// PROCESSING状态继续轮询
|
||||
} catch (e: any) {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
errorMessage.value = '查询任务状态失败: ' + (e.message || '未知错误');
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error: any) {
|
||||
clearTimers();
|
||||
generating.value = false;
|
||||
if (error?.errorFields) {
|
||||
// 表单验证失败,不显示额外错误
|
||||
return;
|
||||
}
|
||||
errorMessage.value = error.message || '提交任务失败';
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
videoUrl.value = '';
|
||||
errorMessage.value = '';
|
||||
elapsedSeconds.value = 0;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!videoUrl.value) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl.value;
|
||||
a.download = `ai-video-${Date.now()}.mp4`;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
width: 550px;
|
||||
min-width: 350px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.config-tabs {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-tabs-nav::before) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 8px 0;
|
||||
margin: 0 24px 0 0;
|
||||
font-size: 15px;
|
||||
|
||||
&.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-prompts {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.preset-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.preset-btn {
|
||||
color: #595959;
|
||||
border-color: #d9d9d9;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
color: #595959;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.elapsed-time {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-video-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.result-video {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-height: calc(100% - 60px);
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
|
||||
.error-text {
|
||||
margin: 16px 0;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.api.ts
Normal file
34
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
generate = '/airag/voice/generate',
|
||||
generateAsync = '/airag/voice/generateAsync',
|
||||
queryTask = '/airag/voice/queryTask',
|
||||
listByUser = '/airag/voice/listByUser',
|
||||
deleteVoiceRecord = '/airag/voice/deleteVoiceRecord',
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交语音生成任务(同步,保留兼容)
|
||||
*/
|
||||
export const submitVoiceTask = (params) => defHttp.post({ url: Api.generate, params }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 异步提交语音生成任务,立即返回 taskId
|
||||
*/
|
||||
export const generateVoiceAsync = (params) => defHttp.post({ url: Api.generateAsync, params }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 查询异步语音任务结果
|
||||
*/
|
||||
export const queryVoiceTask = (taskId: string) => defHttp.get({ url: `${Api.queryTask}/${taskId}` }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 根据用户id查询语音列表
|
||||
*/
|
||||
export const getVoiceListByUser = (params: { userId: string }) => defHttp.get({ url: Api.listByUser, params },{ isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 删除语音记录
|
||||
*/
|
||||
export const deleteVoiceRecord = (params) => defHttp.delete({ url: Api.deleteVoiceRecord, params }, { isTransformResponse: false, joinParamsToUrl: true });
|
||||
117
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.data.ts
Normal file
117
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.data.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
// 左侧语音控制表单
|
||||
export const voiceFormSchemas: FormSchema[] = [
|
||||
/* {
|
||||
label: '模型',
|
||||
field: 'model',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
defaultValue: 'voice-generation-1',
|
||||
componentProps: {
|
||||
placeholder: '请选择语音模型',
|
||||
dictCode: "airag_model where model_type = 'VOICE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},*/
|
||||
{
|
||||
label: '倍速',
|
||||
field: 'speed',
|
||||
component: 'Slider',
|
||||
defaultValue: 1,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.1,
|
||||
marks: {
|
||||
0.5: '0.5x',
|
||||
1: '1x',
|
||||
1.5: '1.5x',
|
||||
2: '2x',
|
||||
3: '3x',
|
||||
4: '4x',
|
||||
},
|
||||
tooltip: {
|
||||
formatter: (value: number) => `${value.toFixed(1)}x`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '音量增益(dB)',
|
||||
field: 'volume',
|
||||
component: 'Slider',
|
||||
defaultValue: 0,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
marks: {
|
||||
'-10': '-10',
|
||||
0: '0',
|
||||
10: '10',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '声色',
|
||||
field: 'voice',
|
||||
component: 'Select',
|
||||
required: true,
|
||||
defaultValue: 'tongtong',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '彤彤', value: 'tongtong' },
|
||||
{ label: '锤锤', value: 'chuichui' },
|
||||
{ label: '小陈', value: 'xiaochen' },
|
||||
{ label: 'Jam', value: 'jam' },
|
||||
{ label: 'Kazi', value: 'kazi' },
|
||||
{ label: 'Douji', value: 'douji' },
|
||||
{ label: 'Luodo', value: 'luodo' },
|
||||
],
|
||||
placeholder: '请选择声色',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '文案',
|
||||
field: 'text',
|
||||
component: 'InputTextArea',
|
||||
required: true,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
rows: 6,
|
||||
placeholder: '请输入要合成的文案内容',
|
||||
maxlength: 500,
|
||||
showCount: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 历史记录表格列配置
|
||||
*/
|
||||
export const historyColumns = [
|
||||
{
|
||||
title: '文案',
|
||||
dataIndex: 'text',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
382
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.less
Normal file
382
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.less
Normal file
@@ -0,0 +1,382 @@
|
||||
// AI Voice 样式文件
|
||||
.ai-voice-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8f959e;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
width: 300px;
|
||||
min-width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中间区域:试听 + 文案 + 常用场景 + 生成按钮
|
||||
.middle-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-panel {
|
||||
flex: 0 0 auto;
|
||||
height: 300px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.audio-info-section {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 12px;
|
||||
|
||||
.current-audio-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-section {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.form-item-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
:deep(.ant-input-textarea) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
.preset-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preset-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1890ff;
|
||||
background: #fff;
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-group {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
width: 400px;
|
||||
min-width: 280px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.history-list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #b2b8c6;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f9ff;
|
||||
border-color: #d9e8f7;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #1f2329;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 10px;
|
||||
color: #b2b8c6;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
min-width: auto;
|
||||
font-size: 12px;
|
||||
|
||||
&.ant-btn-text {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous.ant-btn-text {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #b2b8c6;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-player-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
332
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.vue
Normal file
332
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="ai-voice-page">
|
||||
<!-- 头部区域 -->
|
||||
<div class="page-header">
|
||||
<div class="title">AI语音</div>
|
||||
<div class="subtitle">将文本快速转换为自然流畅的语音</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧语音控制 -->
|
||||
<div class="control-panel">
|
||||
<div class="panel-title">语音控制</div>
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" :schemas="leftFormSchemas" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间区域:试听 + 文案 + 常用场景 + 生成按钮 -->
|
||||
<div class="middle-wrapper">
|
||||
<!-- 试听区域 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">试听区域</div>
|
||||
<div class="preview-content">
|
||||
<div v-if="!currentAudioUrl" class="empty-state">
|
||||
<Icon icon="ant-design:customer-service-outlined" size="72" color="#c0c4cc" />
|
||||
<p>填写下方文案并点击「开始合成」</p>
|
||||
<p class="tip">支持调整倍速、音量增益和声色,生成更加个性化的语音效果</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="audio-player-wrapper">
|
||||
<audio ref="audioRef" :src="currentAudioUrl" controls class="audio-control" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前播放音频信息 -->
|
||||
<div v-if="currentAudioUrl" class="audio-info-section">
|
||||
<div class="current-audio-info">
|
||||
<span class="info-label">当前语音:</span>
|
||||
<span class="info-text">{{ currentText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文案输入和常用场景 -->
|
||||
<div class="input-section">
|
||||
<!-- 文案输入框 -->
|
||||
<div class="form-item-group">
|
||||
<label class="form-label">文案</label>
|
||||
<a-textarea v-model:value="formText" :rows="4" :maxlength="500" show-count placeholder="请输入要合成的文案内容" />
|
||||
</div>
|
||||
|
||||
<!-- 常用场景 -->
|
||||
<div class="preset-group">
|
||||
<div class="preset-label">常用场景</div>
|
||||
<div class="preset-items">
|
||||
<div v-for="item in presetTexts" :key="item" class="preset-item" @click.prevent="handleApplyPreset(item)">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开始合成按钮 -->
|
||||
<div class="action-btn-group">
|
||||
<a-button type="primary" size="large" block :loading="generating" @click="handleSynthesize">
|
||||
<Icon icon="ant-design:sound-outlined" />
|
||||
开始合成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧生成历史 - 列表风格 -->
|
||||
<div class="history-panel">
|
||||
<div class="panel-title">生成历史</div>
|
||||
<div class="history-list-wrapper">
|
||||
<div v-if="historyList.length === 0" class="empty-history">
|
||||
<p>暂无生成历史</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="item in historyList" :key="item.id" class="history-item">
|
||||
<div class="item-header">
|
||||
<span class="item-title" :title="item.content">{{ item.content }}</span>
|
||||
<span class="item-time">{{ item.createTime }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-button type="text" size="small" @click="handlePlay(item)">
|
||||
<Icon icon="ant-design:sound-outlined" />
|
||||
播放
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleUseText(item)">
|
||||
<Icon icon="ant-design:copy-outlined" />
|
||||
复用文案
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleDownload(item)">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载
|
||||
</a-button>
|
||||
<a-button type="text" size="small" danger @click="handleDelete(item.id)">
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { voiceFormSchemas } from './AiVoice.data';
|
||||
import { generateVoiceAsync, queryVoiceTask, getVoiceListByUser, deleteVoiceRecord } from '@/views/super/airag/aivoice/AiVoice.api';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const TASK_ID_KEY = 'ai_voice_task_id';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 左侧表单:只包含模型、倍速、音量、声色,不包含文案
|
||||
const leftFormSchemas = voiceFormSchemas.filter((item) => !['text'].includes(item.field));
|
||||
|
||||
const [registerForm, { validate }] = useForm({
|
||||
schemas: leftFormSchemas,
|
||||
showActionButtonGroup: false,
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
const generating = ref(false);
|
||||
const audioRef = ref<HTMLAudioElement | null>(null);
|
||||
const currentAudioUrl = ref<string>('');
|
||||
const currentText = ref<string>('');
|
||||
const formText = ref<string>(''); // 文案输入框的独立状态
|
||||
const historyList = ref<any[]>([]);
|
||||
const isPresetApplying = ref(false); // 防抖标志
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* 根据当前用户id加载语音列表
|
||||
*/
|
||||
async function loadVoiceList() {
|
||||
try {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
await getVoiceListByUser({ userId }).then((res) =>{
|
||||
if(res && res.result){
|
||||
for (const rs of res.result) {
|
||||
if (rs.voiceUrl) {
|
||||
rs.audioUrl = getFileAccessHttpUrl(rs.voiceUrl) || '';
|
||||
}
|
||||
}
|
||||
historyList.value = res.result || [];
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取历史列表
|
||||
loadVoiceList();
|
||||
|
||||
const presetTexts = [
|
||||
'欢迎来到我们的平台,祝您使用愉快!',
|
||||
'今天的天气非常好,适合出门散步。',
|
||||
'尊敬的客户,您的订单已发货,请注意查收。',
|
||||
'Welcome to our platform, we hope you enjoy the experience!',
|
||||
];
|
||||
|
||||
/**
|
||||
* 应用预设文案
|
||||
*/
|
||||
function handleApplyPreset(text: string) {
|
||||
if (isPresetApplying.value) return;
|
||||
|
||||
isPresetApplying.value = true;
|
||||
formText.value = text;
|
||||
|
||||
// 300ms 后取消防抖标志
|
||||
setTimeout(() => {
|
||||
isPresetApplying.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始合成(异步轮询模式)
|
||||
*/
|
||||
async function handleSynthesize() {
|
||||
try {
|
||||
const values = await validate();
|
||||
|
||||
if (!formText.value.trim()) {
|
||||
createMessage.warn('请输入文案内容');
|
||||
return;
|
||||
}
|
||||
|
||||
generating.value = true;
|
||||
currentAudioUrl.value = '';
|
||||
values.content = formText.value.trim();
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
const res = await generateVoiceAsync(values);
|
||||
if (res.success && res.result) {
|
||||
const taskId = res.result as string;
|
||||
localStorage.setItem(TASK_ID_KEY, taskId);
|
||||
startPolling(taskId);
|
||||
} else {
|
||||
generating.value = false;
|
||||
createMessage.error(res.message || '提交任务失败');
|
||||
}
|
||||
} catch (e) {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询查询语音任务结果 */
|
||||
function startPolling(taskId: string) {
|
||||
const poll = () => {
|
||||
queryVoiceTask(taskId)
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
if (res.result === 'pending' || res.result === null) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
} else {
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
currentAudioUrl.value = getFileAccessHttpUrl(res.result.voiceUrl) || '';
|
||||
currentText.value = formText.value;
|
||||
createMessage.success('语音合成完成');
|
||||
loadVoiceList();
|
||||
}
|
||||
} else {
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.error(res.message || '语音合成失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onMounted(() => {
|
||||
const savedTaskId = localStorage.getItem(TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
generating.value = true;
|
||||
currentAudioUrl.value = '';
|
||||
startPolling(savedTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
/**
|
||||
* 从历史记录中播放
|
||||
*/
|
||||
function handlePlay(record: any) {
|
||||
currentAudioUrl.value = getFileAccessHttpUrl(record.voiceUrl) || '';
|
||||
currentText.value = record.content;
|
||||
setTimeout(() => {
|
||||
(audioRef.value as any)?.play?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将历史记录的文案回填到文案输入框
|
||||
*/
|
||||
function handleUseText(record: any) {
|
||||
formText.value = record.content;
|
||||
createMessage.success('已将文案填入输入框');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载语音文件
|
||||
*/
|
||||
function handleDownload(record: any) {
|
||||
const url = getFileAccessHttpUrl(record.voiceUrl);
|
||||
if (!url) {
|
||||
createMessage.error('下载地址不存在');
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = record.fileName || 'voice.wav';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除历史记录
|
||||
*/
|
||||
async function handleDelete(id: string) {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await deleteVoiceRecord({ userId: userId, recordId: id });
|
||||
if (res.success) {
|
||||
createMessage.success('已删除');
|
||||
loadVoiceList();
|
||||
} else {
|
||||
createMessage.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
createMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import './AiVoice.less';
|
||||
</style>
|
||||
Reference in New Issue
Block a user