新增 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

@@ -1,21 +1,29 @@
<template>
<PageWrapper title="富文本组件示例">
<Tinymce v-model="value" @change="handleChange" width="100%" />
</PageWrapper>
<div :style="{ height: contentHeight, overflowY: 'scroll' }">
<PageWrapper title="富文本组件示例">
<Tinymce v-model="value" @change="handleChange" width="100%" />
<div style="height: 1000px"></div>
</PageWrapper>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { defineComponent, ref, computed } from 'vue';
import { Tinymce } from '/@/components/Tinymce/index';
import { PageWrapper } from '/@/components/Page';
import { useLayoutHeight } from '@/layouts/default/content/useContentViewHeight';
export default defineComponent({
components: { Tinymce, PageWrapper },
setup() {
const value = ref('hello world!');
const { headerHeightRef } = useLayoutHeight();
function handleChange(value: string) {
console.log(value);
}
return { handleChange, value };
//【issues/9448】
const contentHeight = computed(() => {
return `calc(100vh - ${headerHeightRef.value}px)`;
});
return { handleChange, value, contentHeight };
},
});
</script>

View File

@@ -1,5 +1,25 @@
<template>
<PageWrapper title="Icon组件示例">
<PageWrapper title="图标使用示例">
<CollapseContainer title="Icon组件中iconfiy图标使用不推荐使用项目中所有图标都构建在一个js且首屏就加载没有分割到chuck中不是按需引入" class="my-5">
<div class="flex justify-around flex-wrap">
<Icon icon="ion:layers-outline" :size="30" />
<Icon icon="ion:bar-chart-outline" :size="30" />
<Icon icon="ion:tv-outline" :size="30" />
<Icon icon="ion:settings-outline" :size="30" />
<Icon icon="ion:language" :size="30" />
</div>
</CollapseContainer>
<CollapseContainer title="推荐直接使用iconify原生组件分割到chunk中按需引入" class="my-5">
<div class="flex justify-around flex-wrap">
<IconifyonLayersOutline class="text-30px" />
<IconifyIonBarChartOutline class="text-30px" />
<IconifyIonTvOutline class="text-30px" />
<IconifyIonSettingsOutline class="text-30px" />
<IconIonLanguage class="text-30px" />
</div>
</CollapseContainer>
<CollapseContainer title="Antv Icon使用 (直接按需引入相应组件即可)">
<div class="flex justify-around">
<GithubFilled :style="{ fontSize: '30px' }" />
@@ -12,15 +32,6 @@
</div>
</CollapseContainer>
<CollapseContainer title="IconIfy 组件使用" class="my-5">
<div class="flex justify-around flex-wrap">
<Icon icon="ion:layers-outline" :size="30" />
<Icon icon="ion:bar-chart-outline" :size="30" />
<Icon icon="ion:tv-outline" :size="30" />
<Icon icon="ion:settings-outline" :size="30" />
</div>
</CollapseContainer>
<CollapseContainer title="svg 雪碧图" class="my-5">
<div class="flex justify-around flex-wrap">
<SvgIcon name="test" size="32" />
@@ -68,7 +79,7 @@
import { openWindow } from '/@/utils';
import { PageWrapper } from '/@/components/Page';
import IconIonLanguage from '~icons/ion/language'
export default defineComponent({
components: {
PageWrapper,
@@ -84,6 +95,7 @@
Alert,
IconPicker,
SvgIcon,
IconIonLanguage,
},
setup() {
return {

View File

@@ -46,6 +46,15 @@
</div>
</template>
<template #planTimeRangeSlot="{ row, triggerChange }">
<a-range-picker
:value="row.planTimeRange"
value-format="YYYY-MM-DD"
:bordered="false"
@change="(dates) => triggerChange(dates)"
/>
</template>
<template #myAction="props">
<a @click="onLookRow(props)">查看</a>
<a-divider type="vertical" />
@@ -212,6 +221,36 @@
customValue: ['Y', 'N'], // true ,false
defaultChecked: false,
},
{
title: '预估开始日期 ~ 预估结束日期',
key: 'planTimeRange',
type: JVxeTypes.slot,
width: 280,
slotName: 'planTimeRangeSlot',
},
{
title: '自定义树控件',
key: 'sel_tree_demo',
type: JVxeTypes.treeSelect,
width: 200,
// dict 格式:表名,文本字段,存储字段
dict: 'sys_category,name,id',
pidField: 'pid',
pidValue: '0',
hasChildField: 'has_child',
multiple: true,
placeholder: '请选择',
},
{
title: '分类字典树',
key: 'cat_tree_demo',
type: JVxeTypes.catTreeSelect,
width: 200,
// pcode: 根分类编码,'0' 表示加载所有根节点
pcode: 'B01',
multiple: true,
placeholder: '请选择',
},
{
title: '操作',
key: 'action',
@@ -261,6 +300,7 @@
select_search: options[random(0, 3)],
datetime: randomDatetime(),
checkbox: ['Y', 'N'][random(0, 1)],
planTimeRange: [dayjs().subtract(random(1, 30), 'day').format('YYYY-MM-DD'), dayjs().add(random(1, 30), 'day').format('YYYY-MM-DD')],
});
}

View File

@@ -5,7 +5,6 @@
<li>2. 使用 sortKey 属性可以自定义排序保存的 key默认为 orderNum</li>
<li>3. 使用 sortBegin 属性可以自定义排序的起始值默认为 0</li>
<li>4. sortKey 定义的字段不需要定义在 columns 中也能正常获取到值</li>
<li>5. 当存在 fixed 列时拖拽排序将会失效仅能上下排序</li>
</ol>
<p> 以下示例开启了拖拽排序排序值保存字段为 sortNum排序起始值为 3<br /> </p>

View File

@@ -25,7 +25,7 @@
<JPopup
v-model:value="model[field]"
:formElRef="formElRef"
code="ces_app_rep001"
code="withparamreport"
:param="{ sex: '1' }"
:fieldConfig="[{ source: 'name', target: 'pop2' }]"
/>
@@ -57,6 +57,9 @@
<template #superQuery1="{ model, field }">
<super-query :config="superQueryConfig" @search="(value)=>handleSuperQuery(value, model, field)" :isCustomSave="true" :saveSearchData="saveSearchData" :save="handleSuperQuerySave"/>
</template>
<template #tabsSelectUser="{ model, field }">
<JTabsSelectUser v-model:value="model[field]" ></JTabsSelectUser>
</template>
</BasicForm>
</template>
<script lang="ts">
@@ -68,7 +71,7 @@
import { schemas } from './jeecgComponents.data';
import { usePermission } from '/@/hooks/web/usePermission';
import { BasicDragVerify } from '/@/components/Verify';
import JTabsSelectUser from '/@/components/jeecg/JTabsSelectUser/index.vue';
export default defineComponent({
components: {
BasicForm,
@@ -79,6 +82,7 @@
JCheckbox,
JInput,
JEllipsis,
JTabsSelectUser,
BasicDragVerify,
},
name: 'JeecgComponents',

View File

@@ -1,4 +1,5 @@
import { FormSchema, JCronValidator } from '/@/components/Form';
import { FormSchema } from '/@/components/Form';
import JCronValidator from '/@/components/Form/src/jeecg/components/JEasyCron/validator';
import { usePermission } from '/@/hooks/web/usePermission';
const { isDisabledAuth } = usePermission();
@@ -935,7 +936,20 @@ export const schemas: FormSchema[] = [
label: '选中值',
colProps: { span: 12 },
},
{
field: 'tabsSelectUser',
component: 'Input',
label: '用户选择',
helpMessage: ['插槽模式-自己保存查询条件'],
slot: 'tabsSelectUser',
colProps: { span: 14 },
},
{
field: 'tabsSelectUser',
component: 'JEllipsis',
label: '选中值',
colProps: { span: 10 },
},
{
field: 'orderAuth',
component: 'Input',
@@ -952,5 +966,4 @@ export const schemas: FormSchema[] = [
label: '选中值',
colProps: { span: 12 },
},
];

View File

@@ -0,0 +1,46 @@
<template>
<a-card :key="key">
<div class="container">
<p class="title">vxe-table 原生加载示例</p>
<template v-if="isRegistered">
<vxe-table :align="allAlign" :data="tableData1">
<vxe-table-column type="seq" width="60"></vxe-table-column>
<vxe-table-column field="name" title="Name"></vxe-table-column>
<vxe-table-column field="sex" title="Sex"></vxe-table-column>
<vxe-table-column field="age" title="Age"></vxe-table-column>
</vxe-table>
</template>
</div>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useVxeTableRegister } from '/@/components/jeecg/JVxeTable/useVxeTableRegister';
const allAlign = ref<string | null>(null);
const tableData1 = ref([
{ id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'vxe-table 从入门到放弃' },
{ id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
{ id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
{ id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 24, address: 'Shanghai' },
]);
const key = ref(0);
const isRegistered = ref(false);
useVxeTableRegister().then(() => {
console.log('useVxeTableRegister');
isRegistered.value = true;
// vxetable放在插槽中需要更新key才能重新渲染
key.value++;
});
</script>
<style lang="less" scoped>
.container {
padding: 5px;
background-color: #fff;
.title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
}
}
</style>

View File

@@ -77,13 +77,12 @@
<script lang="ts">
import { defineComponent, ref, reactive, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/src/components/Modal';
import { JVxeTable } from '/src/components/jeecg/JVxeTable';
import { columns, columns1 } from './jvxetable.data';
import { orderCustomerList, orderTicketList, saveOrUpdate } from './jvxetable.api';
import { useJvxeMethod } from '/@/hooks/system/useJvxeMethods.ts';
export default defineComponent({
name: 'JVexTableModal',
components: { BasicModal, JVxeTable },
components: { BasicModal },
emits: ['success', 'register'],
setup(props, { emit }) {
const tableH = ref(300);

View File

@@ -1,153 +1,147 @@
<!--用户选择框-->
<template>
<div>
<BasicModal v-bind="$attrs" @register="register" title="数据对比" width="50%" destroyOnClose :showOkBtn="false">
<a-row :gutter="6" v-if="dataVersionList" style="margin-left: 2px">
<span style="margin-top: 5px; margin-right: 3px; margin-left: 4px">版本对比:</span>
<a-select placeholder="版本号" @change="handleChange1" v-model:value="params.dataId1">
<a-select-option v-for="(log, logindex) in dataVersionList" :key="log.value" :value="log.value">
{{ log.text }}
</a-select-option>
</a-select>
<BasicModal v-bind="$attrs" @register="register" title="数据版本对比" width="60%" destroyOnClose :showOkBtn="false">
<!-- 版本选择区 -->
<div class="compare-header">
<div class="compare-header__info">
<span class="compare-header__label">数据表</span>
<a-tag color="blue">{{ dataTable }}</a-tag>
<span class="compare-header__label" style="margin-left: 16px">数据ID</span>
<span class="compare-header__id">{{ dataId }}</span>
</div>
<div class="compare-header__selector">
<span class="compare-header__label">版本对比</span>
<a-select
placeholder="选择版本"
@change="handleChange1"
v-model:value="params.dataId1"
style="width: 120px"
>
<a-select-option v-for="log in dataVersionList" :key="log.value" :value="log.value">
V{{ log.text }}
</a-select-option>
</a-select>
<span class="compare-header__vs">VS</span>
<a-select
placeholder="选择版本"
@change="handleChange2"
v-model:value="params.dataId2"
style="width: 120px"
>
<a-select-option v-for="log in dataVersionList" :key="log.value" :value="log.value">
V{{ log.text }}
</a-select-option>
</a-select>
</div>
</div>
<a-select placeholder="版本号" @change="handleChange2" style="padding-left: 10px" v-model:value="params.dataId2">
<a-select-option v-for="(log, logindex) in dataVersionList" :key="log.value" :value="log.value">
{{ log.text }}
</a-select-option>
</a-select>
</a-row>
<BasicTable
:columns="columns"
v-bind="getBindValue"
:rowClassName="setDataCss"
:striped="false"
:showIndexColumn="false"
:pagination="false"
:canResize="false"
:bordered="true"
:dataSource="dataSource"
:searchInfo="searchInfo"
v-if="isUpdate"
>
<template #dataVersionTitle1="{ record }"> <Icon icon="icon-park-outline:grinning-face" /> 版本:{{ dataVersion1Num }} </template>
<template #dataVersionTitle2="{ record }"> <Icon icon="icon-park-outline:grinning-face" /> 版本:{{ dataVersion2Num }} </template>
<template #avatarslot="{ record }">
<div class="anty-img-wrap" v-if="record.dataVersion1 != record.dataVersion2">
<Icon icon="mdi:arrow-right-bold" style="color: red"></Icon>
</div>
</template>
</BasicTable>
<!-- 差异统计 -->
<div class="compare-stats" v-if="dataSource.length > 0">
<a-tag color="red">{{ diffCount }} 处差异</a-tag>
<a-tag color="green">{{ dataSource.length - diffCount }} 处相同</a-tag>
<span class="compare-stats__total"> {{ dataSource.length }} 个字段</span>
</div>
<!-- 对比表格 -->
<div class="compare-table" v-if="isUpdate">
<table class="compare-table__inner">
<thead>
<tr>
<th class="col-field">字段名</th>
<th class="col-value">
<span class="version-tag version-tag--left">V{{ dataVersion1Num }}</span>
</th>
<th class="col-status"></th>
<th class="col-value">
<span class="version-tag version-tag--right">V{{ dataVersion2Num }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in dataSource" :key="idx" :class="{ 'row-diff': row.isDiff, 'row-same': !row.isDiff }">
<td class="col-field">
<span class="field-name">{{ row.code }}</span>
</td>
<td class="col-value" :class="{ 'cell-diff': row.isDiff }">
<span class="cell-text">{{ formatValue(row.dataVersion1) }}</span>
</td>
<td class="col-status">
<span v-if="row.isDiff" class="diff-icon"></span>
<span v-else class="same-icon">=</span>
</td>
<td class="col-value" :class="{ 'cell-diff': row.isDiff }">
<span class="cell-text">{{ formatValue(row.dataVersion2) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</BasicModal>
</div>
</template>
<script lang="ts">
import { defineComponent, unref, ref, reactive, watch } from 'vue';
import { defineComponent, unref, ref, reactive, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { queryCompareList, queryDataVerList } from './datalog.api';
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { selectProps } from '/@/components/Form/src/jeecg/props/props';
import { useMessage } from '/@/hooks/web/useMessage';
export default defineComponent({
name: 'DataLogCompareModal',
components: {
//此处需要异步加载BasicTable
BasicModal,
BasicTable: createAsyncComponent(() => import('/@/components/Table/src/BasicTable.vue'), { loading: true }),
},
props: {
...selectProps,
},
emits: ['register', 'btnOk'],
setup(props, { emit, refs }) {
setup() {
const { createMessage } = useMessage();
const attrs = useAttrs();
const getBindValue = Object.assign({}, unref(props), unref(attrs));
const dataSource = ref([]);
const dataSource = ref<any[]>([]);
const dataVersion1Num = ref('');
const dataVersion2Num = ref('');
const isUpdate = ref(true);
const searchInfo = {};
const dataId1 = ref('');
const dataId2 = ref('');
const dataId = ref('');
const dataTable1 = ref('');
const dataID3 = ref('');
const dataTable = ref('');
const confirmLoading = ref(false);
const dataVersionList = ref([]);
let params = reactive({ dataId1: '', dataId2: '' });
let dataLog = reactive({});
const [register, { setModalProps, closeModal }] = useModalInner(async (data) => {
const dataVersionList = ref<any[]>([]);
const params = reactive({ dataId1: '', dataId2: '' });
const diffCount = computed(() => dataSource.value.filter((r) => r.isDiff).length);
const [register, { setModalProps }] = useModalInner(async (data) => {
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate)) {
let checkedRows = data.selectedRows;
const checkedRows = data.selectedRows;
dataTable.value = checkedRows[0].dataTable;
dataId.value = checkedRows[0].dataId;
dataId1.value = checkedRows[0].id;
dataId2.value = checkedRows[1].id;
params.dataId1 = dataId1.value;
params.dataId2 = dataId2.value;
params.dataId1 = checkedRows[0].id;
params.dataId2 = checkedRows[1].id;
await initDataVersionList();
await initTableData();
}
});
//定义表格列
const columns = [
{
title: '字段名',
dataIndex: 'code',
width: 20,
align: 'left',
},
{
dataIndex: 'dataVersion1',
align: 'left',
width: 60,
slots: { title: 'dataVersionTitle1' },
},
{
title: '',
dataIndex: 'imgshow',
align: 'center',
slots: { customRender: 'avatarslot' },
width: 5,
},
{
align: 'left',
dataIndex: 'dataVersion2',
width: 60,
filters: [],
filterMultiple: false,
slots: { title: 'dataVersionTitle2' },
},
];
async function initTableData() {
console.info('params', params);
queryCompareList(unref(params)).then((res) => {
console.info('test', res);
dataVersion1Num.value = res[0].dataVersion;
dataVersion2Num.value = res[1].dataVersion;
let json1 = JSON.parse(res[0].dataContent);
let json2 = JSON.parse(res[1].dataContent);
let data = [];
for (var item1 in json1) {
for (var item2 in json2) {
if (item1 == item2) {
data.push({
code: item1,
imgshow: '',
dataVersion1: json1[item1],
dataVersion2: json2[item2],
});
}
}
}
const json1 = JSON.parse(res[0].dataContent);
const json2 = JSON.parse(res[1].dataContent);
// 收集所有字段(兼顾两边都有的和只有一边有的)
const allKeys = new Set([...Object.keys(json1), ...Object.keys(json2)]);
const data: any[] = [];
allKeys.forEach((fieldKey) => {
const v1 = json1[fieldKey] ?? '';
const v2 = json2[fieldKey] ?? '';
data.push({
code: fieldKey,
dataVersion1: v1,
dataVersion2: v2,
isDiff: String(v1) !== String(v2),
});
});
// 差异项排前面
data.sort((a, b) => (a.isDiff === b.isDiff ? 0 : a.isDiff ? -1 : 1));
dataSource.value = data;
});
}
function handleChange1(value) {
if (params.dataId2 == value) {
createMessage.warning('相同版本号不能比较');
@@ -156,6 +150,7 @@
params.dataId1 = value;
initTableData();
}
function handleChange2(value) {
if (params.dataId1 == value) {
createMessage.warning('相同版本号不能比较');
@@ -164,57 +159,223 @@
params.dataId2 = value;
initTableData();
}
function setDataCss(record) {
let className = 'trcolor';
const dataVersion1 = record.dataVersion1;
const dataVersion2 = record.dataVersion2;
if (dataVersion1 != dataVersion2) {
return className;
}
}
async function initDataVersionList() {
queryDataVerList({ dataTable: dataTable.value, dataId: dataId.value }).then((res) => {
dataVersionList.value = res.map((value, key, arr) => {
let item = {};
item['text'] = value['dataVersion'];
item['value'] = value['id'];
return item;
});
dataVersionList.value = res.map((value) => ({
text: value['dataVersion'],
value: value['id'],
}));
});
}
function formatValue(val) {
if (val === null || val === undefined || val === '') return '--';
return String(val);
}
return {
//config,
searchInfo,
dataSource,
setDataCss,
isUpdate,
dataVersionList,
dataVersion1Num,
dataVersion2Num,
queryCompareList,
initDataVersionList,
register,
handleChange1,
handleChange2,
params,
getBindValue,
columns,
dataTable,
dataId,
diffCount,
formatValue,
};
},
});
</script>
<style scoped>
.anty-img-wrap {
height: 25px;
position: relative;
<style lang="less" scoped>
.compare-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-radius: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
&__info {
display: flex;
align-items: center;
}
&__label {
font-size: 13px;
color: #8c8c8c;
white-space: nowrap;
}
&__id {
font-size: 12px;
color: #595959;
font-family: 'Consolas', 'Monaco', monospace;
word-break: break-all;
}
&__selector {
display: flex;
align-items: center;
gap: 8px;
}
&__vs {
font-weight: 600;
color: #faad14;
font-size: 14px;
}
}
.anty-img-wrap > img {
max-height: 100%;
.compare-stats {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
&__total {
font-size: 12px;
color: #8c8c8c;
margin-left: 4px;
}
}
.marginCss {
margin-top: 20px;
.compare-table {
border: 1px solid #f0f0f0;
border-radius: 6px;
overflow: hidden;
&__inner {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead {
tr {
background: #fafafa;
}
th {
padding: 10px 12px;
font-weight: 500;
color: #595959;
border-bottom: 1px solid #f0f0f0;
text-align: left;
}
}
tbody {
tr {
transition: background 0.2s;
&:hover {
background: #fafafa;
}
&:not(:last-child) td {
border-bottom: 1px solid #f5f5f5;
}
}
td {
padding: 8px 12px;
color: #333;
vertical-align: top;
}
}
}
}
.col-field {
width: 140px;
min-width: 120px;
}
.col-value {
width: 40%;
}
.col-status {
width: 36px;
text-align: center !important;
}
.field-name {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: #1890ff;
}
.cell-text {
word-break: break-all;
font-size: 12px;
line-height: 1.5;
}
.row-diff {
.field-name {
font-weight: 600;
}
}
.cell-diff {
background: #fff7e6;
.cell-text {
color: #d46b08;
font-weight: 500;
}
}
.diff-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff1f0;
color: #ff4d4f;
font-size: 12px;
font-weight: 700;
}
.same-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #f6ffed;
color: #52c41a;
font-size: 12px;
font-weight: 700;
}
.version-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&--left {
background: #e6f7ff;
color: #1890ff;
}
&--right {
background: #f6ffed;
color: #52c41a;
}
}
</style>

View File

@@ -1,31 +1,80 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
import { h } from 'vue';
import { Tag, Tooltip } from 'ant-design-vue';
export const columns: BasicColumn[] = [
{
title: '表名',
dataIndex: 'dataTable',
width: 150,
width: 120,
align: 'left',
customRender: ({ text }) => {
return h(Tag, { color: 'blue' }, () => text);
},
},
{
title: '数据ID',
dataIndex: 'dataId',
width: 350,
width: 260,
align: 'left',
ellipsis: true,
customRender: ({ text }) => {
return h(
'span',
{ style: 'font-family: Consolas, Monaco, monospace; font-size: 12px; color: #595959' },
text
);
},
},
{
title: '版本号',
dataIndex: 'dataVersion',
width: 100,
width: 70,
align: 'center',
customRender: ({ text }) => {
return h(Tag, { color: 'green' }, () => 'V' + text);
},
},
{
title: '数据内容',
dataIndex: 'dataContent',
ellipsis: true,
customRender: ({ text }) => {
if (!text) return '--';
// 尝试格式化 JSON 显示关键字段
try {
const obj = JSON.parse(text);
const keys = Object.keys(obj);
const preview = keys
.slice(0, 3)
.map((k) => {
const v = obj[k];
const val = v === null || v === undefined || v === '' ? '--' : String(v);
return `${k}: ${val.length > 20 ? val.substring(0, 20) + '...' : val}`;
})
.join(' | ');
const suffix = keys.length > 3 ? ` (+${keys.length - 3} 字段)` : '';
return h(
Tooltip,
{ title: JSON.stringify(obj, null, 2), overlayStyle: { maxWidth: '500px', whiteSpace: 'pre-wrap', fontFamily: 'Consolas, monospace', fontSize: '12px' } },
() => h('span', { style: 'font-size: 12px; color: #595959' }, preview + suffix)
);
} catch {
return text;
}
},
},
{
title: '创建人',
dataIndex: 'createBy',
sorter: true,
width: 200,
width: 90,
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 120,
sorter: true,
},
];
@@ -34,12 +83,27 @@ export const searchFormSchema: FormSchema[] = [
field: 'dataTable',
label: '表名',
component: 'Input',
colProps: { span: 8 },
componentProps: {
placeholder: '请输入表名',
},
colProps: { span: 6 },
},
{
field: 'dataId',
label: '数据ID',
component: 'Input',
colProps: { span: 8 },
componentProps: {
placeholder: '请输入数据ID',
},
colProps: { span: 6 },
},
{
field: 'createBy',
label: '创建人',
component: 'Input',
componentProps: {
placeholder: '请输入创建人',
},
colProps: { span: 6 },
},
];

View File

@@ -2,34 +2,38 @@
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button preIcon="ant-design:plus-outlined" type="primary" @click="handleCompare" style="margin-right: 5px">数据比较</a-button>
<a-button preIcon="ant-design:swap-outlined" type="primary" size="small" @click="handleCompare">数据比较</a-button>
<span v-if="selectedRowKeys.length === 0" class="compare-tip">请勾选两条相同数据ID的记录</span>
<a-tag v-else-if="selectedRowKeys.length === 1" color="warning" style="margin-left: 10px">再选一条相同数据ID的记录</a-tag>
<a-tag v-else-if="selectedRowKeys.length === 2 && !isSameDataId" color="error" style="margin-left: 10px">数据ID不一致无法比较</a-tag>
<a-tag v-else-if="selectedRowKeys.length === 2 && isSameDataId" color="success" style="margin-left: 10px">可以比较 V{{ selectedRows[0]?.dataVersion }} vs V{{ selectedRows[1]?.dataVersion }}</a-tag>
<a-tag v-else color="error" style="margin-left: 10px">只能选择两条记录</a-tag>
</template>
</BasicTable>
<DataLogCompareModal @register="registerModal" @success="reload" />
</div>
</template>
<script lang="ts" name="monitor-datalog" setup>
import { ref } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { computed } from 'vue';
import { BasicTable } from '/@/components/Table';
import DataLogCompareModal from './DataLogCompareModal.vue';
const [registerModal, { openModal }] = useModal();
import { getDataLogList } from './datalog.api';
import { columns, searchFormSchema } from './datalog.data';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
const { createMessage } = useMessage();
const checkedRows = ref<Array<object | number>>([]);
// 列表页面公共参数、方法
const { prefixCls, tableContext } = useListPage({
const [registerModal, { openModal }] = useModal();
const { createMessage } = useMessage();
const { tableContext } = useListPage({
designScope: 'datalog-template',
tableProps: {
title: '数据日志列表',
api: getDataLogList,
columns: columns,
formConfig: {
labelWidth: 120,
labelWidth: 80,
schemas: searchFormSchema,
},
actionColumn: false,
@@ -38,20 +42,32 @@
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
const isSameDataId = computed(() => {
const rows = selectedRows.value;
if (!rows || rows.length !== 2) return false;
return rows[0].dataId === rows[1].dataId;
});
function handleCompare() {
let obj = selectedRows.value;
console.info('sfsfsf', obj);
if (!obj || obj.length != 2) {
createMessage.warning('请选择两条数据!');
return false;
const rows = selectedRows.value;
if (!rows || rows.length !== 2) {
createMessage.warning('请选择两条数据进行比较!');
return;
}
if (obj[0].dataId != obj[1].dataId) {
createMessage.warning('请选择相同的数据库表和数据ID进行比较!');
return false;
if (rows[0].dataId !== rows[1].dataId) {
createMessage.warning('请选择相同数据ID的记录进行比较');
return;
}
openModal(true, {
selectedRows,
selectedRows: rows,
isUpdate: true,
});
}
</script>
<style lang="less" scoped>
.compare-tip {
margin-left: 10px;
font-size: 12px;
color: #999;
}
</style>

View File

@@ -27,9 +27,9 @@
>
</div>
<div v-if="searchInfo.logType == 4">
<div style="margin-bottom: 5px">
<a-badge status="success" style="vertical-align: middle" />
<span class="error-box" style="vertical-align: middle">异常堆栈:{{ record.requestParam }}</span>
<div class="error-section">
<div class="error-label"><a-badge status="error" /> 异常堆栈:</div>
<pre class="error-box">{{ record.requestParam }}</pre>
</div>
</div>
</template>
@@ -105,19 +105,42 @@
}
</script>
<style lang="less" scoped>
.error-box {
white-space: break-spaces;
.error-section {
.error-label {
font-weight: 500;
margin-bottom: 8px;
color: #ff4d4f;
}
}
.error-box {
margin: 0;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
color: #595959;
}
.table-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.export-btn {
margin-left: auto;
}
:deep(.jeecg-basic-table-header__toolbar){
width:100px !important;
:deep(.jeecg-basic-table-header__toolbar) {
width: 100px !important;
}
</style>

View File

@@ -4,8 +4,9 @@ export const columns: BasicColumn[] = [
{
title: '日志内容',
dataIndex: 'logContent',
width: 100,
width: 200,
align: 'left',
ellipsis: true,
},
{
title: '操作人ID',
@@ -20,12 +21,12 @@ export const columns: BasicColumn[] = [
{
title: 'IP',
dataIndex: 'ip',
width: 80,
width: 60,
},
{
title: '耗时(毫秒)',
dataIndex: 'costTime',
width: 80,
width: 50,
},
{
title: '创建时间',
@@ -36,7 +37,7 @@ export const columns: BasicColumn[] = [
{
title: '客户端类型',
dataIndex: 'clientType_dictText',
width: 60,
width: 50,
},
];
@@ -56,30 +57,35 @@ export const exceptionColumns: BasicColumn[] = [
{
title: '异常标题',
dataIndex: 'logContent',
width: 100,
width: 200,
align: 'left',
ellipsis: true,
},
{
title: '请求地址',
dataIndex: 'requestUrl',
width: 100,
width: 140,
align: 'left',
ellipsis: true,
},
{
title: '请求参数',
title: '请求方法',
dataIndex: 'method',
width: 60,
width: 120,
align: 'left',
ellipsis: true,
},
{
title: '操作人',
dataIndex: 'username',
width: 60,
width: 80,
customRender: ({ record }) => {
let pname = record.username;
let pid = record.userid;
if(!pname && !pid){
return "";
const pname = record.username;
const pid = record.userid;
if (!pname && !pid) {
return '';
}
return pname + " (账号: "+ pid + " )";
return pname + ' (' + pid + ')';
},
},
{
@@ -91,12 +97,12 @@ export const exceptionColumns: BasicColumn[] = [
title: '创建时间',
dataIndex: 'createTime',
sorter: true,
width: 60,
width: 80,
},
{
title: '客户端类型',
dataIndex: 'clientType_dictText',
width: 60,
width: 50,
},
];

View File

@@ -33,7 +33,7 @@
</template>
</a-card-meta>
<a-divider />
<div v-html="content.msgContent" class="article-content"></div>
<div v-html="removeSpecialTags(content.msgContent)" class="article-content"></div>
<div>
<a-button v-if="hasHref" @click="jumpToHandlePage">前往办理<ArrowRightOutlined /></a-button>
</div>
@@ -81,6 +81,7 @@
import { getToken } from '@/utils/auth';
import {defHttp} from "@/utils/http/axios";
import {$electron} from "@/electron";
import { removeSpecialTags } from '@/utils/index';
const router = useRouter();
const glob = useGlobSetting();
const isUpdate = ref(true);
@@ -279,7 +280,9 @@
function handleViewFile(filePath) {
if (filePath) {
console.log('glob.onlineUrl', glob.viewUrl);
let url = encodeURIComponent(encryptByBase64(filePath));
//update-begin-author:scott---date:2026-04-16--for: 【Github #8855】修复文件预览路径处理问题filePath需要先拼接完整URL再编码
let url = encodeURIComponent(encryptByBase64(getFileAccessHttpUrl(filePath)));
//update-end-author:scott---date:2026-04-16--for: 【Github #8855】修复文件预览路径处理问题filePath需要先拼接完整URL再编码
let previewUrl = `${glob.viewUrl}?url=` + url;
//update-begin-author:liusq---date:2025-12-16--for: JHHB-1139桌面端 文件预览统一修改
if($electron.isElectron()){
@@ -377,6 +380,20 @@
max-width: 100%;
height: auto;
}
/* 修复 Word 复制内容中表格边框丢失和间隔问题 */
.article-content {
:deep(table) {
border-collapse: collapse !important;
border-spacing: 0 !important;
}
:deep(table td),
:deep(table th) {
border: 1px solid #d0d0d0;
padding: 4px 8px;
min-width: 20px;
word-break: break-word;
}
}
.basic-title{
position: relative;
display: flex;

View File

@@ -19,10 +19,10 @@ export const options = {
a: ['style', 'target', 'href', 'title', 'rel'],
img: ['style', 'src', 'title','width','height'],
div: ['style'],
table: ['style', 'width', 'border', 'height'],
tr: ['style'],
td: ['style', 'width', 'colspan'],
th: ['style', 'width', 'colspan'],
table: ['style', 'width', 'border', 'height', 'cellspacing', 'cellpadding'],
tr: ['style', 'valign', 'align'],
td: ['style', 'width', 'colspan', 'rowspan', 'border', 'valign', 'align'],
th: ['style', 'width', 'colspan', 'rowspan', 'border', 'valign', 'align'],
tbody: ['style'],
ul: ['style'],
li: ['style'],

View File

@@ -1,6 +1,6 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
import { render } from '/@/utils/common/renderUtils';
import { JCronValidator } from '/@/components/Form';
import JCronValidator from '/@/components/Form/src/jeecg/components/JEasyCron/validator';
export const columns: BasicColumn[] = [
{

View File

@@ -1,130 +1,188 @@
<template>
<div class="p-4">
<a-card>
<!-- Redis 信息实时监控 -->
<a-row :gutter="8">
<a-col :sm="24" :xl="12">
<div ref="chartRef" style="width: 100%; height: 300px"></div>
</a-col>
<a-col :sm="24" :xl="12">
<div ref="chartRef2" style="width: 100%; height: 300px"></div>
</a-col>
</a-row>
</a-card>
<div class="redis-monitor p-4">
<!-- 顶部概览卡片 -->
<a-row :gutter="16" class="overview-row">
<a-col :sm="12" :md="6" v-for="item in overviewCards" :key="item.label">
<div class="overview-card" :style="{ borderTopColor: item.color }">
<div class="overview-card__value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="overview-card__label">{{ item.label }}</div>
</div>
</a-col>
</a-row>
<BasicTable @register="registerTable" :api="getInfo"></BasicTable>
<!-- Redis 信息实时监控 -->
<a-row :gutter="16" class="chart-row">
<a-col :sm="24" :xl="12">
<a-card :bordered="false" class="chart-card">
<div ref="chartRef" style="width: 100%; height: 300px"></div>
</a-card>
</a-col>
<a-col :sm="24" :xl="12">
<a-card :bordered="false" class="chart-card">
<div ref="chartRef2" style="width: 100%; height: 300px"></div>
</a-card>
</a-col>
</a-row>
<!-- Redis 详细信息表格 -->
<a-card :bordered="false" class="table-card" title="Redis 配置详情">
<BasicTable @register="registerTable" :api="getInfo" :canResize="false"></BasicTable>
</a-card>
</div>
</template>
<script lang="ts" name="monitor-redis" setup>
import { onMounted, ref, reactive, Ref, onUnmounted } from 'vue';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { onMounted, ref, reactive, Ref, onUnmounted, computed } from 'vue';
import { BasicTable, useTable } from '/@/components/Table';
import { getInfo, getRedisInfo, getMetricsHistory } from './redis.api';
import dayjs from 'dayjs';
import { columns } from './redis.data';
import { useMessage } from '/@/hooks/web/useMessage';
import { useECharts } from '/@/hooks/web/useECharts';
const dataSource = ref([]);
const chartRef = ref<HTMLDivElement | null>(null);
const chartRef2 = ref<HTMLDivElement | null>(null);
const { setOptions, echarts } = useECharts(chartRef as Ref<HTMLDivElement>);
const { setOptions: setOptions2, echarts: echarts2 } = useECharts(chartRef2 as Ref<HTMLDivElement>);
const loading = ref(false);
let timer = null;
const { createMessage } = useMessage();
const key = reactive({
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
const { setOptions: setOptions2 } = useECharts(chartRef2 as Ref<HTMLDivElement>);
let timer: any = null;
// 概览数据
const currentMemory = ref('--');
const currentKeys = ref('--');
const currentUptime = ref('--');
const currentPort = ref('--');
const overviewCards = computed(() => [
{ label: '已用内存', value: currentMemory.value, color: '#1890ff' },
{ label: 'Key 数量', value: currentKeys.value, color: '#52c41a' },
{ label: '运行时间', value: currentUptime.value, color: '#faad14' },
{ label: '监听端口', value: currentPort.value, color: '#722ed1' },
]);
const memoryOption = reactive({
title: {
text: 'Redis Key 实时数量(个',
text: 'Redis 内存实时占用KB',
textStyle: { fontSize: 14, fontWeight: 500, color: '#333' },
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [],
},
yAxis: {
type: 'value',
},
series: [
{
data: [],
type: 'line',
areaStyle: {
color: '#ff6987',
},
lineStyle: {
color: '#dc143c',
width: 10,
type: 'solid',
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.96)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: { color: '#333', fontSize: 12 },
formatter(params) {
const p = params[0];
return `<div style="font-weight:500;margin-bottom:4px">${p.axisValue}</div>
<span style="color:#1890ff">● 内存</span>${p.value} KB`;
},
],
});
const memory = reactive({
title: {
text: 'Redis 内存实时占用情况KB',
},
grid: { top: 50, right: 20, bottom: 30, left: 60 },
xAxis: {
type: 'category',
boundaryGap: false,
data: [],
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisTick: { show: false },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
series: [
{
data: [],
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
lineStyle: { color: '#1890ff', width: 2 },
itemStyle: { color: '#1890ff' },
areaStyle: {
color: '#74bcff',
},
lineStyle: {
color: '#1890ff',
width: 10,
type: 'solid',
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24,144,255,0.25)' },
{ offset: 1, color: 'rgba(24,144,255,0.02)' },
],
},
},
},
],
});
const [registerTable, { reload }] = useTable({
const keyOption = reactive({
title: {
text: 'Redis Key 实时数量(个)',
textStyle: { fontSize: 14, fontWeight: 500, color: '#333' },
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.96)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: { color: '#333', fontSize: 12 },
formatter(params) {
const p = params[0];
return `<div style="font-weight:500;margin-bottom:4px">${p.axisValue}</div>
<span style="color:#52c41a">● Key 数量</span>${p.value}`;
},
},
grid: { top: 50, right: 20, bottom: 30, left: 60 },
xAxis: {
type: 'category',
boundaryGap: false,
data: [],
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisTick: { show: false },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
series: [
{
data: [],
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
lineStyle: { color: '#52c41a', width: 2 },
itemStyle: { color: '#52c41a' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(82,196,26,0.25)' },
{ offset: 1, color: 'rgba(82,196,26,0.02)' },
],
},
},
},
],
});
const [registerTable] = useTable({
columns,
showIndexColumn: false,
pagination: false,
bordered: true,
canResize: false,
showTableSetting: false,
});
// 获取一组数据中最大和最小的值
function getMaxAndMin(dataSource, field) {
let maxValue = null,
minValue = null;
dataSource.forEach((item) => {
let value = Number.parseInt(item[field]);
// max
if (maxValue == null) {
maxValue = value;
} else if (value > maxValue) {
maxValue = value;
}
// min
if (minValue == null) {
minValue = value;
} else if (value < minValue) {
minValue = value;
}
});
return [maxValue, minValue];
}
function loadRedisInfo() {
getInfo().then((res) => {
dataSource.value = res.result;
});
}
function initCharts() {
setOptions(memory);
setOptions2(key);
setOptions(memoryOption);
setOptions2(keyOption);
}
/** 开启定时器 */
@@ -141,70 +199,127 @@
if (timer) clearInterval(timer);
}
/**
* 加载历史监控数据
*/
/** 加载历史监控数据 */
function loadHistoryData() {
getMetricsHistory().then((res) => {
let dbSizes = res.dbSize;
let memories = res.memory;
const dbSizes = res.dbSize;
const memories = res.memory;
dbSizes.forEach((dbSize) => {
key.xAxis.data.push(dayjs(dbSize.create_time).format('hh:mm:ss'));
key.series[0].data.push(dbSize.dbSize);
keyOption.xAxis.data.push(dayjs(dbSize.create_time).format('HH:mm:ss'));
keyOption.series[0].data.push(dbSize.dbSize);
});
memories.forEach((memoryData) => {
memory.xAxis.data.push(dayjs(memoryData.create_time).format('hh:mm:ss'));
memory.series[0].data.push(memoryData.used_memory / 1000);
memoryOption.xAxis.data.push(dayjs(memoryData.create_time).format('HH:mm:ss'));
memoryOption.series[0].data.push(memoryData.used_memory / 1000);
});
setOptions(memory, false);
setOptions2(key, false);
// 更新概览卡片
if (memories.length > 0) {
const lastMem = memories[memories.length - 1].used_memory / 1000;
currentMemory.value = lastMem.toFixed(0) + ' KB';
}
if (dbSizes.length > 0) {
currentKeys.value = dbSizes[dbSizes.length - 1].dbSize + '';
}
setOptions(memoryOption, false);
setOptions2(keyOption, false);
});
// 加载详细信息获取端口和运行时间
getInfo().then((res) => {
const list = res.result || res;
if (Array.isArray(list)) {
list.forEach((item) => {
if (item.key === 'tcp_port') currentPort.value = item.value;
if (item.key === 'uptime_in_days') currentUptime.value = item.value + ' 天';
});
}
});
}
function loadData() {
getRedisInfo()
.then((res) => {
let time = dayjs().format('hh:mm:ss');
let [{ dbSize: currentSize }, memoryInfo] = res;
let currentMemory = memoryInfo.used_memory / 1000;
// push 数据
key.xAxis.data.push(time);
key.series[0].data.push(currentSize);
memory.xAxis.data.push(time);
memory.series[0].data.push(currentMemory);
const time = dayjs().format('HH:mm:ss');
const [{ dbSize: curSize }, memInfo] = res;
const curMem = memInfo.used_memory / 1000;
keyOption.xAxis.data.push(time);
keyOption.series[0].data.push(curSize);
memoryOption.xAxis.data.push(time);
memoryOption.series[0].data.push(curMem);
// 更新概览
currentMemory.value = curMem.toFixed(0) + ' KB';
currentKeys.value = curSize + '';
// 最大长度为80
if (key.series[0].data.length > 80) {
key.xAxis.data.splice(0, 1);
key.series[0].data.splice(0, 1);
memory.xAxis.data.splice(0, 1);
memory.series[0].data.splice(0, 1);
if (keyOption.series[0].data.length > 80) {
keyOption.xAxis.data.splice(0, 1);
keyOption.series[0].data.splice(0, 1);
memoryOption.xAxis.data.splice(0, 1);
memoryOption.series[0].data.splice(0, 1);
}
setOptions(memory, false);
setOptions2(key, false);
// 计算 Key 最大最小值
//let keyPole = getMaxAndMin(key.dataSource, 'y');
//key.max = Math.floor(keyPole[0]) + 10;
//key.min = Math.floor(keyPole[1]) - 10;
//if (key.min < 0) this.key.min = 0;
// 计算 Memory 最大最小值
//let memoryPole = getMaxAndMin(memory.dataSource, 'y');
//memory.max = Math.floor(memoryPole[0]) + 100;
//memory.min = Math.floor(memoryPole[1]) - 100;
//if (memory.min < 0) memory.min = 0;
setOptions(memoryOption, false);
setOptions2(keyOption, false);
})
.catch((e) => {
//closeTimer()
});
.catch(() => {});
}
onMounted(() => {
initCharts();
openTimer();
});
// 代码逻辑说明: 【issues-615】系统监控中的REDIS监控页面打开再关闭后没有关闭计时器
onUnmounted(() => {
closeTimer();
});
</script>
<style lang="less" scoped>
.redis-monitor {
.overview-row {
margin-bottom: 16px;
}
.overview-card {
background: #fff;
border-radius: 6px;
padding: 20px 24px;
border-top: 3px solid #1890ff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
&__value {
font-size: 28px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 4px;
}
&__label {
font-size: 13px;
color: #8c8c8c;
}
}
.chart-row {
margin-bottom: 16px;
}
.chart-card {
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
:deep(.ant-card-body) {
padding: 16px;
}
}
.table-card {
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
}
</style>

View File

@@ -2,18 +2,25 @@ import { BasicColumn } from '/@/components/Table';
export const columns: BasicColumn[] = [
{
title: 'Key',
title: '配置项',
dataIndex: 'key',
width: 100,
width: 120,
align: 'left',
customRender: ({ text }) => {
return text;
},
},
{
title: 'Description',
title: '说明',
dataIndex: 'description',
width: 80,
width: 200,
align: 'left',
ellipsis: true,
},
{
title: 'Value',
title: '',
dataIndex: 'value',
width: 80,
align: 'right',
},
];

View File

@@ -11,35 +11,34 @@ export const columns: BasicColumn[] = [
align:"center",
dataIndex: 'name'
},
{
title: '请求方法',
align:"center",
dataIndex: 'requestMethod'
},
{
title: '接口地址',
align:"center",
dataIndex: 'requestUrl'
dataIndex: 'requestUrl',
width: 120,
},
{
title: 'IP 黑名单',
title: '请求方式',
align:"center",
dataIndex: 'blackList'
},
// {
// title: '状态',
// align:"center",
// dataIndex: 'status'
// },
{
title: '创建人',
align:"center",
dataIndex: 'createBy'
dataIndex: 'requestMethod',
width: 100,
},
{
title: '创建时间',
title: '原始接口',
align:"center",
dataIndex: 'createTime'
dataIndex: 'originUrl',
ellipsis: true,
},
{
title: 'IP 白名单',
align:"center",
dataIndex: 'whiteList',
ellipsis: true,
customRender: ({ text }) => {
if (!text) return '不限制';
const count = text.split(/[,\n]/).filter(item => item.trim()).length;
return count + ' 条规则';
}
},
];
//查询数据
@@ -50,8 +49,8 @@ export const searchFormSchema: FormSchema[] = [
component: 'JInput',
},
{
label: "创建人",
field: "createBy",
label: "接口地址",
field: "requestUrl",
component: 'JInput',
},
];
@@ -68,12 +67,35 @@ export const formSchema: FormSchema[] = [
},
},
{
label: '原始地址',
label: '原始接口',
field: 'originUrl',
component: 'Input',
componentProps: {
placeholder: '当前系统的原始接口地址,如 /sys/user/list',
},
helpMessage: '当前系统中被代理的原始接口路径',
dynamicRules: () => {
return [
{ required: true, message: '请输入原始接口路径!' },
{
validator: (_, value) => {
if (value && !value.startsWith('/')) {
return Promise.reject('原始接口路径必须以 / 开头');
}
if (value && value.includes('//')) {
return Promise.reject('原始接口路径不能包含 //');
}
if (value && value.includes('..')) {
return Promise.reject('原始接口路径不能包含 ..');
}
return Promise.resolve();
},
},
];
},
},
{
label: '请求方',
label: '请求方',
field: 'requestMethod',
component: 'JSearchSelect',
componentProps:{
@@ -112,7 +134,7 @@ export const formSchema: FormSchema[] = [
},
dynamicRules: ({model,schema}) => {
return [
{ required: true, message: '请输入请求方!'},
{ required: true, message: '请输入请求方!'},
];
},
},
@@ -123,14 +145,36 @@ export const formSchema: FormSchema[] = [
dynamicDisabled:true
},
{
label: 'IP 名单',
field: 'blackList',
component: 'Input',
label: 'IP 名单',
field: 'whiteList',
helpMessage: '支持精确IP、CIDR网段如192.168.1.0/24、通配符如10.2.3.*),每行一个或逗号分隔,为空则不限制',
component: 'InputTextArea',
slot: 'whiteListSlot',
componentProps: {
rows: 5,
placeholder: '示例:\n192.168.1.100\n10.0.0.0/8\n172.16.*.*',
},
colProps: { span: 24 },
},
{
label: '请求体内容',
component:"Input",
field: 'body'
label: '备注',
field: 'comment',
component: 'InputTextArea',
componentProps: {
rows: 2,
placeholder: '请输入白名单备注说明',
},
colProps: { span: 24 },
},
{
label: '接口描述',
field: 'description',
component: 'InputTextArea',
componentProps: {
rows: 3,
placeholder: '请输入接口描述',
},
colProps: { span: 24 },
},
{
label: '删除标识',
@@ -240,6 +284,21 @@ export const openApiHeaderJVxeColumns: JVxeColumn[] = [
defaultValue:'',
customValue: ['1','0']
},
{
title: '参数类型',
key: 'paramType',
type: JVxeTypes.select,
width: '120px',
options: [
{ title: 'string', value: 'string' },
{ title: 'integer', value: 'integer' },
{ title: 'number', value: 'number' },
{ title: 'boolean', value: 'boolean' },
{ title: 'array', value: 'array' },
{ title: 'object', value: 'object' },
],
defaultValue: 'string',
},
{
title: '默认值',
key: 'defaultValue',
@@ -248,6 +307,14 @@ export const openApiHeaderJVxeColumns: JVxeColumn[] = [
placeholder: '请输入${title}',
defaultValue:'',
},
{
title: '示例值',
key: 'example',
type: JVxeTypes.input,
width: '200px',
placeholder: '请输入${title}',
defaultValue: '',
},
{
title: '备注',
key: 'note',
@@ -284,6 +351,21 @@ export const openApiParamJVxeColumns: JVxeColumn[] = [
defaultValue:'',
customValue: ['1','0']
},
{
title: '参数类型',
key: 'paramType',
type: JVxeTypes.select,
width: '120px',
options: [
{ title: 'string', value: 'string' },
{ title: 'integer', value: 'integer' },
{ title: 'number', value: 'number' },
{ title: 'boolean', value: 'boolean' },
{ title: 'array', value: 'array' },
{ title: 'object', value: 'object' },
],
defaultValue: 'string',
},
{
title: '默认值',
key: 'defaultValue',
@@ -292,6 +374,14 @@ export const openApiParamJVxeColumns: JVxeColumn[] = [
placeholder: '请输入${title}',
defaultValue:'',
},
{
title: '示例值',
key: 'example',
type: JVxeTypes.input,
width: '200px',
placeholder: '请输入${title}',
defaultValue: '',
},
{
title: '备注',
key: 'note',
@@ -301,12 +391,45 @@ export const openApiParamJVxeColumns: JVxeColumn[] = [
},
]
export const responseFieldJVxeColumns: JVxeColumn[] = [
{
title: '字段名',
key: 'fieldName',
type: JVxeTypes.input,
width: '200px',
placeholder: '请输入${title}',
defaultValue: '',
},
{
title: '类型',
key: 'fieldType',
type: JVxeTypes.select,
width: '120px',
options: [
{ title: 'string', value: 'string' },
{ title: 'integer', value: 'integer' },
{ title: 'number', value: 'number' },
{ title: 'boolean', value: 'boolean' },
{ title: 'array', value: 'array' },
{ title: 'object', value: 'object' },
],
defaultValue: 'string',
},
{
title: '说明',
key: 'fieldDesc',
type: JVxeTypes.input,
placeholder: '请输入${title}',
defaultValue: '',
},
];
// 高级查询数据
export const superQuerySchema = {
name: {title: '接口名称',order: 0,view: 'text', type: 'string',},
requestMethod: {title: '请求方',order: 1,view: 'list', type: 'string',dictCode: '',},
requestMethod: {title: '请求方',order: 1,view: 'list', type: 'string',dictCode: '',},
requestUrl: {title: '接口地址',order: 2,view: 'text', type: 'string',},
blackList: {title: 'IP 名单',order: 3,view: 'text', type: 'string',},
whiteList: {title: 'IP 名单',order: 3,view: 'text', type: 'string',},
status: {title: '状态',order: 5,view: 'number', type: 'number',},
createBy: {title: '创建人',order: 6,view: 'text', type: 'string',},
createTime: {title: '创建时间',order: 7,view: 'datetime', type: 'string',},

View File

@@ -1,48 +1,84 @@
import {BasicColumn} from '/@/components/Table';
import {FormSchema} from '/@/components/Table';
import { rules} from '/@/utils/helper/validator';
import { render } from '/@/utils/common/renderUtils';
import { getWeekMonthQuarterYear } from '/@/utils';
import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table';
//列表数据
export const columns: BasicColumn[] = [
{
title: '授权名称',
align: "center",
dataIndex: 'name'
title: '授权对象',
align: 'center',
dataIndex: 'name',
},
{
title: 'AK',
align: "center",
dataIndex: 'ak'
},
{
title: 'SK',
align: "center",
dataIndex: 'sk'
title: '访问密钥(AK',
align: 'center',
dataIndex: 'ak',
ellipsis: true,
},
{
title: '创建人',
align: "center",
dataIndex: 'createBy'
align: 'center',
dataIndex: 'createBy',
},
{
title: '创建时间',
align: "center",
dataIndex: 'createTime'
align: 'center',
dataIndex: 'createTime',
},
];
//查询数据
export const searchFormSchema: FormSchema[] = [
{
label: '授权对象',
field: 'name',
component: 'JInput',
},
{
label: '访问密钥',
field: 'ak',
component: 'JInput',
},
];
//授权表单数据
export const authFormSchema: FormSchema[] = [
{
label: '授权对象',
field: 'name',
component: 'Input',
required: true,
},
{
label: '',
field: 'ak',
component: 'Input',
show: false,
},
{
label: '',
field: 'sk',
component: 'Input',
show: false,
},
{
label: '',
field: 'id',
component: 'Input',
show: false,
},
{
label: '',
field: 'systemUserId',
component: 'Input',
show: false,
},
// {
// title: '关联系统用户名',
// align: "center",
// dataIndex: 'createBy',
// },
];
// 高级查询数据
export const superQuerySchema = {
name: {title: '授权名称',order: 0,view: 'text', type: 'string',},
ak: {title: 'AK',order: 1,view: 'text', type: 'string',},
sk: {title: 'SK',order: 2,view: 'text', type: 'string',},
createBy: {title: '关联系统用户名',order: 3,view: 'text', type: 'string',},
createTime: {title: '创建时间',order: 4,view: 'datetime', type: 'string',},
// systemUserId: {title: '关联系统用户名',order: 5,view: 'text', type: 'string',},
name: { title: '授权对象', order: 0, view: 'text', type: 'string' },
ak: { title: '访问密钥(AK', order: 1, view: 'text', type: 'string' },
sk: { title: '签名密钥(SK', order: 2, view: 'text', type: 'string' },
createBy: { title: '创建人', order: 3, view: 'text', type: 'string' },
createTime: { title: '创建时间', order: 4, view: 'datetime', type: 'string' },
};

View File

@@ -1,44 +1,12 @@
<template>
<div class="p-2">
<!--查询区域-->
<div class="jeecg-basic-table-form-container">
<a-form ref="formRef" @keyup.enter.native="searchQuery" :model="queryParam" :label-col="labelCol" :wrapper-col="wrapperCol">
<a-row :gutter="24">
<a-col :lg="6">
<a-form-item name="name">
<template #label><span title="授权名称">授权名称</span></template>
<a-input placeholder="请输入授权名称" v-model:value="queryParam.name" allow-clear ></a-input>
</a-form-item>
</a-col>
<a-col :lg="6">
<a-form-item name="createBy">
<template #label><span title="关联系统用户名">关联系统用户名</span></template>
<JSearchSelect dict="sys_user,username,username" v-model:value="queryParam.createBy" placeholder="请输入关联系统用户名" allow-clear ></JSearchSelect>
<!-- <a-input placeholder="请输入关联系统用户名" v-model:value="queryParam.systemUserId" allow-clear ></a-input>-->
</a-form-item>
</a-col>
<a-col :xl="6" :lg="7" :md="8" :sm="24">
<span style="float: left; overflow: hidden" class="table-page-search-submitButtons">
<a-col :lg="6">
<a-button type="primary" preIcon="ant-design:search-outlined" @click="searchQuery">查询</a-button>
<a-button type="primary" preIcon="ant-design:reload-outlined" @click="searchReset" style="margin-left: 8px">重置</a-button>
<a @click="toggleSearchStatus = !toggleSearchStatus" style="margin-left: 8px">
{{ toggleSearchStatus ? '收起' : '展开' }}
<Icon :icon="toggleSearchStatus ? 'ant-design:up-outlined' : 'ant-design:down-outlined'" />
</a>
</a-col>
</span>
</a-col>
</a-row>
</a-form>
</div>
<div>
<!--引用表格-->
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<!--插槽:table标题-->
<template #tableTitle>
<a-button type="primary" v-auth="'openapi:open_api_auth:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'openapi:open_api_auth:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'openapi:open_api_auth:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-button type="primary" v-auth="'openapi:open_api_auth:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'openapi:open_api_auth:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'openapi:open_api_auth:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -57,15 +25,15 @@
</template>
<!--操作栏-->
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)"/>
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
<!--字段回显插槽-->
<template v-slot:bodyCell="{ column, record, index, text }">
</template>
</BasicTable>
<!-- 表单区域 -->
<OpenApiAuthModal ref="registerModal" @success="handleSuccess"></OpenApiAuthModal>
<AuthModal ref="authModal" @success="handleSuccess"></AuthModal>
<OpenApiAuthDrawer @register="registerAuthDrawer" @success="handleSuccess" />
<AuthDrawer @register="registerPermDrawer" @success="handleSuccess" />
</div>
</template>
@@ -73,63 +41,51 @@
import { ref, reactive } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, superQuerySchema } from './OpenApiAuth.data';
import {
list,
deleteOne,
batchDelete,
getImportUrl,
getExportUrl,
getGenAKSK, saveOrUpdate
} from "./OpenApiAuth.api";
import OpenApiAuthModal from './components/OpenApiAuthModal.vue'
import AuthModal from './components/AuthModal.vue'
import { useUserStore } from '/@/store/modules/user';
import JSearchSelect from "../../components/Form/src/jeecg/components/JSearchSelect.vue";
import { useDrawer } from '/@/components/Drawer';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema, superQuerySchema } from './OpenApiAuth.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, getGenAKSK, saveOrUpdate } from './OpenApiAuth.api';
import OpenApiAuthDrawer from './components/OpenApiAuthDrawer.vue';
import AuthDrawer from './components/AuthDrawer.vue';
const formRef = ref();
const queryParam = reactive<any>({});
const toggleSearchStatus = ref<boolean>(false);
const registerModal = ref();
const authModal = ref();
const userStore = useUserStore();
const { createMessage } = useMessage();
const [registerAuthDrawer, { openDrawer: openAuthDrawer }] = useDrawer();
const [registerPermDrawer, { openDrawer: openPermDrawer }] = useDrawer();
//注册table数据
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '授权管理',
api: list,
columns,
canResize:false,
useSearchForm: false,
canResize: false,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
fieldMapToNumber: [],
fieldMapToTime: [],
},
actionColumn: {
width: 200,
width: 220,
fixed: 'right',
},
beforeFetch: async (params) => {
beforeFetch: (params) => {
return Object.assign(params, queryParam);
},
},
exportConfig: {
name: "授权管理",
name: '授权管理',
url: getExportUrl,
params: queryParam,
},
importConfig: {
url: getImportUrl,
success: handleSuccess
},
});
const [registerTable, { reload, updateTableDataRecord, getDataSource }, { rowSelection, selectedRowKeys }] = tableContext;
const labelCol = reactive({
xs:24,
sm:10,
xl:6,
xxl:10
});
const wrapperCol = reactive({
xs: 24,
sm: 20,
importConfig: {
url: getImportUrl,
success: handleSuccess,
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
// 高级查询配置
const superQueryConfig = reactive(superQuerySchema);
@@ -141,163 +97,144 @@
Object.keys(params).map((k) => {
queryParam[k] = params[k];
});
searchQuery();
reload();
}
/**
* 新增事件
*/
function handleAdd() {
registerModal.value.disableSubmit = false;
registerModal.value.add();
}
/**
* 编辑事件
*/
function handleAuth(record: Recordable) {
authModal.value.disableSubmit = false;
authModal.value.edit(record);
openAuthDrawer(true, {
isUpdate: false,
showFooter: true,
});
}
/**
* 编辑事件
*/
function handleEdit(record: Recordable) {
registerModal.value.disableSubmit = false;
registerModal.value.authDrawerOpen = true;
registerModal.value.edit(record);
openAuthDrawer(true, {
record,
isUpdate: true,
showFooter: true,
});
}
/**
* 重置事件
* @param record
* 授权事件
*/
function handleAuth(record: Recordable) {
openPermDrawer(true, { record });
}
/**
* 重置AK/SK
*/
async function handleReset(record: Recordable) {
const AKSKObj = await getGenAKSK({});
record.ak = AKSKObj[0];
record.sk = AKSKObj[1];
saveOrUpdate(record,true);
// handleSuccess;
await saveOrUpdate(record, true);
reload();
}
/**
* 详情
*/
function handleDetail(record: Recordable) {
registerModal.value.disableSubmit = true;
registerModal.value.edit(record);
openAuthDrawer(true, {
record,
isUpdate: true,
showFooter: false,
});
}
/**
* 删除事件
*/
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
}
/**
* 批量删除事件
*/
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
/**
* 成功回调
*/
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
/**
* 操作栏
*/
/**
* 复制密钥
*/
async function handleCopyKeys(record: Recordable) {
const text = `访问密钥AK: ${record.ak}\n签名密钥SK: ${record.sk}`;
try {
await navigator.clipboard.writeText(text);
createMessage.success('密钥已复制到剪贴板');
} catch (_e) {
createMessage.error('复制失败,请手动复制');
}
}
function getTableAction(record) {
return [
{
label: '授权',
onClick: handleAuth.bind(null, record),
auth: 'openapi:open_api_auth:edit'
label: '复制密钥',
onClick: handleCopyKeys.bind(null, record),
},
{
label: '重置',
popConfirm: {
title: '是否重置AK,SK',
confirm: handleReset.bind(null, record),
placement: 'topLeft',
},
auth: 'openapi:open_api_auth:edit'
label: '分配接口',
onClick: handleAuth.bind(null, record),
auth: 'openapi:open_api_auth:edit',
},
];
}
/**
* 下拉操作栏
*/
function getDropDownAction(record) {
return [
{
label: '详情',
onClick: handleDetail.bind(null, record),
}, {
label: '修改对象',
onClick: handleEdit.bind(null, record),
auth: 'openapi:open_api_auth:edit',
},
{
label: '重置密钥',
popConfirm: {
title: '原密钥将失效,确认重置?',
confirm: handleReset.bind(null, record),
placement: 'topLeft',
},
auth: 'openapi:open_api_auth:edit',
},
{
label: '删除',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
auth: 'openapi:open_api_auth:delete'
}
]
auth: 'openapi:open_api_auth:delete',
},
];
}
/**
* 查询
*/
function searchQuery() {
reload();
}
/**
* 重置
*/
function searchReset() {
formRef.value.resetFields();
selectedRowKeys.value = [];
//刷新数据
reload();
}
</script>
<style lang="less" scoped>
.jeecg-basic-table-form-container {
padding: 0;
.table-page-search-submitButtons {
display: block;
margin-bottom: 24px;
white-space: nowrap;
}
.query-group-cust{
min-width: 100px !important;
}
.query-group-split-cust{
width: 30px;
display: inline-block;
text-align: center
}
.ant-form-item:not(.ant-form-item-with-help){
margin-bottom: 16px;
height: 32px;
}
:deep(.ant-picker),:deep(.ant-input-number){
width: 100%;
}
:deep(.ant-picker),:deep(.ant-input-number) {
width: 100%;
}
</style>

View File

@@ -41,31 +41,30 @@
</template>
<!--字段回显插槽-->
<template v-slot:bodyCell="{ column, record, index, text }">
<template v-if="column.dataIndex === 'requestUrl'">
<a @click="handleCopyUrl(record)" title="点击复制完整接口地址">{{ text }}</a>
</template>
</template>
</BasicTable>
<!-- 表单区域 -->
<OpenApiModal @register="registerModal" @success="handleSuccess"></OpenApiModal>
<OpenApiDrawer @register="registerDrawer" @success="handleSuccess" />
</div>
</template>
<script lang="ts" name="openapi-openApi" setup>
import {ref, reactive, computed, unref} from 'vue';
import {BasicTable, useTable, TableAction} from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage'
import {useModal} from '/@/components/Modal';
import OpenApiModal from './components/OpenApiModal.vue'
import OpenApiHeaderSubTable from './subTables/OpenApiHeaderSubTable.vue'
import OpenApiParamSubTable from './subTables/OpenApiParamSubTable.vue'
import {columns, searchFormSchema, superQuerySchema} from './OpenApi.data';
import {list, deleteOne, batchDelete, getImportUrl,getExportUrl} from './OpenApi.api';
import {downloadFile} from '/@/utils/common/renderUtils';
import { useUserStore } from '/@/store/modules/user';
import { ref, reactive } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useDrawer } from '/@/components/Drawer';
import { useMessage } from '/@/hooks/web/useMessage';
import OpenApiDrawer from './components/OpenApiDrawer.vue';
import { columns, searchFormSchema, superQuerySchema } from './OpenApi.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl } from './OpenApi.api';
const queryParam = reactive<any>({});
// 展开key
const expandedRowKeys = ref<any[]>([]);
//注册model
const [registerModal, {openModal}] = useModal();
const userStore = useUserStore();
const { createMessage } = useMessage();
const API_DOMAIN = import.meta.env.VITE_GLOB_DOMAIN_URL;
const [registerDrawer, { openDrawer }] = useDrawer();
//注册table数据
const { prefixCls,tableContext,onExportXls,onImportXls } = useListPage({
tableProps:{
@@ -84,7 +83,7 @@
],
},
actionColumn: {
width: 120,
width: 200,
fixed:'right'
},
beforeFetch: (params) => {
@@ -130,7 +129,7 @@
* 新增事件
*/
function handleAdd() {
openModal(true, {
openDrawer(true, {
isUpdate: false,
showFooter: true,
});
@@ -139,7 +138,7 @@
* 编辑事件
*/
function handleEdit(record: Recordable) {
openModal(true, {
openDrawer(true, {
record,
isUpdate: true,
showFooter: true,
@@ -149,7 +148,7 @@
* 详情
*/
function handleDetail(record: Recordable) {
openModal(true, {
openDrawer(true, {
record,
isUpdate: true,
showFooter: false,
@@ -176,13 +175,26 @@
/**
* 操作栏
*/
/**
* 复制接口地址
*/
async function handleCopyUrl(record: Recordable) {
const url = API_DOMAIN + '/openapi/call/' + record.requestUrl;
try {
await navigator.clipboard.writeText(url);
createMessage.success('接口地址已复制');
} catch (_e) {
createMessage.error('复制失败,请手动复制');
}
}
function getTableAction(record){
return [
{
label: '编辑',
onClick: handleEdit.bind(null, record),
auth: 'openapi:open_api:edit'
}
},
]
}

View File

@@ -0,0 +1,185 @@
<template>
<BasicDrawer
v-bind="$attrs"
@register="registerDrawer"
title="接口授权"
width="720px"
destroyOnClose
@ok="handleSubmit"
>
<a-spin :spinning="confirmLoading">
<a-row :gutter="[12, 12]">
<a-col :span="12" v-for="item in apiList" :key="item.id">
<a-card
:class="['auth-api-card', { 'auth-api-card--checked': item.checked }]"
hoverable
:body-style="{ padding: '12px' }"
@click="handleSelect(item)"
>
<div style="display: flex; justify-content: space-between; align-items: center">
<span class="auth-api-name">{{ item.name }}</span>
<a-checkbox v-model:checked="item.checked" @click.stop @change="(e) => handleChange(e, item)" />
</div>
<div style="margin-top: 6px; color: #888; font-size: 12px">
<a-tag :color="getMethodColor(item.requestMethod)">{{ item.requestMethod }}</a-tag>
<span style="margin-left: 4px">{{ item.requestUrl }}</span>
</div>
</a-card>
</a-col>
</a-row>
<div v-if="apiList.length === 0 && !confirmLoading" style="text-align: center; padding: 40px 0; color: #999">
暂无接口数据
</div>
<div v-if="total > 0" style="margin-top: 16px; text-align: right">
<a-pagination
:current="pageNo"
:page-size="pageSize"
:page-size-options="['10', '20', '30']"
:total="total"
show-quick-jumper
show-size-changer
size="small"
@change="handlePageChange"
/>
</div>
</a-spin>
</BasicDrawer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { getApiList, getPermissionList, permissionAddFunction } from '../OpenApiAuth.api';
import { useMessage } from '/@/hooks/web/useMessage';
const emit = defineEmits(['register', 'success']);
const { createMessage } = useMessage();
const confirmLoading = ref(false);
const apiAuthId = ref('');
const apiList = ref<any[]>([]);
const selectedRowKeys = ref<string[]>([]);
const pageNo = ref(1);
const pageSize = ref(10);
const total = ref(0);
const [registerDrawer, { setDrawerProps, closeDrawer }] = useDrawerInner(async (data) => {
selectedRowKeys.value = [];
apiList.value = [];
pageNo.value = 1;
pageSize.value = 10;
total.value = 0;
apiAuthId.value = data.record?.id || '';
// Load existing permissions
try {
const permRes = await getPermissionList({ apiAuthId: apiAuthId.value });
if (permRes && permRes.length > 0) {
permRes.forEach((item) => {
if (item.ifCheckBox == '1') {
selectedRowKeys.value.push(item.id);
}
});
}
} catch (_e) {
// ignore
}
await reload();
});
async function reload() {
confirmLoading.value = true;
try {
const res = await getApiList({
pageNo: pageNo.value,
pageSize: pageSize.value,
column: 'createTime',
order: 'desc',
});
if (res.success) {
const records = res.result.records || [];
records.forEach((item) => {
item.checked = selectedRowKeys.value.includes(item.id);
});
apiList.value = records;
total.value = res.result.total || 0;
} else {
apiList.value = [];
total.value = 0;
}
} finally {
confirmLoading.value = false;
}
}
function handleSelect(item) {
item.checked = !item.checked;
toggleSelection(item.id, item.checked);
}
function handleChange(e, item) {
toggleSelection(item.id, e.target.checked);
}
function toggleSelection(id: string, checked: boolean) {
const idx = selectedRowKeys.value.indexOf(id);
if (checked && idx === -1) {
selectedRowKeys.value.push(id);
} else if (!checked && idx !== -1) {
selectedRowKeys.value.splice(idx, 1);
}
}
function handlePageChange(page, current) {
pageNo.value = page;
pageSize.value = current;
reload();
}
function getMethodColor(method: string) {
const map = { GET: 'green', POST: 'blue', PUT: 'orange', DELETE: 'red', PATCH: 'purple' };
return map[method] || 'default';
}
async function handleSubmit() {
confirmLoading.value = true;
try {
setDrawerProps({ confirmLoading: true });
const res = await permissionAddFunction({
apiId: selectedRowKeys.value.join(','),
apiAuthId: apiAuthId.value,
});
if (res.success) {
createMessage.success(res.message);
closeDrawer();
emit('success');
} else {
createMessage.warning(res.message);
}
} finally {
confirmLoading.value = false;
setDrawerProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
.auth-api-card {
transition: all 0.2s;
border: 1px solid #d9d9d9;
&--checked {
border-color: #1890ff;
background-color: #e6f7ff;
}
}
.auth-api-name {
font-weight: 500;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
display: inline-block;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<BasicDrawer
v-bind="$attrs"
@register="registerDrawer"
:title="title"
width="600px"
destroyOnClose
@ok="handleSubmit"
:showFooter="showFooter"
>
<BasicForm @register="registerForm" />
</BasicDrawer>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { BasicForm, useForm } from '/@/components/Form/index';
import { authFormSchema } from '../OpenApiAuth.data';
import { saveOrUpdate } from '../OpenApiAuth.api';
import { useMessage } from '/@/hooks/web/useMessage';
import { USER_INFO_KEY } from '/@/enums/cacheEnum';
import { getAuthCache } from '/@/utils/auth';
const emit = defineEmits(['register', 'success']);
const { createMessage } = useMessage();
const isUpdate = ref(false);
const formDisabled = ref(false);
const showFooter = ref(true);
const [registerForm, { resetFields, setFieldsValue, validate, setProps }] = useForm({
labelWidth: 100,
schemas: authFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerDrawer, { setDrawerProps, closeDrawer }] = useDrawerInner(async (data) => {
await resetFields();
showFooter.value = !!data?.showFooter;
setDrawerProps({ confirmLoading: false, showFooter: showFooter.value });
isUpdate.value = !!data?.isUpdate;
formDisabled.value = !data?.showFooter;
if (unref(isUpdate)) {
await setFieldsValue({ ...data.record });
} else {
// New record: set current user
const userData = getAuthCache(USER_INFO_KEY) as any;
await setFieldsValue({
systemUserId: userData?.id || '',
});
}
setProps({ disabled: !data?.showFooter });
});
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(formDisabled) ? '编辑' : '详情'));
async function handleSubmit() {
try {
const values = await validate();
setDrawerProps({ confirmLoading: true });
const res = await saveOrUpdate(values, isUpdate.value);
if (res.success) {
createMessage.success(res.message);
closeDrawer();
emit('success');
} else {
createMessage.warning(res.message);
}
} finally {
setDrawerProps({ confirmLoading: false });
}
}
</script>

View File

@@ -5,18 +5,18 @@
<a-form ref="formRef" class="antd-modal-form" :labelCol="labelCol" :wrapperCol="wrapperCol" name="OpenApiAuthForm">
<a-row>
<a-col :span="24">
<a-form-item label="授权名称" v-bind="validateInfos.name" id="OpenApiAuthForm-name" name="name">
<a-input v-model:value="formData.name" placeholder="请输入授权名称" allow-clear ></a-input>
<a-form-item label="授权对象" v-bind="validateInfos.name" id="OpenApiAuthForm-name" name="name">
<a-input v-model:value="formData.name" placeholder="请输入授权对象" allow-clear ></a-input>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="AK" v-bind="validateInfos.ak" id="OpenApiAuthForm-ak" name="ak">
<a-input v-model:value="formData.ak" placeholder="请输入AK" disabled allow-clear ></a-input>
<a-form-item label="访问密钥(AK" v-bind="validateInfos.ak" id="OpenApiAuthForm-ak" name="ak">
<a-input v-model:value="formData.ak" placeholder="自动生成" disabled allow-clear ></a-input>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="SK" v-bind="validateInfos.sk" id="OpenApiAuthForm-sk" name="sk">
<a-input v-model:value="formData.sk" placeholder="请输入SK" disabled allow-clear ></a-input>
<a-form-item label="签名密钥(SK" v-bind="validateInfos.sk" id="OpenApiAuthForm-sk" name="sk">
<a-input v-model:value="formData.sk" placeholder="自动生成" disabled allow-clear ></a-input>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
@@ -63,7 +63,7 @@
const confirmLoading = ref<boolean>(false);
//表单验证
const validatorRules = reactive({
name:[{ required: true, message: '请输入授权名称!'},],
name:[{ required: true, message: '请输入授权对象!'},],
systemUserId:[{ required: true, message: '请输入关联系统用户名!'},],
});
const { resetFields, validate, validateInfos } = useForm(formData, validatorRules, { immediate: false });

View File

@@ -0,0 +1,269 @@
<template>
<BasicDrawer
v-bind="$attrs"
@register="registerDrawer"
:title="title"
width="90%"
destroyOnClose
@ok="handleSubmit"
:showFooter="showFooter"
>
<!-- 上部基本信息表单 -->
<BasicForm @register="registerForm" ref="formRef">
<template #whiteListSlot="{ model, field }">
<a-textarea
v-model:value="model[field]"
:rows="5"
placeholder="示例:&#10;192.168.1.100&#10;10.0.0.0/8&#10;172.16.*.*"
:disabled="formDisabled"
/>
<!-- 标签预览 -->
<div v-if="model[field]" style="margin-top: 8px">
<a-tag
v-for="item in parseWhiteList(model[field])"
:key="item"
color="green"
style="margin-bottom: 4px"
>
{{ item }}
</a-tag>
</div>
<!-- 整理按钮 -->
<div v-if="model[field] && !formDisabled" style="margin-top: 4px; text-align: right">
<a-button size="small" @click="formatWhiteList(model, field)"> </a-button>
</div>
</template>
</BasicForm>
<!-- 下部Tabs -->
<a-tabs v-model:activeKey="activeTab" style="margin-top: 16px">
<a-tab-pane key="headers" tab="请求头">
<JVxeTable
keep-source
ref="openApiHeader"
:loading="openApiHeaderTable.loading"
:columns="openApiHeaderTable.columns"
:dataSource="openApiHeaderTable.dataSource"
:height="240"
:disabled="formDisabled"
:rowNumber="true"
:rowSelection="true"
:toolbar="true"
/>
</a-tab-pane>
<a-tab-pane key="params" tab="请求参数">
<JVxeTable
keep-source
ref="openApiParam"
:loading="openApiParamTable.loading"
:columns="openApiParamTable.columns"
:dataSource="openApiParamTable.dataSource"
:height="240"
:disabled="formDisabled"
:rowNumber="true"
:rowSelection="true"
:toolbar="true"
/>
</a-tab-pane>
<a-tab-pane key="body" tab="请求体">
<div style="border: 1px solid #d9d9d9; border-radius: 4px; min-height: 300px">
<CodeEditor v-model:value="bodyContent" mode="application/json" :readonly="formDisabled" />
</div>
</a-tab-pane>
<a-tab-pane key="response" tab="响应配置">
<div style="margin-bottom: 16px">
<h4 style="margin-bottom: 8px">响应示例</h4>
<div style="border: 1px solid #d9d9d9; border-radius: 4px; min-height: 200px">
<CodeEditor v-model:value="responseExample" mode="application/json" :readonly="formDisabled" />
</div>
</div>
<div>
<h4 style="margin-bottom: 8px">响应字段说明</h4>
<JVxeTable
keep-source
ref="responseField"
:loading="responseFieldTable.loading"
:columns="responseFieldTable.columns"
:dataSource="responseFieldTable.dataSource"
:height="240"
:disabled="formDisabled"
:rowNumber="true"
:rowSelection="true"
:toolbar="true"
/>
</div>
</a-tab-pane>
</a-tabs>
</BasicDrawer>
</template>
<script lang="ts" setup>
import { ref, computed, unref, reactive } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { BasicForm, useForm } from '/@/components/Form/index';
import { CodeEditor } from '/@/components/CodeEditor';
import {
formSchema,
openApiHeaderJVxeColumns,
openApiParamJVxeColumns,
responseFieldJVxeColumns,
} from '../OpenApi.data';
import { saveOrUpdate, getGenPath } from '../OpenApi.api';
import { useMessage } from '/@/hooks/web/useMessage';
const emit = defineEmits(['register', 'success']);
const $message = useMessage();
const isUpdate = ref(true);
const formDisabled = ref(false);
const showFooter = ref(true);
const activeTab = ref('headers');
const bodyContent = ref('');
const responseExample = ref('');
const openApiHeader = ref();
const openApiParam = ref();
const responseField = ref();
const openApiHeaderTable = reactive({
loading: false,
dataSource: [] as any[],
columns: openApiHeaderJVxeColumns,
});
const openApiParamTable = reactive({
loading: false,
dataSource: [] as any[],
columns: openApiParamJVxeColumns,
});
const responseFieldTable = reactive({
loading: false,
dataSource: [] as any[],
columns: responseFieldJVxeColumns,
});
const [registerForm, { setProps, resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 12 },
});
const [registerDrawer, { setDrawerProps, closeDrawer }] = useDrawerInner(async (data) => {
await reset();
showFooter.value = !!data?.showFooter;
setDrawerProps({ confirmLoading: false, showFooter: showFooter.value });
isUpdate.value = !!data?.isUpdate;
formDisabled.value = !data?.showFooter;
if (unref(isUpdate)) {
await setFieldsValue({
...data.record,
});
openApiHeaderTable.dataSource = data.record.headersJson ? JSON.parse(data.record.headersJson) : [];
openApiParamTable.dataSource = data.record.paramsJson ? JSON.parse(data.record.paramsJson) : [];
bodyContent.value = data.record.body || '';
responseExample.value = data.record.responseExample || '';
responseFieldTable.dataSource = data.record.responseFieldsJson ? JSON.parse(data.record.responseFieldsJson) : [];
} else {
const requestUrlObj = await getGenPath({});
await setFieldsValue({
requestUrl: requestUrlObj.result,
});
}
setProps({ disabled: !data?.showFooter });
});
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(formDisabled) ? '编辑' : '详情'));
/** 解析白名单文本为条目数组 */
function parseWhiteList(text: string): string[] {
if (!text) return [];
return text
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
/** 整理白名单:去空行、去重、每行一个 */
function formatWhiteList(model: any, field: string) {
const items = parseWhiteList(model[field]);
const unique = [...new Set(items)];
model[field] = unique.join('\n');
}
async function reset() {
await resetFields();
activeTab.value = 'headers';
openApiHeaderTable.dataSource = [];
openApiParamTable.dataSource = [];
responseFieldTable.dataSource = [];
bodyContent.value = '';
responseExample.value = '';
}
async function handleSubmit() {
try {
const values = await validate();
setDrawerProps({ confirmLoading: true });
// Collect JVxeTable data
const headerData = await openApiHeader.value?.getTableData();
const paramData = await openApiParam.value?.getTableData();
const responseFieldData = await responseField.value?.getTableData();
const headersJson = headerData?.tableData?.length ? JSON.stringify(headerData.tableData) : null;
const paramsJson = paramData?.tableData?.length ? JSON.stringify(paramData.tableData) : null;
const responseFieldsJson = responseFieldData?.tableData?.length ? JSON.stringify(responseFieldData.tableData) : null;
// Validate body JSON
if (bodyContent.value) {
try {
if (typeof JSON.parse(bodyContent.value) != 'object') {
$message.createMessage.error('JSON格式化错误,请检查输入数据');
return;
}
} catch (e) {
$message.createMessage.error('JSON格式化错误,请检查输入数据');
return;
}
}
// Validate response example JSON
if (responseExample.value) {
try {
JSON.parse(responseExample.value);
} catch (e) {
$message.createMessage.error('响应示例JSON格式错误,请检查输入数据');
return;
}
}
const submitValues = {
...values,
headersJson,
paramsJson,
body: bodyContent.value || null,
responseExample: responseExample.value || null,
responseFieldsJson,
};
await saveOrUpdate(submitValues, isUpdate.value);
closeDrawer();
emit('success');
} finally {
setDrawerProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
:deep(.ant-input-number) {
width: 100%;
}
:deep(.ant-calendar-picker) {
width: 100%;
}
</style>

View File

@@ -46,7 +46,6 @@
import { ref, computed, unref, reactive } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { JVxeTable } from '/@/components/jeecg/JVxeTable';
import { useJvxeMethod } from '/@/hooks/system/useJvxeMethods.ts';
import { formSchema, openApiHeaderJVxeColumns, openApiParamJVxeColumns } from '../OpenApi.data';
import { saveOrUpdate, queryOpenApiHeader, queryOpenApiParam, getGenPath } from '../OpenApi.api';

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>

View File

@@ -0,0 +1,125 @@
<template>
<div :class="prefixCls">
<!--引用表格-->
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button @click="onShowCustomButton" type="primary" preIcon="ant-design:highlight">自定义按钮</a-button>
<a-button @click="onShowEnhanceJs" type="primary" preIcon="ant-design:strikethrough">JS增强</a-button>
<a-button @click="onShowEnhanceSql" type="primary" preIcon="ant-design:filter">SQL增强</a-button>
<a-button @click="onShowEnhanceJava" type="primary" preIcon="ant-design:tool">Java增强</a-button>
</template>
<template #dbSync="{ text }">
<span v-if="text === 'Y'" style="color: limegreen">已同步</span>
<span v-if="text === 'N'" style="color: red">未同步</span>
</template>
<!--操作栏-->
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
</BasicTable>
</div>
<CgformModal @register="registerCgformModal" :actionButton="false" @success="reload" />
<EnhanceJsModal @register="registerEnhanceJsModal" />
<EnhanceJavaModal @register="registerEnhanceJavaModal" />
<EnhanceSqlModal @register="registerEnhanceSqlModal" />
<DbToOnlineModal @register="registerDbToOnlineModal" @success="reload" />
<CustomButtonList @register="registerCustomButtonModal" />
<AuthManagerDrawer @register="registerAuthManagerDrawer" />
<AuthSetterModal @register="registerAuthSetterModal" />
<CgformAddressModal @register="registerAddressModal" />
</template>
<script lang="ts">
import { watch, provide, defineComponent } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import CgformModal from './components/CgformModal.vue';
import DbToOnlineModal from './components/DbToOnlineModal.vue';
import CustomButtonList from './components/button/CustomButtonList.vue';
import EnhanceJsModal from './components/enhance/EnhanceJsModal.vue';
import EnhanceJavaModal from './components/enhance/EnhanceJavaModal.vue';
import EnhanceSqlModal from './components/enhance/EnhanceSqlModal.vue';
import AuthManagerDrawer from './components/auth/AuthManagerDrawer.vue';
import AuthSetterModal from './components/auth/AuthSetterModal.vue';
import CgformAddressModal from "./components/CgformAddressModal.vue";
import { useCgformList } from './hooks/useCgformList';
import { CgformPageType } from './types';
// noinspection JSUnusedGlobalSymbols
export default defineComponent({
name: 'CgformCopyList',
components: {
BasicTable,
TableAction,
CgformModal,
DbToOnlineModal,
CustomButtonList,
EnhanceJsModal,
EnhanceJavaModal,
EnhanceSqlModal,
AuthManagerDrawer,
AuthSetterModal,
CgformAddressModal,
},
setup() {
const pageType = CgformPageType.copy;
provide('cgformPageType', pageType);
const {
router,
pageContext,
getTableAction,
getDropDownAction,
onShowCustomButton,
onShowEnhanceJs,
onShowEnhanceSql,
onShowEnhanceJava,
registerCustomButtonModal,
registerEnhanceJsModal,
registerEnhanceSqlModal,
registerEnhanceJavaModal,
registerAuthManagerDrawer,
registerAuthSetterModal,
registerCgformModal,
registerDbToOnlineModal,
registerAddressModal,
} = useCgformList({
pageType,
designScope: 'online-cgform-list',
columns: [
{ title: '视图表名', dataIndex: 'tableName' },
{ title: '视图表描述', dataIndex: 'tableTxt' },
{ title: '原表版本', dataIndex: 'copyVersion' },
{ title: '视图版本', dataIndex: 'tableVersion' },
],
formSchemas: [{ label: '表名', field: 'tableName', component: 'JInput' }],
});
const { prefixCls, tableContext } = pageContext;
const [registerTable, { reload }, { rowSelection }] = tableContext;
watch(router.currentRoute, () => reload());
return {
prefixCls,
reload,
rowSelection,
getTableAction,
getDropDownAction,
onShowCustomButton,
onShowEnhanceJs,
onShowEnhanceSql,
onShowEnhanceJava,
registerCustomButtonModal,
registerEnhanceJsModal,
registerEnhanceSqlModal,
registerEnhanceJavaModal,
registerAuthManagerDrawer,
registerAuthSetterModal,
registerTable,
registerCgformModal,
registerDbToOnlineModal,
registerAddressModal,
};
},
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,168 @@
<!-- online查询条件中的下拉搜索 -->
<template>
<a-select
:value="selected"
:placeholder="placeholder"
show-search
:default-active-first-option="false"
:show-arrow="true"
:filter-option="false"
:not-found-content="null"
@search="handleSearch"
@change="handleChange"
@popupScroll="handlePopupScroll"
allowClear
>
<a-select-option v-for="d in selectOptions" :key="d.value">
{{ d.text }}
</a-select-option>
</a-select>
</template>
<script>
import { useDebounceFn } from '@vueuse/core';
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createMessage: $message } = useMessage();
import { watch, ref } from 'vue';
export default {
name: 'JOnlineSearchSelect',
props: {
placeholder: {
type: String,
default: '',
required: false,
},
value: {
type: String,
required: false,
},
// online CgReport item id
fieldId: {
type: String,
required: true,
},
},
emits: ['update:value'],
setup(props, { emit }) {
let selected = ref('');
let selectOptions = ref([]);
let isHasData = true;
let scrollLoading = false;
let searchKeyword = '';
const pageNo = ref(1);
watch(
() => props.value,
(newVal) => {
if (!newVal) {
selected.value = undefined;
} else {
selected.value = newVal;
}
},
{ immediate: true }
);
watch(
() => props.fieldId,
() => {
resetOptions();
},
{ immediate: true }
);
/**
* 2024-07-17
* liaozhiyang
* 【TV360X-1813】online报表查询支持滚动加载
* */
const handleSearch = useDebounceFn((keyword) => {
searchKeyword = keyword;
pageNo.value = 1;
isHasData = true;
searchByKeyword(keyword);
}, 800);
/**
* 2024-07-17
* liaozhiyang
* 【TV360X-1813】online报表查询支持滚动加载
* */
async function searchByKeyword(keyword = '') {
let params = {
keyword: keyword,
fieldId: props.fieldId,
pageSize: 10,
pageNo: pageNo.value,
};
let url = `/online/cgreport/api/getReportDictList`;
await defHttp
.get({ url: url, params }, { isTransformResponse: false })
.then((res) => {
if (res.success) {
if (res.result && res.result.length > 0) {
if (pageNo.value == 1) {
selectOptions.value = [...res.result];
} else {
selectOptions.value.push(...res.result);
}
pageNo.value++;
} else {
if (pageNo.value == 1) {
selectOptions.value = [];
}
isHasData = false;
}
} else {
$message.warning(res.message);
}
})
.catch(() => {
pageNo.value != 1 && pageNo.value--;
});
}
function handleChange(value) {
emit('update:value', value);
//点击clear按钮,重置下拉项
if (!value || value == '') {
resetOptions();
}
}
function resetOptions() {
selectOptions.value = [];
// update-begin--author:liaozhiyang---date:20240717---for【TV360X-1813】online报表查询支持滚动加载
pageNo.value = 1;
isHasData = true;
searchKeyword = '';
// update-end--author:liaozhiyang---date:20240717---for【TV360X-1813】online报表查询支持滚动加载
searchByKeyword();
}
/**
* 2024-07-17
* liaozhiyang
* 【TV360X-1813】online报表查询支持滚动加载
* */
const handlePopupScroll = async (e) => {
const { target } = e;
const { scrollTop, scrollHeight, clientHeight } = target;
if (!scrollLoading && isHasData && scrollTop + clientHeight >= scrollHeight - 10) {
scrollLoading = true;
searchByKeyword(searchKeyword).finally(() => {
scrollLoading = false;
});
}
};
return {
selectOptions,
handleSearch,
handleChange,
selected,
handlePopupScroll,
};
},
};
</script>
<style scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,353 @@
<template>
<div :id="tableName + '_form'">
<!-- 积木报表的打印按钮只有配置了 reportUrl 才显示 -->
<div v-if="!!formData.id && !!onlineExtConfigJson.reportPrintShow" style="text-align: right;position: absolute;top: 15px;right: 20px;z-index: 999">
<PrinterOutlined title="打印" @click="onOpenReportPrint" style="font-size: 16px"/>
</div>
<detail-form :schemas="detailFormSchemas" :data="formData" :span="formSpan"></detail-form>
<!-- 子表 -->
<a-tabs v-if="themeTemplate !== ERP && hasSubTable && showSub" @change="onTabChange">
<a-tab-pane v-for="(sub, index) in subTabInfo" :tab="sub.describe" :key="index + ''" :forceRender="true">
<div :style="{ 'overflow-y': 'auto', 'overflow-x': 'hidden', 'max-height': subFormHeight + 'px' }" v-if="sub.relationType == 1">
<!-- 子表-一对一 -->
<online-sub-form-detail :key="subReloadKey" :table="sub.key" :form-template="formTemplate" :main-id="getSubTableForeignKeyValue(sub.foreignKey)" :properties="sub.properties"> </online-sub-form-detail>
</div>
<div v-else>
<!-- 子表-一对多 -->
<JVxeTable
v-if="showStatus[sub.key]"
:ref="refMap[sub.key]"
keep-source
:row-number="rowNumber"
row-selection
:height="subTableHeight"
:disabled="true"
:columns="sub.columns"
:dataSource="subDataSource[sub.key]"
:authPre="getSubTableAuthPre(sub.key)"
/>
<a-spin v-else :spinning="true"/>
</div>
</a-tab-pane>
</a-tabs>
<Loading :loading="loading" :absolute="false" />
<slot name="bottom"></slot>
</div>
</template>
<script lang="ts">
import { useMessage } from '/@/hooks/web/useMessage';
import { ref, reactive, watch } from 'vue';
import { Loading } from '/@/components/Loading';
import { getToken } from '/@/utils/auth';
import { goJmReportViewPage } from '/@/utils';
import { PrinterOutlined } from '@ant-design/icons-vue';
import DetailForm from '../../extend/form/DetailForm.vue';
import OnlineSubFormDetail from './OnlineSubFormDetail.vue';
import { getDetailFormSchemas } from '../../hooks/auto/useAutoForm';
import { defHttp } from '/@/utils/http/axios';
import { ERP } from "../../util/constant";
import { useAppInject } from '/@/hooks/web/useAppInject';
import { useOnlineFormDetailContext } from '../../hooks/auto/useAutoFormDetail';
import { useEnhance } from '../../hooks/auto/useEnhance';
export default {
name: 'OnlineFormDetail',
components: {
DetailForm,
Loading,
PrinterOutlined,
OnlineSubFormDetail,
},
props: {
id: {
type: String,
default: '',
},
formTemplate: {
type: Number,
default: 1,
},
disabled: {
type: Boolean,
default: false,
},
isTree: {
type: Boolean,
default: false,
},
pidField: {
type: String,
default: '',
},
submitTip: {
type: Boolean,
default: true,
},
showSub:{
type: Boolean,
default: true,
},
themeTemplate: {
type: String,
default: '',
}
},
emits: ['success', 'rendered'],
setup(props, { emit }) {
console.log('onlineForm-setup》》');
const { createMessage: $message } = useMessage();
const { getIsMobile } = useAppInject();
const tableName = ref('');
const single = ref(true);
// 加载状态
const loading = ref(false);
const tableType = ref(1);
const formData = ref<any>({});
// update-begin-author:liaozhiyang---date:20240313---for【QQYUN-9034】online弹窗一对一子表移动端内容高度设置不合理
const subFormHeight = ref(getIsMobile.value ? 'auto' : 300);
// update-end-author:liaozhiyang---date:20240313---for【QQYUN-9034】online弹窗一对一子表移动端内容高度设置不合理
const subReloadKey = ref(0);
// 子表表格高度
// 【VUEN-803】一对多子表固定340高度修复自定义列组件被遮挡的问题
const subTableHeight = ref(340);
const rowNumber = ref(getIsMobile.value ? false : true);
let detailData = {};
// 字段展示状态
const fieldDisplayStatus = reactive<any>({});
/**
* online表单扩展配置
*/
const onlineExtConfigJson = reactive({
reportPrintShow: 0,
reportPrintUrl: '',
joinQuery: 0,
modelFullscreen: 0,
modalMinWidth: '',
});
const { detailFormSchemas, hasSubTable, subTabInfo, refMap, showStatus, subDataSource, createFormSchemas, formSpan } = getDetailFormSchemas(props);
/**
* 处理扩展配置
*/
function handleExtConfigJson(jsonStr) {
let extConfigJson = { reportPrintShow: 0, reportPrintUrl: '', joinQuery: 0, modelFullscreen: 1, modalMinWidth: '' };
if (jsonStr) {
extConfigJson = JSON.parse(jsonStr);
}
Object.keys(extConfigJson).map((k) => {
onlineExtConfigJson[k] = extConfigJson[k];
});
}
// update-begin--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
const { onlineFormDetailContext, resetContext } = useOnlineFormDetailContext();
let { EnhanceJS, initCgEnhanceJs } = useEnhance(onlineFormDetailContext, false);
// update-end--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
// 渲染表单
async function createRootProperties(data) {
tableType.value = data.head.tableType;
tableName.value = data.head.tableName;
single.value = data.head.tableType == 1;
handleExtConfigJson(data.head.extConfigJson);
createFormSchemas(data.schema.properties);
// update-begin--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
EnhanceJS = initCgEnhanceJs(data.enhanceJs);
// update-end--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
emit('rendered', onlineExtConfigJson);
}
/**
* status: 是否是修改页面
* record: 列表页面的行数据
* param 树形列表添加子节点 传入的父级节点id
* */
async function show(_status, record) {
console.log('进入表单详情》》form', record);
// -update-begin--author:liaozhiyang---date:20251209---for【QQYUN-13970】一对一子表编辑之后查看详情不会更新
subReloadKey.value++;
// -update-end--author:liaozhiyang---date:20251209---for【QQYUN-13970】一对一子表编辑之后查看详情不会更新
await edit(record);
changeShowStatus(true);
}
function getFormData(dataId) {
let url = `/online/cgform/api/detail/${props.id}/${dataId}`;
return new Promise((resolve, reject) => {
defHttp
.get({ url }, { isTransformResponse: false })
.then((res) => {
if (res.success) {
resolve(res.result);
} else {
reject();
$message.warning(res.message);
}
})
.catch(() => {
reject();
});
});
}
//update-begin-author:taoyan date:2023-2-13 for: QQYUN-4226【vue3】online 一对多子表 详情界面,序号错位了 点一下子表表格就正常了
function changeShowStatus(flag){
Object.keys(showStatus).map(k=>{
showStatus[k] = flag;
})
}
function onTabChange(){
changeShowStatus(false);
setTimeout(()=>{
changeShowStatus(true);
}, 300);
}
//update-end-author:taoyan date:2023-2-13 for: QQYUN-4226【vue3】online 一对多子表 详情界面,序号错位了 点一下子表表格就正常了
async function edit(record) {
let temp: any = await getFormData(record.id);
// update-begin--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
detailData = temp;
// 每次打开有js增强设置隐藏的都要先置成初始化。
detailFormSchemas.value.filter((item) => item.hidden).forEach((item) => (item.hidden = false));
Object.keys(fieldDisplayStatus).forEach(function (key) {
delete fieldDisplayStatus[key];
});
handleEnhanceJS({ buttonCode: 'loaded' });
// 表单赋值
formData.value = { ...detailData };
editSubVxeTableData(detailData);
// update-end--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
}
function editSubVxeTableData(record) {
if (!record) {
// 新增页面需要清空子表数据
record = {};
}
let keys = Object.keys(subDataSource.value);
if (keys && keys.length > 0) {
let obj = {};
for (let key of keys) {
obj[key] = record[key] || [];
}
subDataSource.value = obj;
}
}
function getSubTableAuthPre(table) {
return 'online_' + table + ':';
}
//跳转至积木报表页面
function onOpenReportPrint() {
let url = onlineExtConfigJson.reportPrintUrl;
let temp: any = formData.value;
if (temp) {
let id = temp.id;
let token = getToken();
goJmReportViewPage(url, id, token);
}
}
function getSubTableForeignKeyValue(key) {
let temp = formData.value;
console.log('getValueIgnoreCase(temp, key)', temp, key, getValueIgnoreCase(temp, key));
return getValueIgnoreCase(temp, key);
}
/**
* VUEN-1056 30、生成的一对多编辑的时候子表数据挂不上
*/
function getValueIgnoreCase(data, key) {
if (data) {
let temp = data[key];
if (!temp && temp !== 0) {
temp = data[key.toLowerCase()];
if (!temp && temp !== 0) {
temp = data[key.toUpperCase()];
}
}
return temp;
}
return '';
}
// update-begin--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
function handleEnhanceJS({ buttonCode }) {
if (EnhanceJS && EnhanceJS[buttonCode]) {
EnhanceJS[buttonCode].call(onlineFormDetailContext, onlineFormDetailContext);
}
}
watch(fieldDisplayStatus, (newValue) => {
Object.entries(newValue).forEach(([key, value]) => {
if (value == false) {
const findItem = detailFormSchemas.value.find((item) => item.field === key);
if (findItem) {
findItem.hidden = true;
}
}
});
});
const context = {
setFieldsValue: (values) => {
Object.entries(values).forEach(([key, value]) => {
detailData[key] = value;
});
},
getFieldsValue: () => {
return { ...detailData };
},
sh: fieldDisplayStatus,
isUpdate: ref(false),
isDetail: ref(true),
};
resetContext(context);
// update-end--author:liaozhiyang---date:20240425---for【issues/6139】online详情支持js增强loaded事件及设置值、获取值、隐藏功能
return {
detailFormSchemas,
formData,
formSpan,
//主表
tableName,
loading,
//子表
hasSubTable,
subTabInfo,
subFormHeight,
subTableHeight,
refMap,
onTabChange,
subReloadKey,
//一对多子表
subDataSource,
getSubTableAuthPre,
//父组件调用
show,
createRootProperties,
// 扩展配置
onOpenReportPrint,
onlineExtConfigJson,
getSubTableForeignKeyValue,
showStatus,
ERP,
rowNumber,
};
},
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,911 @@
<template>
<div :id="tableName + '_form'" class="onlinePopFormWrap" :class="[`formTemplate_${formTemplate}`]">
<BasicForm ref="onlineFormRef" @register="registerForm" />
</div>
</template>
<script lang="ts">
import { useMessage } from '/@/hooks/web/useMessage';
import { computed, ref, unref, nextTick, toRaw, reactive } from 'vue';
import { BasicForm, useForm } from '/@/components/Form/index';
import { SUBMIT_FLOW_KEY, VALIDATE_FAILED } from '../../types/onlineRender';
import { defHttp } from '/@/utils/http/axios';
import { pick } from 'lodash-es';
import { useFormItems, getRefPromise, useOnlineFormContext } from '../../hooks/auto/useAutoForm';
import { Loading } from '/@/components/Loading';
import { useEnhance } from '../../hooks/auto/useEnhance';
import OnlineSubForm from './OnlineSubForm.vue';
import { loadFormFieldsDefVal } from '../../util/FieldDefVal';
import { getToken } from '/@/utils/auth';
import { goJmReportViewPage } from '/@/utils'
import { PrinterOutlined, DiffOutlined, FormOutlined } from '@ant-design/icons-vue';
import { useModal } from '/@/components/Modal';
import { Method } from 'axios';
import { isObject } from '/@/utils/is';
const urlObject = {
optPre: '/online/cgform/api/form/',
urlButtonAction: '/online/cgform/api/doButton',
};
export default {
name: 'OnlinePopForm',
components: {
BasicForm,
Loading,
OnlineSubForm,
PrinterOutlined,
DiffOutlined,
FormOutlined
},
props: {
id: {
type: String,
default: '',
},
formTemplate: {
type: Number,
default: 1,
},
disabled: {
type: Boolean,
default: false,
},
isTree: {
type: Boolean,
default: false,
},
pidField: {
type: String,
default: '',
},
submitTip:{
type: Boolean,
default: true
},
modalClass:{
type: String,
default: '',
},
//是否发送请求-即表单的保存/编辑请求false则只将表单数据抛出去
request: {
type: Boolean,
default: true
},
// 是否是vxeTable上方按钮点击打开的表单数据
isVxeTableData: {
type: Boolean,
default: false
}
},
emits: ['success', 'rendered', 'dataChange'],
setup(props, { emit }) {
console.log('onlineForm-setup》》');
const { createMessage: $message } = useMessage();
const [registerVxeFormModal, { openModal:openVxeFormModal }] = useModal();
const vxeTableId = ref('');
// 表单ref
const onlineFormRef = ref(null);
const single = ref(true);
// 加载状态
const loading = ref(false);
const tableType = ref(1);
// 表单提交且提交流程
const submitFlowFlag = ref(false);
const isUpdate = ref(false);
/**
* online表单扩展配置
*/
const onlineExtConfigJson = reactive({
reportPrintShow: 0,
reportPrintUrl: '',
joinQuery: 0,
modelFullscreen: 0,
modalMinWidth: '',
});
const { onlineFormContext, resetContext } = useOnlineFormContext();
const {
formSchemas,
defaultValueFields,
changeDataIfArray2String,
tableName,
dbData,
checkOnlyFieldValue,
hasSubTable,
subTabInfo,
refMap,
subDataSource,
baseColProps,
createFormSchemas,
fieldDisplayStatus,
labelCol,
wrapperCol,
labelWidth
} = useFormItems(props, onlineFormRef);
let { EnhanceJS, initCgEnhanceJs } = useEnhance(onlineFormContext, false);
//表单配置
const [registerForm, { setProps, validate, resetFields, setFieldsValue, updateSchema, getFieldsValue, scrollToField }] = useForm({
schemas: formSchemas,
showActionButtonGroup: false,
baseColProps: baseColProps,
// update-begin--author:liaozhiyang---date:20240329---for【QQYUN-7872】online表单label较长优化
labelWidth,
// update-end--author:liaozhiyang---date:20240329---for【QQYUN-7872】online表单label较长优化
// update-begin--author:liaozhiyang---date:20240105---for【QQYUN-7499】多列风格富文本、markdown增加独占一行功能
labelCol,
wrapperCol
// update-end--author:liaozhiyang---date:20240105---for【QQYUN-7499】多列风格富文本、markdown增加独占一行功能
});
// 表单禁用
const onlineFormDisabled = ref(false);
function handleFormDisabled() {
let flag = props.disabled;
onlineFormDisabled.value = flag;
setProps({ disabled: flag });
}
/**
* status: 是否是修改页面
* record: 列表页面的行数据
* param 树形列表添加子节点 传入的父级节点id
* */
async function show(status, record, param) {
console.log('onlinepopform新增编辑进入表单》》form', record);
await resetFields();
dbData.value = '';
let flag = unref(status);
isUpdate.value = flag;
if (flag) {
// 编辑页面
await edit(record);
}
await nextTick(() => {
if (!flag && param) {
//如果是新增页面 且 param传入有值 需要设置表单
setFieldsValue(param);
}
handleDefaultValue();
// 所有信息加载完毕 触发loaded事件
handleCgButtonClick('js', 'loaded');
//处理表单的禁用效果
handleFormDisabled();
});
}
/**
* 当前表单默认值逻辑-进入新增页面触发
*/
function handleDefaultValue() {
if (unref(isUpdate) === false) {
let fieldProperties = toRaw(defaultValueFields[tableName.value]);
loadFormFieldsDefVal(fieldProperties, (values) => {
setFieldsValue(values);
});
}
}
async function edit(record) {
// 查询数据库
let formData:any = await getFormData(record.id);
if(!formData || Object.keys(formData).length==0){
//没有查询出数据
formData = {...toRaw(record)}
}
dbData.value = Object.assign({}, formData);
//表单赋值
let arr = realFormFieldNames.value;
let values = pick(formData, ...arr);
// 如果是vxetable上方按钮打开的表单那么表单值以record为主而不是数据库查询的数据,否则第一次修改后第二次打开表单,表单值和数据库一致但是和当前页面不一致
if(props.isVxeTableData === true){
values = Object.assign({},values, record)
}
await setFieldsValue(values);
// editSubVxeTableData(formData);
}
function editSubVxeTableData(record) {
if (!record) {
// 新增页面需要清空子表数据
record = {};
}
let keys = Object.keys(subDataSource.value);
if (keys && keys.length > 0) {
let obj = {};
for (let key of keys) {
obj[key] = record[key] || [];
}
subDataSource.value = obj;
}
}
let realFormFieldNames = computed(() => {
let arr = formSchemas.value;
let names = [];
for (let a of arr) {
names.push(a.field);
}
return names;
});
function getFormData(dataId) {
let url = `${urlObject.optPre}${props.id}/${dataId}`;
return new Promise((resolve, reject) => {
defHttp
.get({ url }, { isTransformResponse: false })
.then((res) => {
if (res.success) {
resolve(res.result);
} else {
reject();
$message.warning(res.message);
}
})
.catch(() => {
reject();
});
});
}
// 渲染表单
async function createRootProperties(data) {
tableType.value = data.head.tableType;
tableName.value = data.head.tableName;
single.value = data.head.tableType == 1;
handleExtConfigJson(data.head.extConfigJson);
createFormSchemas(data.schema.properties, data.schema.required, checkOnlyFieldValue, onlineExtConfigJson);
EnhanceJS = initCgEnhanceJs(data.enhanceJs);
emit('rendered', onlineExtConfigJson);
//监听表单改变事件
let formRefObject:any = await getRefPromise(onlineFormRef);
formRefObject.$formValueChange = (field, value, changeFormData) => {
onValuesChange(field, value);
if(changeFormData){
//如果存在其他表单控件的数据,直接设置该值
setFieldsValue(changeFormData)
}
};
}
/**
* 处理扩展配置
*/
function handleExtConfigJson(jsonStr) {
let extConfigJson = { reportPrintShow: 0, reportPrintUrl: '', joinQuery: 0, modelFullscreen: 1, modalMinWidth: '', formLabelLength: null };
if (jsonStr) {
extConfigJson = JSON.parse(jsonStr);
}
Object.keys(extConfigJson).map((k) => {
onlineExtConfigJson[k] = extConfigJson[k];
});
}
function handleSubmit() {
if (single.value === true) {
handleSingleSubmit();
} else {
handleOne2ManySubmit();
}
}
function handleOne2ManySubmit() {
validateAll().then((formData) => {
handleApplyRequest(formData);
});
}
// 触发所有表单验证
function validateAll() {
let temp = {};
return new Promise((resolve, reject) => {
// 验证主表表单
validate().then(
(values) => resolve(values),
({ errorFields }) => {
reject({
code: VALIDATE_FAILED,
key: tableName.value,
// 滚动到未通过校验的字段上
scrollToField: () => errorFields[0] && scrollToField(errorFields[0].name, { behavior: 'smooth', block: 'center' }),
});
}
);
})
.then((result) => {
Object.assign(temp, changeDataIfArray2String(result));
return validateSubTableFields();
})
.then((allTableData) => {
Object.assign(temp, allTableData);
return Promise.resolve(temp);
})
.catch((e) => {
if (e === VALIDATE_FAILED || e?.code === VALIDATE_FAILED) {
$message.warning('校验未通过');
if (e.key) {
changeTab(e.key);
if (e.scrollToField) {
setTimeout(() => e.scrollToField(), 150)
}
}
} else {
console.error(e);
}
return Promise.reject(null);
});
}
/**
* 切换tab到出现校验错误的页面
* */
function changeTab(key) {
let arr = subTabInfo.value;
for (let i = 0; i < arr.length; i++) {
if (key == arr[i].key) {
subActiveKey.value = i + '';
break;
}
}
}
// 验证子表
function validateSubTableFields() {
return new Promise(async (resolve, reject) => {
let subData = {};
try {
let arr = subTabInfo.value;
for (let i = 0; i < arr.length; i++) {
let key = arr[i].key;
let instance = refMap[key].value;
// 兼容写法:如果取到的是一个数组类型,则取第一个元素
if (instance instanceof Array) {
instance = instance[0];
}
if (arr[i].relationType == 1) {
try {
let subFormData = await instance.getAll();
subData[key] = [];
subData[key].push(subFormData);
} catch (e) {
return reject({code: VALIDATE_FAILED, key, ...e});
}
} else {
let errMap = await instance.fullValidateTable();
if (errMap) {
return reject({code: VALIDATE_FAILED, key});
}
subData[key] = instance.getTableData();
}
}
} catch (e) {
reject(e);
}
resolve(subData);
});
}
/**
* 表单提交-单表
*/
async function handleSingleSubmit() {
try {
let values = await validate();
values = Object.assign({}, dbData.value, values);
values = changeDataIfArray2String(values);
loading.value = true;
handleApplyRequest(values);
} catch (error) {
// update-begin--author:liaozhiyang---date:20240524---for【TV360X-420】关联记录校验不通过的项在可视区外时点击保存没任何效果
if (isObject(error)) {
const errorFields = error.errorFields;
if (errorFields?.length && errorFields[0].errors) {
$message.warning(errorFields[0].errors[0]);
scrollToField(errorFields[0].name, { behavior: 'smooth', block: 'center' });
}
}
console.log(error);
// update-end--author:liaozhiyang---date:20240524---for【TV360X-420】关联记录校验不通过的项在可视区外时点击保存没任何效果
} finally {
loading.value = false;
}
}
//提交数据前 先走一下自定义的JS校验
function handleApplyRequest(formData) {
customBeforeSubmit(context, formData)
.then(() => {
doApplyRequest(formData);
})
.catch((msg) => {
$message.warning(msg);
});
}
function triggleChangeValues(values, id, target) {
if (id && target) {
if (target.setValues) {
//一对一子表
target.setValues(values);
} else {
//一对多子表
target.setValues([
{
rowKey: id,
values: values,
},
]);
}
} else {
//主表
setFieldsValue(values);
}
}
function triggleChangeValue(field, value) {
let obj = {};
obj[field] = value;
setFieldsValue(obj);
}
// 一对多子表Tab的Key用于校验未通过时自动跳转
const subActiveKey = ref('0');
const subFormHeight = ref(300);
// 子表表格高度
// 【VUEN-803】一对多子表固定340高度修复自定义列组件被遮挡的问题
const subTableHeight = ref(340);
function getSubTableForeignKeyValue(key) {
if (isUpdate.value === true) {
let formData = dbData.value;
return getValueIgnoreCase(formData, key);
}
return '';
}
/**
* VUEN-1056 30、生成的一对多编辑的时候子表数据挂不上
*/
function getValueIgnoreCase(data, key) {
if (data) {
let temp = data[key];
if (!temp && temp !== 0) {
temp = data[key.toLowerCase()];
if (!temp && temp !== 0) {
temp = data[key.toUpperCase()];
}
}
return temp;
}
return '';
}
//处理一对一子表的表单改变事件
function handleSubFormChange(valueObj, tableKey) {
if (EnhanceJS && EnhanceJS[tableKey + '_onlChange']) {
let tableChangeObj = EnhanceJS[tableKey + '_onlChange']();
let columnKey = Object.keys(valueObj)[0];
if (tableChangeObj[columnKey]) {
let subRef = refMap[tableKey].value;
if (subRef instanceof Array) {
subRef = subRef[0];
}
let formEvent = subRef.getFormEvent();
let event = {
column: { key: columnKey },
value: valueObj[columnKey],
...formEvent,
};
tableChangeObj[columnKey].call(onlineFormContext, onlineFormContext, event);
}
}
}
//处理一对多子表的改变事件
function handleValueChange(event, tableKey) {
if (EnhanceJS && EnhanceJS[tableKey + '_onlChange']) {
let tableChangeObj = EnhanceJS[tableKey + '_onlChange'](onlineFormContext);
if (tableChangeObj[event.column.key]) {
tableChangeObj[event.column.key].call(onlineFormContext, onlineFormContext, event);
}
}
}
// 当行编辑新增完成后触发的事件
function handleAdded(sub, event) {
console.log('handleAdded', sub, event)
//update-begin-author:taoyan date:2022-6-26 for: 控制台警告 这里直接调用函数 不触发事件了
// event.target.emit('executeFillRule', event);
//update-end-author:taoyan date:2022-6-26 for: 控制台警告 这里直接调用函数 不触发事件了
}
function getSubTableAuthPre(table) {
return 'online_' + table + ':';
}
//监听表单改变事件
async function onValuesChange(columnKey, value) {
//console.log('columnKey-value', `${columnKey}-${value}`)
// 将老数据和新数据比较 如果不同 往外抛出改变事件
let oldFormData = dbData.value;
if(oldFormData[columnKey]!=value){
emit('dataChange', columnKey);
}
if (!EnhanceJS || !EnhanceJS['onlChange']) {
return false;
}
if (!columnKey) {
return false;
}
//let tableChangeObj = EnhanceJS["onlChange"].call(onlineFormContext);
let tableChangeObj = EnhanceJS['onlChange']();
if (tableChangeObj[columnKey]) {
let formData = await getFieldsValue();
let event = {
row: formData,
column: { key: columnKey },
value: value,
};
tableChangeObj[columnKey].call(onlineFormContext, onlineFormContext, event);
}
}
// 自定义按钮 增强触发事件
function handleCgButtonClick(optType, buttonCode) {
if ('js' == optType) {
if (EnhanceJS && EnhanceJS[buttonCode]) {
EnhanceJS[buttonCode].call(onlineFormContext, onlineFormContext);
}
} else if ('action' == optType) {
let formData = dbData.value;
let params = {
formId: props.id,
buttonCode: buttonCode,
dataId: formData.id,
uiFormData: Object.assign({}, formData),
};
//console.log("自定义按钮请求后台参数:",params)
defHttp
.post(
{
url: `${urlObject.urlButtonAction}`,
params,
},
{ isTransformResponse: false }
)
.then((res) => {
if (res.success) {
$message.success('处理完成!');
} else {
$message.warning('处理失败!');
}
});
}
}
// --------------------增强-------------------------
/**
* 清除子表数据
* @param tbname
*/
function clearSubRows(tbname) {
let instance = refMap[tbname].value;
let rows = [...instance.getNewDataWithId(), ...subDataSource.value[tbname]];
if (!rows || rows.length == 0) {
return false;
}
let ids = [];
for (let i of rows) {
ids.push(i.id);
}
instance.removeRowsById(ids);
}
/**
* 添加子表数据
* @param tbname
* @param rows 可以是数组也可以是对象
* @returns {boolean}
*/
function addSubRows(tbname, rows) {
if (!rows) {
return false;
}
let instance = refMap[tbname].value;
if (typeof rows == 'object') {
instance.addRows(rows, true);
} else {
$message.error('添加子表数据,参数不识别!');
}
}
/**
* 先删除后添加
* @param tbname
* @param rows
*/
function clearThenAddRows(tbname, rows) {
clearSubRows(tbname);
addSubRows(tbname, rows);
}
/**
* 修改下拉框的下拉选项
* @param field
* @param options
*/
function changeOptions(field, options) {
if (!options && options.length <= 0) {
options = [];
}
options.map((item) => {
if (!item.hasOwnProperty('label')) {
item['label'] = item.text;
}
});
updateSchema({
field,
componentProps: {
options,
},
});
}
/**
* 表单提交前事件
* @param that
* @param formData
* @returns {Promise<void>|*}
*/
function customBeforeSubmit(that, formData) {
if (EnhanceJS && EnhanceJS['beforeSubmit']) {
return EnhanceJS['beforeSubmit'](that, formData);
} else {
return Promise.resolve();
}
}
/**
* 处理自定义弹框 字段的显示隐藏
* @param show
* @param hide
*/
function handleCustomFormSh(show, hide) {
let plain = toRaw(fieldDisplayStatus);
if (show && show.length > 0) {
Object.keys(plain).map((k) => {
if (!k.endsWith('_load') && show.indexOf(k) < 0) {
fieldDisplayStatus[k] = false;
}
});
} else if (hide && hide.length > 0) {
Object.keys(plain).map((k) => {
if (hide.indexOf(k) >= 0) {
fieldDisplayStatus[k] = false;
}
});
}
}
async function handleCustomFormEdit(record) {
console.log('自定义弹窗打开online表单》》form', record);
await resetFields();
dbData.value = '';
isUpdate.value = true;
// 编辑数据
await edit(record);
await nextTick(() => {
// 所有信息加载完毕 触发loaded事件
handleCgButtonClick('js', 'loaded');
});
}
/**
* VUEN-1036
* 获取子表的实例对象 可以直接调用子表的方法
*/
function getSubTableInstance(tableName) {
let instance = refMap[tableName].value;
if (instance instanceof Array) {
instance = instance[0];
}
return instance;
}
//跳转至积木报表页面
function onOpenReportPrint(){
let url = onlineExtConfigJson.reportPrintUrl;
let id = dbData.value.id;
let token = getToken();
goJmReportViewPage(url, id, token)
}
//----
function openSubFormModalForAdd(sub){
console.log(sub)
vxeTableId.value = sub.id;
openVxeFormModal(true, )
}
function openSubFormModalForEdit(sub){
console.log(sub)
}
/**
* 保存数据
* @param formData
*/
function doApplyRequest(formData) {
// 数组没有元素直接置空
Object.keys(formData).map((key) => {
if (Array.isArray(formData[key])) {
if (formData[key].length == 0) {
formData[key] = '';
}
}
});
console.log('提交pop表单数据》》》form:', formData);
if(props.request == false){
emit('success', formData);
}else{
let url = `${urlObject.optPre}${props.id}?tabletype=${tableType.value}`;
console.log('提交pop表单url》》》url:', url);
// 如果需要提交流程 需要额外设置一个参数
if (submitFlowFlag.value === true) {
formData[SUBMIT_FLOW_KEY] = 1;
}
let method:Method = isUpdate.value === true ? 'put' : 'post';
defHttp.request({ url, method, params: formData }, { isTransformResponse: false })
.then((res) => {
//console.log('表单提交完成', res)
if (res.success) {
if (res.result) {
//formData[SUBMIT_FLOW_ID] = res.result;
if(!formData.id){
formData['id'] = res.result;
}
}
//刷新列表
emit('success', formData);
dbData.value = formData;
isUpdate.value = true;
$message.success('操作成功!')
// 工单申请提交的表单也会走这个逻辑,保存成功不需要提示信息
} else {
$message.warning(res.message);
}
})
.finally(() => {
loading.value = false;
});
}
}
/**
* 数据恢复到 dbdata
*/
async function recoverFormData(){
let record = dbData.value;
let arr = realFormFieldNames.value;
let values = pick(record, ...arr);
if(record){
await setFieldsValue(values);
}else{
let temp:any = {}
for(let key of arr){
temp[key] = ''
}
await setFieldsValue(temp);
}
}
let context = {
tableName,
loading,
subActiveKey,
onlineFormRef,
getFieldsValue,
setFieldsValue,
submitFlowFlag,
subFormHeight,
subTableHeight,
refMap,
triggleChangeValues,
triggleChangeValue,
sh: fieldDisplayStatus,
clearSubRows,
addSubRows,
clearThenAddRows,
changeOptions,
isUpdate,
getSubTableInstance,
};
resetContext(context);
return {
//主表
tableName,
onlineFormRef,
registerForm,
loading,
//子表
subActiveKey,
hasSubTable,
subTabInfo,
refMap,
//一对一子表
subFormHeight,
getSubTableForeignKeyValue,
isUpdate,
handleSubFormChange,
//一对多子表
subTableHeight,
onlineFormDisabled,
subDataSource,
getSubTableAuthPre,
handleAdded,
handleValueChange,
openSubFormModalForAdd,
openSubFormModalForEdit,
registerVxeFormModal,
vxeTableId,
//父组件调用
show,
createRootProperties,
handleSubmit,
sh: fieldDisplayStatus,
handleCgButtonClick,
handleCustomFormSh,
handleCustomFormEdit,
//跳转
dbData,
onOpenReportPrint,
onlineExtConfigJson,
recoverFormData
};
},
};
</script>
<style lang="less" scoped>
.onlinePopFormWrap {
// update-begin--author:liaozhiyang---date:20240429---for【QQYUN-7632】 label栅格改成labelwidth固宽
padding: 20px 1.5% 0 1.5%;
// update-begin--author:liaozhiyang---date:20240506---for【QQYUN-9229】间隔调整
&.formTemplate_1 {
> form {
padding-left: 5%;
padding-right: 5%;
}
}
&.formTemplate_2 {
> form {
padding-left: 1%;
padding-right: 1%;
}
}
// update-end--author:liaozhiyang---date:20240506---for【QQYUN-9229】间隔调整
:deep(.ant-form) {
> .ant-row {
> .ant-col {
padding: 0 6px;
}
}
}
// update-end--author:liaozhiyang---date:20240429---for【QQYUN-7632】 label栅格改成labelwidth固宽
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<BasicModal @register="registerModal" :width="popModalFixedWidth" :dialogStyle="{top: '70px'}" :bodyStyle="popBodyStyle" :title="modalTitle" wrapClassName="jeecg-online-pop-list-modal">
<template #footer>
<div style="display: inline-block;width: calc(100% - 140px);text-align: left;">
<a-button v-if="addAuth" style="border-radius: 50px" type="primary" @click="handleAdd"><PlusOutlined/>新增记录</a-button>
</div>
<a-button key="back" @click="handleCancel">关闭</a-button>
<a-button :disabled="submitDisabled" key="submit" type="primary" @click="handleSubmit" :loading="submitLoading">确定</a-button>
</template>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<!-- update-begin-author:taoyan date:2023-7-11 for: issues/4992 online表单开发 字段控件类型是关联记录 新增的时候选择列表可以添加查询么 -->
<template #tableTitle>
<a-input-search v-model:value="searchText" @search="onSearch" placeholder="请输入关键词,按回车搜索" style="width: 240px" />
</template>
<!-- update-end-author:taoyan date:2023-7-11 for: issues/4992 online表单开发 字段控件类型是关联记录 新增的时候选择列表可以添加查询么 -->
<!--操作栏-->
<template #action="{ record }">
<TableAction :actions="getTableAction(record)">
</TableAction>
</template>
<template #fileSlot="{ text }">
<span v-if="!text" style="font-size: 12px; font-style: italic">无文件</span>
<a-button v-else :ghost="true" type="primary" preIcon="ant-design:download" size="small" @click="downloadRowFile(text)"> 下载 </a-button>
</template>
<template #imgSlot="{ text }">
<span v-if="!text" style="font-size: 12px; font-style: italic">无图片</span>
<img v-else :src="getImgView(text)" alt="图片不存在" class="online-cell-image" @click="viewOnlineCellImage(text)" />
</template>
<template #htmlSlot="{ text }">
<div v-html="text"></div>
</template>
<template #pcaSlot="{ text, column }">
<div :title="getPcaText(text, column)">{{ getPcaText(text, column) }}</div>
</template>
<template #dateSlot="{ text, column }">
<span>{{ getFormatDate(text, column) }}</span>
</template>
</BasicTable>
</BasicModal>
<!-- 弹窗到另外一张表单用-可编辑表单 -->
<online-pop-modal :id="id" @register="registerPopModal" @success="handleDataSave" topTip></online-pop-modal>
</template>
<script lang="ts">
import { defineComponent, watch, ref, toRaw, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicTable, TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { defHttp } from '/@/utils/http/axios';
import { useTableColumns } from '../../hooks/auto/useTableColumns';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useModal } from '/@/components/Modal';
import OnlinePopModal from './OnlinePopModal.vue';
import { useFixedHeightModal } from '../../hooks/auto/useAutoModal';
export default defineComponent({
name: 'OnlinePopListModal',
props: {
/**可以是表名 可以是ID*/
id: {
type: String,
default: '',
},
multi:{
type: Boolean,
default: false
},
addAuth:{
type: Boolean,
default: true
}
},
components: {
BasicModal,
BasicTable,
TableAction,
PlusOutlined,
OnlinePopModal
},
emits: ['success', 'register'],
setup(props, { emit }) {
const { createMessage: $message } = useMessage();
// 弹窗高度控制
const { popModalFixedWidth, resetBodyStyle, popBodyStyle } = useFixedHeightModal();
const searchText = ref('');
const modalWidth = ref(800);
//useModalInner
const [registerModal, {closeModal}] = useModalInner((data) => {
searchText.value = '';
// update-begin--author:liaozhiyang---date:20240517---for【TV360X-43】修复关联记录可以添加重复数据
selectedRowKeys.value = data.selectedRowKeys;
selectedRows.value = data.selectedRows;
// update-end--author:liaozhiyang---date:20240517---for【TV360X-43】修复关联记录可以添加重复数据
setPagination({current:1})
reload();
resetBodyStyle();
});
// 用于 online表单中 弹出别的表单
const [registerPopModal, { openModal: openPopModal }] = useModal();
function handleCancel() {
closeModal();
}
const submitDisabled = computed(()=>{
const arr = selectedRowKeys.value;
if(arr && arr.length>0){
return false;
}
return true;
})
const submitLoading = ref(false);
function handleSubmit(){
submitLoading.value = true;
let arr = toRaw(selectedRows.value);
if(arr && arr.length>0){
emit('success', arr)
closeModal();
}
setTimeout(()=>{
submitLoading.value = false
}, 200);
}
//---------------------列表------------------------
function queryTableData(params){
const url = '/online/cgform/api/getData/'+props.id;
return defHttp.get({ url, params });
}
function list(params){
params['column'] = 'id';
return new Promise(async (resolve, _reject) => {
const aa = await queryTableData(params)
resolve(aa);
})
}
const onlineTableContext = {
isPopList: true,
reloadTable(){
console.log('reloadTable')
},
isTree(){
return false;
}
};
const extConfigJson = ref<any>({});
// 处理 BasicTable 的配置
const {
columns,
downloadRowFile,
getImgView,
getPcaText,
getFormatDate,
handleColumnResult,
hrefComponent,
viewOnlineCellImage,
} = useTableColumns(onlineTableContext, extConfigJson);
/**
* 查询table列信息 及其他配置
*/
function getColumnList() {
const url = '/online/cgform/api/getColumns/'+props.id;
return new Promise((resolve, reject) => {
defHttp.get({url}, { isTransformResponse: false }).then((res) => {
if (res.success) {
resolve(res.result);
} else {
$message.warning(res.message);
reject();
}
})
});
}
const modalTitle = ref('')
watch(()=>props.id, async ()=>{
let columnResult:any = await getColumnList();
handleColumnResult(columnResult);
modalTitle.value = columnResult.description;
}, {immediate: true})
const { tableContext } = useListPage({
designScope: 'process-design',
pagination: true,
tableProps: {
title: '',
api: list,
clickToRowSelect: true,
columns: columns,
showTableSetting: false,
immediate:false,
//showIndexColumn: true,
canResize: false,
showActionColumn: false,
actionColumn: {
dataIndex: 'action',
slots: { customRender: 'action' },
},
useSearchForm: false,
beforeFetch: (params) => {
return addQueryParams(params);
},
},
});
const [registerTable, { reload, setPagination }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
watch(()=>props.multi, (val)=>{
if(val==true){
rowSelection.type = 'checkbox'
}else{
rowSelection.type = 'radio'
}
}, {immediate: true});
/**
* 操作栏
*/
function getTableAction(record) {
return [
{
label: '编辑',
onClick: handleUpdate.bind(null, record),
}
];
}
function handleUpdate(record){
console.log('handleUpdate', record)
}
function onSearch(){
reload();
}
const eqConditonTypes = ['int', 'double', 'Date', 'Datetime', 'BigDecimal']
function addQueryParams(params){
let text = searchText.value;
if(!text){
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = ''
return params;
}
let arr = columns.value;
let conditions:any[] = []
if(arr && arr.length>0){
for(let item of arr){
if(item.dbType){
if(item.dbType == 'string'){
conditions.push({field: item.dataIndex,type:item.dbType.toLowerCase(), rule: 'like', val: text})
}else if(item.dbType == 'Date'){
if(text.length=='2020-10-10'.length){
conditions.push({field: item.dataIndex,type:item.dbType.toLowerCase(), rule: 'eq', val: text})
}
}else if(item.dbType == 'Datetime'){
if(text.length=='2020-10-10 10:10:10'.length){
conditions.push({field: item.dataIndex,type:item.dbType.toLowerCase(), rule: 'eq', val: text})
}
}else if(eqConditonTypes.indexOf(item.dbType)){
conditions.push({field: item.dataIndex, type:item.dbType.toLowerCase(), rule: 'eq', val: text})
}else{
//text blob不做处理
}
}
}
}
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = encodeURI(JSON.stringify(conditions));
return params;
}
function handleAdd(){
openPopModal(true, {})
}
// modal数据新增完成 直接关闭list将新增的数据带回表单
function handleDataSave(data){
console.log('handleDateSave' ,data)
// update-begin--author:liaozhiyang---date:20250429---for【issues/8163】关联记录新增丢失
let arr = [data, ...selectedRows.value];
// update-end--author:liaozhiyang---date:20250429---for【issues/8163】关联记录新增丢失
emit('success', arr);
closeModal();
//reload();
}
return {
registerModal,
modalWidth,
handleCancel,
submitDisabled,
submitLoading,
handleSubmit,
registerTable,
getTableAction,
searchText,
onSearch,
downloadRowFile,
getImgView,
getPcaText,
getFormatDate,
hrefComponent,
viewOnlineCellImage,
rowSelection,
modalTitle,
registerPopModal,
handleAdd,
reload,
popModalFixedWidth,
popBodyStyle,
handleDataSave
};
},
});
</script>
<style lang="less" scoped>
.online-cell-image {
max-height: 30px;
max-width: 50px;
object-fit: contain;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<BasicModal :width="popModalFixedWidth" :dialogStyle="{top: '70px'}" :bodyStyle="popBodyStyle" v-bind="$attrs" :footer="modalFooter" cancelText="关闭" @register="registerModal" wrapClassName="jeecg-online-pop-modal" @ok="handleSubmit">
<template #title>
{{title}}
<j-modal-tip v-if="showTopTip" :visible="topTipVisible" @save="handleSaveData" @cancel="handleRecover"></j-modal-tip>
</template>
<online-pop-form
ref="onlineFormCompRef"
:id="id"
:disabled="disableSubmit"
:form-template="formTemplate"
:isTree="isTreeForm"
:pidField="pidFieldName"
:request="request"
:isVxeTableData="isVxeTableData"
@rendered="renderSuccess"
@success="handleSuccess"
@data-change="handleDataChange"
modal-class="jeecg-online-pop-modal"
>
</online-pop-form>
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, watch, watchEffect, ref, computed, h } from 'vue';
import { BasicModal } from '/@/components/Modal';
import OnlinePopForm from './OnlinePopForm.vue';
import { useAutoModal } from '../../hooks/auto/useAutoModal';
import JModalTip from '../../extend/linkTable/JModalTip.vue'
import { Button } from 'ant-design-vue';
export default defineComponent({
name: 'OnlinePopModal',
props: {
/**可以是表名 可以是ID*/
id: {
type: String,
default: '',
},
/*展示字段名*/
showFields:{
type: Array,
default: ()=>[],
},
/*隐藏字段名*/
hideFields:{
type: Array,
default: ()=>[],
},
topTip:{
type: Boolean,
default: false,
},
request:{
type: Boolean,
default: true,
},
saveClose:{
type: Boolean,
default: false
},
// 是否是vxeTable上方按钮点击打开的表单数据
isVxeTableData:{
type: Boolean,
default: false
},
formTableType:{
type: String,
default: '',
},
// -update-begin--author:liaozhiyang---date:20240613---for【TV360X-1000】流程一对多走流程的接口
// 有taskId即是流程
taskId: {
type: String,
},
tableName: {
type: String,
},
// -update-end--author:liaozhiyang---date:20240613---for【TV360X-1000】流程一对多走流程的接口
},
components: {
BasicModal,
OnlinePopForm,
JModalTip,
Button
},
emits: ['success', 'register', 'formConfig'],
setup(props, { emit }) {
console.log('进入表单弹框》》》》modal');
const {
title,
registerModal,
cgButtonList,
handleCgButtonClick,
disableSubmit,
handleSubmit,
submitLoading,
handleCancel,
handleFormConfig,
onlineFormCompRef,
formTemplate,
isTreeForm,
pidFieldName,
renderSuccess,
formRendered,
handleSuccess,
topTipVisible,
successThenClose,
isUpdate,
popBodyStyle,
popModalFixedWidth,
getFormStatus
} = useAutoModal(false, { emit });
// 监听id变化 表单重新渲染
watch(() => props.id, renderFormItems, { immediate: true });
async function renderFormItems() {
formRendered.value = false;
if (!props.id) {
return;
}
console.log('重新渲染表单》》》》modal');
//update-begin-author:taoyan date:2023-4-10 for: issues/4655 online在线表单一对多对子表记录进行新增或编辑时无法获取到表单信息 #4655
let params = {}
if(props.formTableType){
params['tabletype'] = props.formTableType
}
// -update-begin--author:liaozhiyang---date:20240613---for【TV360X-1000】流程一对多走流程的接口
if (props.taskId) {
await handleFormConfig(props.id, params,null,props.taskId, props.tableName );
} else {
await handleFormConfig(props.id, params);
}
// -update-end--author:liaozhiyang---date:20240613---for【TV360X-1000】流程一对多走流程的接口
//update-end-author:taoyan date:2023-4-10 for: issues/4655 online在线表单一对多对子表记录进行新增或编辑时无法获取到表单信息 #4655
}
// 上方保存按钮触发
function handleSaveData() {
//如果props的saveClose没有设置为true则弹窗不会关闭
if(props.saveClose === false){
successThenClose.value = false;
}
handleSubmit();
}
// 上方取消按钮触发
function handleRecover(){
topTipVisible.value = false;
onlineFormCompRef.value.recoverFormData()
}
// 表单数据改变触发modal事件
function handleDataChange(){
topTipVisible.value = true;
}
// 只有编辑页面才需要显示顶部保存按钮
const showTopTip = computed(()=>{
// update-begin--author:liaozhiyang---date:20250318---for【issues/7930】表格列表中支持关联记录配置是否只读
if (disableSubmit.value) {
return false;
}
// update-end--author:liaozhiyang---date:20250318---for【issues/7930】表格列表中支持关联记录配置是否只读
if(!isUpdate.value){
return false;
}
return props.topTip
});
// 编辑页面没有底部按钮
const modalFooter = computed(()=>{
if(isUpdate.value==true){
return null;
}else{
let flag = submitLoading.value;
const defaultFooter:any[] = [
h(Button, { type: 'primary', loading: flag, onClick: handleSubmit },()=>'确定'),
h(Button, { onClick:handleCancel },()=>'关闭')
];
return defaultFooter
}
});
const that = {
title,
topTipVisible,
handleSaveData,
handleRecover,
onlineFormCompRef,
renderSuccess,
registerModal,
handleSubmit,
handleSuccess,
handleCancel,
formTemplate,
disableSubmit,
cgButtonList,
handleCgButtonClick,
isTreeForm,
pidFieldName,
submitLoading,
handleDataChange,
isUpdate,
showTopTip,
modalFooter,
popBodyStyle,
popModalFixedWidth,
getFormStatus
};
return that;
},
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,863 @@
<template>
<div class="jeecg-basic-table-form-container online-query-form p-0" v-if="formSchemas && formSchemas.length > 0">
<BasicForm ref="onlineQueryFormRef" @register="registerForm">
<!-- 范围查询日期 -->
<template #groupDate="{ model, field, schema }">
<!-- update-begin--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
<!-- <a-date-picker
:showTime="false"
valueFormat="YYYY-MM-DD"
placeholder="开始日期"
v-model:value="model[field + '_begin']"
style="width: calc(50% - 15px);min-width: 100px;"
v-bind="schema.componentProps"
></a-date-picker>
<span class="group-query-string">~</span>
<a-form-item-rest>
<a-date-picker
:showTime="false"
valueFormat="YYYY-MM-DD"
placeholder="结束日期"
v-model:value="model[field + '_end']"
style="width: calc(50% - 15px);min-width: 100px;"
v-bind="schema.componentProps"
></a-date-picker>
</a-form-item-rest> -->
<a-range-picker :style="{ width: '100%' }" v-model:value="model[field]" v-bind="schema.componentProps" :placeholder="getGroupDatePlaceholder(schema.componentProps)" valueFormat="YYYY-MM-DD"/>
<!-- update-end--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
</template>
<!-- 范围查询时间 -->
<template #groupDatetime="{ model, field }">
<!-- update-begin--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
<!-- <a-date-picker
:showTime="true"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="开始时间"
v-model:value="model[field + '_begin']"
style="min-width: 100px;width: calc(50% - 15px);"
></a-date-picker>
<span class="group-query-string">~</span>
<a-form-item-rest>
<a-date-picker
:showTime="true"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="结束时间"
v-model:value="model[field + '_end']"
style="min-width: 100px;width: calc(50% - 15px);"
></a-date-picker>
</a-form-item-rest> -->
<a-range-picker :style="{ width: '100%' }" v-model:value="model[field]" :show-time="true" valueFormat="YYYY-MM-DD HH:mm:ss"/>
<!-- update-end--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
</template>
<!-- update-begin--author:liaozhiyang---date:20240517---forQQYUN-9348增加online查询区域时间范围查询功能 -->
<!-- 范围查询时间 -->
<template #groupTime="{ model, field }">
<!-- update-begin--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
<!-- <a-time-picker
placeholder="开始时间"
value-format="HH:mm:ss"
v-model:value="model[field + '_begin']"
style="min-width: 100px;width: calc(50% - 15px);"
></a-time-picker>
<span class="group-query-string">~</span>
<a-form-item-rest>
<a-time-picker
placeholder="结束时间"
value-format="HH:mm:ss"
v-model:value="model[field + '_end']"
style="min-width: 100px;width: calc(50% - 15px);"
></a-time-picker>
</a-form-item-rest> -->
<a-time-range-picker :style="{ width: '100%' }" v-model:value="model[field]" value-format="HH:mm:ss" />
<!-- update-end--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
</template>
<!-- update-end--author:liaozhiyang---date:20240517---forQQYUN-9348增加online查询区域时间范围查询功能 -->
<!-- 范围查询数值 -->
<template #groupNumber="{ model, field, schema }">
<!-- update-begin--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
<!-- <a-input-number placeholder="开始值" v-model:value="model[field + '_begin']" style="width: calc(50% - 15px)"></a-input-number>
<span class="group-query-string">~</span>
<a-form-item-rest>
<a-input-number placeholder="结束值" v-model:value="model[field + '_end']" style="width: calc(50% - 15px)"></a-input-number>
</a-form-item-rest> -->
<JRangeNumber v-model:value="model[field]" v-bind="schema.componentProps" />
<!-- update-end--author:liaozhiyang---date:20240530---forTV360X-213普通查询日期数值组件更换 -->
</template>
<!-- 查询/重置按钮-->
<template #formFooter>
<a-col :md="6" :sm="8">
<span style="float: left; overflow: hidden; margin-left: 10px" class="table-page-search-submitButtons">
<a-button
v-if="queryBtnCfg.enabled"
type="primary"
:preIcon="queryBtnCfg.buttonIcon"
@click="doSearch"
>
<span>{{ queryBtnCfg.buttonName }}</span>
</a-button>
<a-button
v-if="resetBtnCfg.enabled"
type="primary"
:preIcon="resetBtnCfg.buttonIcon"
style="margin-left: 8px"
@click="resetSearch"
>
<span>{{ resetBtnCfg.buttonName }}</span>
</a-button>
<a v-if="toggleButtonShow" @click="toggleSearchStatus = !toggleSearchStatus" style="margin-left: 8px">
{{ toggleSearchStatus ? '收起' : '展开' }}
<a-icon :type="toggleSearchStatus ? 'up' : 'down'" />
</a>
</span>
</a-col>
</template>
</BasicForm>
</div>
</template>
<script>
import { BasicForm, useForm } from '/@/components/Form/index';
import { watch, ref, reactive, toRaw, isProxy } from 'vue';
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
import FormSchemaFactory from './factory/FormSchemaFactory';
import IFormSchema from './factory/IFormSchema';
import { handleLinkDown, getFieldIndex, getRefPromise, LINK_DOWN } from '../../hooks/auto/useAutoForm';
import { ONL_QUERY_LABEL_COL, ONL_QUERY_WRAPPER_COL, FORM_VIEW_TO_QUERY_VIEW } from '../../types/onlineRender';
import { loadOneFieldDefVal } from '../../util/FieldDefVal';
import { useExtendComponent } from '../../hooks/auto/useExtendComponent';
import { LABELLENGTH } from '../../util/constant';
import dayjs from 'dayjs';
import JRangeNumber from '/@/components/Form/src/jeecg/components/JRangeNumber.vue'
import { useDebounceFn } from "@vueuse/core";
export default {
name: 'OnlineQueryForm',
components: {
BasicForm,
JRangeNumber,
},
props: {
id: {
type: String,
default: '',
},
queryBtnCfg: {
type: Object,
default: () => {
return {
enabled: true,
buttonName: '查询',
buttonIcon: 'ant-design:search',
}
}
},
resetBtnCfg: {
type: Object,
default: () => {
return {
enabled: true,
buttonName: '重置',
buttonIcon: 'ant-design:reload',
}
}
},
},
emits: ['search', 'loaded'],
setup(props, { emit }) {
// 获取查询条件请求地址
const LOAD_URL = '/online/cgform/api/getQueryInfoVue3/';
// 表单ref
const onlineQueryFormRef = ref(null);
// 表单渲染用到的配置
const formSchemas = ref([]);
// 表单栅格 VUEN-2493【优化】online默认查询条件太宽了参考online报表
const baseColProps = ref({ xs:24, sm: 24, md: 12, lg:6, xl:6 });
// 切换字段显示隐藏按钮是否显示
const toggleButtonShow = ref(false);
// 是否显示所有查询字段
const toggleSearchStatus = ref(false);
// 查询条件
const queryParams = ref({});
// 需要隐藏的字段
const hideList = ref([]);
const { createMessage: $message } = useMessage();
const { linkTableCard2Select } = useExtendComponent();
const formLabelWidth = ref(80);
/**
* 默认值分三种,param > cache > config
* 1.表单配置-config
* 2.路由缓存-cache
* 3.地址栏参数-param
* 当表单id发生改变config改变列表页传入cacheparam;监听status change然后重置表单的值
*/
const defaultValues = reactive({
config: {},
cache: {},
param: {},
status: false,
});
const debouncedCustomSetFieldsValue = useDebounceFn(customSetFieldsValue, 500);
/**
* 监听cacheFormValues
*/
watch(
() => defaultValues.status,
async (val) => {
console.log('-------------defaultValues发生改变,需要重置表单---------------');
const { config, cache, param } = toRaw(defaultValues);
let rawValues = Object.assign({}, config, cache, param);
//update-begin---author:wangshuai---date:2025-10-11---for:【issues/8790】online 表单重大 bug影响配置了查询 的所有表单---
await debouncedCustomSetFieldsValue(rawValues);
//update-end---author:wangshuai---date:2025-10-11---for:【issues/8790】online 表单重大 bug影响配置了查询 的所有表单---
},
{ immediate: true, deep: true }
);
/**
* 设置默认值
* @param values
*/
async function initDefaultValues(cache, param) {
defaultValues.cache = { ...cache };
defaultValues.param = { ...param };
defaultValues.status = !defaultValues.status;
}
const clearObj = (obj) => {
Object.keys(obj).map((key) => {
delete obj[key];
});
};
// 监听
watch(
() => props.id,
(val) => {
if (val) {
resetForm();
} else {
formSchemas.value = [];
}
},
{ immediate: true }
);
/**
* 获取表单配置
*/
async function initSchemas(formProperties) {
let arr = [];
let configValue = {};
let keys = Object.keys(formProperties);
let setLabelLength = -1;
for (let key of keys) {
const item = formProperties[key];
//update-begin-author:taoyan date:2023-7-19 for:QQYUN-5783 配置的数据字典参数问题是在查询的部门选择组件那儿表单定义不能更改传给后端org_code。看后端代码给组件value传的也是id这儿需要调整。
if(key === 'sys_org_code'){
if(!item.fieldExtendJson){
item.fieldExtendJson = '{"store":"orgCode"}'
}
}
//update-end-author:taoyan date:2023-7-19 for:QQYUN-5783 配置的数据字典参数问题是在查询的部门选择组件那儿表单定义不能更改传给后端org_code。看后端代码给组件value传的也是id这儿需要调整。
let view = item.view;
// update-begin--author:liaozhiyang---date:20240611---for【TV360X-461】字段类型是string控件是text则默认模糊查询
item.originView = item.view;
// update-end--author:liaozhiyang---date:20240611---for【TV360X-461】字段类型是string控件是text则默认模糊查询
if (FORM_VIEW_TO_QUERY_VIEW[view]) {
item.view = FORM_VIEW_TO_QUERY_VIEW[view];
}
await loadOneFieldDefVal(key, item, configValue);
if (item.mode == 'group' && ('date' == view || 'datetime' == view || 'number' == view || 'time' == view )) {
// 范围查询-日期,时间,数值
let temp = FormSchemaFactory.createSlotFormSchema(key, item);
arr.push(temp);
} else {
if (item.view === LINK_DOWN) {
let array = handleLinkDown(item, key);
for (let linkDownItem of array) {
let temp = FormSchemaFactory.createFormSchema(linkDownItem.key, linkDownItem);
let tempIndex = getFieldIndex(arr, linkDownItem.key);
if (tempIndex == -1) {
arr.push(temp);
} else {
arr[tempIndex] = temp;
}
}
} else {
let tempIndex = getFieldIndex(arr, key);
if (tempIndex == -1) {
let temp = FormSchemaFactory.createFormSchema(key, item);
arr.push(temp);
}
}
}
// update-begin--author:liaozhiyang---date:20231205---for【QQYUN-7140】online label默认显示6个
let fieldExtendJson = item.fieldExtendJson;
if (fieldExtendJson) {
fieldExtendJson = JSON.parse(fieldExtendJson);
if (fieldExtendJson.labelLength) {
console.log(key, fieldExtendJson.labelLength);
if (setLabelLength > -1) {
// 取配置中设置的最大值所有label的长度应一致否则多行会对不齐
setLabelLength = fieldExtendJson.labelLength > setLabelLength ? fieldExtendJson.labelLength : setLabelLength;
} else {
setLabelLength = fieldExtendJson.labelLength;
}
}
}
// update-end--author:liaozhiyang---date:20231205---for【QQYUN-7140】online label默认显示6个
}
// update-begin--author:liaozhiyang---date:20231205---for【QQYUN-7140】online label默认显示6个
// 配置中没有设置,读取默认的长度
if (setLabelLength == -1) {
setLabelLength = LABELLENGTH;
} else {
// update-begin--author:liaozhiyang---date:20240517---for【TV360X-98】label展示的文字必须和labelLength配置一致
arr.forEach(item=>{
item.labelLength = setLabelLength;
})
// update-end--author:liaozhiyang---date:20240517---for【TV360X-98】label展示的文字必须和labelLength配置一致
}
// update-end--author:liaozhiyang---date:20231205---for【QQYUN-7140】online label默认显示6个
arr.sort(function (a, b) {
return a.order - b.order;
});
let schemaArray = [];
if (arr.length > 2) {
toggleButtonShow.value = true;
}
let hideFieldName = [];
for (let i = 0; i < arr.length; i++) {
let item = arr[i];
item.setFormRef(onlineQueryFormRef);
item.noChange();
item.asSearchForm();
if (i > 1) {
hideFieldName.push(item.field);
item.isHidden();
}
//update-begin-author:taoyan date:2022-10-24 for: VUEN-2493【优化】online默认查询条件太宽了参考online报表
let tempSchema = item.getFormItemSchema();
if(item.slot == 'groupDatetime'){
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
//如果是时间类型 重新设定colprops (小于3个时才重置否则多行会导致对不齐)
arr.length <= 3 && (tempSchema['colProps'] = { xs:24, sm: 24, md: 12, lg:8, xl:8 })
// update-end--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
}
// update-begin--author:liaozhiyang---date:20240522---for【TV360X-250】查询区域把Switch开关组件改成select组件
if (tempSchema.component === 'JSwitch') {
const componentProps = tempSchema.componentProps ?? {};
tempSchema.componentProps = { ...componentProps, query: true };
}
// update-end--author:liaozhiyang---date:20240522---for【TV360X-250】查询区域把Switch开关组件改成select组件
linkTableCard2Select(tempSchema);
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-389】普通查询关联记录去掉编辑按钮
if (tempSchema.component === 'LinkTableSelect') {
let componentProps = tempSchema.componentProps ?? {};
tempSchema.componentProps = { ...componentProps, editBtnShow: false };
}
// update-end--author:liaozhiyang---date:20240530---for【TV360X-389】普通查询关联记录去掉编辑按钮
// update-begin--author:liaozhiyang---date:20240614---for【TV360X-1231】查询区域有的下拉组件显示不全
const compProps = tempSchema.componentProps ?? {};
if (!compProps.getPopupContainer) {
tempSchema.componentProps = { ...compProps, getPopupContainer: () => document.body };
}
// update-end--author:liaozhiyang---date:20240614---for【TV360X-1231】查询区域有的下拉组件显示不全
// update-begin--author:liaozhiyang---date:20240725---for【TV360X-1857】online查询增加模糊查询
const fieldData = formProperties[tempSchema.field] ?? {};
// 【TV360X-1966】页面属性控件配置了文本框且字段类型是string个性化查询配置了用户组件用户组件不生效
if (fieldData.mode == 'like' && fieldData.view === 'text' && fieldData.originView === 'text') {
tempSchema.component = 'JInput';
}
// update-end--author:liaozhiyang---date:20240725---for【TV360X-1857】online查询增加模糊查询
schemaArray.push(tempSchema);
//update-end-author:taoyan date:2022-10-24 for: VUEN-2493【优化】online默认查询条件太宽了参考online报表
}
hideList.value = hideFieldName;
formSchemas.value = schemaArray;
//设置表单默认值
defaultValues.config = { ...configValue };
defaultValues.status = !defaultValues.status;
// update-begin--author:liaozhiyang---date:20231204---for【QQYUN-7140】online label默认显示6个
setTimeout(() => {
// 14是文字size24是间隙
const w = setLabelLength * 14 + setLabelLength + 24;
formLabelWidth.value = w;
}, 0);
// update-end--author:liaozhiyang---date:20231204---for【QQYUN-7140】online label默认显示6个
}
/**
* 2024-05-31
* liaozhiyang
* 【TV360X-415】个性化查询支持年、月、周、季度.
* 解析特定view(组件)字段的值把view字段值为date_year、date_month、date_week、date_quarter
* 改成date并组装fieldExtendJson
*/
const analysisComponent = (res) => {
const properties = res.properties;
if (properties) {
Object.entries(properties).forEach(([key, value]) => {
const data = value;
if (['date_year', 'date_month', 'date_week', 'date_quarter'].includes(data.view)) {
const fieldExtendJson = data.fieldExtendJson ? JSON.parse(data.fieldExtendJson) : {};
fieldExtendJson.picker = data.view.split('_')[1];
data.fieldExtendJson = JSON.stringify(fieldExtendJson);
data.view = 'date';
}
});
}
};
async function resetForm() {
let json = await loadQueryInfo();
// update-begin--author:liaozhiyang---date:20240531---for【TV360X-415】个性化查询支持年、月、周、季度
analysisComponent(json);
// update-end--author:liaozhiyang---date:20240318---for【TV360X-415】个性化查询支持年、月、周、季度
// update-begin--author:liaozhiyang---date:20240524---for【TV360X-516】高级查询过滤掉不支持查询的组件
// filterComponent(json);
// update-end--author:liaozhiyang---date:20240524---for【TV360X-516】高级查询过滤掉不支持查询的组件
// 获取所有字段配置 通过事件回传给高级查询组件
let allFields = getAllFields(json);
emit('loaded', json);
// 获取查询条件表单页面配置
let { formProperties, hasField } = getQueryFormProperties(allFields, json);
if (hasField == false) {
formSchemas.value = [];
return;
}
// 获取表单配置formSchemas
await initSchemas(formProperties);
}
/**
* 2024-05-24
* liaozhiyang
* 过滤掉不支持查询的组件(图片、文件、密码、关联记录、联动)
*/
const filterComponent = (data) => {
const { properties = {} } = data;
Object.entries(properties).forEach(([field, value]) => {
if (value.view === 'table') {
filterComponent(value);
}
if (['image', 'password', 'file', 'link_table', 'link_down'].includes(value.view)) {
delete properties[field];
}
});
};
/**
* 设置表单的值
*/
async function customSetFieldsValue(rawValues) {
await getRefPromise(onlineQueryFormRef);
console.log('rawValues', rawValues);
// update-begin--author:liaozhiyang---date:20240618---foronline普通查询默认值范围查询不好使
const values = transformGroupDefValus(rawValues);
await setFieldsValue(values);
// update-end--author:liaozhiyang---date:20240618---foronline普通查询默认值范围查询不好使
if (Object.keys(values).length > 0) {
doSearch();
}
}
/**
* 转化
*/
function getQueryFormProperties(allFields, json) {
const { searchFieldList, joinQuery, table } = json;
let hasField = false;
let formProperties = {};
if (allFields) {
Object.keys(allFields).map((field) => {
if (searchFieldList.indexOf(field) >= 0) {
//只找需要查询的字段
if (joinQuery == true) {
//判断是不是联合查询
if (field.indexOf('@') < 0) {
//没有@说明是主表字段, 手动拼接上@
formProperties[table + '@' + field] = allFields[field];
hasField = true;
} else {
//有@说明是子表字段,直接获取
formProperties[field] = allFields[field];
hasField = true;
}
} else {
// 不是联合查询 只查主表字段
if (field.indexOf('@') < 0) {
formProperties[field] = allFields[field];
hasField = true;
}
}
}
});
}
return {
formProperties,
hasField,
};
}
/**
* 获取查询条件表单配置
* json结构
* 主表字段1{配置1}
* 主表字段2{配置2}
* 子表名@子表字段1{子表字段配置1}
* 子表名@子表字段2{子表字段配置2}
*/
function getAllFields(json) {
// 获取所有配置 查询字段 是否联合查询
const { properties, searchFieldList, joinQuery, table } = json;
let allFields = {};
let order = 1;
let hasField = false;
Object.keys(properties).map((field) => {
let item = properties[field];
if (item.view == 'table') {
// 子表字段
// 联合查询开启才需要子表字段作为查询条件
let subProps = item['properties'];
let subTableOrder = order * 100;
Object.keys(subProps).map((subField) => {
let subItem = subProps[subField];
// 保证排序统一
subItem['order'] = subTableOrder + Number(subItem['order']);
let subFieldKey = field + '@' + subField;
allFields[subFieldKey] = subItem;
});
order++;
} else {
// 主表字段
item['order'] = Number(item['order']);
allFields[field] = item;
}
});
return allFields;
}
/**
* 查询返回数据格式, 需要经过getQueryFormProperties转成表单配置
* json结构
* table(表名)
* title(表描述)
* properties(表字段)
* field1
* field2
* fieldxxx
* sub-table1(子表名1)
* title(子表描述1)
* properties(子表字段)
* sub-table2(子表名2)
* title(子表描述2)
* properties(子表字段)
*/
function loadQueryInfo() {
let url = `${LOAD_URL}${props.id}`;
return new Promise((resolve) => {
defHttp
.get({ url }, { isTransformResponse: false })
.then((res) => {
// console.log("-online列表查询条件获取配置", res);
if (res.success) {
resolve(res.result);
} else {
resolve(false);
$message.warning(res.message);
}
})
.catch(() => {
$message.warning('获取查询条件失败!');
resolve(false);
});
});
}
//表单配置
const [registerForm, { resetFields, setFieldsValue, updateSchema, getFieldsValue }] = useForm({
name: 'online-query-form',
schemas: formSchemas,
showActionButtonGroup: false,
baseColProps: baseColProps,
autoSubmitOnEnter: true,
labelWidth: formLabelWidth,
wrapperCol: null,
submitFunc() {
//update-begin---author:wangshuai---date:2025-10-11---for:【issues/8790】online 表单重大 bug影响配置了查询 的所有表单---
//doSearch();
//update-end---author:wangshuai---date:2025-10-11---for:【issues/8790】online 表单重大 bug影响配置了查询 的所有表单---
},
/* labelCol: ONL_QUERY_LABEL_COL,
wrapperCol: ONL_QUERY_WRAPPER_COL*/
});
/**
* 执行查询
*/
function doSearch() {
let formValues = getFieldsValue();
// update-begin--author:liaozhiyang---date:20240517---for【TV360X-28】年年月周查询出结果不准
transformDateValus(formValues);
// update-end--author:liaozhiyang---date:20240517---for【TV360X-28】年年月周查询出结果不准
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
transformGroupValus(formValues);
// update-end--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
// 还需要把地址栏参数添加进去
let data = Object.assign({}, toRaw(defaultValues.param), changeDataIfArray2String(formValues));
emit('search', data, true);
}
/**
* 2024-06-18
* liaozhiyang
* online普通查询默认值范围查询不好使
* */
const transformGroupDefValus = (obj) => {
const values = { ...obj };
const groupSchemas = formSchemas.value.filter((item) => ['groupTime', 'groupDatetime', 'groupNumber', 'groupDate'].includes(item.slot));
if (groupSchemas.length) {
Object.keys(values).forEach((filed) => {
let key;
const findItem = groupSchemas.find((item) => {
if (item.field === filed) {
key = filed;
return true;
}
return false;
});
if (findItem) {
const value = values[key];
if (typeof value === 'string') {
const arr = value.split(',');
values[key] = [...arr];
}
}
});
}
return values;
};
/**
* 2024-05-20
* liaozhiyang
* 【TV360X-213】把groupDatetime,groupTime,groupDate,groupNumber等范围字段分割成两个字段
*/
const transformGroupValus = (values) => {
if (values) {
const groupSchemas = formSchemas.value.filter((item) => ['groupTime', 'groupDatetime', 'groupDate', 'groupNumber'].includes(item.slot));
if (groupSchemas.length) {
Object.keys(values).forEach((filed) => {
let key;
const findItem = groupSchemas.find((item) => {
if (item.field === filed) {
key = filed;
return true;
}
return false;
});
if (findItem) {
const value = values[key];
if (typeof value === 'string') {
const arr = value.split(',');
values[`${key}_begin`] = arr[0];
values[`${key}_end`] = arr[1];
delete values[key];
}
}
});
}
}
};
/**
* 2024-05-20
* liaozhiyang
* 把年,年月,周等时间重置到当前格式的第一天,因为存的时候也是第一天 (【TV360X-180】兼容时间范围)
*/
const transformDateValus = (values) => {
const dateSchemas = formSchemas.value.filter((item) => item.componentProps?.picker && item.componentProps.picker != 'default');
if (dateSchemas.length) {
Object.keys(values).forEach((filed) => {
let key;
const findItem = dateSchemas.find((item) => {
if (item.field === filed || `${item.field}_begin` === filed || `${item.field}_end` === filed) {
key = filed;
return true;
}
return false;
});
if (findItem) {
const value = values[key];
if (value) {
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
const auto = (value, key, isEnd) => {
const picker = findItem.componentProps.picker;
if (picker === 'year') {
if (isEnd) {
values[key] = dayjs(value).endOf('year').format('YYYY-MM-DD');
} else {
values[key] = dayjs(value).startOf('year').format('YYYY-MM-DD');
}
} else if (picker === 'month') {
if (isEnd) {
values[key] = dayjs(value).endOf('month').format('YYYY-MM-DD');
} else {
values[key] = dayjs(value).startOf('month').format('YYYY-MM-DD');
}
} else if (picker === 'week') {
if (isEnd) {
values[key] = dayjs(value).endOf('week').format('YYYY-MM-DD');
} else {
values[key] = dayjs(value).startOf('week').format('YYYY-MM-DD');
}
} else if (picker === 'quarter') {
if (isEnd) {
values[key] = dayjs(value).endOf('quarter').format('YYYY-MM-DD');
} else {
values[key] = dayjs(value).startOf('quarter').format('YYYY-MM-DD');
}
}
};
if (findItem?.slot === 'groupDate') {
const arr = value.split(',');
auto(arr[0], `${key}_begin`, false);
auto(arr[1], `${key}_end`, true);
delete values[key];
} else {
auto(value, key, false);
}
// update-end--author:liaozhiyang---date:20240530---for【TV360X-213】普通查询日期数值组件更换
}
}
});
}
}
/**
* 重置是将 查询控件的值 重置到默认值
* 2024-05-23
* liaozhiyang
* 【TV360X-124】提供clearSearch方法回到初始状态
*/
async function clearSearch() {
await resetFields();
const { config, param } = toRaw(defaultValues);
let rawValues = Object.assign({}, config, param);
if (Object.keys(rawValues).length > 0) {
await setFieldsValue(rawValues);
}
return rawValues;
}
async function resetSearch() {
const rawValues = await clearSearch();
emit('search', rawValues, false);
}
/**
* 有些数据是数组格式的 强转成字符串
*/
function changeDataIfArray2String(data) {
Object.keys(data).map((k) => {
if (data[k]) {
if (data[k] instanceof Array) {
data[k] = data[k].join(',');
}
}
});
return data;
}
watch(
() => toggleSearchStatus.value,
(status) => {
let names = hideList.value;
if (names && names.length > 0) {
let arr = [];
for (let name of names) {
arr.push({
field: name,
show: status,
});
}
updateSchema(arr);
}
},
{ immediate: false }
);
/**
* 2024-05-30
* liaozhiyang
* 【TV360X-392】日期placeholder修改
* */
const getGroupDatePlaceholder = (data) => {
let result = ['开始日期', '结束日期'];
console.log(data);
if (data?.picker) {
switch (data?.picker) {
case 'year':
result = ['开始年份', '结束年份'];
break;
case 'month':
result = ['开始月份', '结束月份'];
break;
case 'week':
result = ['开始周', '结束周'];
break;
case 'quarter':
result = ['开始季度', '结束季度'];
break;
default:
result = ['开始日期', '结束日期'];
}
}
return result;
};
return {
onlineQueryFormRef,
registerForm,
initDefaultValues,
toggleButtonShow,
toggleSearchStatus,
doSearch,
resetSearch,
queryParams,
formSchemas,
clearSearch,
getGroupDatePlaceholder,
};
},
};
</script>
<style scoped lang="less">
.group-query-string {
width: 20px;
display: inline-block;
text-align: center;
}
// 查询条件的边距要和列表对齐所以查询条件的边距要设为0
.jeecg-basic-table-form-container.p-0 {
padding: 0;
}
// update-begin--author:liaozhiyang---date:20240514---for【QQYUN-9241】form表单上下间距大点
.jeecg-basic-table-form-container {
:deep(.ant-form-item) {
&:not(.ant-form-item-with-help) {
margin-bottom: 16px;
}
}
}
// update-end--author:liaozhiyang---date:20240514---for【QQYUN-9241】form表单上下间距大点
.online-query-form {
:deep(.ant-form) {
max-height: 40vh;
overflow-y: auto;
}
}
</style>

View File

@@ -0,0 +1,415 @@
<!-- 此文件已没有使用的地方 -->
<template>
<a-form-item :labelCol="labelCol" :class="'jeecg-online-search'">
<template #label>
<span :title="item.label" class="label-text">{{ item.label }}</span>
</template>
<!-- 1.日期 -->
<template v-if="item.view == 'date'">
<template v-if="single_mode === item.mode">
<a-date-picker
style="width: 100%"
:showTime="false"
valueFormat="YYYY-MM-DD"
:placeholder="'请选择' + item.label"
v-model:value="innerValue"
></a-date-picker>
</template>
<template v-else>
<a-date-picker
:showTime="false"
valueFormat="YYYY-MM-DD"
placeholder="开始日期"
v-model:value="beginValue"
style="width: calc(50% - 15px)"
></a-date-picker>
<span class="group-query-strig">~</span>
<a-date-picker
:showTime="false"
valueFormat="YYYY-MM-DD"
placeholder="结束日期"
v-model:value="endValue"
style="width: calc(50% - 15px)"
></a-date-picker>
</template>
</template>
<!-- 2.时间 -->
<template v-else-if="item.view == 'datetime'">
<template v-if="single_mode === item.mode">
<a-date-picker
style="width: 100%"
:showTime="true"
valueFormat="YYYY-MM-DD hh:mm:ss"
:placeholder="'请选择' + item.label"
v-model:value="innerValue"
></a-date-picker>
</template>
<template v-else>
<a-date-picker
:showTime="true"
valueFormat="YYYY-MM-DD hh:mm:ss"
placeholder="开始时间"
v-model:value="beginValue"
style="width: calc(50% - 15px)"
></a-date-picker>
<span class="group-query-strig">~</span>
<a-date-picker
:showTime="true"
valueFormat="YYYY-MM-DD hh:mm:ss"
placeholder="结束时间"
v-model:value="endValue"
style="width: calc(50% - 15px)"
></a-date-picker>
</template>
</template>
<!-- 3 TODO 时分秒 -->
<!-- 4.简单下拉框 -->
<template v-else-if="isEasySelect()">
<JDictSelectTag v-if="item.config === '1'" :placeholder="'请选择' + item.label" v-model:value="innerValue" :dictCode="getDictCode()" />
<a-select v-else :placeholder="'请选择' + item.label" v-model:value="innerValue">
<template v-for="(obj, index) in dictOptions[getDictOptionKey(item)]" :key="index">
<a-select-option :value="obj.value"> {{ obj.text }}</a-select-option>
</template>
</a-select>
</template>
<!-- 5.下拉树 -->
<template v-else-if="item.view === 'sel_tree'">
<JTreeSelect
:placeholder="'请选择' + item.label"
v-model:value="innerValue"
:dict="item.dict"
:pidField="item.pidField"
:pidValue="item.pidValue"
:hasChildField="item.hasChildField"
load-triggle-change
>
</JTreeSelect>
</template>
<!-- 6.分类树 -->
<template v-else-if="item.view === 'cat_tree'">
<JCategorySelect
@change="handleCategoryTreeChange"
:loadTriggleChange="true"
:pcode="item.pcode"
v-model:value="innerValue"
:placeholder="'请选择' + item.label"
/>
</template>
<!-- 7.下拉搜索 -->
<template v-else-if="item.view === 'sel_search'">
<JDictSelectTag v-if="item.config === '1'" v-model:value="innerValue" :placeholder="'请选择' + item.label" :dict="getDictCode()" />
<JOnlineSearchSelect v-else v-model:value="innerValue" :placeholder="'请选择' + item.label" :sql="getSqlByDictCode()" />
</template>
<!-- 8.用户 -->
<JSelectUser
v-else-if="item.view == 'sel_user'"
v-bind="userSelectProp"
v-model:value="innerValue"
:placeholder="'请选择' + item.label"
></JSelectUser>
<!-- 9.部门 -->
<JSelectDept
v-else-if="item.view == 'sel_depart'"
:showButton="false"
v-bind="depSelectProp"
v-model:value="innerValue"
:placeholder="'请选择' + item.label"
/>
<!-- 10.popup -->
<JPopup
v-else-if="item.view == 'popup'"
:placeholder="'请选择' + item.label"
v-model:value="innerValue"
:code="item.dictTable"
:setFieldsValue="setFieldsValue"
:field-config="getPopupFieldConfig(item)"
:multi="true"
>
</JPopup>
<!-- 11.省市区 -->
<JAreaSelect v-else-if="item.view == 'pca'" :placeholder="'请选择' + item.label" v-model:value="innerValue" />
<!-- 12.下拉多选 -->
<template v-else-if="item.view == 'checkbox' || item.view == 'list_multi'" :label="item.label">
<JSelectMultiple :dictCode="getDictCode()" :placeholder="'请选择' + item.label" v-model:value="innerValue"></JSelectMultiple>
<!--<JDictSelectTag mode="multiple" @change="handleSelectChange" :dictCode="getDictCode()"/>-->
</template>
<!-- 13.普通输入框 -->
<template v-else>
<template v-if="single_mode === item.mode">
<a-input :placeholder="'请选择' + item.label" v-model:value="innerValue"></a-input>
</template>
<template v-else>
<a-input placeholder="开始值" v-model:value="beginValue" style="width: calc(50% - 15px)"></a-input>
<span class="group-query-strig">~</span>
<a-input placeholder="结束值" v-model:value="endValue" style="width: calc(50% - 15px)"></a-input>
</template>
</template>
</a-form-item>
</template>
<script lang="ts">
import { defineComponent, nextTick, ref, unref, watch, toRaw } from 'vue';
import {
JDictSelectTag,
JTreeSelect,
JSearchSelect,
JCategorySelect,
JSelectUserByDept,
JSelectDept,
JPopup,
JAreaLinkage,
JSelectUser,
JSelectMultiple,
JAreaSelect,
FormActionType,
} from '/@/components/Form';
import JOnlineSearchSelect from '../../auto/comp/JOnlineSearchSelect.vue';
export default defineComponent({
name: 'OnlineSearchFormItem',
components: {
JOnlineSearchSelect,
JDictSelectTag,
JTreeSelect,
JCategorySelect,
JSelectUser,
JSelectUserByDept,
JSelectDept,
JPopup,
JAreaLinkage,
JAreaSelect,
JSelectMultiple,
},
props: {
value: {
type: String,
default: '',
},
item: {
type: Object,
default: () => {},
required: true,
},
dictOptions: {
type: Object,
default: () => {},
required: false,
},
onlineForm: {
type: Object,
default: () => {},
required: false,
},
},
emits: ['update:value', 'change'],
setup(props, { emit }) {
// 定义查询条件 文本label的最大宽度 比起单纯的控制字体个数更好
const labelTextMaxWidth = '120px';
const labelCol = {
style: {
'max-width': labelTextMaxWidth,
},
};
const single_mode = 'single';
let innerValue = ref<string | undefined | []>('');
let beginValue = ref('');
let endValue = ref('');
watch(
() => props.value,
() => {
if (isEasySelect()) {
// 下拉框这里设置空数组 不知道为什么会有警告
innerValue.value = !!props.value ? props.value : undefined;
} else {
innerValue.value = props.value;
}
if (!props.value) {
beginValue.value = '';
endValue.value = '';
}
},
{ deep: true, immediate: true }
);
watch(
innerValue,
(newVal) => {
console.log('innerValue-change', newVal);
emit('update:value', newVal);
},
{ immediate: true }
);
watch(beginValue, (newVal) => {
emit('change', props.item.field + '_begin', newVal);
emit('update:value', '1');
});
watch(endValue, (newVal) => {
emit('change', props.item.field + '_end', newVal);
emit('update:value', '1');
});
function getDictOptionKey(item) {
console.log('ddictOptions', props.dictOptions);
if (item.dbField) {
return item.dbField;
} else {
return item.field;
}
}
function isEasySelect() {
let item = props.item;
if (!item) {
return false;
}
return item.view == 'list' || item.view == 'radio' || item.view == 'switch';
}
function getDictCode() {
let item = props.item;
if (item.dictTable && item.dictTable.length > 0) {
return item.dictTable + ',' + item.dictText + ',' + item.dictCode;
} else {
return item.dictCode;
}
}
function getSqlByDictCode() {
let item = props.item;
let { dictTable, dictCode, dictText } = item;
let temp = dictTable.toLowerCase();
let arr = temp.split('where');
let condition = '';
if (arr.length > 1) {
condition = ' where' + arr[1];
}
let sql = 'select ' + dictCode + " as 'value', " + dictText + " as 'text' from " + arr[0] + condition;
console.log('sql', sql);
return sql;
}
function getPopupFieldConfig(item) {
let { dictText: destFields, dictCode: orgFields } = item;
if (!destFields || destFields.length == 0) {
return [];
}
let arr1 = destFields.split(',');
let arr2 = orgFields.split(',');
let config: any[] = [];
for (let i = 0; i < arr1.length; i++) {
config.push({
target: arr1[i],
source: arr2[i],
});
}
return config;
}
function setFieldsValue<T>(values: T) {
let { dictText: destFields } = props.item;
let arr1 = destFields.split(',');
let field = arr1[0];
emit('change', field, values[field]);
}
function handleCategoryTreeChange(value) {
emit('update:value', value);
}
function getComponentProps(item, labelKey, rowKey) {
let props = {
labelKey,
rowKey,
};
let fieldExtendJson = item.fieldExtendJson;
if (fieldExtendJson) {
if (typeof fieldExtendJson == 'string') {
let json = JSON.parse(fieldExtendJson);
let extend = { ...json };
if (extend.text) {
props['labelKey'] = extend.text;
}
if (extend.store) {
props['rowKey'] = extend.store;
}
}
}
return props;
}
let userSelectProp = getComponentProps(props.item, 'realname', 'username');
console.log('userSelectProp', userSelectProp);
let depSelectProp = getComponentProps(props.item, 'departName', 'id');
function handleSelectChange(array) {
if (array && array.length > 0) {
emit('update:value', array.join(','));
} else {
emit('update:value', '');
}
}
return {
getPopupFieldConfig,
userSelectProp,
depSelectProp,
handleSelectChange,
setFieldsValue,
innerValue,
beginValue,
endValue,
isEasySelect,
getDictOptionKey,
getDictCode,
labelTextMaxWidth,
labelCol,
single_mode,
getSqlByDictCode,
handleCategoryTreeChange,
};
},
});
</script>
<style lang="less" scoped>
.group-query-strig {
width: 30px;
text-align: center;
display: inline-block;
}
/* 查询条件左对齐样式设置 */
.jeecg-online-search :deep(.ant-form-item-label) {
flex: 0 0 auto !important;
width: auto;
}
.jeecg-online-search :deep(.ant-form-item-control) {
max-width: 100%;
padding-right: 16px;
}
/* label显示宽度 超出显示... */
.jeecg-online-search :deep(.label-text) {
max-width: v-bind(labelTextMaxWidth);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
overflow-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<!-- 级联下拉框 form组件 暂且只在online使用 不对外提供api -->
<a-select show-search :filter-option="filterOption" :placeholder="placeholder" :value="selectedValue" @change="handleChange" allowClear style="width: 100%">
<a-select-option v-for="(item, index) in dictOptions" :key="index" :value="item.store">
<span style="display: inline-block; width: 100%" :title="item.label">{{ item.label }}</span>
</a-select-option>
</a-select>
</template>
<script lang="ts">
import { defineComponent, watch, ref } from 'vue';
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
/**获取下拉选项*/
const SELECT_OPTIONS_URL = '/online/cgform/api/querySelectOptions';
export default defineComponent({
name: 'OnlineSelectCascade',
props: {
table: { type: String, default: '' },
txt: { type: String, default: '' },
store: { type: String, default: '' },
idField: { type: String, default: '' },
pidField: { type: String, default: '' },
pidValue: { type: String, default: '-1' },
origin: { type: Boolean, default: false },
condition: { type: String, default: '' },
value: { type: String, default: '' },
isNumber: { type: Boolean, default: false },
placeholder: { type: String, default: '请选择' },
},
emits: ['change', 'next'],
setup(props, { emit }) {
const { createMessage: $message } = useMessage();
// 选中值
const selectedValue = ref<any>('');
// 选项数组
const dictOptions = ref<any[]>([]);
const optionsLoad = ref(true);
// 选项改变事件
function handleChange(value) {
console.log('handleChange', value);
// 这个value是 存储的值 实际还需要获取id值
let temp = value || '';
emit('change', temp);
valueChangeThenEmitNext(temp);
}
// 第一个节点 选项加载走condition
watch(
() => props.condition,
(val) => {
optionsLoad.value = true;
if (val) {
loadOptions();
}
},
{ immediate: true }
);
// 被联动节点 选项加载走pidValue
watch(
() => props.pidValue,
(val) => {
if (val === '-1') {
dictOptions.value = [];
} else {
loadOptions();
}
}
);
// 值回显
watch(
() => props.value,
(newVal, oldVal) => {
console.log('值改变事件', newVal, oldVal);
if (!newVal) {
// value不存在的时候--
selectedValue.value = [];
if (oldVal) {
// 如果oldVal存在 需要往上抛事件
emit('change', '');
emit('next', '-1');
}
} else {
// value存在的时候
selectedValue.value = newVal;
}
if (newVal && !oldVal) {
// 有新值没有旧值 表单第一次加载赋值 需要往外抛一个事件 触发下级options的加载
handleFirstValueSetting(newVal);
}
},
{ immediate: true }
);
/**
* 第一次加载赋值
*/
async function handleFirstValueSetting(value) {
if (props.idField === props.store) {
// 如果id字段就是存储字段 那么可以不用调用请求
emit('next', value);
} else {
if (props.origin === true) {
// 如果是联动组件的第一个组件等待options加载完后从options中取值
await getSelfOptions();
valueChangeThenEmitNext(value);
} else {
// 如果是联动组件的后续组件根据选中的value加载一遍数据
let arr = await loadValueText();
valueChangeThenEmitNext(value, arr);
}
}
}
function loadOptions() {
let params = getQueryParams();
if (props.origin === true) {
params['condition'] = props.condition;
} else {
params['pidValue'] = props.pidValue;
}
console.log('请求参数', params);
dictOptions.value = [];
defHttp.get({ url: SELECT_OPTIONS_URL, params }, { isTransformResponse: false }).then((res) => {
if (res.success) {
dictOptions.value = [...res.result];
console.log('请求结果', res.result, dictOptions);
} else {
$message.warning('联动组件数据加载失败,请检查配置!');
}
});
}
function getQueryParams() {
let params = {
table: props.table,
txt: props.txt,
key: props.store,
idField: props.idField,
pidField: props.pidField,
};
return params;
}
function loadValueText() {
return new Promise((resolve) => {
if (!props.value) {
selectedValue.value = [];
resolve([]);
} else {
let params = getQueryParams();
if (props.isNumber === true) {
params['condition'] = `${props.store} = ${props.value}`;
} else {
params['condition'] = `${props.store} = '${props.value}'`;
}
defHttp.get({ url: SELECT_OPTIONS_URL, params }, { isTransformResponse: false }).then((res) => {
if (res.success) {
resolve(res.result);
} else {
$message.warning('联动组件数据加载失败,请检查配置!');
resolve([]);
}
});
}
});
}
/**
* 获取下拉选项
*/
function getSelfOptions() {
return new Promise((resolve) => {
let index = 0;
(function next(index) {
if (index > 10) {
resolve([]);
}
let arr = dictOptions.value;
if (arr && arr.length > 0) {
resolve(arr);
} else {
setTimeout(() => {
next(index++);
}, 300);
}
})(index);
});
}
/**
* 值改变后 需要往外抛事件 触发下级节点的选项改变
*/
function valueChangeThenEmitNext(value, arr: any = []) {
if (value && value.length > 0) {
if (!arr || arr.length == 0) {
arr = dictOptions.value;
}
let selected = arr.filter((item) => item.store === value);
if (selected && selected.length > 0) {
let id = selected[0].id;
emit('next', id);
}
}
}
/**
* 下拉框筛选
* @param input
* @param option
*/
const filterOption = (input: string, option: any) => {
let labelIf = option.children()[0]?.children.toLowerCase().indexOf(input.toLowerCase()) >= 0;
if (labelIf) {
return true;
}
return false;
};
return {
selectedValue,
dictOptions,
handleChange,
filterOption,
};
},
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,301 @@
<template>
<BasicForm ref="onlineFormRef" @register="registerForm" />
</template>
<script>
import { useMessage } from '/@/hooks/web/useMessage';
import { computed, defineComponent, ref, unref, watch, nextTick, toRaw } from 'vue';
import { BasicForm, useForm } from '/@/components/Form/index';
import { SUBMIT_FLOW_ID, SUBMIT_FLOW_KEY, VALIDATE_FAILED } from '../../types/onlineRender';
import { defHttp } from '/@/utils/http/axios';
import { pick } from 'lodash-es';
import { useFormItems, getRefPromise } from '../../hooks/auto/useAutoForm';
import { Loading } from '/@/components/Loading';
import { loadFormFieldsDefVal } from '../../util/FieldDefVal';
const urlObject = {
optPre: '/online/cgform/api/form/',
urlButtonAction: '/online/cgform/api/doButton',
};
const baseUrl = '/online/cgform/api/subform';
export default {
name: 'OnlineSubForm',
components: {
BasicForm,
Loading,
},
props: {
properties: {
type: Object,
required: true,
},
mainId: {
type: String,
default: '',
},
table: {
type: String,
default: '',
},
formTemplate: {
type: Number,
default: 1,
},
requiredFields: {
type: Array,
default: [],
},
isUpdate: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: '',
},
},
emits: ['formChange'],
setup(props, { emit }) {
console.log('进入online子表表单页面》》》》' + props.table);
// 表单ref
const onlineFormRef = ref(null);
// 表单是否渲染完成
const formRendered = ref(false);
const { createMessage: $message } = useMessage();
const {
formSchemas,
defaultValueFields,
changeDataIfArray2String,
tableName,
dbData,
checkOnlyFieldValue,
fieldDisplayStatus,
createFormSchemas,
baseColProps,
labelCol,
wrapperCol,
labelWidth,
} = useFormItems(props, onlineFormRef);
//表单配置
const [registerForm, { setProps, validate, resetFields, setFieldsValue, getFieldsValue, updateSchema, scrollToField }] = useForm({
schemas: formSchemas,
showActionButtonGroup: false,
baseColProps: baseColProps,
// update-begin--author:liaozhiyang---date:20240429---for【QQYUN-7632】 label栅格改成labelwidth固宽
labelWidth,
// update-end--author:liaozhiyang---date:20240429---for【QQYUN-7632】 label栅格改成labelwidth固宽
// update-begin--author:liaozhiyang---date:20240105---for【QQYUN-7499】多列风格富文本、markdown增加独占一行功能
labelCol,
wrapperCol
// update-end--author:liaozhiyang---date:20240105---for【QQYUN-7499】多列风格富文本、markdown增加独占一行功能
});
const getFormItem = () => {
return new Promise((resolve, reject) => {
defHttp.get({ url: `online/cgform/api/getFormItem/${props.id}` }, { isTransformResponse: false }).then((res) => {
resolve(res.result);
});
});
}
let extConfigJson;
watch(
() => props.table,
() => {
tableName.value = props.table;
},
{ immediate: true }
);
//监听配置改变事件
watch(
() => props.properties,
async (valueObj) => {
//重新渲染表单
console.log('主表properties改变', props.properties);
formRendered.value = false;
addFormChangeEvent();
// update-begin--author:liaozhiyang---date:20260318---for:【issues/9414】一对一子表设置label长度不生效
if (!extConfigJson) {
try {
const data = await getFormItem();
extConfigJson = JSON.parse(data.head.extConfigJson);
extConfigJson = {
formLabelLength: extConfigJson.formLabelLength,
formLabelLengthShow: extConfigJson.formLabelLengthShow,
};
} catch (error) {
console.log(error);
}
}
// update-end--author:liaozhiyang---date:20260318---for:【issues/9414】一对一子表设置label长度不生效
createFormSchemas(props.properties, props.requiredFields, checkOnlyFieldValue, extConfigJson);
formRendered.value = true;
},
{ deep: true, immediate: true }
);
//监听主表数据ID
watch(
() => props.mainId,
(valueObj) => {
//重新加载子表数据
console.log('主表ID改变', props.mainId);
// 此处延迟100毫秒是为了让properties的监听先执行
setTimeout(() => {
resetSubForm();
}, 100);
},
{ immediate: true }
);
watch(
() => props.disabled,
(val) => {
setProps({ disabled: val });
}
);
/**
* 监听表单改变事件
*/
async function addFormChangeEvent() {
let formRefObject = await getRefPromise(onlineFormRef);
formRefObject.$formValueChange = (field, value, changeFormData) => {
let emitArgument = { [field]: value };
// update-begin--author:liaozhiyang---date:20260317---for:【QQYUN-9441】online一对多加上关联记录和他表字段
// 一对一子表 关联记录和他表字段
if(changeFormData){
setFieldsValue(changeFormData);
}
// update-end--author:liaozhiyang---date:20260317---for:【QQYUN-9441】online一对多加上关联记录和他表字段
emit('formChange', emitArgument);
};
}
/**
* 当前表单默认值逻辑-进入新增页面触发
*/
function handleDefaultValue() {
if (unref(props.isUpdate) === false) {
let fieldProperties = toRaw(defaultValueFields[tableName.value]);
loadFormFieldsDefVal(fieldProperties, (values) => {
setFieldsValue(values);
});
}
}
/**
* 当主表数据ID发生改变子表重现获取数据
* @returns {Promise<void>}
*/
async function resetSubForm() {
//TODO 填值规则
// update-begin--author:sunjianlei --- date:20191111 --- for: 每次加载数据的时候都重新执行一遍填值规则 -----------
//this.$emit('executeFillRule', {form:this.form, target: this})
// update-end--author:sunjianlei --- date:20191111 --- for: 每次加载数据的时候都重新执行一遍填值规则 -----------
await getRefPromise(formRendered);
await resetFields();
handleDefaultValue();
const { table, mainId } = props;
if (!table || !mainId) {
return;
}
let values = await loadData(table, mainId);
dbData.value = values;
// VUEN-1033
await setFieldsValue(values);
}
function loadData(table, mainId) {
let url = `${baseUrl}/${table}/${mainId}`;
return new Promise((resolve, reject) => {
defHttp.get({ url }, { isTransformResponse: false }).then((res) => {
console.log(res);
if (res.success) {
resolve(res.result);
} else {
console.log(res.message);
reject();
}
});
}).finally(() => {
//resetFields()
dbData.value = '';
});
}
function getAll() {
return new Promise((resolve, reject) => {
validate()
.then(() => {
let formData = getFieldsValue();
formData = changeDataIfArray2String(formData);
resolve(formData);
})
.catch((e) => {
if (e.errorFields) {
e.scrollToField = () => e.errorFields[0] && scrollToField(e.errorFields[0].name, { behavior: 'smooth', block: 'center' });
}
reject(e);
});
});
}
//获取表单事件对象 监听表单改变用到
function getFormEvent() {
let row = getFieldsValue();
if (!row.id) {
row.id = 'sub-change-temp-id';
}
return {
row,
target: context,
};
}
//设置表单的值
function setValues(values) {
setFieldsValue(values);
}
function executeFillRule() {
let formData = getFieldsValue();
let fieldProperties = toRaw(defaultValueFields[tableName.value]);
loadFormFieldsDefVal(fieldProperties, (values) => {
setFieldsValue(values);
}, formData);
}
const context = {
onlineFormRef,
baseColProps,
formSchemas,
registerForm,
setFieldsValue,
getFieldsValue,
getFormEvent,
setValues,
getAll,
executeFillRule,
sh: fieldDisplayStatus,
resetFields,
updateSchema,
};
return context;
},
};
</script>
<style lang="less" scoped>
// update-begin--author:liaozhiyang---date:20240527---for【TV360X-263】tab风格一对一子表上传组件有数据没渲染出来
:deep(.ant-upload-list-item-container) {
&.ant-motion-collapse {
height: auto !important;
}
}
// update-end--author:liaozhiyang---date:20240527---for【TV360X-263】tab风格一对一子表上传组件有数据没渲染出来
</style>

View File

@@ -0,0 +1,167 @@
<template>
<detail-form :schemas="detailFormSchemas" :data="subFormData" :span="formSpan"></detail-form>
</template>
<script lang="ts">
import { useMessage } from '/@/hooks/web/useMessage';
import { ref, watch } from 'vue';
import { BasicForm } from '/@/components/Form/index';
import { defHttp } from '/@/utils/http/axios';
import { getRefPromise } from '../../hooks/auto/useAutoForm';
import { Loading } from '/@/components/Loading';
import DetailForm from '../../extend/form/DetailForm.vue';
import { getDetailFormSchemas } from '../../hooks/auto/useAutoForm';
const baseUrl = '/online/cgform/api/subform';
export default {
name: 'OnlineSubFormDetail',
components: {
BasicForm,
Loading,
DetailForm,
},
props: {
properties: {
type: Object,
required: true,
},
mainId: {
type: String,
default: '',
},
table: {
type: String,
default: '',
},
formTemplate: {
type: Number,
default: 1,
},
},
emits: ['formChange'],
setup(props) {
// 表单是否渲染完成
const formRendered = ref(false);
const { createMessage: $message } = useMessage();
const tableName = ref('');
const subFormData = ref<any>({});
const { detailFormSchemas, createFormSchemas, formSpan } = getDetailFormSchemas(props);
watch(
() => props.table,
() => {
tableName.value = props.table;
},
{ immediate: true }
);
//监听配置改变事件
watch(
() => props.properties,
() => {
//重新渲染表单
console.log('主表properties改变', props.properties);
formRendered.value = false;
createFormSchemas(props.properties);
formRendered.value = true;
},
{ deep: true, immediate: true }
);
//监听主表数据ID
watch(
() => props.mainId,
() => {
//重新加载子表数据
console.log('主表ID改变', props.mainId);
// 此处延迟100毫秒是为了让properties的监听先执行
setTimeout(() => {
resetSubForm();
}, 100);
},
{ immediate: true }
);
/**
* 当主表数据ID发生改变子表重现获取数据
* @returns {Promise<void>}
*/
async function resetSubForm() {
await getRefPromise(formRendered);
subFormData.value = {};
const { table, mainId } = props;
if (!table || !mainId) {
return;
}
// update-begin--author:liaozhiyang---date:20260413---for【QQYUN-14951】一对一他表字段详情没值
let data: any = (await loadData(table, mainId)) || {};
await fillLinkTableFields(data);
subFormData.value = data;
// update-end--author:liaozhiyang---date:20260413---for【QQYUN-14951】一对一他表字段详情没值
}
// update-begin--author:liaozhiyang---date:20260413---for【QQYUN-14951】一对一他表字段详情没值
/**
* 详情页一对一子表中,根据关联记录字段(link_table)的值查询关联数据,自动填充他表字段(link_table_field)的值
*/
async function fillLinkTableFields(data) {
const schemas = detailFormSchemas.value;
for (const schema of schemas) {
if (schema.view === 'link_table' && (schema as any).linkFields?.length > 0) {
const fieldValue = data[schema.field];
if (fieldValue) {
const valueField = (schema as any).dictCode || 'id';
const vals = String(fieldValue).split(',');
const params = {
pageSize: vals.length,
pageNo: 1,
superQueryMatchType: 'and',
superQueryParams: encodeURI(JSON.stringify([{ field: valueField, rule: 'in', val: fieldValue }])),
};
try {
const result = await defHttp.get({ url: '/online/cgform/api/getData/' + (schema as any).dictTable, params });
const records = result?.records || [];
for (const linkField of (schema as any).linkFields) {
const [formField, tableField] = linkField.split(',');
if (records.length > 0) {
data[formField] = records.map((r: any) => r[tableField] ?? '').join(',');
} else {
data[formField] = '';
}
}
} catch (e) {
console.warn('填充他表字段失败:', e);
}
}
}
}
}
// update-end--author:liaozhiyang---date:20260413---for【QQYUN-14951】一对一他表字段详情没值
async function loadData(table, mainId) {
let url = `${baseUrl}/${table}/${mainId}`;
return new Promise((resolve, reject) => {
defHttp.get({ url }, { isTransformResponse: false }).then((res) => {
console.log(res);
if (res.success) {
resolve(res.result);
} else {
reject(res.message);
}
});
}).catch((e) => {
console.warn('子表获取数据失败:', e);
return Promise.resolve({});
});
}
return {
detailFormSchemas,
subFormData,
formSpan,
};
},
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,177 @@
import InputWidget from './impl/InputWidget';
import { FormSchema } from '/@/components/Form';
import DateWidget from './impl/DateWidget';
import SelectWidget from './impl/SelectWidget';
import PasswordWidget from './impl/PasswordWidget';
import FileWidget from './impl/FileWidget';
import ImageWidget from './impl/ImageWidget';
import TextAreaWidget from './impl/TextAreaWidget';
import SelectMultiWidget from './impl/SelectMultiWidget';
import SelectSearchWidget from './impl/SelectSearchWidget';
import PopupWidget from './impl/PopupWidget';
// update-begin--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
import PopupDictWidget from './impl/PopupDictWidget';
// update-end--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
import TreeCategoryWidget from './impl/TreeCategoryWidget';
import SelectDepartWidget from './impl/SelectDepartWidget';
import SelectUserWidget from './impl/SelectUserWidget';
import EditorWidget from './impl/EditorWidget';
import MarkdownWidget from './impl/MarkdownWidget';
import PcaWidget from './impl/PcaWidget';
import AreaLinkage from './impl/AreaLinkage';
import TreeSelectWidget from './impl/TreeSelectWidget';
import RadioWidget from './impl/RadioWidget';
import CheckboxWidget from './impl/CheckboxWidget';
import SwitchWidget from './impl/SwitchWidget';
import TimeWidget from './impl/TimeWidget';
import LinkDownWidget from './impl/LinkDownWidget';
import SlotWidget from './impl/SlotWidget';
import NumberWidget from './impl/NumberWidget';
import LinkTableWidget from './impl/LinkTableWidget'
import LinkTableFieldWidget from './impl/LinkTableFieldWidget'
import LinkTableForQueryWidget from './impl/LinkTableForQueryWidget'
import CascaderPcaForQueryWidget from './impl/CascaderPcaForQueryWidget'
import SelectUser2Widget from './impl/SelectUser2Widget'
import RangeWidget from "./impl/RangeWidget";
export default class FormSchemaFactory {
static createFormSchema(key, data, queryItem) {
let view = data.view;
switch (view) {
case 'password':
//2.密码输入框
return new PasswordWidget(key, data);
case 'list':
//3.下拉框
return new SelectWidget(key, data);
case 'radio':
// 4. 单选
return new RadioWidget(key, data);
case 'checkbox':
// 5.多选
return new CheckboxWidget(key, data);
case 'date':
case 'datetime':
// 6.日期
// 7.日期时间
return new DateWidget(key, data, queryItem);
case 'time':
// 8 时间
return new TimeWidget(key, data);
case 'file':
// 9.文件
return new FileWidget(key, data);
case 'image':
// 10.图片
return new ImageWidget(key, data);
case 'textarea':
// 11.多行文本
return new TextAreaWidget(key, data);
case 'list_multi':
// 12.下拉多选框
return new SelectMultiWidget(key, data);
case 'sel_search':
// 13.下拉搜索框
return new SelectSearchWidget(key, data);
case 'popup':
// 14. popup
return new PopupWidget(key, data);
case 'cat_tree':
// 15.分类字典树
return new TreeCategoryWidget(key, data);
case 'sel_depart':
// 16.部门选择
return new SelectDepartWidget(key, data);
case 'sel_user':
// 17.用户选择
return new SelectUserWidget(key, data);
case 'umeditor':
// 18.富文本
return new EditorWidget(key, data);
case 'markdown':
// 19.MarkDown
return new MarkdownWidget(key, data);
case 'pca':
// 20.省市区
// update-begin--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
// return new PcaWidget(key, data);
return new AreaLinkage(key, data);
// update-end--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
case 'link_down':
// 21.联动组件
return new LinkDownWidget(key, data);
case 'sel_tree':
// 22.自定义树控件
return new TreeSelectWidget(key, data);
case 'switch':
// 23.开关组件
return new SwitchWidget(key, data);
case 'link_table':
// 24.关联记录
return new LinkTableWidget(key, data);
case 'link_table_field':
// 25.他表字段
return new LinkTableFieldWidget(key, data);
// update-begin--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
case 'popup_dict':
// 14. popup字典
return new PopupDictWidget(key, data);
// update-end--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
case 'slot':
// slot
return new SlotWidget(key, data);
case 'LinkTableForQuery':
return new LinkTableForQueryWidget(key, data);
case 'CascaderPcaForQuery':
return new CascaderPcaForQueryWidget(key, data, queryItem);
case 'select_user2':
return new SelectUser2Widget(key, data);
case 'rangeDate':
case 'rangeTime':
case 'rangeNumber':
return new RangeWidget(key, data);
case 'hidden':
// 隐藏的控件 如分类树的文本
return new InputWidget(key, data).isHidden();
default:
if (data.type == 'number') {
return new NumberWidget(key, data);
} else {
//1.普通输入框
return new InputWidget(key, data);
}
}
}
static createSlotFormSchema(key, data) {
let slotFs = new SlotWidget(key, data);
let view = data.view;
if ('date' == view) {
slotFs.groupDate();
} else if ('datetime' == view) {
slotFs.groupDatetime();
} else if ('time' == view) {
// update-begin--author:liaozhiyang---date:20240517---for【QQYUN-9348】增加online查询区域时间范围查询功能
slotFs.groupTime();
// update-end--author:liaozhiyang---date:20240517---for【QQYUN-9348】增加online查询区域时间范围查询功能
} else {
let type = data.type;
if (type == 'number' || type == 'integer') {
slotFs.groupNumber();
}
}
return slotFs;
}
/**
* 表单ID 默认是隐藏的
*/
static createIdField(): FormSchema {
return {
label: '',
field: 'id',
component: 'Input',
show: false,
};
}
}

View File

@@ -0,0 +1,469 @@
import {computed, watch} from 'vue'
import { FormSchema, Rule } from '/@/components/Form';
import { FieldExtends, POP_CONTAINER } from '../../../types/onlineRender';
import { LABELLENGTH } from '../../../util/constant';
import {replaceUserInfoByExpression} from "@/utils/common/compUtils";
/**
* 1.部门选择/用户选择 无:单选配置
* 控件类
*/
export default abstract class IFormSchema {
_data;
field: string;
label: string;
labelLength: number;
formRef: any;
hidden: boolean;
order: number;
required: boolean;
onlyValidator: any;
hasChange: boolean;
pre: string;
setFieldsValue: any;
schemaProp: any;
searchForm: boolean;
disabled: boolean;
popContainer: string;
inPopover: boolean;
constructor(key, data) {
// 考虑不需要存data
this._data = data;
this.field = key;
this.label = data.title;
this.hidden = false;
this.order = data.order || 999;
this.required = false;
this.onlyValidator = '';
this.setFieldsValue = '';
this.hasChange = true;
if (key.indexOf('@') > 0) {
this.pre = key.substring(0, key.indexOf('@') + 1);
} else {
this.pre = '';
}
this.schemaProp = {};
this.searchForm = false;
this.disabled = false;
this.popContainer = '';
this.handleWidgetAttr(data);
this.inPopover = false;
this.labelLength = LABELLENGTH;
this.initLabelLength();
}
/**
* 获取最终的表单配置项,外面获取调用此方法
*/
getFormItemSchema(): FormSchema {
let schema = this.getItem();
this.addDefaultChangeEvent(schema);
return schema;
}
/**
* 获取表单配置,子类重写此方法
*/
getItem(): FormSchema {
let fs: FormSchema = {
field: this.field,
label: this.label,
labelLength: this.labelLength,
component: 'Input',
itemProps:{
labelCol:{
class: 'online-form-label'
}
}
};
let rules = this.getRule();
if (rules.length > 0 && this.onlyValidator) {
fs['rules'] = rules;
}
if (this.hidden === true) {
fs['show'] = false;
}
return fs;
}
/**
* 设置表单ref
* popup、分类树需要关联设置其他表单值的时候用到
* @param ref
*/
setFormRef(ref) {
this.formRef = ref;
}
/**
* 设置表单元素隐藏
*/
isHidden() {
this.hidden = true;
return this;
}
/**
* 设置是否必填项
* @param array
*/
isRequired(array) {
// 子表必填 TODO
if (array && array.length > 0) {
if (array.indexOf(this.field) >= 0) {
this.required = true;
}
}
return this;
}
/**
* 初始化 label长度
*/
initLabelLength(){
let obj = this.getExtendData()
if(obj && obj.labelLength){
this.labelLength = obj.labelLength;
}
}
/**
* 获取扩展参数
*/
getExtendData() {
let extend: FieldExtends = {};
let { fieldExtendJson } = this._data;
if (fieldExtendJson) {
if (typeof fieldExtendJson == 'string') {
try {
let json = JSON.parse(fieldExtendJson);
extend = { ...json };
} catch (e) {
console.error(e);
}
}
}
return extend;
}
/***
* 获取和此字段相关的其他字段 需要设置其为隐藏
*/
getRelatedHideFields(): string[] {
return [];
}
/**
* placeholder
*/
getPlaceholder(view) {
let text = '请输入';
// update-begin--author:liaozhiyang---date:20240521---for【TV360X-218】针对组件分别提示对应的校验语
if (
[
'list',
'radio',
'checkbox',
'date',
'datetime',
'time',
'list_multi',
'sel_search',
'popup',
'cat_tree',
'sel_depart',
'sel_user',
'pca',
'link_down',
'sel_tree',
'switch',
'link_table',
'link_table_field',
'popup_dict',
'LinkTableForQuery',
'CascaderPcaForQuery',
'select_user2',
'rangeDate',
'rangeTime',
'rangeNumber',
].includes(view)
) {
text = '请选择';
} else if (['file', 'image'].includes(view)) {
text = '请上传';
}
// update-end--author:liaozhiyang---date:20240521---for【TV360X-218】针对组件分别提示对应的校验语
return text + this.label;
}
/**
* 唯一校验
*/
setOnlyValidateFun(validateFun) {
if (validateFun) {
this.onlyValidator = async (rule, value) => {
let error = await validateFun(rule, value);
if (!error) {
return Promise.resolve();
} else {
return Promise.reject(error);
}
};
}
}
/**
* 获取校验规则
*/
getRule(): any[] {
let rules: Rule[] = [];
const { view, errorInfo, pattern, type, fieldExtendJson } = this._data;
if (this.required === true) {
let msg = this.getPlaceholder(view);
// update-begin--author:liaozhiyang---date:20240520---for【TV360X-80】扩展参数配置中的校验提示不生效
if (fieldExtendJson) {
const json = JSON.parse(fieldExtendJson);
if (json.validateError) {
msg = json.validateError;
}
}
// update-end--author:liaozhiyang---date:20240520---for【TV360X-80】扩展参数配置中的校验提示不生效
if (errorInfo) {
msg = errorInfo;
}
if (view == 'sel_depart' || view == 'sel_user') {
//如果是部门和用户组件 使用 requiredtrue
this.schemaProp['required'] = true;
// update-begin--author:liaozhiyang---date:20240429---for【QQYUN-9109】online使用部门和用户组件必填时label前面没有必填的*号
rules.push({ required: true, message: msg });
// update-end--author:liaozhiyang---date:20240429---for【QQYUN-9109】online使用部门和用户组件必填时label前面没有必填的*号
} else {
rules.push({ required: true, message: msg });
}
}
if ('sel_user' == view) {
if (pattern === 'only' && this.onlyValidator) {
rules.push({ validator: this.onlyValidator });
}
}
if ('list' === view || 'radio' === view || 'markdown' === view || 'pca' === view || view.indexOf('sel') >= 0 || 'time' === view) {
return rules;
}
if (view.indexOf('upload') >= 0 || view.indexOf('file') >= 0 || view.indexOf('image') >= 0) {
return rules;
}
if (pattern) {
if (pattern === 'only') {
if (this.onlyValidator) {
rules.push({ validator: this.onlyValidator });
}
} else if (pattern === 'z') {
if (type == 'number' || type == 'integer') {
// this.onlyInteger=true TODO
} else {
rules.push({ pattern: /^-?\d+$/, message: '请输入整数' });
}
} else {
let msg = errorInfo || '正则校验失败';
let reg
try {
reg = new RegExp(pattern);
if (!reg) {
reg = pattern;
}
} catch {
reg = pattern;
}
rules.push({ pattern: reg, message: msg });
}
}
return rules;
}
/**
* 添加默认的change事件
* @param schema
*/
addDefaultChangeEvent(schema) {
if (this.hasChange) {
if (!schema.componentProps) {
schema.componentProps = {};
}
//update-begin-author:taoyan date:2022-5-24 for: VUEN-1095 只读未控制住
if (this.disabled == true) {
schema.componentProps.disabled = true;
}
//update-end-author:taoyan date:2022-5-24 for: VUEN-1095 只读未控制住
if (!schema.componentProps.hasOwnProperty('onChange')) {
schema.componentProps['onChange'] = (value, formData) => {
if (value instanceof Event) {
// 输入框 value是event对象
value = (value.target as any).value;
}
// 部门组件抛出事件的value是数组
if (value instanceof Array) {
value = value.join(',');
}
// VUEN-1467【vue3 工作流】流程处理 一对多表单 子表tab切换后关闭不了 导致整个浏览器无法操作 多操作几次,不一定每次必现---
if(!this.formRef || !this.formRef.value || !this.formRef.value.$formValueChange){
console.log('当前表单无法触发change事件,field'+this.field)
}else{
this.formRef.value.$formValueChange(this.field, value, formData)
}
};
// update-begin--author:liaozhiyang---date:20251011---for【issues/8791】js增强popup弹框的onlChange()没生效
if (schema.component === 'JPopup') {
schema.componentProps['onPopUpChange'] = schema.componentProps['onChange']
}
// update-end--author:liaozhiyang---date:20251011---for【issues/8791】js增强popup弹框的onlChange()没生效
}
}
// 顺带处理其他的 schemaProp
Object.keys(this.schemaProp).map((k) => {
schema[k] = this.schemaProp[k];
});
}
noChange() {
this.hasChange = false;
}
updateField(field) {
this.field = field;
}
/**
* 高级查询 没有表单ref对象 手动设置setFieldValue方法用于 popup设置表单值
*/
setFunctionForFieldValue(func) {
if (func) {
this.setFieldsValue = func;
}
}
asSearchForm() {
this.searchForm = true;
}
/**获取modal作为类下拉组件pop的父容器*/
getModalAsContainer() {
let ele = this.getPopContainer();
// update-begin--author:liaozhiyang---date:20231205---for【QQYUN-7150】online缓存路由打开多页导致下拉类型的组件打不开
if (ele != 'body') {
const elems = document.querySelectorAll(ele);
if (elems && elems.length > 1) {
const data: HTMLElement[] = [];
elems.forEach((item: HTMLElement) => {
if (!(item.offsetWidth == 0 && item.offsetHeight == 0)) {
data.push(item);
}
});
if (data.length === 1) {
return data[0];
}
}
}
// update-end--author:liaozhiyang---date:20231205---for【QQYUN-7150】online缓存路由打开多页导致下拉类型的组件打不开
return document.querySelector(ele);
}
/**区分modal表单和查询表单*/
getPopContainer() {
if (this.searchForm === true) {
return 'body';
} else if(this.inPopover === true){
return `.${this.popContainer}`;
}else if(this.popContainer){
return `.${this.popContainer} .ant-modal-content`
}else {
return POP_CONTAINER;
}
}
handleWidgetAttr(data) {
if (data.ui) {
if (data.ui.widgetattrs) {
if (data.ui.widgetattrs.disabled == true) {
this.disabled = true;
}
}
}
}
/**
* 设置 popContainer
*/
setCustomPopContainer(modalClass){
this.popContainer = modalClass;
}
//update-begin-author:taoyan date:2022-8-5 for: 他表字段/关联记录用
// 获取他表字段的 配置信息
getLinkFieldInfo():any{
return '';
}
// 1.将他表字段的配置信息设置到关联记录字段上
setOtherInfo(_arg){
}
//update-end-author:taoyan date:2022-8-5 for: 他表字段/关联记录用
// 表单设计器高级查询用
isInPopover(){
this.inPopover = true;
}
handleDictTableParams() {
if (!this.formRef.value) {
return
}
const dictTable = this._data.dictTable as string
if (!dictTable) {
return
}
const matches = dictTable.match(/\${([^}]+)}/g)
if (!matches || matches.length == 0) {
return
}
// 去除 ${}
const keys = matches.map((item: string) => item.replace('${', '').replace('}', ''))
const values = computed(() => {
const formModel = this.formRef.value.formModel
return keys.map((key) => formModel[key]).join('');
})
let timer: ReturnType<typeof setTimeout> | null = null;
watch(values, () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
const formModel = this.formRef.value.formModel
// 替换动态参数,如果有 ${xxx} 则替换为实际值
let tempDictTable = dictTable.replace(/\${([^}]+)}/g, (_$0, $1) => {
if (formModel[$1] == null) {
return ''
}
return formModel[$1]
});
this.updateDictTable(tempDictTable)
}, 150)
}, {immediate: true})
}
updateDictTable(_dictTable: string) {
console.log('请在子类实现 updateDictTable 方法')
}
/**
* 获取表字典的编码,可替换系统变量
* @param dictTable
* @param dictText
* @param dictCode
*/
genDictTableCode(dictTable: string, dictText: string, dictCode: string) {
// 替换系统变量
dictTable = replaceUserInfoByExpression(dictTable)
return encodeURI(`${dictTable},${dictText},${dictCode}`);
}
}

View File

@@ -0,0 +1,26 @@
import IFormSchema from '../IFormSchema';
import { FormSchema } from '/@/components/Form';
/**
* 省市区
*/
export default class PcaWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
// update-begin--author:liaozhiyang---date:20260204---for:【QQYUN-14694】online支持配置独立的省、市、县
const extendData: any = this.getExtendData();
const componentProps: any = {}
if (extendData.displayLevel) {
componentProps.displayLevel = extendData.displayLevel;
componentProps.saveCode = extendData.displayLevel === 'all' ? 'region' : componentProps.displayLevel;
}
// update-end--author:liaozhiyang---date:20260204---for:【QQYUN-14694】online支持配置独立的省、市、县
return Object.assign({}, item, {
component: 'JAreaLinkage',
componentProps: {
saveCode: 'region',
...componentProps,
},
});
}
}

View File

@@ -0,0 +1,38 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 表单设计器-省市区查询
*/
export default class CascaderPcaForQueryWidget extends IFormSchema {
schema: Recordable;
// 省市县联动级别
areaLevel: number;
// 是否允许更改级别
allowChangeLevel: boolean;
constructor(key: string, data: Recordable, queryItem: Recordable) {
super(key, data);
this.schema = data
this.areaLevel = data['areaLevel'] ?? 3;
// 只有等于和不等于才能更改级别
this.allowChangeLevel = ['eq', 'ne'].includes(queryItem?.rule)
}
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'CascaderPcaInFilter',
componentProps:{
areaLevel: this.areaLevel,
allowChangeLevel: this.allowChangeLevel,
placeholder: '请选择…',
style: {
width: '100%',
}
}
});
}
}

View File

@@ -0,0 +1,60 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* checkbox
*/
export default class CheckboxWidget extends IFormSchema {
/*title-value*/
options: any[];
constructor(key, data) {
super(key, data);
this.options = this.getOptions(data['enum']);
}
setFormRef(ref) {
super.setFormRef(ref);
this.handleDictTableParams();
}
updateDictTable(dictTable: string) {
this.formRef.value.updateSchema(({
field: this.field,
componentProps: {
options:[],
dictCode: this.genDictTableCode(dictTable, this._data.dictText, this._data.dictCode),
}
}))
}
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'JCheckbox',
componentProps: {
options: this.options,
triggerChange: true,
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
useDicColor: true,
// update-end--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
},
});
}
getOptions(array) {
if (!array || array.length == 0) {
return [];
}
let arr: any[] = [];
for (let item of array) {
arr.push({
value: item.value,
label: item.title,
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
color: item.color,
// update-end--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
});
}
return arr;
}
}

View File

@@ -0,0 +1,59 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
enum DateFormat {
datetime = 'YYYY-MM-DD HH:mm:ss',
date = 'YYYY-MM-DD',
}
/**
* 日期、时间
*/
export default class DateWidget extends IFormSchema {
format: string;
showTime: boolean;
picker: string | undefined;
allowSelectRange: boolean;
constructor(key, data, queryItem) {
super(key, data);
this.format = DateFormat[data.view];
this.showTime = data.view == 'date' ? false : true;
// update-begin--author:liaozhiyang---date:20240430---for【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
let fieldExtendJson = data.fieldExtendJson;
if (data.view == 'date' && fieldExtendJson) {
fieldExtendJson = JSON.parse(fieldExtendJson);
if (fieldExtendJson.picker && fieldExtendJson.picker != 'default') {
this.picker = fieldExtendJson.picker;
} else {
this.picker = undefined;
}
}
// update-end--author:liaozhiyang---date:20240430---for【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
// 只有等于和不等于才能选择预设范围(今天、昨天、本周等)
this.allowSelectRange = ['eq', 'ne'].includes(queryItem?.rule)
}
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'DatePickerInFilter',
componentProps: {
placeholder: `请选择${this.label}`,
showTime: this.showTime,
valueFormat: this.format,
allowSelectRange: this.allowSelectRange,
// update-begin--author:liaozhiyang---date:20240430---for【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
picker: this.picker,
// update-end--author:liaozhiyang---date:20240430---for【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
style: {
width: '100%',
},
getPopupContainer: (_node) => {
return this.getModalAsContainer();
},
},
});
}
}

View File

@@ -0,0 +1,25 @@
import IFormSchema from '../IFormSchema';
import { FormSchema } from '/@/components/Form';
/**
* 富文本
*/
export default class EditorWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'JEditor',
componentProps: {
//update-begin-author:taoyan date:2022-6-1 for: VUEN-1159 第一次加载时,点击第一个输入框,光标会跑到富文本输入框
options: {
auto_focus: false,
},
//update-end-author:taoyan date:2022-6-1 for: VUEN-1159 第一次加载时,点击第一个输入框,光标会跑到富文本输入框
// fileMax:1,
// showImageUpload:false,
// width:"966px",
// height:"200px"
},
});
}
}

View File

@@ -0,0 +1,26 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 文件
*/
export default class FileWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
let componentProps = this.getComponentProps();
return Object.assign({}, item, {
component: 'JUpload',
componentProps,
});
}
getComponentProps() {
let json = this.getExtendData();
if (json && json.uploadnum) {
return {
maxCount: Number(json.uploadnum),
};
}
return {};
}
}

View File

@@ -0,0 +1,28 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
import { UploadTypeEnum } from '/@/components/Form/src/jeecg/components/JUpload';
/**
* 图片
*/
export default class ImageWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
let componentProps = this.getComponentProps();
return Object.assign({}, item, {
component: 'JUpload',
componentProps,
});
}
getComponentProps() {
let props = {
fileType: UploadTypeEnum.image,
};
let json = this.getExtendData();
if (json && json.uploadnum) {
props['maxCount'] = Number(json.uploadnum);
}
return props;
}
}

View File

@@ -0,0 +1,15 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 输入框
*/
export default class InputWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
if (this.hidden === true) {
item['show'] = false;
}
return item;
}
}

View File

@@ -0,0 +1,108 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 下拉联动- 原理是:
* 使用JDictSelectTag组件(2022-03-09测试可行版 后续如有改动请注意)
* 监听表单的change事件清空下级表单值并改变props
* 问题在于1.没有code的时候 不需要设置选项
* 优势在于: 可以不考虑组件位置(但是需要改后台接口)
*/
export default class LinkDownWidget extends IFormSchema {
/*title-value*/
options: any[];
next: string;
type: string;
table: string;
txt: string;
store: string;
pidField: string;
idField: string;
origin: boolean;
condition: string;
constructor(key, data) {
super(key, data);
const { dictTable, dictText, dictCode, pidField, idField, origin, condition } = data;
this.table = dictTable;
this.txt = dictText;
this.store = dictCode;
this.idField = idField;
this.pidField = pidField;
this.origin = origin;
this.condition = condition;
// 都是空数组
this.options = [];
this.next = data.next || '';
this.type = data.type;
}
getItem(): FormSchema {
let item = super.getItem();
let componentProps = this.getComponentProps();
return Object.assign({}, item, {
component: 'OnlineSelectCascade',
componentProps,
});
}
getComponentProps() {
let baseProp = {
table: this.table,
txt: this.txt,
store: this.store,
pidField: this.pidField,
idField: this.idField,
origin: this.origin,
pidValue: '-1',
style: {
width: '100%',
},
onChange: (value) => {
console.log('级联组件-onChange', value);
this.valueChange(value);
},
onNext: (pidValue) => {
console.log('级联组件-onNext', pidValue);
this.nextOptionsChange(pidValue);
},
};
if (this._data.origin === true) {
baseProp['condition'] = this.condition;
}
return baseProp;
}
async nextOptionsChange(pidValue) {
if (!this.formRef) {
console.error('表单引用找不到');
return;
}
if (!this.next) {
return;
}
let ref = this.formRef.value;
await ref.updateSchema({
field: this.next,
componentProps: {
pidValue,
},
});
}
async valueChange(value) {
if (!this.formRef) {
console.error('表单引用找不到');
return;
}
// update-begin--author:liaozhiyang---date:20240717---for【TV360X-1856】联动组件最后一个js增强onchang方法不生效
let ref = this.formRef.value;
// 触发form层级的change事件
ref.$formValueChange(this.field, value);
if (this.next) {
// 重置value
await ref.setFieldsValue({ [this.next]: '' });
}
// update-end--author:liaozhiyang---date:20240717---for【TV360X-1856】联动组件最后一个js增强onchang方法不生效
}
}

View File

@@ -0,0 +1,42 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 他表字段
*/
export default class LinkTableFieldWidget extends IFormSchema {
dictTable: string;
dictText: string;
constructor(key, data) {
super(key, data);
this.dictTable = data['dictTable'];
this.dictText = data['dictText'];
}
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
componentProps: {
readOnly: true,
allowClear: false,
disabled: true,
style:{
background: 'none',
color:'rgba(0, 0, 0, 0.85)',
border:'none'
}
}
});
return item;
}
/**
* 获取他表字段的关联信息
*/
getLinkFieldInfo(){
let arr = [this.dictTable, `${this.field},${this.dictText}`];
return arr;
}
}

View File

@@ -0,0 +1,35 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 表单设计器-关联记录查询 使用下拉搜索
*/
export default class LinkTableForQueryWidget extends IFormSchema {
code: string;
titleField: string;
multi: boolean;
constructor(key, data) {
super(key, data);
this.code = data['code'];
this.titleField = data['titleField'];
this.multi = data['multi']||false;
}
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'LinkTableForQuery',
componentProps:{
code: this.code,
multi: this.multi,
field: this.titleField,
style: {
width: '100%',
}
}
});
}
}

View File

@@ -0,0 +1,72 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 关联记录
*/
export default class LinkTableWidget extends IFormSchema {
dictTable: string;
dictText: string;
dictCode: string;
view: string;
componentString: string;
linkFields: Array<string>;
constructor(key, data) {
super(key, data);
this.dictTable = data.dictTable;
this.dictText = data.dictText;
this.dictCode = data.dictCode;
this.view = data.view;
this.componentString = ''
this.linkFields = []
}
getItem(): FormSchema {
let item = super.getItem();
const componentProps = this.getComponentProps()
return Object.assign({}, item, {
component: this.componentString,
componentProps: componentProps
});
}
getComponentProps() {
let props = {
textField: this.dictText,
tableName: this.dictTable,
valueField: this.dictCode,
};
let extend = this.getExtendData();
// 是否多选
if (extend.multiSelect) {
props['multi'] = true;
}else{
props['multi'] = false;
}
//封面图
if (extend.imageField) {
props['imageField'] = extend.imageField;
}else{
props['imageField'] = ''
}
//显示类型
if (extend.showType=='select') {
this.componentString = 'LinkTableSelect'
let popContainer = this.getPopContainer();
props['popContainer'] = popContainer
}else{
this.componentString = 'LinkTableCard'
}
if(this.linkFields.length>0){
props['linkFields'] = this.linkFields;
}
return props;
}
// 他表字段用于翻译
setOtherInfo(arr){
// ["表单字段,表字典字段","表单字段,表字典字段"]
this.linkFields = arr;
}
}

View File

@@ -0,0 +1,17 @@
import IFormSchema from '../IFormSchema';
import { FormSchema } from '/@/components/Form';
/**
* markdown
*/
export default class MarkdownWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'JMarkdownEditor',
componentProps: {
// height: 300,
},
});
}
}

View File

@@ -0,0 +1,49 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 输入框-数字
*/
export default class NumberWidget extends IFormSchema {
dbPointLength: number;
constructor(key, data) {
super(key, data);
this.dbPointLength = data.dbPointLength;
}
getItem(): FormSchema {
let item = super.getItem();
let componentProps = this.getComponentProps();
const safeIntRule = {
validator: (_rule, value) => {
if (value !== null && value !== undefined && value !== '') {
if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
return Promise.reject(`数值超出安全范围(${Number.MIN_SAFE_INTEGER}~${Number.MAX_SAFE_INTEGER}),精度将丢失,请重新输入`);
}
}
return Promise.resolve();
},
};
const existingRules = item.rules || [];
return Object.assign({}, item, {
component: 'InputNumber',
componentProps,
// update-begin--author:liaozhiyang---date:20260413---for:【QQYUN-9790】online中数字类型超出js语言数值范围加提示
rules: [...existingRules, safeIntRule],
// update-end--author:liaozhiyang---date:20260413---for:【QQYUN-9790】online中数字类型超出js语言数值范围加提示
});
}
getComponentProps() {
const props = {
style: {
width: '100%',
},
};
if (this.dbPointLength >= 0) {
props['precision'] = this.dbPointLength;
}
return props;
}
}

View File

@@ -0,0 +1,14 @@
import { FormSchema } from '/@/components/Form';
import IFormSchema from '../IFormSchema';
/**
* 输入框- 密码
*/
export default class PasswordWidget extends IFormSchema {
getItem(): FormSchema {
let item = super.getItem();
return Object.assign({}, item, {
component: 'InputPassword',
});
}
}

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