新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。

This commit is contained in:
geht
2026-05-18 20:05:03 +08:00
parent 67ca5287e2
commit 140f4a816e
589 changed files with 65043 additions and 4682 deletions

View File

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

View File

@@ -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有哪些优势" },

View File

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

View File

@@ -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()" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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' },
],
},
},
];

View File

@@ -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】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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}});
/**
* 删除单个
*/

View File

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

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

View 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 },
],
},
}
];

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

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

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

View File

@@ -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[]> = {
通用演示: [
'一只金毛犬在金色的沙滩上奔跑,海浪轻轻拍打着岸边,阳光明媚,慢动作镜头',
'航拍壮丽的山脉全景,云雾缭绕在山峰之间,镜头缓缓推进',
'樱花树下,花瓣随风飘落,一条小溪静静流淌,春日午后的宁静氛围',
],
产品营销: [
'一杯咖啡被缓缓倒入透明玻璃杯中,咖啡与牛奶融合形成美丽的纹理,微距特写',
'一款高端智能手表在旋转展示台上缓缓旋转,灯光打在表面上反射出金属光泽,黑色背景',
'一双运动鞋踩入水洼溅起水花,慢动作特写,动感活力的画面',
],
教育培训: [
'地球从太空视角缓缓旋转,可以看到大气层和云层的细节,星空背景',
'一本书的书页被风吹动快速翻动,文字和插图若隐若现,知识流动的意象',
'显微镜下的细胞分裂过程,色彩鲜明的科学可视化风格',
],
创意设计: [
'一座未来主义的城市在日落时分,霓虹灯光倒映在雨水的路面上,赛博朋克风格',
'水墨在水中缓缓扩散,形成抽象的山水画意境,中国风艺术效果',
'星空下的极光在天空中舞动,色彩绚烂,延时摄影效果',
],
};

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

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

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

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

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