初始化

This commit is contained in:
2026-02-22 18:56:10 +08:00
commit 26677972a6
3112 changed files with 255972 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { About } from '@easyflow/common-ui';
import { api } from '#/api/request';
import { useDictStore } from '#/store';
defineOptions({ name: 'About' });
const dictStore = useDictStore();
onMounted(() => {
test();
dictStore.fetchDictionary('accountType');
});
const accountInfo = ref<any>();
function test() {
api
.get('/api/v1/sysAccount/myProfile')
.then((res) => {
accountInfo.value = res.data;
// console.log('res', res);
})
.catch((error) => {
console.error('error', error);
});
}
</script>
<template>
<div>
<div>{{ accountInfo?.loginName }}</div>
<div v-for="(item, index) in [0, 1, 99]" :key="index">
<div>
{{ dictStore.getDictLabel('accountType', item) }}
</div>
</div>
<About />
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { EasyFlowFormSchema } from '@easyflow/common-ui';
import type { Recordable } from '@easyflow/types';
import { computed, ref } from 'vue';
import { AuthenticationCodeLogin, z } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
defineOptions({ name: 'CodeLogin' });
const loading = ref(false);
const CODE_LENGTH = 6;
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
component: 'EasyFlowInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'phoneNumber',
label: $t('authentication.mobile'),
rules: z
.string()
.min(1, { message: $t('authentication.mobileTip') })
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
}),
},
{
component: 'EasyFlowPinInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
const text =
countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
return text;
},
placeholder: $t('authentication.code'),
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
];
});
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param values 登录表单数据
*/
async function handleLogin(values: Recordable<any>) {
// eslint-disable-next-line no-console
console.log(values);
}
</script>
<template>
<AuthenticationCodeLogin
:form-schema="formSchema"
:loading="loading"
@submit="handleLogin"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { EasyFlowFormSchema } from '@easyflow/common-ui';
import type { Recordable } from '@easyflow/types';
import { computed, ref } from 'vue';
import { AuthenticationForgetPassword, z } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
defineOptions({ name: 'ForgetPassword' });
const loading = ref(false);
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
component: 'EasyFlowInput',
componentProps: {
placeholder: 'example@example.com',
},
fieldName: 'email',
label: $t('authentication.email'),
rules: z
.string()
.min(1, { message: $t('authentication.emailTip') })
.email($t('authentication.emailValidErrorTip')),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
}
</script>
<template>
<AuthenticationForgetPassword
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { EasyFlowFormSchema } from '@easyflow/common-ui';
import { computed, onMounted } from 'vue';
import { AuthenticationLogin, z } from '@easyflow/common-ui';
import { useAppConfig } from '@easyflow/hooks';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { api } from '#/api/request';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
onMounted(() => {});
const authStore = useAuthStore();
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
type PlatformType = 'ding_talk' | 'wx_web';
const title = computed(() => preferences.auth.welcomeBack);
const subTitle = computed(() => preferences.auth.loginSubtitle);
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
component: 'EasyFlowInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'account',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
];
});
function onSubmit(values: any) {
// config 对象为TAC验证码的一些配置和验证的回调
const config = {
// 生成接口 (必选项,必须配置, 要符合tianai-captcha默认验证码生成接口规范)
requestCaptchaDataUrl: `${apiURL}/api/v1/public/getCaptcha`,
// 验证接口 (必选项,必须配置, 要符合tianai-captcha默认验证码校验接口规范)
validCaptchaUrl: `${apiURL}/api/v1/public/check`,
// 验证码绑定的div块 (必选项,必须配置)
bindEl: '#captcha-box',
// 验证成功回调函数(必选项,必须配置)
validSuccess: (res: any, _: any, tac: any) => {
// 销毁验证码服务
tac.destroyWindow();
// 调用具体的login方法
values.validToken = res.data;
authStore.authLogin(values);
},
// 验证失败的回调函数(可忽略,如果不自定义 validFail 方法时,会使用默认的)
validFail: (_: any, __: any, tac: any) => {
// 验证失败后重新拉取验证码
tac.reloadCaptcha();
},
// 刷新按钮回调事件
btnRefreshFun: (_: any, tac: any) => {
tac.reloadCaptcha();
},
// 关闭按钮回调事件
btnCloseFun: (_: any, tac: any) => {
tac.destroyWindow();
},
};
const style = {
logoUrl: null, // 去除logo
// logoUrl: "/xx/xx/xxx.png" // 替换成自定义的logo
btnUrl: '/tac-btn.png',
};
window
// @ts-ignore
.initTAC('/tac', config, style)
.then((tac: any) => {
tac.init(); // 调用init则显示验证码
})
.catch((error: any) => {
console.error('初始化tac失败', error);
});
}
function getAuthUrl(platform: PlatformType) {
return api.get('/thirdAuth/getAuthUrl', {
params: {
platform,
},
});
}
</script>
<template>
<div>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
:title="title"
:sub-title="subTitle"
@submit="onSubmit"
/>
<div id="captcha-box" class="captcha-div"></div>
</div>
</template>
<style scoped>
.captcha-div {
position: absolute;
top: 30vh;
left: 21vh;
}
.platform-icon {
width: 30px;
height: 30px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAccessStore, useUserStore } from '@easyflow/stores';
import { getAccessCodesApi, getUserInfoApi } from '#/api';
const accessStore = useAccessStore();
const userStore = useUserStore();
const route = useRoute();
const token: any = route.query.token;
onMounted(() => {
redirect();
});
async function redirect() {
accessStore.setAccessToken(token);
const [fetchUserInfoResult, accessCodes] = await Promise.all([
getUserInfoApi(),
getAccessCodesApi(),
]);
userStore.setUserInfo(fetchUserInfoResult);
accessStore.setAccessCodes(accessCodes);
window.location.href = '/';
}
</script>
<template>
<div>redirecting...</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
import { AuthenticationQrCodeLogin } from '@easyflow/common-ui';
import { LOGIN_PATH } from '@easyflow/constants';
defineOptions({ name: 'QrCodeLogin' });
</script>
<template>
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
</template>

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { EasyFlowFormSchema } from '@easyflow/common-ui';
import type { Recordable } from '@easyflow/types';
import { computed, h, ref } from 'vue';
import { AuthenticationRegister, z } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
defineOptions({ name: 'Register' });
const loading = ref(false);
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
component: 'EasyFlowInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
renderComponentContent() {
return {
strengthText: () => $t('authentication.passwordStrength'),
};
},
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
});
},
triggerFields: ['password'],
},
fieldName: 'confirmPassword',
label: $t('authentication.confirmPassword'),
},
{
component: 'EasyFlowCheckbox',
fieldName: 'agreePolicy',
renderComponentContent: () => ({
default: () =>
h('span', [
$t('authentication.agree'),
h(
'a',
{
class: 'easyflow-link ml-1 ',
href: '',
},
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
),
]),
}),
rules: z.boolean().refine((value) => !!value, {
message: $t('authentication.agreeTip'),
}),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>
<template>
<AuthenticationRegister
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@easyflow/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@easyflow/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@easyflow/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@easyflow/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@easyflow/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, markRaw, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@easyflow/common-ui';
import { ElMessage } from 'element-plus';
import { api } from '#/api/request';
import Cropper from '#/components/upload/Cropper.vue';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
const { fetchUserInfo } = useAuthStore();
const profileBaseSettingRef = ref();
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
fieldName: 'avatar',
component: markRaw(Cropper),
componentProps: {
crop: true,
},
label: $t('sysAccount.avatar'),
},
{
fieldName: 'nickname',
component: 'Input',
label: $t('sysAccount.nickname'),
},
{
fieldName: 'mobile',
component: 'Input',
label: $t('sysAccount.mobile'),
},
{
fieldName: 'email',
component: 'Input',
label: $t('sysAccount.email'),
},
];
});
onMounted(async () => {
await getInfo();
});
async function getInfo() {
loading.value = true;
const data = await fetchUserInfo();
await profileBaseSettingRef.value.getFormApi().setValues(data);
loading.value = false;
}
const loading = ref(false);
const updateLoading = ref(false);
function handleSubmit(values: any) {
updateLoading.value = true;
api.post('/api/v1/sysAccount/updateProfile', values).then((res) => {
updateLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
getInfo();
}
});
}
</script>
<template>
<ProfileBaseSetting
v-loading="loading"
:button-loading="updateLoading"
ref="profileBaseSettingRef"
:form-schema="formSchema"
:button-text="$t('button.update')"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Profile } from '@easyflow/common-ui';
import { useUserStore } from '@easyflow/stores';
import { $t } from '#/locales';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const route = useRoute();
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = [
{
label: $t('settingsConfig.basic'),
value: 'basic',
},
{
label: $t('settingsConfig.updatePwd'),
value: 'password',
},
];
onMounted(() => {
if (route.query.tab) {
tabsValue.value = route.query.tab as string;
}
});
</script>
<template>
<Profile
v-model:model-value="tabsValue"
:title="$t('page.auth.profile')"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@easyflow/common-ui';
import { $t } from '#/locales';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: $t('page.auth.accountPassword'),
description: $t('page.description.accountPassword'),
},
{
value: true,
fieldName: 'systemMessage',
label: $t('page.auth.systemMessage'),
description: $t('page.description.systemMessage'),
},
{
value: true,
fieldName: 'todoTask',
label: $t('page.auth.todoTasks'),
description: $t('page.description.todoTasks'),
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const profilePasswordSettingRef = ref();
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
},
{
fieldName: 'newPassword',
label: $t('sysAccount.newPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
},
},
{
fieldName: 'confirmPassword',
label: $t('sysAccount.confirmPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('sysAccount.repeatPwd'),
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: $t('sysAccount.repeatPwd') })
.min(1, { message: $t('sysAccount.repeatPwd') })
.refine((value) => value === newPassword, {
message: $t('sysAccount.notSamePwd'),
});
},
triggerFields: ['newPassword'],
},
},
];
});
const updateLoading = ref(false);
function handleSubmit(values: any) {
updateLoading.value = true;
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
updateLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
}
});
}
</script>
<template>
<ProfilePasswordSetting
:button-loading="updateLoading"
:button-text="$t('button.update')"
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@easyflow/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { BotInfo } from '@easyflow/types';
import type { ActionButton } from '#/components/page/CardList.vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Plus, Setting } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { tryit } from 'radash';
import { removeBotFromId } from '#/api';
import { api } from '#/api/request';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import { useDictStore } from '#/store';
import Modal from './modal.vue';
interface FieldDefinition {
// 字段名称
prop: string;
// 字段标签
label: string;
// 字段类型input, number, select, radio, checkbox, switch, date, datetime
type?: 'input' | 'number';
// 是否必填
required?: boolean;
// 占位符
placeholder?: string;
}
onMounted(() => {
initDict();
getSideList();
});
const router = useRouter();
const pageDataRef = ref();
const modalRef = ref<InstanceType<typeof Modal>>();
const dictStore = useDictStore();
// 操作按钮配置
const headerButtons = [
{
key: 'create',
text: `${$t('button.create')}${$t('bot.chatAssistant')}`,
icon: markRaw(Plus),
type: 'primary',
data: { action: 'create' },
permission: '/api/v1/documentCollection/save',
},
];
const actions: ActionButton[] = [
{
icon: Edit,
text: $t('button.edit'),
className: '',
permission: '',
onClick(row: BotInfo) {
modalRef.value?.open('edit', row);
},
},
{
icon: Setting,
text: $t('button.setting'),
className: '',
permission: '',
onClick(row: BotInfo) {
router.push({ path: `/ai/bots/setting/${row.id}` });
},
},
{
icon: Delete,
text: $t('button.delete'),
className: 'item-danger',
permission: '/api/v1/bot/remove',
onClick(row: BotInfo) {
removeBot(row);
},
},
];
const removeBot = async (bot: BotInfo) => {
const [action] = await tryit(ElMessageBox.confirm)(
$t('message.deleteAlert'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
},
);
if (!action) {
const [err, res] = await tryit(removeBotFromId)(bot.id);
if (!err && res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({});
}
}
};
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
const handleButtonClick = () => {
modalRef.value?.open('create');
};
const fieldDefinitions = ref<FieldDefinition[]>([
{
prop: 'categoryName',
label: $t('aiWorkflowCategory.categoryName'),
type: 'input',
required: true,
placeholder: $t('aiWorkflowCategory.categoryName'),
},
{
prop: 'sortNo',
label: $t('aiWorkflowCategory.sortNo'),
type: 'number',
required: false,
placeholder: $t('aiWorkflowCategory.sortNo'),
},
]);
const formData = ref<any>({});
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const saveLoading = ref(false);
const sideList = ref<any[]>([]);
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row: any) {
showControlDialog(row);
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row: any) {
removeCategory(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
showControlDialog({});
},
};
const formRules = computed(() => {
const rules: Record<string, any[]> = {};
fieldDefinitions.value.forEach((field) => {
const fieldRules = [];
if (field.required) {
fieldRules.push({
required: true,
message: `${$t('message.required')}`,
trigger: 'blur',
});
}
if (fieldRules.length > 0) {
rules[field.prop] = fieldRules;
}
});
return rules;
});
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
}
function showControlDialog(item: any) {
formRef.value?.resetFields();
formData.value = { ...item };
dialogVisible.value = true;
}
function removeCategory(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/botCategory/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
done();
getSideList();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function handleSubmit() {
formRef.value?.validate((valid) => {
if (valid) {
saveLoading.value = true;
const url = formData.value.id
? '/api/v1/botCategory/update'
: '/api/v1/botCategory/save';
api.post(url, formData.value).then((res) => {
saveLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
dialogVisible.value = false;
getSideList();
}
});
}
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/botCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
if (res && res.errorCode === 0) {
sideList.value = [
{
id: '',
categoryName: $t('common.allCategories'),
},
...res.data,
];
}
};
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
<div class="flex flex-1 gap-6">
<PageSide
label-key="categoryName"
value-key="id"
:menus="sideList"
:control-btns="controlBtns"
:footer-button="footerButton"
@change="changeCategory"
/>
<div class="h-[calc(100vh-192px)] flex-1 overflow-auto">
<PageData
ref="pageDataRef"
page-url="/api/v1/bot/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
>
<template #default="{ pageList }">
<CardList
:default-icon="defaultAvatar"
:data="pageList"
:actions="actions"
/>
</template>
</PageData>
</div>
</div>
<!-- 创建&编辑Bot弹窗 -->
<Modal ref="modalRef" @success="pageDataRef.setQuery({})" />
<ElDialog
v-model="dialogVisible"
:title="formData.id ? `${$t('button.edit')}` : `${$t('button.add')}`"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<!-- 动态生成表单项 -->
<ElFormItem
v-for="field in fieldDefinitions"
:key="field.prop"
:label="field.label"
:prop="field.prop"
>
<ElInput
v-if="!field.type || field.type === 'input'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="saveLoading">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { BotInfo } from '@easyflow/types';
import type { SaveBotParams, UpdateBotParams } from '#/api/ai/bot';
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { tryit } from 'radash';
import { saveBot, updateBotApi } from '#/api/ai/bot';
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
const emit = defineEmits(['success']);
const initialFormData = {
icon: '',
title: '',
alias: '',
description: '',
categoryId: '',
status: 1,
};
const dialogVisible = ref(false);
const dialogType = ref<'create' | 'edit'>('create');
const formRef = ref<InstanceType<typeof ElForm>>();
const formData = ref<SaveBotParams | UpdateBotParams>(initialFormData);
const rules = {
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
alias: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
};
const loading = ref(false);
const handleSubmit = async () => {
loading.value = true;
const [err, res] = await tryit(
dialogType.value === 'create' ? saveBot : updateBotApi,
)(formData.value as any);
if (!err && res.errorCode === 0) {
emit('success');
ElMessage.success($t('message.saveOkMessage'));
dialogVisible.value = false;
}
loading.value = false;
};
defineExpose({
open(type: typeof dialogType.value, bot?: BotInfo) {
formData.value = bot
? {
id: bot.id,
icon: bot.icon,
title: bot.title,
alias: bot.alias,
description: bot.description,
categoryId: bot.categoryId,
status: bot.status,
}
: initialFormData;
dialogType.value = type;
dialogVisible.value = true;
},
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="`${$t(`button.${dialogType}`)}${$t('bot.chatAssistant')}`"
draggable
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="150px">
<ElFormItem :label="$t('common.avatar')" prop="icon">
<UploadAvatar v-model="formData.icon" />
</ElFormItem>
<ElFormItem prop="categoryId" :label="$t('aiWorkflow.categoryId')">
<DictSelect v-model="formData.categoryId" dict-code="aiBotCategory" />
</ElFormItem>
<ElFormItem :label="$t('aiWorkflow.title')" prop="title">
<ElInput v-model="formData.title" />
</ElFormItem>
<ElFormItem :label="$t('plugin.alias')" prop="alias">
<ElInput v-model="formData.alias" />
</ElFormItem>
<ElFormItem :label="$t('plugin.description')" prop="description">
<ElInput type="textarea" :rows="3" v-model="formData.description" />
</ElFormItem>
<ElFormItem prop="status" :label="$t('aiWorkflow.status')">
<DictSelect v-model="formData.status" dict-code="showOrNot" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:loading="loading"
:disabled="loading"
@click="handleSubmit"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { BotInfo, Session } from '@easyflow/types';
import { onMounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import { preferences } from '@easyflow/preferences';
import { uuid } from '@easyflow/utils';
import {
ElAside,
ElButton,
ElContainer,
ElEmpty,
ElMain,
ElSpace,
} from 'element-plus';
import { tryit } from 'radash';
import { getBotDetails, getSessionList } from '#/api';
import BotAvatar from '#/components/botAvatar/botAvatar.vue';
import Chat from '#/components/chat/chat.vue';
import { $t } from '#/locales';
const route = useRoute();
const router = useRouter();
const bot = ref<BotInfo>();
const sessionList = ref<Session[]>([]);
const sessionId = ref<string>(route.params.sessionId as string);
watchEffect(() => {
sessionId.value = route.params.sessionId as string;
});
// 内置菜单点击方法
// function handleMenuCommand(
// command: ConversationMenuCommand,
// item: ConversationItem,
// ) {
// console.warn('内置菜单点击事件:', command, item);
// // 直接修改 item 是否生效
// if (command === 'delete') {
// const index = menuTestItems.value.findIndex(
// (itemSlef) => itemSlef.key === item.key,
// );
// if (index !== -1) {
// menuTestItems.value.splice(index, 1);
// console.warn('删除成功');
// ElMessage.success('删除成功');
// }
// }
// if (command === 'rename') {
// item.label = '已修改';
// console.warn('重命名成功');
// ElMessage.success('重命名成功');
// }
// }
onMounted(() => {
if (route.params.botId) {
fetchBotDetail(route.params.botId as string);
fetchSessionList(route.params.botId as string);
}
});
const fetchBotDetail = async (id: string) => {
const [, res] = await tryit(getBotDetails)(id);
if (res?.errorCode === 0) {
bot.value = res.data;
}
};
const fetchSessionList = async (id: string) => {
const [, res] = await tryit(getSessionList)({
botId: id,
tempUserId: uuid().toString() + id,
});
if (res?.errorCode === 0) {
sessionList.value = res.data.cons;
}
};
const updateActive = (_sessionId?: number | string) => {
sessionId.value = `${_sessionId ?? ''}`;
router.push(
`/ai/bots/run/${bot.value?.id}${_sessionId ? `/${_sessionId}` : ''}`,
);
};
</script>
<template>
<ElContainer class="h-screen" v-if="bot">
<ElAside width="240px" class="flex flex-col items-center bg-[#f5f5f580]">
<ElSpace class="py-7">
<BotAvatar :src="bot.icon" :size="40" />
<span class="text-base font-medium text-black/85">{{ bot.title }}</span>
</ElSpace>
<ElButton
type="primary"
class="!h-10 w-full max-w-[208px]"
plain
@click="updateActive()"
>
<template #icon>
<IconifyIcon icon="mdi:chat-outline" />
</template>
{{ $t('button.newConversation') }}
</ElButton>
<span class="self-start p-6 pb-2 text-sm text-[#969799]">{{
$t('common.history')
}}</span>
<div class="w-full max-w-[208px] flex-1 overflow-hidden">
<ElConversations
v-show="sessionList.length > 0"
class="!w-full !shadow-none"
v-model:active="sessionId"
:items="sessionList"
:label-max-width="120"
:show-tooltip="true"
row-key="sessionId"
label-key="title"
tooltip-placement="right"
:tooltip-offset="35"
show-to-top-btn
show-built-in-menu
show-built-in-menu-type="hover"
@update:active="updateActive"
/>
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
v-show="sessionList.length === 0"
/>
</div>
</ElAside>
<ElMain>
<Chat :session-id="sessionId" :bot="bot" />
</ElMain>
</ElContainer>
</template>
<style lang="css" scoped>
.conversations-container :deep(.conversations-list) {
width: 100% !important;
padding: 0 !important;
background: none !important;
}
.conversations-container :deep(.conversation-item) {
margin: 0;
}
.conversations-container :deep(.conversation-label) {
color: #1a1a1a;
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElDialog, ElForm, ElFormItem, ElInput } from 'element-plus';
import { sseClient } from '#/api/request';
const emit = defineEmits(['success']);
const dialogVisible = ref(false);
const formRef = ref<InstanceType<typeof ElForm>>();
const formData = ref();
const rules = {
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
};
const loading = ref(false);
const handleSubmit = async () => {
loading.value = true;
const data = {
botId: formData.value.botId,
prompt: formData.value.prompt,
};
formData.value.prompt = '';
sseClient.post('/api/v1/bot/prompt/chore/chat', data, {
onMessage(message) {
const event = message.event;
// done
if (event === 'done') {
loading.value = false;
return;
}
if (!message.data) {
return;
}
const sseData = JSON.parse(message.data);
const delta = sseData.payload?.delta;
formData.value.prompt += delta;
},
});
};
const handleReplace = () => {
emit('success', formData.value.prompt);
dialogVisible.value = false;
};
defineExpose({
open(botId: string, systemPrompt: string) {
formData.value = {
botId,
prompt: systemPrompt,
};
dialogVisible.value = true;
handleSubmit();
},
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('bot.aiOptimizedPrompts')"
draggable
align-center
width="550px"
>
<ElForm ref="formRef" :model="formData" :rules="rules">
<ElFormItem prop="prompt">
<ElInput type="textarea" :rows="20" v-model="formData.prompt" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleReplace" v-if="!loading">
{{ $t('button.replace') }}
</ElButton>
<ElButton
type="primary"
:loading="loading"
:disabled="loading"
@click="handleSubmit"
>
{{ loading ? $t('button.optimizing') : $t('button.regenerate') }}
</ElButton>
</template>
</ElDialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { BotInfo } from '@easyflow/types';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { tryit } from 'radash';
import { getBotDetails } from '#/api';
import { hasPermission } from '#/api/common/hasPermission';
import Config from './config.vue';
import Preview from './preview.vue';
import Prompt from './prompt.vue';
const route = useRoute();
const hasSavePermission = computed(() =>
hasPermission(['/api/v1/bot/save', '/api/v1/bot/updateLlmId']),
);
const bot = ref<BotInfo>();
onMounted(() => {
if (route.params.id) {
fetchBotDetail(route.params.id as string);
}
});
const fetchBotDetail = async (id: string) => {
const [, res] = await tryit(getBotDetails)(id);
if (res?.errorCode === 0) {
bot.value = res.data;
}
};
</script>
<template>
<div class="settings-container">
<div class="row-container">
<div class="row-item">
<Prompt :bot="bot" :has-save-permission="hasSavePermission" />
</div>
<div class="row-item">
<Config :bot="bot" :has-save-permission="hasSavePermission" />
</div>
<div class="row-item">
<Preview :bot="bot" />
</div>
</div>
</div>
</template>
<style scoped>
.settings-container {
height: calc(100vh - 90px);
padding: 20px;
}
.row-container {
height: 100%;
display: flex;
gap: 20px;
}
.row-item {
height: 100%;
flex: 1;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Brush } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
import Chat from '#/components/chat/chat.vue';
const chatRef = ref();
const handleClear = () => {
chatRef.value.clear();
};
</script>
<template>
<div
class="bg-background dark:border-border flex h-full flex-col gap-3 rounded-lg p-3 dark:border"
>
<div class="flex justify-between">
<h1 class="text-base font-medium">
{{ $t('button.preview') }}
</h1>
<ElButton text @click="handleClear">
<ElIcon class="rotate-180"><Brush /></ElIcon>
</ElButton>
</div>
<div class="relative flex-1">
<Chat
ref="chatRef"
class="absolute inset-0"
:ishow-chat-conversations="false"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { BotInfo } from '@easyflow/types';
import { ref, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { ElIcon, ElInput } from 'element-plus';
import { updateLlmOptions } from '#/api';
import MagicStaffIcon from '#/components/icons/MagicStaffIcon.vue';
import { $t } from '#/locales';
import PromptChoreChatModal from '#/views/ai/bots/pages/setting/PromptChoreChatModal.vue';
const props = defineProps<{
bot?: BotInfo;
hasSavePermission?: boolean;
}>();
const systemPrompt = ref($t('bot.placeholder.prompt'));
const promptChoreChatModalRef = ref();
watch(
() => props.bot?.modelOptions.systemPrompt,
(newPrompt) => {
if (newPrompt) {
systemPrompt.value = newPrompt;
}
},
{ immediate: true },
);
const handleInput = useDebounceFn((value: string) => {
updateLlmOptions({
id: props.bot?.id || '',
llmOptions: {
systemPrompt: value,
},
});
}, 1000);
const handelReplacePrompt = (value: string) => {
systemPrompt.value = value;
handleInput(value);
};
</script>
<template>
<div
class="bg-background dark:border-border flex h-full flex-col gap-2 rounded-lg p-3 dark:border"
>
<div class="flex justify-between">
<h1 class="text-base font-medium">
{{ $t('bot.systemPrompt') }}
</h1>
<button
@click="promptChoreChatModalRef.open(props.bot?.id, systemPrompt)"
type="button"
class="flex items-center gap-0.5 rounded-lg bg-[#f7f7f7] px-3 py-1"
>
<ElIcon size="16"><MagicStaffIcon /></ElIcon>
<span
class="bg-[linear-gradient(106.75666073298856deg,#F17E47,#D85ABF,#717AFF)] bg-clip-text text-sm text-transparent"
>
{{ $t('bot.aiOptimization') }}
</span>
</button>
</div>
<ElInput
class="flex-1"
type="textarea"
resize="none"
v-model="systemPrompt"
:title="!hasSavePermission ? $t('bot.placeholder.permission') : ''"
:disabled="!hasSavePermission"
@input="handleInput"
/>
<!--系统提示词优化模态框-->
<PromptChoreChatModal
ref="promptChoreChatModalRef"
@success="handelReplacePrompt"
/>
</div>
</template>
<style lang="css" scoped>
.el-textarea :deep(.el-textarea__inner) {
--el-input-bg-color: #f7f7f7;
height: 100%;
padding: 12px;
font-size: 14px;
font-weight: 500;
line-height: 1.25;
border-radius: 8px;
box-shadow: none;
}
.dark .el-textarea :deep(.el-textarea__inner) {
--el-input-bg-color: hsl(var(--background-deep));
border: 1px solid hsl(var(--border));
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Delete, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
const props = defineProps({
documentId: {
type: String,
default: '',
},
});
const dialogVisible = ref(false);
const pageDataRef = ref();
const handleEdit = (row: any) => {
form.value = { id: row.id, content: row.content };
openDialog();
};
const handleDelete = (row: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
})
.then(() => {
btnLoading.value = true;
api
.post('/api/v1/documentChunk/removeChunk', { id: row.id })
.then((res: any) => {
btnLoading.value = false;
if (res.errorCode !== 0) {
ElMessage.error(res.message);
return;
}
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery(queryParams);
});
})
.catch(() => {});
};
const openDialog = () => {
dialogVisible.value = true;
};
const closeDialog = () => {
dialogVisible.value = false;
};
const queryParams = ref({
documentId: props.documentId,
sortKey: 'sorting',
sortType: 'asc',
});
const save = () => {
btnLoading.value = true;
api.post('/api/v1/documentChunk/update', form.value).then((res: any) => {
btnLoading.value = false;
if (res.errorCode !== 0) {
ElMessage.error(res.message);
return;
}
ElMessage.success($t('message.updateOkMessage'));
pageDataRef.value.setQuery(queryParams);
closeDialog();
});
};
const btnLoading = ref(false);
const basicFormRef = ref();
const form = ref({
id: '',
content: '',
});
</script>
<template>
<div>
<PageData
page-url="/api/v1/documentChunk/page"
ref="pageDataRef"
:page-size="10"
:extra-query-params="queryParams"
>
<template #default="{ pageList }">
<ElTable :data="pageList" style="width: 100%" size="large">
<ElTableColumn
prop="content"
:label="$t('documentCollection.content')"
min-width="240"
/>
<ElTableColumn :label="$t('common.handle')" width="100" align="right">
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="handleEdit(row)">
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleDelete(row)">
<ElButton link type="danger" :icon="Delete">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
<ElDialog v-model="dialogVisible" :title="$t('button.edit')" width="50%">
<ElForm
ref="basicFormRef"
style="width: 100%; margin-top: 20px"
:model="form"
>
<ElFormItem>
<ElInput v-model="form.content" :rows="20" type="textarea" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { $t } from '@easyflow/locales';
import { ElTable, ElTableColumn, ElTag } from 'element-plus';
import { api } from '#/api/request';
const props = defineProps({
filesList: {
default: () => [],
type: Array<any>,
},
splitterParams: {
default: () => {},
type: Object,
},
});
const emit = defineEmits(['loadingFinish']);
const route = useRoute();
const knowledgeIdRef = ref<string>((route.query.id as string) || '');
const localFilesList = ref<any[]>([]);
watch(
() => props.filesList,
(newVal) => {
localFilesList.value = [...newVal];
},
{ immediate: true },
);
defineExpose({
handleSave() {
localFilesList.value.forEach((file, index) => {
localFilesList.value[index].progressUpload = 'loading';
saveDoc(file.filePath, 'saveText', file.fileName, index);
});
},
});
function saveDoc(
filePath: string,
operation: string,
fileOriginName: string,
index: number,
) {
api
.post('/api/v1/document/saveText', {
filePath,
operation,
knowledgeId: knowledgeIdRef.value,
fileOriginName,
...props.splitterParams,
})
.then((res) => {
if (res.errorCode === 0) {
localFilesList.value[index].progressUpload = 'success';
emit('loadingFinish');
}
/* if (index === localFilesList.value.length - 1) {
emit('loadingFinish');
}*/
});
}
</script>
<template>
<div class="import-doc-file-list">
<ElTable :data="localFilesList" size="large" style="width: 100%">
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.importDoc.fileName')"
width="250"
/>
<ElTableColumn
prop="progressUpload"
:label="$t('documentCollection.splitterDoc.uploadStatus')"
>
<template #default="{ row }">
<ElTag type="success" v-if="row.progressUpload === 'success'">
{{ $t('documentCollection.splitterDoc.completed') }}
</ElTag>
<ElTag type="primary" v-else>
{{ $t('documentCollection.splitterDoc.pendingUpload') }}
</ElTag>
</template>
</ElTableColumn>
</ElTable>
</div>
</template>
<style scoped>
.import-doc-file-list {
width: 100%;
}
</style>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
import { ElIcon, ElImage } from 'element-plus';
import { api } from '#/api/request';
import bookIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageSide from '#/components/page/PageSide.vue';
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
const route = useRoute();
const router = useRouter();
const knowledgeId = ref<string>((route.query.id as string) || '');
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
const knowledgeInfo = ref<any>({});
const getKnowledge = () => {
api
.get('/api/v1/documentCollection/detail', {
params: { id: knowledgeId.value },
})
.then((res) => {
if (res.errorCode === 0) {
knowledgeInfo.value = res.data;
}
});
};
onMounted(() => {
if (activeMenu.value) {
defaultSelectedMenu.value = activeMenu.value;
}
getKnowledge();
});
const back = () => {
router.push({ path: '/ai/documentCollection' });
};
const categoryData = [
{ key: 'documentList', name: $t('documentCollection.documentList') },
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
{ key: 'config', name: $t('documentCollection.config') },
];
const headerButtons = [
{
key: 'importFile',
text: $t('button.importFile'),
icon: Plus,
type: 'primary',
data: { action: 'importFile' },
},
];
const isImportFileVisible = ref(false);
const selectedCategory = ref('documentList');
const documentTableRef = ref();
const handleSearch = (searchParams: string) => {
documentTableRef.value.search(searchParams);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
switch (event.key) {
case 'back': {
router.push({ path: '/ai/knowledge' });
break;
}
case 'importFile': {
isImportFileVisible.value = true;
break;
}
}
};
const handleCategoryClick = (category: any) => {
selectedCategory.value = category.key;
viewDocVisible.value = false;
};
const viewDocVisible = ref(false);
const documentId = ref('');
// 子组件传递事件,显示查看文档详情
const viewDoc = (docId: string) => {
viewDocVisible.value = true;
documentId.value = docId;
};
const backDoc = () => {
isImportFileVisible.value = false;
};
const defaultSelectedMenu = ref('documentList');
</script>
<template>
<div class="document-container">
<div v-if="!isImportFileVisible" class="doc-header-container">
<div class="doc-knowledge-container">
<div @click="back()" style="cursor: pointer">
<ElIcon><ArrowLeft /></ElIcon>
</div>
<div>
<ElImage :src="bookIcon" style="width: 36px; height: 36px" />
</div>
<div class="knowledge-info-container">
<div class="title">{{ knowledgeInfo.title || '' }}</div>
<div class="description">
{{ knowledgeInfo.description || '' }}
</div>
</div>
</div>
<div class="doc-content">
<div>
<PageSide
label-key="name"
value-key="key"
:menus="categoryData"
:default-selected="defaultSelectedMenu"
@change="handleCategoryClick"
/>
</div>
<div
class="doc-table-content menu-container border border-[var(--el-border-color)]"
>
<div v-if="selectedCategory === 'documentList'" class="doc-table">
<div class="doc-header" v-if="!viewDocVisible">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
</div>
<DocumentTable
ref="documentTableRef"
:knowledge-id="knowledgeId"
@view-doc="viewDoc"
v-if="!viewDocVisible"
/>
<ChunkDocumentTable
v-else
:document-id="documentId"
:default-summary-prompt="knowledgeInfo.summaryPrompt"
/>
</div>
<!--知识检索-->
<div
v-if="selectedCategory === 'knowledgeSearch'"
class="doc-search-container"
>
<KnowledgeSearchConfig :document-collection-id="knowledgeId" />
<KnowledgeSearch :knowledge-id="knowledgeId" />
</div>
<!--配置-->
<div v-if="selectedCategory === 'config'">
<DocumentCollectionDataConfig
:detail-data="knowledgeInfo"
@reload="getKnowledge"
/>
</div>
</div>
</div>
</div>
<div v-else class="doc-imp-container">
<ImportKnowledgeDocFile @import-back="backDoc" />
</div>
</div>
</template>
<style scoped>
.document-container {
width: 100%;
display: flex;
height: 100%;
padding: 24px 24px 30px 24px;
}
.doc-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.doc-table-content {
border-radius: 8px;
width: 100%;
box-sizing: border-box;
padding: 20px 14px 0 14px;
background-color: var(--el-bg-color);
flex: 1;
}
.doc-header {
width: 100%;
margin: 0 auto;
padding-bottom: 21px;
}
.doc-content {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
gap: 12px;
}
.doc-table {
background-color: var(--el-bg-color);
}
.doc-imp-container {
flex: 1;
width: 100%;
box-sizing: border-box;
}
.doc-header-container {
display: flex;
flex-direction: column;
width: 100%;
}
.doc-knowledge-container {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
gap: 8px;
}
.knowledge-info-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-weight: 500;
font-size: 16px;
line-height: 24px;
text-align: left;
font-style: normal;
text-transform: none;
}
.description {
font-weight: 400;
font-size: 14px;
color: #75808d;
line-height: 22px;
text-align: left;
font-style: normal;
text-transform: none;
}
.doc-search-container {
width: 100%;
height: 100%;
display: flex;
}
.menu-container {
flex: 1;
}
</style>

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { ActionButton } from '#/components/page/CardList.vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Notebook, Plus, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import defaultIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
const router = useRouter();
interface FieldDefinition {
// 字段名称
prop: string;
// 字段标签
label: string;
// 字段类型input, number, select, radio, checkbox, switch, date, datetime
type?: 'input' | 'number';
// 是否必填
required?: boolean;
// 占位符
placeholder?: string;
}
// 操作按钮配置
const actions: ActionButton[] = [
{
icon: Edit,
text: $t('button.edit'),
className: '',
permission: '/api/v1/documentCollection/save',
onClick(row) {
aiKnowledgeModalRef.value.openDialog(row);
},
},
{
icon: Notebook,
text: $t('documentCollection.actions.knowledge'),
className: '',
permission: '/api/v1/documentCollection/save',
onClick(row) {
router.push({
path: '/ai/documentCollection/document',
query: {
id: row.id,
pageKey: '/ai/documentCollection',
},
});
},
},
{
icon: Search,
text: $t('documentCollection.actions.retrieve'),
className: '',
permission: '',
onClick(row) {
router.push({
path: '/ai/documentCollection/document',
query: {
id: row.id,
pageKey: '/ai/documentCollection',
activeMenu: 'knowledgeSearch',
},
});
},
},
{
text: $t('button.delete'),
icon: Delete,
className: 'item-danger',
permission: '/api/v1/documentCollection/remove',
onClick(row) {
handleDelete(row);
},
},
];
onMounted(() => {
getCategoryList();
});
const handleDelete = (item: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
})
.then(() => {
api
.post('/api/v1/documentCollection/remove', { id: item.id })
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({});
}
});
})
.catch(() => {});
};
const pageDataRef = ref();
const aiKnowledgeModalRef = ref();
const headerButtons = [
{
key: 'add',
text: $t('documentCollection.actions.addKnowledge'),
icon: Plus,
type: 'primary',
data: { action: 'add' },
permission: '/api/v1/documentCollection/save',
},
];
const handleButtonClick = (event: any, _item: any) => {
switch (event.key) {
case 'add': {
aiKnowledgeModalRef.value.openDialog({});
break;
}
}
};
const fieldDefinitions = ref<FieldDefinition[]>([
{
prop: 'categoryName',
label: $t('aiWorkflowCategory.categoryName'),
type: 'input',
required: true,
placeholder: $t('aiWorkflowCategory.categoryName'),
},
{
prop: 'sortNo',
label: $t('aiWorkflowCategory.sortNo'),
type: 'number',
required: false,
placeholder: $t('aiWorkflowCategory.sortNo'),
},
]);
const formRules = computed(() => {
const rules: Record<string, any[]> = {};
fieldDefinitions.value.forEach((field) => {
const fieldRules = [];
if (field.required) {
fieldRules.push({
required: true,
message: `${$t('message.required')}`,
trigger: 'blur',
});
}
if (fieldRules.length > 0) {
rules[field.prop] = fieldRules;
}
});
return rules;
});
const handleSearch = (params: any) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
const formData = ref<any>({});
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
function showControlDialog(item: any) {
formRef.value?.resetFields();
formData.value = { ...item };
dialogVisible.value = true;
}
const categoryList = ref<any[]>([]);
const getCategoryList = async () => {
const [, res] = await tryit(api.get)(
'/api/v1/documentCollectionCategory/list',
{
params: { sortKey: 'sortNo', sortType: 'asc' },
},
);
if (res && res.errorCode === 0) {
categoryList.value = [
{
id: '',
categoryName: $t('common.allCategories'),
},
...res.data,
];
}
};
function removeCategory(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/documentCollectionCategory/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
done();
getCategoryList();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row: any) {
showControlDialog(row);
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row: any) {
removeCategory(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
showControlDialog({});
},
};
const saveLoading = ref(false);
function handleSubmit() {
formRef.value?.validate((valid) => {
if (valid) {
saveLoading.value = true;
const url = formData.value.id
? '/api/v1/documentCollectionCategory/update'
: '/api/v1/documentCollectionCategory/save';
api.post(url, formData.value).then((res) => {
saveLoading.value = false;
if (res.errorCode === 0) {
getCategoryList();
ElMessage.success(res.message);
dialogVisible.value = false;
}
});
}
});
}
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
}
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<div class="knowledge-header">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
</div>
<div class="flex max-h-[calc(100vh-191px)] flex-1 gap-6">
<PageSide
label-key="categoryName"
value-key="id"
:menus="categoryList"
:control-btns="controlBtns"
:footer-button="footerButton"
@change="changeCategory"
/>
<div class="h-full flex-1 overflow-auto">
<PageData
ref="pageDataRef"
page-url="/api/v1/documentCollection/page"
:page-size="12"
:page-sizes="[12, 24, 36, 48]"
:init-query-params="{ status: 1 }"
>
<template #default="{ pageList }">
<CardPage
:default-icon="defaultIcon"
title-key="title"
avatar-key="icon"
description-key="description"
:data="pageList"
:actions="actions"
/>
</template>
</PageData>
</div>
</div>
<ElDialog
v-model="dialogVisible"
:title="formData.id ? `${$t('button.edit')}` : `${$t('button.add')}`"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<!-- 动态生成表单项 -->
<ElFormItem
v-for="field in fieldDefinitions"
:key="field.prop"
:label="field.label"
:prop="field.prop"
>
<ElInput
v-if="!field.type || field.type === 'input'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="saveLoading">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
<!-- 新增知识库模态框-->
<DocumentCollectionModal ref="aiKnowledgeModalRef" @reload="handleSearch" />
</div>
</template>
<style scoped>
h1 {
text-align: center;
margin-bottom: 30px;
color: #303133;
}
</style>

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref, watch } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const props = defineProps({
detailData: {
type: Object,
default: () => ({
id: '',
alias: '',
deptId: '',
icon: '',
title: '',
description: '',
slug: '',
vectorStoreEnable: false,
vectorStoreType: '',
vectorStoreCollection: '',
vectorStoreConfig: '',
vectorEmbedModelId: '',
options: {
canUpdateEmbeddingModel: true,
},
rerankModelId: '',
searchEngineEnable: false,
englishName: '',
}),
required: true,
},
});
const emit = defineEmits(['reload']);
const entity = ref<any>({ ...props.detailData });
watch(
() => props.detailData,
(newVal) => {
entity.value = { ...newVal };
},
{ immediate: true, deep: true },
);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
const vecotrDatabaseList = ref<any>([
// { value: 'milvus', label: 'Milvus' },
{ value: 'redis', label: 'Redis' },
{ value: 'opensearch', label: 'OpenSearch' },
{ value: 'elasticsearch', label: 'ElasticSearch' },
{ value: 'aliyun', label: $t('documentCollection.alibabaCloud') },
{ value: 'qcloud', label: $t('documentCollection.tencentCloud') },
]);
const getEmbeddingLlmListData = async () => {
try {
const url = `/api/v1/model/list?modelType=embeddingModel`;
const res = await api.get(url, {});
if (res.errorCode === 0) {
embeddingLlmList.value = res.data;
}
} catch (error) {
ElMessage.error($t('message.apiError'));
console.error('获取嵌入模型列表失败:', error);
}
};
const getRerankerLlmListData = async () => {
try {
const res = await api.get('/api/v1/model/list?modelType=rerankModel');
rerankerLlmList.value = res.data;
} catch (error) {
ElMessage.error($t('message.apiError'));
console.error('获取重排模型列表失败:', error);
}
};
onMounted(async () => {
await getEmbeddingLlmListData();
await getRerankerLlmListData();
});
const saveForm = ref<FormInstance>();
const btnLoading = ref(false);
const rules = ref({
deptId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
englishName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
vectorStoreType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorStoreCollection: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorStoreConfig: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorEmbedModelId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
async function save() {
try {
const valid = await saveForm.value?.validate();
if (!valid) return;
btnLoading.value = true;
const res = await api.post(
'/api/v1/documentCollection/update',
entity.value,
);
if (res.errorCode === 0) {
ElMessage.success($t('message.saveOkMessage'));
emit('reload');
}
} catch (error) {
ElMessage.error($t('message.saveFail'));
console.error('保存失败:', error);
} finally {
btnLoading.value = false;
}
}
</script>
<template>
<div class="document-config-container">
<ElForm
label-width="150px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem
prop="icon"
:label="$t('documentCollection.icon')"
style="display: flex; align-items: center"
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="title" :label="$t('documentCollection.title')">
<ElInput
v-model.trim="entity.title"
:placeholder="$t('documentCollection.placeholder.title')"
/>
</ElFormItem>
<ElFormItem prop="alias" :label="$t('documentCollection.alias')">
<ElInput v-model.trim="entity.alias" />
</ElFormItem>
<ElFormItem
prop="englishName"
:label="$t('documentCollection.englishName')"
>
<ElInput v-model.trim="entity.englishName" />
</ElFormItem>
<ElFormItem
prop="description"
:label="$t('documentCollection.description')"
>
<ElInput
v-model.trim="entity.description"
:rows="4"
type="textarea"
:placeholder="$t('documentCollection.placeholder.description')"
/>
</ElFormItem>
<!-- <ElFormItem
prop="vectorStoreEnable"
:label="$t('documentCollection.vectorStoreEnable')"
>
<ElSwitch v-model="entity.vectorStoreEnable" />
</ElFormItem>-->
<ElFormItem
prop="vectorStoreType"
:label="$t('documentCollection.vectorStoreType')"
>
<ElSelect
v-model="entity.vectorStoreType"
:placeholder="$t('documentCollection.placeholder.vectorStoreType')"
>
<ElOption
v-for="item in vecotrDatabaseList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="vectorStoreCollection"
:label="$t('documentCollection.vectorStoreCollection')"
>
<ElInput
v-model.trim="entity.vectorStoreCollection"
:placeholder="
$t('documentCollection.placeholder.vectorStoreCollection')
"
/>
</ElFormItem>
<ElFormItem
prop="vectorStoreConfig"
:label="$t('documentCollection.vectorStoreConfig')"
>
<ElInput
v-model.trim="entity.vectorStoreConfig"
:rows="4"
type="textarea"
/>
</ElFormItem>
<ElFormItem prop="vectorEmbedModelId">
<template #label>
<span style="display: flex; align-items: center">
{{ $t('documentCollection.vectorEmbedLlmId') }}
<ElTooltip
:content="$t('documentCollection.vectorEmbedModelTips')"
placement="top"
effect="light"
>
<ElIcon
style="
margin-left: 4px;
color: #909399;
cursor: pointer;
font-size: 14px;
"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElSelect
v-model="entity.vectorEmbedModelId"
:disabled="!entity?.options?.canUpdateEmbeddingModel"
:placeholder="$t('documentCollection.placeholder.embedLlm')"
>
<ElOption
v-for="item in embeddingLlmList"
:key="item.id"
:label="item.title"
:value="item.id || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="dimensionOfVectorModel"
:label="$t('documentCollection.dimensionOfVectorModel')"
>
<template #label>
<span style="display: flex; align-items: center">
{{ $t('documentCollection.dimensionOfVectorModel') }}
<ElTooltip
:content="$t('documentCollection.dimensionOfVectorModelTips')"
placement="top"
effect="light"
>
<ElIcon
style="
margin-left: 4px;
color: #909399;
cursor: pointer;
font-size: 14px;
"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInput
:disabled="!entity?.options?.canUpdateEmbeddingModel"
v-model.trim="entity.dimensionOfVectorModel"
type="number"
/>
</ElFormItem>
<ElFormItem
prop="rerankModelId"
:label="$t('documentCollection.rerankLlmId')"
>
<ElSelect
v-model="entity.rerankModelId"
:placeholder="$t('documentCollection.placeholder.rerankLlm')"
>
<ElOption
v-for="item in rerankerLlmList"
:key="item.id"
:label="item.title"
:value="item.id || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="searchEngineEnable"
:label="$t('documentCollection.searchEngineEnable')"
>
<ElSwitch v-model="entity.searchEngineEnable" />
</ElFormItem>
<ElFormItem style="margin-top: 20px; text-align: right">
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped>
.document-config-container {
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,381 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
const getEmbeddingLlmListData = async () => {
try {
const url = `/api/v1/model/list?modelType=embeddingModel`;
const res = await api.get(url, {});
if (res.errorCode === 0) {
embeddingLlmList.value = res.data;
}
} catch (error) {
ElMessage.error($t('message.apiError'));
console.error('获取嵌入模型列表失败:', error);
}
};
const getRerankerLlmListData = async () => {
try {
const res = await api.get('/api/v1/model/list?modelType=rerankModel');
rerankerLlmList.value = res.data;
} catch (error) {
ElMessage.error($t('message.apiError'));
console.error('获取重排模型列表失败:', error);
}
};
onMounted(async () => {
await getEmbeddingLlmListData();
await getRerankerLlmListData();
});
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const vecotrDatabaseList = ref<any>([
{ value: 'milvus', label: 'Milvus' },
{ value: 'redis', label: 'Redis' },
{ value: 'opensearch', label: 'OpenSearch' },
{ value: 'elasticsearch', label: 'ElasticSearch' },
{ value: 'aliyun', label: $t('documentCollection.alibabaCloud') },
{ value: 'qcloud', label: $t('documentCollection.tencentCloud') },
]);
const defaultEntity = {
alias: '',
deptId: '',
icon: '',
title: '',
categoryId: '',
description: '',
slug: '',
vectorStoreEnable: false,
vectorStoreType: '',
vectorStoreCollection: '',
vectorStoreConfig: '',
vectorEmbedModelId: '',
dimensionOfVectorModel: undefined,
options: {
canUpdateEmbeddingModel: true,
},
rerankModelId: '',
searchEngineEnable: '',
englishName: '',
};
const entity = ref<any>({ ...defaultEntity });
const btnLoading = ref(false);
const rules = ref({
deptId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
englishName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
vectorStoreType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorStoreCollection: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorStoreConfig: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
vectorEmbedModelId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
function openDialog(row: any = {}) {
if (row.id) {
isAdd.value = false;
entity.value = {
...defaultEntity,
...row,
options: { ...defaultEntity.options, ...row.options },
};
} else {
isAdd.value = true;
entity.value = { ...defaultEntity };
}
dialogVisible.value = true;
}
async function save() {
try {
const valid = await saveForm.value?.validate();
if (!valid) return;
btnLoading.value = true;
const res = await api.post(
isAdd.value
? '/api/v1/documentCollection/save'
: '/api/v1/documentCollection/update',
entity.value,
);
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveSuccess'));
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('message.saveFail'));
}
} catch (error) {
ElMessage.error($t('message.saveFail'));
console.error('保存失败:', error);
} finally {
btnLoading.value = false;
}
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = { ...defaultEntity };
dialogVisible.value = false;
}
defineExpose({
openDialog,
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
>
<ElForm
label-width="150px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem
prop="icon"
:label="$t('documentCollection.icon')"
style="display: flex; align-items: center"
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="title" :label="$t('documentCollection.title')">
<ElInput
v-model.trim="entity.title"
:placeholder="$t('documentCollection.placeholder.title')"
/>
</ElFormItem>
<ElFormItem
prop="categoryId"
:label="$t('documentCollection.categoryId')"
>
<DictSelect
v-model="entity.categoryId"
dict-code="aiDocumentCollectionCategory"
/>
</ElFormItem>
<ElFormItem prop="alias" :label="$t('documentCollection.alias')">
<ElInput v-model.trim="entity.alias" />
</ElFormItem>
<ElFormItem
prop="englishName"
:label="$t('documentCollection.englishName')"
>
<ElInput v-model.trim="entity.englishName" />
</ElFormItem>
<ElFormItem
prop="description"
:label="$t('documentCollection.description')"
>
<ElInput
v-model.trim="entity.description"
:rows="4"
type="textarea"
:placeholder="$t('documentCollection.placeholder.description')"
/>
</ElFormItem>
<!-- <ElFormItem
prop="vectorStoreEnable"
:label="$t('documentCollection.vectorStoreEnable')"
>
<ElSwitch v-model="entity.vectorStoreEnable" />
</ElFormItem>-->
<ElFormItem
prop="vectorStoreType"
:label="$t('documentCollection.vectorStoreType')"
>
<ElSelect
v-model="entity.vectorStoreType"
:placeholder="$t('documentCollection.placeholder.vectorStoreType')"
>
<ElOption
v-for="item in vecotrDatabaseList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="vectorStoreCollection"
:label="$t('documentCollection.vectorStoreCollection')"
>
<ElInput
v-model.trim="entity.vectorStoreCollection"
:placeholder="
$t('documentCollection.placeholder.vectorStoreCollection')
"
/>
</ElFormItem>
<ElFormItem
prop="vectorStoreConfig"
:label="$t('documentCollection.vectorStoreConfig')"
>
<ElInput
v-model.trim="entity.vectorStoreConfig"
:rows="4"
type="textarea"
/>
</ElFormItem>
<ElFormItem prop="vectorEmbedModelId">
<template #label>
<span style="display: flex; align-items: center">
{{ $t('documentCollection.vectorEmbedLlmId') }}
<ElTooltip
:content="$t('documentCollection.vectorEmbedModelTips')"
placement="top"
effect="light"
>
<ElIcon
style="
margin-left: 4px;
color: #909399;
cursor: pointer;
font-size: 14px;
"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElSelect
v-model="entity.vectorEmbedModelId"
:disabled="!entity?.options?.canUpdateEmbeddingModel"
:placeholder="$t('documentCollection.placeholder.embedLlm')"
>
<ElOption
v-for="item in embeddingLlmList"
:key="item.id"
:label="item.title"
:value="item.id || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="dimensionOfVectorModel"
:label="$t('documentCollection.dimensionOfVectorModel')"
>
<template #label>
<span style="display: flex; align-items: center">
{{ $t('documentCollection.dimensionOfVectorModel') }}
<ElTooltip
:content="$t('documentCollection.dimensionOfVectorModelTips')"
placement="top"
effect="light"
>
<ElIcon
style="
margin-left: 4px;
color: #909399;
cursor: pointer;
font-size: 14px;
"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInput
:disabled="!entity?.options?.canUpdateEmbeddingModel"
v-model.trim="entity.dimensionOfVectorModel"
type="number"
/>
</ElFormItem>
<ElFormItem
prop="rerankModelId"
:label="$t('documentCollection.rerankLlmId')"
>
<ElSelect
v-model="entity.rerankModelId"
:placeholder="$t('documentCollection.placeholder.rerankLlm')"
>
<ElOption
v-for="item in rerankerLlmList"
:key="item.id"
:label="item.title"
:value="item.id || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="searchEngineEnable"
:label="$t('documentCollection.searchEngineEnable')"
>
<ElSwitch v-model="entity.searchEngineEnable" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Delete, Download, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElImage,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import documentIcon from '#/assets/ai/knowledge/document.svg';
import PageData from '#/components/page/PageData.vue';
const props = defineProps({
knowledgeId: {
required: true,
type: String,
},
});
const emits = defineEmits(['viewDoc']);
defineExpose({
search(searchText: string) {
pageDataRef.value.setQuery({
title: searchText,
});
},
});
const pageDataRef = ref();
const handleView = (row: any) => {
emits('viewDoc', row.id);
};
const handleDownload = (row: any) => {
window.open(row.documentPath, '_blank');
};
const handleDelete = (row: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
}).then(() => {
api.post('/api/v1/document/removeDoc', { id: row.id }).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({ id: props.knowledgeId });
}
});
// 删除逻辑
});
};
</script>
<template>
<PageData
page-url="/api/v1/document/documentList"
ref="pageDataRef"
:page-size="10"
:extra-query-params="{
id: props.knowledgeId,
sort: 'desc',
sortKey: 'created',
}"
>
<template #default="{ pageList }">
<ElTable :data="pageList" style="width: 100%" size="large">
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.fileName')"
>
<template #default="{ row }">
<span class="file-name-container">
<ElImage :src="documentIcon" class="mr-1" />
<span class="title">
{{ row.title }}
</span>
</span>
</template>
</ElTableColumn>
<ElTableColumn
prop="documentType"
:label="$t('documentCollection.documentType')"
width="180"
/>
<ElTableColumn
prop="chunkCount"
:label="$t('documentCollection.knowledgeCount')"
width="180"
/>
<ElTableColumn
:label="$t('documentCollection.createdModifyTime')"
width="200"
>
<template #default="{ row }">
<div class="time-container">
<span>{{ row.created }}</span>
<span>{{ row.modified }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.handle')" width="120" align="right">
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="handleView(row)">
{{ $t('button.viewSegmentation') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleDownload(row)">
<ElButton link :icon="Download">
{{ $t('button.download') }}
</ElButton>
</ElDropdownItem>
<ElDropdownItem @click="handleDelete(row)">
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</template>
<style scoped>
.time-container {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.file-name-container {
display: flex;
align-items: center;
}
.title {
font-weight: 500;
font-size: 14px;
color: #1a1a1a;
line-height: 20px;
text-align: left;
font-style: normal;
text-transform: none;
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Back } from '@element-plus/icons-vue';
import {
ElButton,
ElMessage,
ElPagination,
ElStep,
ElSteps,
} from 'element-plus';
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
const emits = defineEmits(['importBack']);
const back = () => {
emits('importBack');
};
const files = ref([]);
const splitterParams = ref({});
const activeStep = ref(0);
const fileUploadRef = ref();
const confirmImportRef = ref();
const segmenterDocRef = ref();
const pagination = ref({
pageSize: 10,
currentPage: 1,
total: 0,
});
const goToNextStep = () => {
if (activeStep.value === 0) {
if (fileUploadRef.value.getFilesData().length === 0) {
ElMessage.error($t('message.uploadFileFirst'));
return;
}
files.value = fileUploadRef.value.getFilesData();
}
if (activeStep.value === 1 && segmenterDocRef.value) {
splitterParams.value = segmenterDocRef.value.getSplitterFormValues();
}
activeStep.value += 1;
};
const goToPreviousStep = () => {
activeStep.value -= 1;
};
const handleSizeChange = (val: number) => {
pagination.value.pageSize = val;
};
const handleCurrentChange = (val: number) => {
pagination.value.currentPage = val;
};
const handleTotalUpdate = (newTotal: number) => {
pagination.value.total = newTotal; // 同步到父组件的 pagination.total
};
const loadingSave = ref(false);
const confirmImport = () => {
loadingSave.value = true;
// 确认导入
confirmImportRef.value.handleSave();
};
const finishImport = () => {
loadingSave.value = false;
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
emits('importBack');
};
</script>
<template>
<div class="imp-doc-kno-container">
<div class="imp-doc-header">
<ElButton @click="back" :icon="Back">
{{ $t('button.back') }}
</ElButton>
</div>
<div class="imp-doc-kno-content">
<div class="rounded-lg bg-[var(--table-header-bg-color)] py-5">
<ElSteps :active="activeStep" align-center>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">1</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.fileUpload')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">2</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.parameterSettings')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">3</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.segmentedPreview')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">4</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.confirmImport')
}}</span>
</div>
</template>
</ElStep>
</ElSteps>
</div>
<div style="margin-top: 20px">
<!-- 文件上传导入-->
<div class="knw-file-upload" v-if="activeStep === 0">
<ImportKnowledgeFileContainer ref="fileUploadRef" />
</div>
<!-- 分割参数设置-->
<div class="knw-file-splitter" v-if="activeStep === 1">
<SegmenterDoc ref="segmenterDocRef" />
</div>
<!-- 分割预览-->
<div class="knw-file-preview" v-if="activeStep === 2">
<SplitterDocPreview
:flies-list="files"
:splitter-params="splitterParams"
:page-number="pagination.currentPage"
:page-size="pagination.pageSize"
@update-total="handleTotalUpdate"
/>
</div>
<!-- 确认导入-->
<div class="knw-file-confirm" v-if="activeStep === 3">
<ComfirmImportDocument
:splitter-params="splitterParams"
:files-list="files"
ref="confirmImportRef"
@loading-finish="finishImport"
/>
</div>
</div>
</div>
<div style="height: 40px"></div>
<div class="imp-doc-footer">
<div v-if="activeStep === 2" class="imp-doc-page-container">
<ElPagination
:page-sizes="[10, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<ElButton @click="goToPreviousStep" type="primary" v-if="activeStep >= 1">
{{ $t('button.previousStep') }}
</ElButton>
<ElButton @click="goToNextStep" type="primary" v-if="activeStep < 3">
{{ $t('button.nextStep') }}
</ElButton>
<ElButton
@click="confirmImport"
type="primary"
v-if="activeStep === 3"
:loading="loadingSave"
:disabled="loadingSave"
>
{{ $t('button.startImport') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.imp-doc-kno-container {
position: relative;
height: 100%;
background-color: var(--el-bg-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
}
.imp-doc-kno-content {
flex: 1;
padding-top: 20px;
overflow: auto;
}
.imp-doc-footer {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
height: 40px;
background-color: var(--el-bg-color);
align-items: center;
justify-content: flex-end;
}
.knw-file-preview {
flex: 1;
overflow: auto;
}
.imp-doc-page-container {
margin-right: 12px;
}
.knw-file-confirm {
width: 100%;
}
:deep(.el-step__head) {
--step-item-bg: rgba(0, 0, 0, 0.06);
--step-item-solid-bg: rgba(0, 0, 0, 0.15);
--accent-foreground: rgba(0, 0, 0, 0.45);
}
:deep(.el-step__head:where(.dark, .dark *)) {
--step-item-bg: var(--el-text-color-placeholder);
--step-item-solid-bg: var(--el-text-color-placeholder);
--accent-foreground: var(--primary-foreground);
}
:deep(.el-step__head.is-finish) {
--step-item-bg: hsl(var(--primary));
--step-item-solid-bg: hsl(var(--primary));
--accent-foreground: var(--primary-foreground);
}
:deep(.el-step__icon.is-icon) {
width: 120px;
background-color: var(--table-header-bg-color);
}
:deep(.el-step__line) {
background-color: var(--step-item-solid-bg);
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElProgress, ElTable, ElTableColumn } from 'element-plus';
import { formatFileSize } from '#/api/common/file';
import DragFileUpload from '#/components/upload/DragFileUpload.vue';
interface FileInfo {
uid: string;
fileName: string;
progressUpload: number;
fileSize: number;
status: string;
filePath: string;
}
const fileData = ref<FileInfo[]>([]);
const filesPath = ref([]);
defineExpose({
getFilesData() {
return fileData.value;
},
});
function handleSuccess(response: any) {
filesPath.value = response.data;
}
function handleChange(file: any) {
const existingFile = fileData.value.find((item) => item.uid === file.uid);
if (existingFile) {
fileData.value = fileData.value.map((item) => {
if (item.uid === file.uid) {
return {
...item,
fileSize: file.size,
progressUpload: file.percentage,
status: file.status,
filePath: file?.response?.data?.path,
};
}
return item;
});
} else {
fileData.value.push({
uid: file.uid,
fileName: file.name,
progressUpload: file.percentage,
fileSize: file.size,
status: file.status,
filePath: file?.response?.data?.path,
});
}
}
function handleRemove(row: any) {
fileData.value = fileData.value.filter((item) => item.uid !== row.uid);
}
</script>
<template>
<div>
<div>
<DragFileUpload @success="handleSuccess" @on-change="handleChange" />
</div>
<div>
<ElTable :data="fileData" style="width: 100%" size="large">
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.importDoc.fileName')"
width="250"
/>
<ElTableColumn
prop="progressUpload"
:label="$t('documentCollection.importDoc.progressUpload')"
width="180"
>
<template #default="{ row }">
<ElProgress
:percentage="row.progressUpload"
v-if="row.status === 'success'"
status="success"
/>
<ElProgress v-else :percentage="row.progressUpload" />
</template>
</ElTableColumn>
<ElTableColumn
prop="fileSize"
:label="$t('documentCollection.importDoc.fileSize')"
>
<template #default="{ row }">
<span>{{ formatFileSize(row.fileSize) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.handle')">
<template #default="{ row }">
<ElButton type="danger" size="small" @click="handleRemove(row)">
{{ $t('button.delete') }}
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
const props = defineProps({
knowledgeId: {
type: String,
required: true,
},
});
const searchDataList = ref([]);
const keyword = ref('');
const previewSearchKnowledgeRef = ref();
const handleSearch = () => {
if (!keyword.value) {
ElMessage.error($t('message.pleaseInputContent'));
return;
}
previewSearchKnowledgeRef.value.loadingContent(true);
api
.get(
`/api/v1/documentCollection/search?knowledgeId=${props.knowledgeId}&keyword=${keyword.value}`,
)
.then((res) => {
previewSearchKnowledgeRef.value.loadingContent(false);
searchDataList.value = res.data;
});
};
</script>
<template>
<div class="search-container">
<div class="search-input">
<ElInput
v-model="keyword"
:placeholder="$t('common.searchPlaceholder')"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('button.query') }}
</ElButton>
</div>
<div class="search-result">
<PreviewSearchKnowledge
:data="searchDataList"
ref="previewSearchKnowledgeRef"
/>
</div>
</div>
</template>
<style scoped>
.search-container {
width: 100%;
height: 100%;
padding: 0 0 20px 0;
display: flex;
flex-direction: column;
}
.search-input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.search-result {
padding-top: 20px;
flex: 1;
}
</style>

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { $t } from '@easyflow/locales';
import { InfoFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
ElFormItem,
ElInputNumber,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { api } from '#/api/request';
const props = defineProps({
documentCollectionId: {
type: String,
required: true,
},
});
onMounted(() => {
getDocumentCollectionConfig();
});
const searchEngineEnable = ref(false);
const getDocumentCollectionConfig = () => {
api
.get(`/api/v1/documentCollection/detail?id=${props.documentCollectionId}`)
.then((res) => {
const { data } = res;
searchConfig.docRecallMaxNum = data.options.docRecallMaxNum
? Number(data.options.docRecallMaxNum)
: 5;
searchConfig.simThreshold = data.options.simThreshold
? Number(data.options.simThreshold)
: 0.5;
searchConfig.searchEngineType = data.options.searchEngineType || 'lucene';
searchEngineEnable.value = !!data.searchEngineEnable;
});
};
const searchConfig = reactive({
docRecallMaxNum: 5,
simThreshold: 0.5,
searchEngineType: 'lucene',
});
const submitConfig = () => {
const submitData = {
id: props.documentCollectionId,
options: {
docRecallMaxNum: searchConfig.docRecallMaxNum,
simThreshold: searchConfig.simThreshold,
searchEngineType: searchConfig.searchEngineType,
},
searchEngineEnable: searchEngineEnable.value,
};
api
.post('/api/v1/documentCollection/update', submitData)
.then(() => {
ElMessage.success($t('documentCollectionSearch.message.saveSuccess'));
})
.catch((error) => {
ElMessage.error($t('documentCollectionSearch.message.saveFailed'));
console.error('保存配置失败:', error);
});
};
const searchEngineOptions = [
{
label: 'Lucene',
value: 'lucene',
},
{
label: 'ElasticSearch',
value: 'elasticSearch',
},
];
const handleSearchEngineEnableChange = () => {
api.post('/api/v1/documentCollection/update', {
id: props.documentCollectionId,
searchEngineEnable: searchEngineEnable.value,
});
};
</script>
<template>
<div class="search-config-sidebar">
<div class="config-header">
<h3>{{ $t('documentCollectionSearch.title') }}</h3>
</div>
<ElForm
class="config-form"
:model="searchConfig"
label-width="100%"
size="small"
>
<ElFormItem prop="docRecallMaxNum" class="form-item">
<div class="form-item-label">
<span>{{
$t('documentCollectionSearch.docRecallMaxNum.label')
}}</span>
<ElTooltip
:content="$t('documentCollectionSearch.docRecallMaxNum.tooltip')"
placement="top"
effect="dark"
class="label-tooltip"
>
<InfoFilled class="info-icon" />
</ElTooltip>
</div>
<div class="form-item-content">
<ElInputNumber
v-model="searchConfig.docRecallMaxNum"
:min="1"
:max="50"
:step="1"
:placeholder="$t('documentCollectionSearch.placeholder.count')"
class="form-control"
>
<template #append>
{{ $t('documentCollectionSearch.unit.count') }}
</template>
</ElInputNumber>
</div>
</ElFormItem>
<ElFormItem prop="simThreshold" class="form-item">
<div class="form-item-label">
<span>{{ $t('documentCollectionSearch.simThreshold.label') }}</span>
<ElTooltip
:content="$t('documentCollectionSearch.simThreshold.tooltip')"
placement="top"
effect="dark"
class="label-tooltip"
>
<InfoFilled class="info-icon" />
</ElTooltip>
</div>
<div class="form-item-content">
<ElInputNumber
v-model="searchConfig.simThreshold"
:min="0"
:max="1"
:step="0.01"
show-input
class="form-control"
/>
</div>
</ElFormItem>
<!-- 搜索引擎启用开关 -->
<ElFormItem class="form-item">
<div class="form-item-label">
<span>{{
$t('documentCollectionSearch.searchEngineEnable.label')
}}</span>
<ElTooltip
:content="$t('documentCollectionSearch.searchEngineEnable.tooltip')"
placement="top"
effect="dark"
class="label-tooltip"
>
<InfoFilled class="info-icon" />
</ElTooltip>
</div>
<div class="form-item-content">
<ElSwitch
v-model="searchEngineEnable"
@change="handleSearchEngineEnableChange"
:active-text="$t('documentCollectionSearch.switch.on')"
:inactive-text="$t('documentCollectionSearch.switch.off')"
class="form-control switch-control"
/>
</div>
</ElFormItem>
<!-- 通过 searchEngineEnable 控制显示/隐藏 -->
<ElFormItem
v-if="searchEngineEnable"
prop="searchEngineType"
class="form-item"
>
<div class="form-item-label">
<span>{{
$t('documentCollectionSearch.searchEngineType.label')
}}</span>
<ElTooltip
:content="$t('documentCollectionSearch.searchEngineType.tooltip')"
placement="top"
effect="dark"
class="label-tooltip"
>
<InfoFilled class="info-icon" />
</ElTooltip>
</div>
<div class="form-item-content">
<ElSelect
v-model="searchConfig.searchEngineType"
:placeholder="
$t('documentCollectionSearch.searchEngineType.placeholder')
"
class="form-control"
>
<ElOption
v-for="option in searchEngineOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
</ElFormItem>
</ElForm>
<div class="config-footer">
<ElButton type="primary" @click="submitConfig" class="submit-btn">
{{ $t('documentCollectionSearch.button.save') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.search-config-sidebar {
width: 60%;
height: 100%;
padding: 16px;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
}
.config-header {
margin-bottom: 16px;
border-bottom: 1px solid #e6e6e6;
padding-bottom: 8px;
}
.config-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.config-form {
margin-bottom: 24px;
}
.form-item {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-item-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #606266;
line-height: 1.4;
}
.label-tooltip {
display: inline-block;
cursor: help;
}
:deep(.form-item .el-form-item__content) {
width: 100%;
margin-left: 0 !important;
}
.form-item-content {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 4px;
}
.form-control {
flex: 1;
width: 100%;
min-width: 0;
}
.switch-control {
width: auto;
flex: none;
min-width: 80px;
}
.info-icon {
font-size: 14px;
color: #909399;
cursor: help;
width: 16px;
height: 16px;
flex-shrink: 0;
flex: none;
}
.info-icon:hover {
color: #409eff;
}
.submit-btn {
width: 100%;
padding: 8px 0;
}
.config-footer {
position: sticky;
bottom: 0;
padding-top: 8px;
}
:deep(.el-form-item__content) {
width: 100%;
box-sizing: border-box;
}
:deep(.el-slider) {
--el-slider-input-width: 60px;
}
:deep(.el-input-number),
:deep(.el-select) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Document } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
// 定义类型接口(与 React 版本一致)
interface PreviewItem {
sorting: string;
content: string;
score: string;
}
const props = defineProps({
hideScore: {
type: Boolean,
default: false,
},
data: {
type: Array as () => PreviewItem[],
default: () => [],
},
total: {
type: Number,
default: 0,
},
loading: {
type: Boolean,
default: false,
},
confirmImport: {
type: Boolean,
default: false,
},
disabledConfirm: {
type: Boolean,
default: false,
},
onCancel: {
type: Function,
default: () => {},
},
onConfirm: {
type: Function,
default: () => {},
},
isSearching: {
type: Boolean,
default: false,
},
});
const loadingStatus = ref(false);
defineExpose({
loadingContent: (state: boolean) => {
loadingStatus.value = state;
},
});
</script>
<template>
<div class="preview-container" v-loading="loadingStatus">
<!-- 头部区域标题 + 统计信息 -->
<div class="preview-header">
<h3>
<ElIcon class="header-icon" size="20">
<Document />
</ElIcon>
{{
isSearching
? $t('documentCollection.searchResults')
: $t('documentCollection.documentPreview')
}}
</h3>
<span class="preview-stats" v-if="props.data.length > 0">
{{ $t('documentCollection.total') }}
{{ total > 0 ? total : data.length }}
{{ $t('documentCollection.segments') }}
</span>
</div>
<!-- 内容区域列表预览 -->
<div class="preview-content">
<div class="preview-list">
<div
v-for="(item, index) in data"
:key="index"
class="el-list-item-container"
>
<div class="el-list-item">
<div class="segment-badge">
{{ item.sorting ?? index + 1 }}
</div>
<div class="el-list-item-meta">
<div v-if="!hideScore">
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
</div>
<div class="content-desc">{{ item.content }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮区域仅导入确认模式显示 -->
<div class="preview-actions" v-if="confirmImport">
<div class="action-buttons">
<ElButton
:style="{ minWidth: '100px', height: '36px' }"
click="onCancel"
>
{{ $t('documentCollection.actions.confirmImport') }}
</ElButton>
<ElButton
type="primary"
:style="{ minWidth: '100px', height: '36px' }"
:loading="disabledConfirm"
click="onConfirm"
>
{{ $t('documentCollection.actions.cancelImport') }}
</ElButton>
</div>
</div>
</div>
</template>
<style scoped>
.preview-container {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color);
h3 {
display: flex;
gap: 8px;
align-items: center;
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
.header-icon {
color: var(--el-color-primary);
}
}
.preview-stats {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.preview-content {
padding: 20px;
overflow-y: auto;
.preview-list {
.segment-badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-radius: 50%;
}
.similarity-score {
font-size: 14px;
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.content-desc {
width: 100%;
padding: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
background-color: var(--el-bg-color);
border-left: 3px solid #e2e8f0;
border-radius: 6px;
transition: all 0.2s;
&:hover {
border-color: #4361ee;
box-shadow: 0 4px 12px rgb(67 97 238 / 8%);
transform: translateY(-2px);
}
}
.el-list-item {
display: flex;
gap: 12px;
align-items: center;
padding: 18px;
border-radius: 8px;
}
.el-list-item-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
}
}
.preview-actions {
padding: 16px 20px;
background-color: var(--el-bg-color-page);
border-top: 1px solid var(--el-border-color);
.action-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
}
}
/* 适配 Element Plus 加载状态样式 */
.el-list--loading .el-list-loading {
padding: 40px 0;
}
.el-list-item {
width: 100%;
margin-top: 12px;
border: 1px solid var(--el-border-color-lighter);
&:hover {
border-color: var(--el-color-primary);
}
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSlider,
} from 'element-plus';
const formRef = ref();
const form = reactive({
fileType: 'doc',
splitterName: 'SimpleDocumentSplitter',
chunkSize: 512,
overlapSize: 128,
regex: '',
rowsPerChunk: 0,
mdSplitterLevel: 1,
});
const fileTypes = [
{
label: $t('documentCollection.splitterDoc.document'),
value: 'doc',
},
];
const splitterNames = [
{
label: $t('documentCollection.splitterDoc.simpleDocumentSplitter'),
value: 'SimpleDocumentSplitter',
},
{
label: $t('documentCollection.splitterDoc.simpleTokenizeSplitter'),
value: 'SimpleTokenizeSplitter',
},
{
label: $t('documentCollection.splitterDoc.regexDocumentSplitter'),
value: 'RegexDocumentSplitter',
},
{
label: $t('documentCollection.splitterDoc.markdownHeaderSplitter'),
value: 'MarkdownHeaderSplitter',
},
];
const mdSplitterLevel = [
{
label: '#',
value: 1,
},
{
label: '##',
value: 2,
},
{
label: '###',
value: 3,
},
{
label: '####',
value: 4,
},
{
label: '#####',
value: 5,
},
{
label: '######',
value: 6,
},
];
const rules = {
name: [
{ required: true, message: 'Please input Activity name', trigger: 'blur' },
],
region: [
{
required: true,
message: 'Please select Activity zone',
trigger: 'change',
},
],
};
defineExpose({
getSplitterFormValues() {
return form;
},
});
</script>
<template>
<div class="splitter-doc-container">
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="auto"
class="custom-form"
>
<ElFormItem
:label="$t('documentCollection.splitterDoc.fileType')"
prop="fileType"
>
<ElSelect v-model="form.fileType">
<ElOption
v-for="item in fileTypes"
:key="item.value"
v-bind="item"
:label="item.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.splitterName')"
prop="splitterName"
>
<ElSelect v-model="form.splitterName">
<ElOption
v-for="item in splitterNames"
:key="item.value"
v-bind="item"
:label="item.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.chunkSize')"
v-if="
form.splitterName === 'SimpleDocumentSplitter' ||
form.splitterName === 'SimpleTokenizeSplitter'
"
prop="chunkSize"
>
<ElSlider v-model="form.chunkSize" show-input :max="2048" />
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.overlapSize')"
v-if="
form.splitterName === 'SimpleDocumentSplitter' ||
form.splitterName === 'SimpleTokenizeSplitter'
"
prop="overlapSize"
>
<ElSlider v-model="form.overlapSize" show-input :max="2048" />
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.regex')"
prop="regex"
v-if="form.splitterName === 'RegexDocumentSplitter'"
>
<ElInput v-model="form.regex" />
</ElFormItem>
<ElFormItem
v-if="form.splitterName === 'MarkdownHeaderSplitter'"
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
prop="splitterName"
>
<ElSelect v-model="form.mdSplitterLevel">
<ElOption
v-for="item in mdSplitterLevel"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped>
.splitter-doc-container {
height: 100%;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
}
.custom-form {
width: 500px;
}
.custom-form :deep(.el-input),
.custom-form :deep(.ElSelect) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '#/api/request';
import CategoryPanel from '#/components/categoryPanel/CategoryPanel.vue';
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
export interface FileInfo {
filePath: string;
fileName: string;
}
const props = defineProps({
pageNumber: {
default: 1,
type: Number,
},
pageSize: {
default: 10,
type: Number,
},
knowledgeId: {
default: '',
type: String,
},
fliesList: {
default: () => [],
type: Array<FileInfo>,
},
splitterParams: {
default: () => {},
type: Object,
},
});
const emit = defineEmits(['updateTotal']);
const documentList = ref<any[]>([]);
const route = useRoute();
defineExpose({
getFilesData() {
return documentList.value.length;
},
});
const knowledgeIdRef = ref<string>((route.query.id as string) || '');
const selectedCategory = ref<any>();
watch(
() => props.pageNumber,
(newVal) => {
if (selectedCategory.value) {
splitterDocPreview(
newVal,
props.pageSize,
selectedCategory.value.value,
'textSplit',
selectedCategory.value.label,
);
} else {
splitterDocPreview(
newVal,
props.pageSize,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
}
},
);
watch(
() => props.pageSize,
(newVal) => {
if (selectedCategory.value) {
splitterDocPreview(
props.pageNumber,
newVal,
selectedCategory.value.value,
'textSplit',
selectedCategory.value.label,
);
} else {
splitterDocPreview(
props.pageNumber,
newVal,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
}
},
);
function splitterDocPreview(
pageNumber: number,
pageSize: number,
filePath: string,
operation: string,
fileOriginName: string,
) {
api
.post('/api/v1/document/textSplit', {
pageNumber,
pageSize,
filePath,
operation,
knowledgeId: knowledgeIdRef.value,
fileOriginName,
...props.splitterParams,
})
.then((res) => {
if (res.errorCode === 0) {
documentList.value = res.data.previewData;
emit('updateTotal', res.data.total);
}
});
}
onMounted(() => {
if (props.fliesList.length === 0) {
return;
}
splitterDocPreview(
props.pageNumber,
props.pageSize,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
});
const changeCategory = (category: any) => {
selectedCategory.value = category;
splitterDocPreview(
props.pageNumber,
props.pageSize,
category.value,
'textSplit',
category.label,
);
};
</script>
<template>
<div class="splitter-doc-container">
<div>
<CategoryPanel
:categories="fliesList"
title-key="fileName"
:need-hide-collapse="true"
:expand-width="200"
value-key="filePath"
:default-selected-category="fliesList[0]!.filePath"
@click="changeCategory"
/>
</div>
<div class="preview-container">
<PreviewSearchKnowledge :data="documentList" :hide-score="true" />
</div>
</div>
</template>
<style scoped>
.splitter-doc-container {
height: 100%;
display: flex;
}
.preview-container {
flex: 1;
overflow: scroll;
}
</style>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { markRaw, ref } from 'vue';
import { Delete, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElMessage,
ElMessageBox,
ElSwitch,
ElTable,
ElTableColumn,
ElTooltip,
} from 'element-plus';
import { api } from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import McpModal from './McpModal.vue';
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const saveDialog = ref();
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/mcp/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
const handleUpdate = (row: any, isRefresh: boolean) => {
if (isRefresh) {
refreshLoadingMap.value[row.id] = true;
} else {
loadingMap.value[row.id] = true;
}
api.post('/api/v1/mcp/update', { ...row }).then((res) => {
if (isRefresh) {
refreshLoadingMap.value[row.id] = false;
} else {
loadingMap.value[row.id] = false;
}
if (res.errorCode === 0) {
if (row.status) {
ElMessage.success($t('mcp.message.startupSuccessful'));
} else {
ElMessage.success($t('mcp.message.stopSuccessful'));
}
}
pageDataRef.value.setQuery({});
});
};
const headerButtons = [
{
key: 'create',
type: 'primary',
text: $t('button.add'),
icon: markRaw(Plus),
data: { action: 'create' },
},
];
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ packageName: params, isQueryOr: true });
};
const handleHeaderButtonClick = (button: any) => {
if (button.key === 'create') {
showDialog({});
}
};
const loadingMap = ref<Record<number | string, boolean>>({});
const refreshLoadingMap = ref<Record<number | string, boolean>>({});
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<McpModal ref="saveDialog" @reload="reset" />
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleHeaderButtonClick"
/>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<PageData ref="pageDataRef" page-url="/api/v1/mcp/page" :page-size="10">
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn prop="title" :label="$t('mcp.title')">
<template #default="{ row }">
<ElTooltip
:content="
row.clientOnline
? $t('mcp.labels.clientOnline')
: $t('mcp.labels.clientOffline')
"
placement="top"
>
<span
class="mr-2 inline-block h-2 w-2 rounded-full"
:class="row.clientOnline ? 'bg-green-500' : 'bg-red-500'"
></span>
</ElTooltip>
{{ row.title }}
</template>
</ElTableColumn>
<ElTableColumn prop="description" :label="$t('mcp.description')">
<template #default="{ row }">
{{ row.description }}
</template>
</ElTableColumn>
<ElTableColumn prop="created" :label="$t('mcp.created')">
<template #default="{ row }">
{{ row.created }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" :label="$t('mcp.status')">
<template #default="{ row }">
<ElSwitch
v-model="row.status"
@change="() => handleUpdate(row, false)"
:loading="loadingMap[row.id]"
:disabled="loadingMap[row.id]"
/>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('common.handle')"
width="150"
align="right"
>
<template #default="{ row }">
<div class="flex items-center gap-3">
<div v-access:code="'/api/v1/mcp/save'">
<ElButton
@click="handleUpdate({ ...row, status: true }, true)"
type="primary"
link
:icon="Refresh"
:loading="refreshLoadingMap[row.id]"
>
{{ $t('重启') }}
</ElButton>
</div>
<div v-access:code="'/api/v1/mcp/save'">
<ElButton type="primary" link @click="showDialog(row)">
{{ $t('button.edit') }}
</ElButton>
</div>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<div v-access:code="'/api/v1/mcp/remove'">
<ElDropdownItem @click="remove(row)">
<ElButton type="danger" :icon="Delete" link>
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElSwitch,
ElTable,
ElTableColumn,
ElTabPane,
ElTabs,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
interface PropValue {
type?: string;
description?: string;
}
interface McpTool {
name: string;
description: string;
status: boolean;
inputSchema?: {
properties: Record<string, PropValue>;
required?: string[];
};
}
interface McpEntity {
id?: string;
title: string;
description: string;
configJson: string;
deptId: string;
status: boolean;
tools: McpTool[];
}
const emit = defineEmits(['reload']);
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const defaultEntity: McpEntity = {
title: '',
description: '',
configJson: '',
deptId: '',
status: false,
tools: [],
};
const entity = ref<McpEntity>({ ...defaultEntity });
const rules = ref({
title: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
configJson: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
});
function openDialog(row: Partial<McpEntity> = {}) {
isAdd.value = !row.id;
entity.value = { ...defaultEntity, ...row };
if (!isAdd.value) {
getMcpTools(row);
}
dialogVisible.value = true;
}
function getMcpTools(row: Partial<McpEntity>) {
api.post('api/v1/mcp/getMcpTools', { id: row.id }).then((res) => {
if (res.errorCode === 0) {
entity.value.tools = res.data.tools;
}
});
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
api
.post(
isAdd.value ? 'api/v1/mcp/save' : 'api/v1/mcp/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
if (isAdd.value) {
ElMessage.success($t('message.saveOkMessage'));
} else {
ElMessage.success($t('message.updateOkMessage'));
}
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = { ...defaultEntity };
dialogVisible.value = false;
}
const jsonPlaceholder = ref(`{
"mcpServers": {
"12306-mcp": {
"command": "npx.cmd",
"args": [
"-y",
"12306-mcp"
]
}
}
}`);
const activeName = ref('config');
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
>
<ElTabs v-model="activeName" class="demo-tabs">
<ElTabPane :label="$t('mcp.modal.config')" name="config">
<ElForm
label-width="120px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem prop="title" :label="$t('mcp.title')">
<ElInput v-model.trim="entity.title" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('mcp.description')">
<ElInput v-model.trim="entity.description" />
</ElFormItem>
<ElFormItem prop="configJson" :label="$t('mcp.configJson')">
<ElInput
type="textarea"
:rows="15"
v-model.trim="entity.configJson"
:placeholder="$t('mcp.example') + jsonPlaceholder" />
</ElFormItem>
<ElFormItem prop="status" :label="$t('mcp.status')">
<ElSwitch v-model="entity.status" />
</ElFormItem>
</ElForm>
</ElTabPane>
<div v-if="!isAdd">
<ElTabPane :label="$t('mcp.modal.tool')" name="tool">
<ElTable
:data="entity.tools"
border
:preserve-expanded-content="true"
>
<ElTableColumn type="expand">
<template #default="scope">
<!-- 解构获取properties和required同时做空值保护 -->
<div
v-if="scope.row?.inputSchema?.properties"
class="params-list"
>
<div
v-for="([propKey, propValue], index) in Object.entries(
scope.row.inputSchema.properties,
)"
:key="index"
class="params-content-container"
>
<div class="params-left-title-container">
<div class="content-title">
{{ propKey }}
<span
v-if="
scope.row.inputSchema.required &&
scope.row.inputSchema.required.includes(propKey)
"
class="required-mark"
>
*
</span>
</div>
</div>
<div class="params-desc-container">
<div class="content-title">
{{ (propValue as PropValue).type || '未知类型' }}
</div>
<div class="content-desc">
{{ (propValue as PropValue).description || '无描述' }}
</div>
</div>
</div>
</div>
<div v-else class="params-name">暂无属性配置</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('mcp.modal.table.availableTools')">
<template #default="{ row }">
<div class="content-left">
<span class="content-title">{{ row.name }}</span>
<span class="content-desc">{{ row.description }}</span>
</div>
</template>
</ElTableColumn>
<!-- <ElTableColumn :label="$t('mcp.status')">
<template #default="{ row }">
<ElSwitch v-model="row.status" />
</template>
</ElTableColumn>-->
</ElTable>
</ElTabPane>
</div>
</ElTabs>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.content-left {
display: flex;
flex-direction: column;
}
.content-title {
font-weight: 500;
font-size: 12px;
color: rgba(0, 0, 0, 0.85);
line-height: 24px;
text-align: left;
font-style: normal;
text-transform: none;
}
.content-desc {
font-weight: 400;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 22px;
text-align: left;
font-style: normal;
text-transform: none;
}
.params-name {
flex: 1;
background-color: #fafafa;
display: flex;
align-items: center;
border: 1px solid #e6e9ee;
border-radius: 8px;
padding: 8px;
}
.params-content-container {
display: flex;
flex-direction: row;
gap: 8px;
flex: 1;
border-radius: 8px;
padding: 8px;
}
.params-desc-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
border-radius: 8px;
border: 1px solid #e6e9ee;
padding: 8px;
}
.params-list {
display: flex;
flex-direction: column;
}
.params-left-title-container {
display: flex;
flex-direction: row;
background-color: #fafafa;
gap: 8px;
flex: 1;
border: 1px solid #e6e9ee;
border-radius: 8px;
padding: 8px;
align-items: center;
}
.required-mark {
color: #f56c6c;
margin-left: 2px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,377 @@
<script setup lang="ts">
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
import { reactive, ref, watch } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import {
getDefaultModelAbility,
handleTagClick as handleTagClickUtil,
syncTagSelectedStatus as syncTagSelectedStatusUtil,
} from '#/views/ai/model/modelUtils/model-ability';
import {
generateFeaturesFromModelAbility,
resetModelAbility,
} from '#/views/ai/model/modelUtils/model-ability-utils';
interface FormData {
modelType: string;
title: string;
modelName: string;
groupName: string;
providerId: string;
provider: string;
apiKey: string;
endpoint: string;
requestPath: string;
supportThinking: boolean;
supportTool: boolean;
supportImage: boolean;
supportAudio: boolean;
supportFree: boolean;
supportVideo: boolean;
supportImageB64Only: boolean;
supportToolMessage: boolean;
options: {
chatPath: string;
embedPath: string;
llmEndpoint: string;
rerankPath: string;
};
}
const props = defineProps({
providerId: {
type: String,
default: '',
},
});
const emit = defineEmits(['reload']);
const selectedProviderId = ref<string>(props.providerId ?? '');
// 监听 providerId 的变化
watch(
() => props.providerId,
(newVal) => {
if (newVal) {
selectedProviderId.value = newVal;
}
},
{ immediate: true },
);
const formDataRef = ref();
const isAdd = ref(true);
const dialogVisible = ref(false);
// 表单数据
const formData = reactive<FormData>({
modelType: '',
title: '',
modelName: '',
groupName: '',
providerId: '',
provider: '',
apiKey: '',
endpoint: '',
requestPath: '',
supportThinking: false,
supportTool: false,
supportImage: false,
supportAudio: false,
supportFree: false,
supportVideo: false,
supportImageB64Only: false,
supportToolMessage: false,
options: {
llmEndpoint: '',
chatPath: '',
embedPath: '',
rerankPath: '',
},
});
// 使用抽取的函数获取模型能力配置
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
/**
* 同步标签选中状态与formData中的布尔字段
*/
const syncTagSelectedStatus = () => {
syncTagSelectedStatusUtil(modelAbility.value, formData);
};
/**
* 处理标签点击事件
*/
const handleTagClick = (item: ModelAbilityItem) => {
// handleTagClickUtil(modelAbility.value, item, formData);
handleTagClickUtil(item, formData);
};
// 打开新增弹窗
defineExpose({
openAddDialog(modelType: string) {
isAdd.value = true;
if (formDataRef.value) {
formDataRef.value.resetFields();
}
// 重置表单数据
Object.assign(formData, {
id: '',
modelType,
title: '',
modelName: '',
groupName: '',
provider: '',
endPoint: '',
providerId: '',
supportThinking: false,
supportTool: false,
supportAudio: false,
supportVideo: false,
supportImage: false,
supportImageB64Only: false,
supportFree: false,
supportToolMessage: true,
options: {
llmEndpoint: '',
chatPath: '',
embedPath: '',
rerankPath: '',
},
});
showMoreFields.value = false;
// 重置标签状态
resetModelAbility(modelAbility.value);
syncTagSelectedStatus();
dialogVisible.value = true;
},
openEditDialog(item: any) {
dialogVisible.value = true;
isAdd.value = false;
// 填充表单数据
Object.assign(formData, {
id: item.id,
modelType: item.modelType || '',
title: item.title || '',
modelName: item.modelName || '',
groupName: item.groupName || '',
provider: item.provider || '',
endpoint: item.endpoint || '',
requestPath: item.requestPath || '',
supportThinking: item.supportThinking || false,
supportAudio: item.supportAudio || false,
supportImage: item.supportImage || false,
supportImageB64Only: item.supportImageB64Only || false,
supportVideo: item.supportVideo || false,
supportTool: item.supportTool || false,
supportFree: item.supportFree || false,
supportToolMessage: item.supportToolMessage || false,
options: {
llmEndpoint: item.options?.llmEndpoint || '',
chatPath: item.options?.chatPath || '',
embedPath: item.options?.embedPath || '',
rerankPath: item.options?.rerankPath || '',
},
});
showMoreFields.value = false;
// 同步标签状态
syncTagSelectedStatus();
},
});
const closeDialog = () => {
dialogVisible.value = false;
};
const rules = {
title: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
modelName: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
groupName: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
provider: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
};
const btnLoading = ref(false);
const save = async () => {
btnLoading.value = true;
// 使用工具函数从模型能力生成features
const features = generateFeaturesFromModelAbility(modelAbility.value);
try {
await formDataRef.value.validate();
const submitData = { ...formData, ...features };
if (isAdd.value) {
submitData.providerId = selectedProviderId.value;
const res = await api.post('/api/v1/model/save', submitData);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} else {
const res = await api.post('/api/v1/model/update', submitData);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
}
} catch (error) {
console.error('Save model error:', error);
ElMessage.error($t('ui.actionMessage.operationFailed'));
} finally {
btnLoading.value = false;
}
};
const showMoreFields = ref(false);
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
width="482"
>
<ElForm
label-width="100px"
ref="formDataRef"
:model="formData"
status-icon
:rules="rules"
>
<ElFormItem prop="title" :label="$t('llm.title')">
<ElInput v-model.trim="formData.title" />
</ElFormItem>
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
<ElInput v-model.trim="formData.modelName" />
</ElFormItem>
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
<ElInput v-model.trim="formData.groupName" />
</ElFormItem>
<ElFormItem prop="ability" :label="$t('llm.ability')">
<div class="model-ability">
<ElTag
class="model-ability-tag"
v-for="item in modelAbility"
:key="item.value"
:type="item.selected ? item.activeType : item.defaultType"
@click="handleTagClick(item)"
:class="{ 'tag-selected': item.selected }"
>
{{ item.label }}
</ElTag>
</div>
</ElFormItem>
<ElFormItem label=" " v-if="!showMoreFields">
<ElButton @click="showMoreFields = !showMoreFields" type="primary">
{{ showMoreFields ? $t('button.hide') : $t('button.more') }}
</ElButton>
</ElFormItem>
<ElFormItem
prop="apiKey"
:label="$t('llmProvider.apiKey')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.apiKey" />
</ElFormItem>
<ElFormItem
prop="endpoint"
:label="$t('llmProvider.endpoint')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.endpoint" />
</ElFormItem>
<ElFormItem
prop="requestPath"
:label="$t('llm.requestPath')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.requestPath" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.model-ability {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.model-ability-tag {
cursor: pointer;
transition: all 0.2s;
}
.tag-selected {
font-weight: bold;
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
import providerList from '#/views/ai/model/modelUtils/providerList.json';
const emit = defineEmits(['reload']);
const formDataRef = ref();
defineExpose({
openAddDialog() {
formDataRef.value?.resetFields();
dialogVisible.value = true;
},
openEditDialog(item: any) {
dialogVisible.value = true;
isAdd.value = false;
Object.assign(formData, item);
},
});
const providerOptions =
ref<Array<{ label: string; options: any; value: string }>>(providerList);
const isAdd = ref(true);
const dialogVisible = ref(false);
const formData = reactive({
id: '',
icon: '',
providerName: '',
providerType: '',
apiKey: '',
endpoint: '',
chatPath: '',
embedPath: '',
rerankPath: '',
});
const closeDialog = () => {
dialogVisible.value = false;
};
const rules = {
providerName: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
providerType: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
};
const btnLoading = ref(false);
const save = async () => {
btnLoading.value = true;
try {
if (!isAdd.value) {
api.post('/api/v1/modelProvider/update', formData).then((res) => {
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
});
return;
}
await formDataRef.value.validate();
api.post('/api/v1/modelProvider/save', formData).then((res) => {
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
});
} finally {
btnLoading.value = false;
}
};
const handleChangeProvider = (val: string) => {
const tempProvider = providerList.find((item) => item.value === val);
if (!tempProvider) {
return;
}
formData.providerName = tempProvider.label;
formData.endpoint = providerOptions.value.find(
(item) => item.value === val,
)?.options.llmEndpoint;
formData.chatPath = providerOptions.value.find(
(item) => item.value === val,
)?.options.chatPath;
formData.embedPath = providerOptions.value.find(
(item) => item.value === val,
)?.options.embedPath;
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
width="482"
>
<ElForm
label-width="100px"
ref="formDataRef"
:model="formData"
status-icon
:rules="rules"
>
<ElFormItem
prop="icon"
style="display: flex; align-items: center"
:label="$t('llmProvider.icon')"
>
<UploadAvatar v-model="formData.icon" />
</ElFormItem>
<ElFormItem prop="providerName" :label="$t('llmProvider.providerName')">
<ElInput v-model.trim="formData.providerName" />
</ElFormItem>
<ElFormItem prop="provider" :label="$t('llmProvider.apiType')">
<ElSelect v-model="formData.providerType" @change="handleChangeProvider">
<ElOption
v-for="item in providerOptions"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
<ElInput v-model.trim="formData.apiKey" />
</ElFormItem>
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
<ElInput v-model.trim="formData.endpoint" />
</ElFormItem>
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
<ElInput v-model.trim="formData.chatPath" />
</ElFormItem>
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
<ElInput v-model.trim="formData.rerankPath" />
</ElFormItem>
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
<ElInput v-model.trim="formData.embedPath" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.headers-container-reduce {
align-items: center;
}
.addHeadersBtn {
width: 100%;
border-style: dashed;
border-color: var(--el-color-primary);
border-radius: 8px;
margin-top: 8px;
}
.head-con-content {
margin-bottom: 8px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,350 @@
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue';
import {
CirclePlus,
Loading,
Minus,
RefreshRight,
} from '@element-plus/icons-vue';
import {
ElCollapse,
ElCollapseItem,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessageBox,
ElTabPane,
ElTabs,
ElTooltip,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
const emit = defineEmits(['reload']);
const tabList = ref<any>([]);
const isLoading = ref(false);
const chatModelTabList = [
// {
// label: $t('llm.all'),
// name: 'all',
// },
{
label: $t('llmProvider.chatModel'),
name: 'chatModel',
},
// {
// label: $t('llm.modelAbility.free'),
// name: 'supportFree',
// },
];
const embeddingModelTabList = [
{
label: $t('llmProvider.embeddingModel'),
name: 'embeddingModel',
},
];
const rerankModelTabList = [
{
label: $t('llmProvider.rerankModel'),
name: 'rerankModel',
},
];
const formDataRef = ref();
const providerInfo = ref<any>();
const getProviderInfo = (id: string) => {
api.get(`/api/v1/modelProvider/detail?id=${id}`).then((res) => {
if (res.errorCode === 0) {
providerInfo.value = res.data;
}
});
};
const modelList = ref<any>([]);
const getLlmList = (providerId: string, modelType: string) => {
isLoading.value = true;
const url =
modelType === ''
? `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&supportFree=true`
: `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&selectText=${searchFormDada.searchText}`;
api.get(url).then((res) => {
if (res.errorCode === 0) {
const chatModelMap = res.data || {};
modelList.value = Object.entries(chatModelMap).map(
([groupName, llmList]) => ({
groupName,
llmList,
}),
);
}
isLoading.value = false;
});
};
const selectedProviderId = ref('');
defineExpose({
// providerId: 供应商id clickModelType 父组件点击的是什么类型的模型 可以是chatModel or embeddingModel
openDialog(providerId: string, clickModelType: string) {
switch (clickModelType) {
case 'chatModel': {
tabList.value = [...chatModelTabList];
break;
}
case 'embeddingModel': {
tabList.value = [...embeddingModelTabList];
break;
}
case 'rerankModel': {
tabList.value = [...rerankModelTabList];
break;
}
// No default
}
selectedProviderId.value = providerId;
formDataRef.value?.resetFields();
modelList.value = [];
activeName.value = tabList.value[0]?.name;
getProviderInfo(providerId);
getLlmList(providerId, clickModelType);
dialogVisible.value = true;
},
openEditDialog(item: any) {
dialogVisible.value = true;
isAdd.value = false;
formData.icon = item.icon;
formData.providerName = item.providerName;
formData.provider = item.provider;
},
});
const isAdd = ref(true);
const dialogVisible = ref(false);
const formData = reactive({
icon: '',
providerName: '',
provider: '',
apiKey: '',
endPoint: '',
chatPath: '',
embedPath: '',
});
const closeDialog = () => {
dialogVisible.value = false;
};
const handleTabClick = async () => {
await nextTick();
getLlmList(providerInfo.value.id, activeName.value);
};
const activeName = ref('all');
const handleGroupNameDelete = (groupName: string) => {
ElMessageBox.confirm(
$t('message.deleteModelGroupAlert'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
},
).then(() => {
api
.post(`/api/v1/model/removeByEntity`, {
groupName,
providerId: selectedProviderId.value,
})
.then((res) => {
if (res.errorCode === 0) {
getLlmList(providerInfo.value.id, activeName.value);
emit('reload');
}
});
});
};
const handleDeleteLlm = (id: any) => {
ElMessageBox.confirm(
$t('message.deleteModelAlert'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
},
).then(() => {
api.post(`/api/v1/model/removeLlmByIds`, { id }).then((res) => {
if (res.errorCode === 0) {
getLlmList(providerInfo.value.id, activeName.value);
emit('reload');
}
});
});
};
const handleAddLlm = (id: string) => {
api
.post(`/api/v1/model/update`, {
id,
withUsed: true,
})
.then((res) => {
if (res.errorCode === 0) {
getLlmList(providerInfo.value.id, activeName.value);
emit('reload');
}
});
};
const searchFormDada = reactive({
searchText: '',
});
const handleAddAllLlm = () => {
api
.post(`/api/v1/model/addAllLlm`, {
providerId: selectedProviderId.value,
withUsed: true,
})
.then((res) => {
if (res.errorCode === 0) {
getLlmList(providerInfo.value.id, activeName.value);
emit('reload');
}
});
};
const handleRefresh = () => {
if (isLoading.value) return;
getLlmList(providerInfo.value.id, activeName.value);
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="`${providerInfo?.providerName}${$t('llmProvider.model')}`"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
width="762"
>
<div class="manage-llm-container">
<div class="form-container">
<ElForm ref="formDataRef" :model="searchFormDada" status-icon>
<ElFormItem prop="searchText">
<div class="search-container">
<ElInput
v-model.trim="searchFormDada.searchText"
@input="handleRefresh"
:placeholder="$t('llm.searchTextPlaceholder')"
/>
<ElTooltip
:content="$t('llm.button.addAllLlm')"
placement="top"
effect="dark"
>
<ElIcon
size="20"
@click="handleAddAllLlm"
class="cursor-pointer"
>
<CirclePlus />
</ElIcon>
</ElTooltip>
<ElTooltip
:content="$t('llm.button.RetrieveAgain')"
placement="top"
effect="dark"
>
<ElIcon size="20" @click="handleRefresh" class="cursor-pointer">
<RefreshRight />
</ElIcon>
</ElTooltip>
</div>
</ElFormItem>
</ElForm>
</div>
<div class="llm-table-container">
<ElTabs v-model="activeName" @tab-click="handleTabClick">
<ElTabPane
:label="item.label"
:name="item.name"
v-for="item in tabList"
default-active="all"
:key="item.name"
>
<div v-if="isLoading" class="collapse-loading">
<ElIcon class="is-loading" size="24">
<Loading />
</ElIcon>
</div>
<div v-else>
<ElCollapse
expand-icon-position="left"
v-if="modelList.length > 0"
>
<ElCollapseItem
v-for="group in modelList"
:key="group.groupName"
:title="group.groupName"
:name="group.groupName"
>
<template #title>
<div class="flex items-center justify-between pr-2">
<span>{{ group.groupName }}</span>
<span>
<ElIcon
@click.stop="handleGroupNameDelete(group.groupName)"
>
<Minus />
</ElIcon>
</span>
</div>
</template>
<ModelViewItemOperation
:need-hidden-setting-icon="true"
:llm-list="group.llmList"
@delete-llm="handleDeleteLlm"
@add-llm="handleAddLlm"
:is-management="true"
/>
</ElCollapseItem>
</ElCollapse>
</div>
</ElTabPane>
</ElTabs>
</div>
</div>
</ElDialog>
</template>
<style scoped>
.manage-llm-container {
height: 540px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-container {
height: 30px;
}
.search-container {
width: 100%;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.llm-table-container {
flex: 1;
}
.collapse-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px !important;
background-color: #e4e7ed !important;
}
</style>

View File

@@ -0,0 +1,622 @@
<script setup>
import { onMounted, ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Minus, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { getLlmProviderList } from '#/api/ai/llm.js';
import { api } from '#/api/request.js';
import ManageIcon from '#/components/icons/ManageIcon.vue';
import PageSide from '#/components/page/PageSide.vue';
import AddModelModal from '#/views/ai/model/AddModelModal.vue';
import AddModelProviderModal from '#/views/ai/model/AddModelProviderModal.vue';
import ManageModelModal from '#/views/ai/model/ManageModelModal.vue';
import {
getIconByValue,
isSvgString,
} from '#/views/ai/model/modelUtils/defaultIcon.ts';
import { modelTypes } from '#/views/ai/model/modelUtils/modelTypes.ts';
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
const brandListData = ref([]);
const defaultSelectProviderId = ref('');
const defaultIcon = ref('');
const modelListData = ref([]);
onMounted(() => {
getLlmProviderListData();
});
const checkAndFillDefaultIcon = (list) => {
if (!list || list.length === 0) return;
list.forEach((item) => {
if (!item.icon) {
item.icon = getIconByValue(item.providerType);
}
});
};
const chatModelListData = ref([]);
const embeddingModelListData = ref([]);
const rerankModelListData = ref([]);
const getLlmDetailList = (providerId) => {
api
.get(`/api/v1/model/getList?providerId=${providerId}&withUsed=true`, {})
.then((res) => {
if (res.errorCode === 0) {
modelListData.value = res.data;
// 初始化模型分组数据按modelType分类存储groupName和对应的llm列表
chatModelListData.value = [];
embeddingModelListData.value = [];
// 处理chatModel数据
const chatModelMap = res.data.chatModel || {};
// 将chatModel的key-valuegroupName-llmList转为数组方便v-for遍历
chatModelListData.value = Object.entries(chatModelMap).map(
([groupName, llmList]) => ({
groupName,
llmList,
}),
);
// 处理embeddingModel数据
const embeddingModelMap = res.data.embeddingModel || {};
embeddingModelListData.value = Object.entries(embeddingModelMap).map(
([groupName, llmList]) => ({
groupName,
llmList,
}),
);
// 处理rerankModel数据
const rerankModelMap = res.data.rerankModel || {};
rerankModelListData.value = Object.entries(rerankModelMap).map(
([groupName, llmList]) => ({
groupName,
llmList,
}),
);
}
});
};
const getLlmProviderListData = () => {
getLlmProviderList().then((res) => {
brandListData.value = res.data;
checkAndFillDefaultIcon(brandListData.value);
if (!defaultSelectProviderId.value) {
defaultSelectProviderId.value = res.data[0].id;
defaultIcon.value = res.data[0].icon;
}
llmProviderForm.value = {
...res.data[0],
};
getLlmDetailList(defaultSelectProviderId.value);
});
};
const selectCategory = ref({
providerName: '',
provider: '',
});
const handleCategoryClick = (category) => {
selectCategory.value.providerName = category.providerName;
selectCategory.value.provider = category.provider;
defaultSelectProviderId.value = category.id;
defaultIcon.value = category.icon;
llmProviderForm.value = {
...category,
};
getLlmDetailList(category.id);
};
// 添加模型供应商
const addLlmProviderRef = ref();
// 模型管理ref
const manageLlmRef = ref();
// 模型验证配置ref
const llmVerifyConfigRef = ref();
// 添加模型
const addLlmRef = ref();
const handleDeleteProvider = (row) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
}).then(() => {
api
.post('/api/v1/modelProvider/remove', {
id: row.id,
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success(res.message);
getLlmProviderListData();
}
});
});
};
const llmProviderForm = ref({});
const llmProviderFormRef = ref();
const isEdit = ref(false);
const dialogAddProviderVisible = ref(false);
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row) {
isEdit.value = true;
dialogAddProviderVisible.value = true;
const tempRow = {
...row,
};
if (isSvgString(tempRow.icon)) {
tempRow.icon = '';
}
addLlmProviderRef.value.openEditDialog(tempRow);
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row) {
handleDeleteProvider(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
dialogAddProviderVisible.value = true;
addLlmProviderRef.value.openAddDialog();
isEdit.value = false;
},
};
const handleAddLlm = (modelType) => {
addLlmRef.value.openAddDialog(modelType);
};
const handleManageLlm = (clickModelType) => {
manageLlmRef.value.openDialog(defaultSelectProviderId.value, clickModelType);
};
const handleDeleteLlm = (id) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
}).then(() => {
api.post('/api/v1/model/remove', { id }).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
getLlmDetailList(defaultSelectProviderId.value);
}
});
});
};
const handleEditLlm = (id) => {
api.get(`/api/v1/model/detail?id=${id}`).then((res) => {
if (res.errorCode === 0) {
addLlmRef.value.openEditDialog(res.data);
}
});
};
const handleGroupNameUpdateModel = (groupName) => {
api
.post('/api/v1/model/updateByEntity', {
providerId: defaultSelectProviderId.value,
groupName,
withUsed: false,
})
.then((res) => {
if (res.errorCode === 0) {
getLlmDetailList(defaultSelectProviderId.value);
}
});
};
// 输入框失去焦点时更新配置
const handleFormBlur = async () => {
if (!defaultSelectProviderId.value) return;
try {
const res = await api.post('/api/v1/modelProvider/update', {
id: defaultSelectProviderId.value,
apiKey: llmProviderForm.value.apiKey,
endpoint: llmProviderForm.value.endpoint,
chatPath: llmProviderForm.value.chatPath,
embedPath: llmProviderForm.value.embedPath,
rerankPath: llmProviderForm.value.rerankPath,
});
if (res.errorCode === 0) {
getLlmProviderList().then((res) => {
brandListData.value = res.data;
checkAndFillDefaultIcon(res.data);
brandListData.value.forEach((item) => {
if (item.id === defaultSelectProviderId.value) {
llmProviderForm.value = { ...item };
}
});
});
} else {
ElMessage.error(res.message || $t('message.updateFail'));
}
} catch (error) {
ElMessage.error($t('message.networkError'));
console.error('更新失败:', error);
}
};
const handleTest = () => {
llmVerifyConfigRef.value.openDialog(defaultSelectProviderId.value);
};
const handleUpdateLlm = (id) => {
api.post('/api/v1/model/update', { id, withUsed: false }).then((res) => {
if (res.errorCode === 0) {
getLlmDetailList(defaultSelectProviderId.value);
}
});
};
</script>
<template>
<div class="llm-container">
<div>
<PageSide
:title="$t('llm.addProvider')"
label-key="providerName"
value-key="id"
:menus="brandListData"
:control-btns="controlBtns"
:footer-button="footerButton"
@change="handleCategoryClick"
:default-selected="defaultSelectProviderId"
:icon-size="21"
/>
</div>
<div class="llm-table-container">
<div class="llm-form-container">
<div class="title">{{ selectCategory.providerName }}</div>
<ElForm
ref="llmProviderFormRef"
:model="llmProviderForm"
status-icon
label-position="top"
>
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
<ElInput
v-model="llmProviderForm.apiKey"
@blur="handleFormBlur"
type="password"
show-password
>
<template #append>
<ElButton
@click="handleTest"
style="
background-color: var(--el-bg-color);
width: 80px;
border: 1px solid #f0f0f0;
border-radius: 0 8px 8px 0;
"
>
{{ $t('llm.button.test') }}
</ElButton>
</template>
</ElInput>
</ElFormItem>
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
<ElInput
v-model.trim="llmProviderForm.endpoint"
@blur="handleFormBlur"
/>
</ElFormItem>
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
<ElInput
v-model.trim="llmProviderForm.chatPath"
@blur="handleFormBlur"
/>
</ElFormItem>
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
<ElInput
v-model.trim="llmProviderForm.embedPath"
@blur="handleFormBlur"
/>
</ElFormItem>
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
<ElInput
v-model.trim="llmProviderForm.rerankPath"
@blur="handleFormBlur"
/>
</ElFormItem>
</ElForm>
<div class="llm-manage-container">
<div
v-for="(model, index) in modelTypes"
:key="model.value"
class="model-container"
>
<div
class="model-common-title"
:class="[index === 0 ? 'first-model-title' : '']"
>
{{ model.label }}
</div>
<!-- 对话模型chatModel遍历 -->
<div
v-if="model.value === 'chatModel' && chatModelListData.length > 0"
>
<ElCollapse expand-icon-position="left">
<ElCollapseItem
v-for="group in chatModelListData"
:key="group.groupName"
:title="group.groupName"
:name="group.groupName"
>
<template #title>
<div class="flex items-center justify-between pr-2">
<span>{{ group.groupName }}</span>
<span>
<ElIcon
@click.stop="
handleGroupNameUpdateModel(group.groupName)
"
>
<Minus />
</ElIcon>
</span>
</div>
</template>
<ModelViewItemOperation
:llm-list="group.llmList"
:icon="defaultIcon"
@delete-llm="handleDeleteLlm"
@edit-llm="handleEditLlm"
@update-with-used="handleUpdateLlm"
/>
</ElCollapseItem>
</ElCollapse>
</div>
<!-- 嵌入模型embeddingModel遍历-->
<div
v-if="
model.value === 'embeddingModel' &&
embeddingModelListData.length > 0
"
>
<ElCollapse expand-icon-position="left">
<ElCollapseItem
v-for="group in embeddingModelListData"
:key="group.groupName"
:title="group.groupName"
:name="group.groupName"
>
<template #title>
<div class="flex items-center justify-between pr-2">
<span>{{ group.groupName }}</span>
<span
@click.stop="
handleGroupNameUpdateModel(group.groupName)
"
>
<ElIcon>
<Minus />
</ElIcon>
</span>
</div>
</template>
<ModelViewItemOperation
:llm-list="group.llmList"
:icon="defaultIcon"
@delete-llm="handleDeleteLlm"
@edit-llm="handleEditLlm"
@update-with-used="handleUpdateLlm"
/>
</ElCollapseItem>
</ElCollapse>
</div>
<!-- 重排模型rerankModel遍历-->
<div
v-if="
model.value === 'rerankModel' &&
embeddingModelListData.length > 0
"
>
<ElCollapse expand-icon-position="left">
<ElCollapseItem
v-for="group in rerankModelListData"
:key="group.groupName"
:title="group.groupName"
:name="group.groupName"
>
<template #title>
<div class="flex items-center justify-between pr-2">
<span>{{ group.groupName }}</span>
<span
@click.stop="
handleGroupNameUpdateModel(group.groupName)
"
>
<ElIcon>
<Minus />
</ElIcon>
</span>
</div>
</template>
<ModelViewItemOperation
:llm-list="group.llmList"
:icon="defaultIcon"
@delete-llm="handleDeleteLlm"
@edit-llm="handleEditLlm"
@update-with-used="handleUpdateLlm"
/>
</ElCollapseItem>
</ElCollapse>
</div>
<div class="model-operation-container">
<ElButton
type="primary"
@click="handleManageLlm(model.value)"
:icon="ManageIcon"
>
{{ $t('llm.button.management') }}
</ElButton>
<ElButton :icon="Plus" @click="handleAddLlm(model.value)">
{{ $t('button.add') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
<!--添加模型供应商模态框-->
<AddModelProviderModal
ref="addLlmProviderRef"
@reload="getLlmProviderListData()"
/>
<!--添加模型模态框-->
<AddModelModal
ref="addLlmRef"
@reload="getLlmProviderListData()"
:provider-id="defaultSelectProviderId"
/>
<!--模型管理模态框-->
<ManageModelModal ref="manageLlmRef" @reload="getLlmProviderListData()" />
<!--模型检测配置模态框-->
<ModelVerifyConfig ref="llmVerifyConfigRef" />
</div>
</template>
<style scoped>
.llm-container {
display: flex;
flex-direction: row;
padding: 20px;
height: calc(100vh - 90px);
gap: 20px;
}
.title {
font-weight: 500;
font-size: 16px;
color: #333333;
line-height: 22px;
text-align: left;
font-style: normal;
margin-bottom: 20px;
}
.llm-table-container {
flex: 1;
padding: 24px;
background-color: var(--el-bg-color);
border-radius: 8px;
overflow: auto;
border: 1px solid #f0f0f0;
}
.llm-form-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.model-common-title {
font-weight: 500;
font-size: 14px;
color: #333333;
line-height: 20px;
text-align: left;
font-style: normal;
margin: 24px 0 12px 0;
}
.first-model-title {
margin: 0 0 12px 0;
}
/* 折叠面板容器 */
:deep(.el-collapse) {
border: none;
border-radius: 8px !important;
display: flex;
flex-direction: column;
gap: 12px;
}
:deep(.el-collapse-item) {
border-radius: 8px;
overflow: hidden;
margin-bottom: 0;
}
:deep(.el-collapse-item__header) {
background-color: #f9fafc;
padding: 0 9px 0 17px;
border-radius: 8px 8px 0 0;
border: 1px solid #f0f0f0;
height: 20px !important;
line-height: 20px !important;
font-size: 14px;
color: #333333;
}
:deep(.el-collapse-item__arrow) {
line-height: 38px;
margin-right: 8px;
}
:deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
:deep(.el-collapse-item__content) {
border: 1px solid #f0f0f0;
background: #ffffff;
border-radius: 0 0 8px 8px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
box-sizing: border-box;
border-top: none;
}
:deep(.el-collapse-item:last-child) {
margin-bottom: 0;
}
.model-operation-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.flex.items-center.justify-between.pr-2 {
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const options = ref<any[]>([]);
const getLlmList = (providerId: string) => {
api.get(`/api/v1/model/list?providerId=${providerId}`, {}).then((res) => {
if (res.errorCode === 0) {
options.value = res.data;
}
});
};
const modelType = ref('');
const vectorDimension = ref('');
const formDataRef = ref();
const dialogVisible = ref(false);
defineExpose({
openDialog(providerId: string) {
formDataRef.value?.resetFields();
modelType.value = '';
vectorDimension.value = '';
getLlmList(providerId);
dialogVisible.value = true;
},
});
const formData = reactive({
llmId: '',
});
const rules = {
llmId: [
{
required: true,
message: $t('message.required'),
trigger: 'change',
},
],
};
const save = async () => {
btnLoading.value = true;
await formDataRef.value.validate();
api
.get(`/api/v1/model/verifyLlmConfig?id=${formData.llmId}`, {})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('llm.testSuccess'));
if (modelType.value === 'embeddingModel' && res?.data?.dimension) {
vectorDimension.value = res?.data?.dimension;
}
}
btnLoading.value = false;
});
};
const btnLoading = ref(false);
const closeDialog = () => {
dialogVisible.value = false;
};
const getModelInfo = (id: string) => {
options.value.forEach((item: any) => {
if (item.id === id) {
modelType.value = item.modelType;
}
});
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="$t('llm.verifyLlmTitle')"
:close-on-click-modal="false"
align-center
width="482"
>
<ElForm ref="formDataRef" :model="formData" status-icon :rules="rules">
<ElFormItem prop="llmId" :label="$t('llm.modelToBeTested')">
<ElSelect v-model="formData.llmId" @change="getModelInfo">
<ElOption
v-for="item in options"
:key="item.id"
:label="item.title"
:value="item.id || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="modelType === 'embeddingModel' && vectorDimension"
:label="$t('documentCollection.dimensionOfVectorModel')"
label-width="100px"
>
{{ vectorDimension }}
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.headers-container-reduce {
align-items: center;
}
.addHeadersBtn {
width: 100%;
border-style: dashed;
border-color: var(--el-color-primary);
border-radius: 8px;
margin-top: 8px;
}
.head-con-content {
margin-bottom: 8px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { llmType } from '#/api';
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
import { Minus, Plus, Setting } from '@element-plus/icons-vue';
import { ElIcon, ElImage, ElTag } from 'element-plus';
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
import { getDefaultModelAbility } from '#/views/ai/model/modelUtils/model-ability';
import { mapLlmToModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
defineProps({
llmList: {
type: Array as PropType<llmType[]>,
default: () => [],
},
icon: {
type: String,
default: '',
},
needHiddenSettingIcon: {
type: Boolean,
default: false,
},
isManagement: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['deleteLlm', 'editLlm', 'addLlm', 'updateWithUsed']);
const handleDeleteLlm = (id: string) => {
emit('deleteLlm', id);
};
const handleAddLlm = (id: string) => {
emit('addLlm', id);
};
const handleEditLlm = (id: string) => {
emit('editLlm', id);
};
// 修改该模型为未使用状态修改数据库的with_used字段为false
const handleUpdateWithUsedLlm = (id: string) => {
emit('updateWithUsed', id);
};
/**
* 获取LLM支持的选中的能力标签
* 只返回 selected 为 true 的标签
*/
const getSelectedAbilityTagsForLlm = (llm: llmType): ModelAbilityItem[] => {
const defaultAbility = getDefaultModelAbility();
const allTags = mapLlmToModelAbility(llm, defaultAbility);
return allTags.filter((tag) => tag.selected);
};
</script>
<template>
<div v-for="llm in llmList" :key="llm.id" class="container">
<div class="llm-item">
<div class="start">
<ElImage
v-if="llm.modelProvider.icon"
:src="llm.modelProvider.icon"
style="width: 21px; height: 21px"
/>
<div
v-else
v-html="getIconByValue(llm.modelProvider.providerType)"
:style="{
width: '21px',
height: '21px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}"
class="svg-container"
></div>
<div>{{ llm?.modelProvider?.providerName }}/{{ llm.title }}</div>
<!-- 模型能力 -->
<div
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
class="ability-tags"
>
<ElTag
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
:key="tag.value"
class="ability-tag"
:type="tag.activeType"
size="small"
>
{{ tag.label }}
</ElTag>
</div>
</div>
<div class="end">
<ElIcon
v-if="!needHiddenSettingIcon"
size="16"
@click="handleEditLlm(llm.id)"
style="cursor: pointer"
>
<Setting />
</ElIcon>
<template v-if="!isManagement">
<ElIcon
size="16"
@click="handleUpdateWithUsedLlm(llm.id)"
style="cursor: pointer"
>
<Minus />
</ElIcon>
</template>
<template v-if="isManagement">
<ElIcon
v-if="llm.withUsed"
size="16"
@click="handleDeleteLlm(llm.id)"
style="cursor: pointer"
>
<Minus />
</ElIcon>
<ElIcon
v-else
size="16"
@click="handleAddLlm(llm.id)"
style="cursor: pointer"
>
<Plus />
</ElIcon>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.llm-item {
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
}
.container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 18px;
border-bottom: 1px solid #e4e7ed;
}
.container:last-child {
border-bottom: none;
}
.start {
display: flex;
align-items: center;
gap: 12px;
font-weight: 500;
}
.end {
display: flex;
align-items: center;
gap: 12px;
}
.ability-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ability-tag {
cursor: default;
user-select: none;
}
.svg-container {
display: flex;
align-items: center;
justify-content: center;
}
.svg-container :deep(svg) {
width: 21px;
height: 21px;
}
</style>

View File

@@ -0,0 +1,27 @@
import { ref } from 'vue';
import providerList from './providerList.json';
const providerOptions =
ref<Array<{ icon: string; label: string; options: any; value: string }>>(
providerList,
);
/**
* 根据传入的value返回对应的icon属性
* @param targetValue 要匹配的value值
* @returns 匹配到的icon字符串未匹配到返回空字符串
*/
export const getIconByValue = (targetValue: string): string => {
const matchItem = providerOptions.value.find(
(item) => item.value === targetValue,
);
return matchItem?.icon || '';
};
export const isSvgString = (icon: any) => {
if (typeof icon !== 'string') return false;
// 简单判断:是否包含 SVG 根标签
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
};

View File

@@ -0,0 +1,71 @@
import type { BooleanField, ModelAbilityItem } from './model-ability';
import type { llmType } from '#/api';
/**
* 将 llm 数据转换为标签选中状态
* @param llm LLM数据对象
* @param modelAbility 模型能力数组
* @returns 更新后的模型能力数组
*/
export const mapLlmToModelAbility = (
llm: llmType,
modelAbility: ModelAbilityItem[],
): ModelAbilityItem[] => {
return modelAbility.map((tag) => ({
...tag,
selected: Boolean(llm[tag.field as keyof llmType]),
}));
};
/**
* 从标签选中状态生成 features 对象
* @param modelAbility 模型能力数组
* @returns 包含所有字段的features对象
*/
export const generateFeaturesFromModelAbility = (
modelAbility: ModelAbilityItem[],
): Record<BooleanField, boolean> => {
const features: Partial<Record<BooleanField, boolean>> = {};
modelAbility.forEach((tag) => {
features[tag.field] = tag.selected;
});
return features as Record<BooleanField, boolean>;
};
/**
* 过滤显示选中的标签
* @param modelAbility 模型能力数组
* @returns 选中的标签数组
*/
export const getSelectedModelAbility = (
modelAbility: ModelAbilityItem[],
): ModelAbilityItem[] => {
return modelAbility.filter((tag) => tag.selected);
};
/**
* 重置所有标签为未选中状态
* @param modelAbility 模型能力数组
*/
export const resetModelAbility = (modelAbility: ModelAbilityItem[]): void => {
modelAbility.forEach((tag) => {
tag.selected = false;
});
};
/**
* 根据标签选中状态更新表单数据
* @param modelAbility 模型能力数组
* @param formData 表单数据对象
*/
export const updateFormDataFromModelAbility = (
modelAbility: ModelAbilityItem[],
formData: Record<BooleanField, boolean>,
): void => {
modelAbility.forEach((tag) => {
formData[tag.field] = tag.selected;
});
};

View File

@@ -0,0 +1,169 @@
import { $t } from '#/locales';
export type BooleanField =
| 'supportAudio'
| 'supportFree'
| 'supportImage'
| 'supportImageB64Only'
| 'supportThinking'
| 'supportTool'
| 'supportToolMessage'
| 'supportVideo';
export interface ModelAbilityItem {
activeType: 'danger' | 'info' | 'primary' | 'success' | 'warning';
defaultType: 'info';
field: BooleanField;
label: string;
selected: boolean;
value: string;
}
/**
* 获取模型能力标签的默认配置
* @returns ModelAbilityItem[] 模型能力配置数组
*/
export const getDefaultModelAbility = (): ModelAbilityItem[] => [
{
label: $t('llm.modelAbility.supportThinking'),
value: 'thinking',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportThinking',
},
{
label: $t('llm.modelAbility.supportTool'),
value: 'tool',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportTool',
},
{
label: $t('llm.modelAbility.supportVideo'),
value: 'video',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportVideo',
},
{
label: $t('llm.modelAbility.supportImage'),
value: 'image',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportImage',
},
{
label: $t('llm.modelAbility.supportFree'),
value: 'free',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportFree',
},
{
label: $t('llm.modelAbility.supportAudio'),
value: 'audio',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportAudio',
},
{
label: $t('llm.modelAbility.supportImageB64Only'),
value: 'imageB64',
defaultType: 'info',
activeType: 'success',
selected: false,
field: 'supportImageB64Only',
},
{
label: $t('llm.modelAbility.supportToolMessage'),
value: 'toolMessage',
defaultType: 'info',
activeType: 'success',
selected: true,
field: 'supportToolMessage',
},
];
/**
* 根据字段数组获取对应的标签选中状态
* @param modelAbility 模型能力数组
* @param fields 需要获取的字段数组
* @returns 以字段名为键、选中状态为值的对象
*/
export const getTagsSelectedStatus = (
modelAbility: ModelAbilityItem[],
fields: BooleanField[],
): Record<BooleanField, boolean> => {
const result: Partial<Record<BooleanField, boolean>> = {};
fields.forEach((field) => {
const tagItem = modelAbility.find((tag) => tag.field === field);
result[field] = tagItem?.selected ?? false;
});
return result as Record<BooleanField, boolean>;
};
/**
* 同步标签选中状态与formData中的布尔字段
* @param modelAbility 模型能力数组
* @param formData 表单数据对象
*/
export const syncTagSelectedStatus = (
modelAbility: ModelAbilityItem[],
formData: Record<BooleanField, boolean>,
): void => {
modelAbility.forEach((tag) => {
tag.selected = formData[tag.field] ?? false;
});
};
/**
* 处理标签点击事件
* @param modelAbility 模型能力数组
* @param item 被点击的标签项
* @param formData 表单数据对象
*/
export const handleTagClick = (
// modelAbility: ModelAbilityItem[],
item: ModelAbilityItem,
formData: Record<BooleanField, boolean>,
): void => {
// 切换标签选中状态
item.selected = !item.selected;
// 同步更新formData中的布尔字段
formData[item.field] = item.selected;
};
/**
* 根据字段获取对应的标签项
* @param modelAbility 模型能力数组
* @param field 布尔字段名
* @returns 标签项 | undefined
*/
export const getTagByField = (
modelAbility: ModelAbilityItem[],
field: BooleanField,
): ModelAbilityItem | undefined => {
return modelAbility.find((tag) => tag.field === field);
};
/**
* 获取所有支持的BooleanField数组
*/
export const getAllBooleanFields = (): BooleanField[] => [
'supportThinking',
'supportTool',
'supportImage',
'supportImageB64Only',
'supportVideo',
'supportAudio',
'supportFree',
];

View File

@@ -0,0 +1,16 @@
import { $t } from '@easyflow/locales';
export const modelTypes = [
{
label: $t('llmProvider.chatModel'),
value: 'chatModel',
},
{
label: $t('llmProvider.embeddingModel'),
value: 'embeddingModel',
},
{
label: $t('llmProvider.rerankModel'),
value: 'rerankModel',
},
];

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { Plus, Remove } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElRadio,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
interface headersType {
label: string;
value: string;
}
const authTypeList = ref<headersType[]>([
{
label: 'None',
value: 'none',
},
{
label: 'Service token / ApiKey',
value: 'apiKey',
},
]);
onMounted(() => {
api.get('/api/v1/model/list?supportEmbed=true').then((res) => {
embeddingLlmList.value = res.data;
});
api.get('/api/v1/model/list?supportRerankerLlmList=true').then((res) => {
rerankerLlmList.value = res.data;
});
});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const tempAddHeaders = ref<headersType[]>([]);
const entity = ref<any>({
alias: '',
deptId: '',
icon: '',
title: '',
authType: 'none',
description: '',
englishName: '',
headers: '',
position: '',
});
const btnLoading = ref(false);
const rules = ref({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
baseUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
authType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenKey: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenValue: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
position: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
if (row.headers) {
tempAddHeaders.value = JSON.parse(row.headers);
}
}
entity.value = {
...row,
authType: row.authType || 'none',
};
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
const plainEntity = { ...entity.value };
const plainHeaders = [...tempAddHeaders.value];
if (isAdd.value) {
api
.post('/api/v1/plugin/plugin/save', {
...plainEntity,
headers: plainHeaders,
})
.then((res) => {
if (res.errorCode === 0) {
dialogVisible.value = false;
ElMessage.success($t('message.saveOkMessage'));
emit('reload');
}
});
} else {
api
.post('/api/v1/plugin/plugin/update', {
...plainEntity,
headers: plainHeaders,
})
.then((res) => {
if (res.errorCode === 0) {
dialogVisible.value = false;
ElMessage.success($t('message.updateOkMessage'));
emit('reload');
}
});
}
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
dialogVisible.value = false;
}
function addHeader() {
tempAddHeaders.value.push({
label: '',
value: '',
});
}
function removeHeader(index: number) {
tempAddHeaders.value.splice(index, 1);
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
>
<ElForm
label-width="150px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem
prop="icon"
:label="$t('plugin.icon')"
style="display: flex; align-items: center"
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="name" :label="$t('plugin.name')">
<ElInput
v-model.trim="entity.name"
:placeholder="$t('plugin.placeholder.name')"
/>
</ElFormItem>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('plugin.description')">
<ElInput
v-model.trim="entity.description"
:rows="4"
type="textarea"
:placeholder="$t('plugin.placeholder.description')"
/>
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
class="headers-container-reduce flex flex-row gap-4"
v-for="(item, index) in tempAddHeaders"
:key="index"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon size="20" @click="removeHeader" style="cursor: pointer">
<Remove />
</ElIcon>
</div>
</div>
<ElButton @click="addHeader" class="addHeadersBtn">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
{{ $t('button.add') }}headers
</ElButton>
</ElFormItem>
<ElFormItem prop="authType" :label="$t('plugin.authType')">
<ElSelect v-model="entity.authType">
<ElOption
v-for="item in authTypeList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="position"
:label="$t('plugin.position')"
v-if="entity.authType === 'apiKey'"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
<ElRadio value="query">query</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
prop="tokenKey"
:label="$t('plugin.tokenKey')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
prop="tokenValue"
:label="$t('plugin.tokenValue')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.headers-container-reduce {
align-items: center;
}
.addHeadersBtn {
width: 100%;
border-style: dashed;
border-color: var(--el-color-primary);
border-radius: 8px;
margin-top: 8px;
}
.head-con-content {
margin-bottom: 8px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const props = defineProps({
pluginId: {
type: String,
default: '',
},
});
const emit = defineEmits(['reload']);
const entity = reactive({
pluginId: '',
name: '',
englishName: '',
description: '',
});
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const ENGLISH_NAME_REG = /^[\w-]{1,64}$/;
const rules = ref({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
englishName: [
{
required: true,
message: $t('message.englishNameRule'),
trigger: 'blur',
pattern: ENGLISH_NAME_REG,
},
],
});
// 安全地打开对话框
function openDialog(row: any) {
try {
// 重置表单状态
saveForm.value?.resetFields();
// 安全地处理传入的数据
if (row && row.id) {
isAdd.value = false;
// 使用 Object.assign 避免直接 Proxy 赋值
Object.assign(entity, {
...row,
pluginId: props.pluginId,
});
} else {
isAdd.value = true;
// 重置 entity 数据
Object.assign(entity, {
pluginId: props.pluginId,
name: '',
description: '',
});
}
dialogVisible.value = true;
} catch (error) {
console.error('打开对话框错误:', error);
}
}
// 保存数据
function save() {
if (!saveForm.value) return;
saveForm.value.validate((valid) => {
if (!valid) {
return;
}
btnLoading.value = true;
const apiUrl = isAdd.value
? 'api/v1/pluginItem/tool/save'
: 'api/v1/pluginItem/tool/update';
// 创建纯对象提交,避免 Proxy
const submitData = { ...entity };
api
.post(apiUrl, submitData)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success($t('message.saveOkMessage'));
closeDialog();
emit('reload');
}
})
.catch((error) => {
console.error('API请求错误:', error);
btnLoading.value = false;
});
});
}
// 关闭对话框
function closeDialog() {
try {
if (saveForm.value) {
saveForm.value.resetFields();
}
// 重置数据
Object.assign(entity, {
pluginId: props.pluginId,
name: '',
description: '',
});
isAdd.value = true;
dialogVisible.value = false;
} catch (error) {
console.error('关闭对话框错误:', error);
// 强制关闭
dialogVisible.value = false;
}
}
onMounted(() => {
Object.assign(entity, {
pluginId: props.pluginId,
name: '',
description: '',
});
});
defineExpose({
openDialog,
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
width="600px"
@closed="closeDialog"
>
<ElForm
ref="saveForm"
:model="entity"
:rules="rules"
label-width="80px"
status-icon
>
<ElFormItem :label="$t('pluginItem.name')" prop="name">
<ElInput v-model.trim="entity.name" />
</ElFormItem>
<ElFormItem :label="$t('pluginItem.englishName')" prop="englishName">
<ElInput v-model.trim="entity.englishName" />
</ElFormItem>
<ElFormItem :label="$t('pluginItem.description')" prop="description">
<ElInput v-model.trim="entity.description" type="textarea" :rows="4" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog" :disabled="btnLoading">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:loading="btnLoading"
:disabled="btnLoading"
@click="save"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const categoryList = ref<any[]>([]);
const getPluginCategoryList = async () => {
return api.get('/api/v1/pluginCategory/list').then((res) => {
if (res.errorCode === 0) {
categoryList.value = res.data;
}
});
};
onMounted(() => {
getPluginCategoryList();
});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
id: '',
categoryIds: [],
});
const btnLoading = ref(false);
function getPluginCategories(id: string) {
return api
.get(`/api/v1/pluginCategoryMapping/getPluginCategories?pluginId=${id}`)
.then((res) => {
if (res.errorCode === 0) {
entity.value.categoryIds = res.data;
}
});
}
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
}
getPluginCategories(row.id).then(() => {
entity.value.categoryIds = row.categoryIds.map((item: any) => item.id);
});
entity.value = row;
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
const tempParams = {
pluginId: entity.value.id,
categoryIds: entity.value.categoryIds,
};
api
.post('/api/v1/pluginCategoryMapping/updateRelation', tempParams)
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
closeDialog();
emit('reload');
}
});
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
dialogVisible.value = false;
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
width="500px"
:title="$t('plugin.placeholder.categorize')"
:before-close="closeDialog"
:close-on-click-modal="false"
align-center
>
<ElForm ref="saveForm" :model="entity" status-icon>
<ElFormItem prop="authType" :label="$t('plugin.category')">
<ElSelect
v-model="entity.categoryIds"
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
>
<ElOption
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import type { ActionButton } from '#/components/page/CardList.vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { api } from '#/api/request';
import defaultPluginIcon from '#/assets/ai/plugin/defaultPluginIcon.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CategorizeIcon from '#/components/icons/CategorizeIcon.vue';
import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
import CategoryPluginModal from '#/views/ai/plugin/CategoryPluginModal.vue';
const router = useRouter();
// 操作按钮配置
const actions: ActionButton[] = [
{
icon: Edit,
text: $t('button.edit'),
className: '',
permission: '/api/v1/plugin/save',
onClick(item) {
aiPluginModalRef.value.openDialog(item);
},
},
{
icon: PluginToolIcon,
text: $t('plugin.button.tools'),
className: '',
permission: '/api/v1/plugin/save',
onClick(item) {
router.push({
path: '/ai/plugin/tools',
query: {
id: item.id,
pageKey: '/ai/plugin',
},
});
},
},
{
icon: CategorizeIcon,
text: $t('plugin.button.categorize'),
className: '',
permission: '/api/v1/plugin/save',
onClick(item) {
categoryCategoryModal.value.openDialog(item);
},
},
{
icon: Delete,
text: $t('button.delete'),
className: 'item-danger',
permission: '/api/v1/plugin/remove',
onClick(item) {
handleDelete(item);
},
},
];
const categoryList = ref([]);
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row) {
formData.value.name = row.name;
formData.value.id = row.id;
isEdit.value = true;
dialogVisible.value = true;
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row) {
handleDeleteCategory(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
dialogVisible.value = true;
isEdit.value = false;
},
};
const getPluginCategoryList = async () => {
return api.get('/api/v1/pluginCategory/list').then((res) => {
if (res.errorCode === 0) {
categoryList.value = [
{ id: '0', name: $t('common.allCategories') },
...res.data,
];
}
});
};
onMounted(() => {
getPluginCategoryList();
});
const handleDelete = (item) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
})
.then(() => {
api.post('/api/v1/plugin/plugin/remove', { id: item.id }).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({});
}
});
})
.catch(() => {});
};
const pageDataRef = ref();
const aiPluginModalRef = ref();
const categoryCategoryModal = ref();
const headerButtons = [
{
key: 'add',
text: $t('plugin.button.addPlugin'),
icon: Plus,
type: 'primary',
data: { action: 'add' },
},
];
const pluginCategoryId = ref('0');
const dialogVisible = ref(false); // 弹窗显隐
const isEdit = ref(false); // 是否为编辑模式
const formData = ref({ name: '', id: '' });
const handleSubmit = () => {
// 触发对应事件,传递表单数据
if (isEdit.value) {
handleEditCategory(formData.value);
} else {
handleAddCategory(formData.value);
}
// 提交后关闭弹窗
dialogVisible.value = false;
};
const handleButtonClick = (event, _item) => {
switch (event.key) {
case 'add': {
aiPluginModalRef.value.openDialog({});
break;
}
}
};
const handleSearch = (params) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
const handleEditCategory = (params) => {
api
.post('/api/v1/pluginCategory/update', {
id: params.id,
name: params.name,
})
.then((res) => {
if (res.errorCode === 0) {
getPluginCategoryList();
ElMessage.success($t('message.updateOkMessage'));
}
});
};
const handleAddCategory = (params) => {
api.post('/api/v1/pluginCategory/save', { name: params.name }).then((res) => {
if (res.errorCode === 0) {
getPluginCategoryList();
ElMessage.success($t('message.saveOkMessage'));
}
});
};
const handleDeleteCategory = (params) => {
api
.get(`/api/v1/pluginCategory/doRemoveCategory?id=${params.id}`)
.then((res) => {
if (res.errorCode === 0) {
getPluginCategoryList();
ElMessage.success($t('message.deleteOkMessage'));
}
});
};
const handleClickCategory = (item) => {
pageDataRef.value.setQuery({ category: item.id });
};
</script>
<template>
<div class="knowledge-container">
<div class="knowledge-header">
<HeaderSearch
:buttons="headerButtons"
:search-placeholder="$t('plugin.searchUsers')"
@search="handleSearch"
@button-click="handleButtonClick"
/>
</div>
<div class="plugin-content-container">
<div class="category-panel-container">
<PageSide
label-key="name"
value-key="id"
:menus="categoryList"
:control-btns="controlBtns"
:footer-button="footerButton"
default-selected="0"
@change="handleClickCategory"
/>
</div>
<div class="plugin-content-data-container h-full overflow-auto">
<PageData
ref="pageDataRef"
page-url="/api/v1/plugin/pageByCategory"
:page-size="12"
:page-sizes="[12, 24, 36, 48]"
:extra-query-params="{ category: pluginCategoryId }"
>
<template #default="{ pageList }">
<CardPage
title-key="title"
avatar-key="icon"
description-key="description"
:data="pageList"
:actions="actions"
:default-icon="defaultPluginIcon"
/>
</template>
</PageData>
</div>
</div>
<AddPluginModal ref="aiPluginModalRef" @reload="handleSearch" />
<CategoryPluginModal ref="categoryCategoryModal" @reload="handleSearch" />
<ElDialog
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
v-model="dialogVisible"
width="500px"
:close-on-click-modal="false"
>
<ElForm :model="formData" status-icon>
<ElFormItem>
<ElInput v-model.trim="formData.name" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.knowledge-container {
width: 100%;
padding: 24px;
margin: 0 auto;
}
h1 {
margin-bottom: 30px;
color: #303133;
text-align: center;
}
.plugin-content-container {
display: flex;
gap: 24px;
height: calc(100vh - 161px);
padding-top: 24px;
}
.plugin-content-data-container {
/* padding: 20px; */
/* background-color: var(--el-bg-color); */
width: 100%;
border-top-right-radius: var(--el-border-radius-base);
border-bottom-right-radius: var(--el-border-radius-base);
}
</style>

View File

@@ -0,0 +1,703 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Delete, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
} from 'element-plus';
import { $t } from '#/locales';
export interface TreeTableNode {
key: string;
name: string;
description: string;
method?: 'Body' | 'Header' | 'Path' | 'Query';
required?: boolean;
defaultValue?: string;
enabled?: boolean;
type?: string;
children?: TreeTableNode[];
}
interface Props {
modelValue?: TreeTableNode[];
editable?: boolean;
isEditOutput?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: TreeTableNode[]): void;
(e: 'submit', value: TreeTableNode[]): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
editable: false,
isEditOutput: false,
});
const emit = defineEmits<Emits>();
const data = ref<TreeTableNode[]>([]);
const expandedKeys = ref<string[]>(['1']);
const errors = ref<
Record<string, Partial<Record<keyof TreeTableNode, string>>>
>({});
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
data.value = newVal;
}
},
{ immediate: true, deep: true },
);
// 计算缩进宽度
const getIndentWidth = (record: TreeTableNode): number => {
const level = String(record.key).split('-').length - 1;
const indentSize = 20;
return level > 0 ? level * indentSize : 0;
};
// 获取类型选项
const getTypeOptions = (record: TreeTableNode) => {
if (record.name === 'arrayItem') {
return [
{ label: 'Array[String]', value: 'Array[String]' },
{ label: 'Array[Number]', value: 'Array[Number]' },
{ label: 'Array[Boolean]', value: 'Array[Boolean]' },
{ label: 'Array[Object]', value: 'Array[Object]' },
];
}
return [
{ label: 'String', value: 'String' },
{ label: 'Boolean', value: 'Boolean' },
{ label: 'Number', value: 'Number' },
{ label: 'Object', value: 'Object' },
{ label: 'Array', value: 'Array' },
{ label: 'File', value: 'File' },
];
};
// 数据变化处理
const handleDataChange = () => {
emit('update:modelValue', data.value);
};
// 类型变化处理
const handleTypeChange = (record: TreeTableNode, newType: string) => {
const updateNode = (nodes: TreeTableNode[]): TreeTableNode[] => {
return nodes.map((node) => {
if (node.key === record.key) {
// 如果是简单类型,移除 children
if (
[
'Array[Boolean]',
'Array[Integer]',
'Array[Number]',
'Array[Object]',
'Array[String]',
'Boolean',
'Number',
'String',
].includes(newType)
) {
return {
...node,
type: newType,
children: undefined,
};
}
// 如果是 Object 或 Array保留或初始化 children
return {
...node,
type: newType,
children: node.children || [],
};
}
if (node.children) {
return {
...node,
children: updateNode(node.children),
};
}
return node;
});
};
data.value = updateNode(data.value);
handleDataChange();
// 如果是 Object 或 Array添加默认子节点并展开
if (
newType === 'Object' ||
newType === 'Array' ||
newType === 'Array[Object]'
) {
const newChild: TreeTableNode = {
key: `${record.key}-${Date.now()}`,
name: newType === 'Array' ? 'arrayItem' : '',
description: '',
enabled: true,
...(props.isEditOutput
? {}
: { method: 'Query', defaultValue: '', required: false }),
type: newType === 'Array' ? 'Array[String]' : 'String',
};
const addChildToNode = (nodes: TreeTableNode[]): TreeTableNode[] => {
return nodes.map((node) => {
if (node.key === record.key) {
return {
...node,
children: [newChild],
};
}
if (node.children) {
return {
...node,
children: addChildToNode(node.children),
};
}
return node;
});
};
data.value = addChildToNode(data.value);
handleDataChange();
// 自动展开父节点
if (!expandedKeys.value.includes(record.key)) {
expandedKeys.value = [...expandedKeys.value, record.key];
}
}
};
// 展开/折叠处理
const onExpand = (_row: TreeTableNode, expandedRows: TreeTableNode[]) => {
expandedKeys.value = expandedRows.map((item) => item.key);
};
// 添加根节点
const addNewRootNode = () => {
if (!props.editable) return;
const newKey = `${Date.now()}`;
const newNode: TreeTableNode = {
key: newKey,
name: '',
description: '',
enabled: true,
type: 'String',
...(props.isEditOutput
? {}
: { method: 'Query', defaultValue: '', required: false }),
};
data.value = [...data.value, newNode];
handleDataChange();
};
// 添加子节点
const handleAddChild = (parentKey: string) => {
if (!props.editable || !parentKey) return;
const newChild: TreeTableNode = {
key: `${parentKey}-${Date.now()}`,
name: '',
description: '',
required: false,
enabled: true,
type: 'String',
...(props.isEditOutput ? {} : { method: 'Query', defaultValue: '' }),
};
const addChildToNode = (nodes: TreeTableNode[]): TreeTableNode[] => {
return nodes.map((node) => {
if (node.key === parentKey) {
return {
...node,
children: [...(node.children || []), newChild],
};
}
if (node.children) {
return {
...node,
children: addChildToNode(node.children),
};
}
return node;
});
};
data.value = addChildToNode(data.value);
handleDataChange();
if (!expandedKeys.value.includes(parentKey)) {
expandedKeys.value = [...expandedKeys.value, parentKey];
}
};
// 删除节点
const deleteNode = (key: string) => {
if (!props.editable) return;
const removeNodeRecursively = (nodes: TreeTableNode[]): TreeTableNode[] => {
return nodes.filter((node) => {
if (node.key === key) return false;
if (node.children) {
node.children = removeNodeRecursively(node.children);
}
return true;
});
};
data.value = removeNodeRecursively(data.value);
handleDataChange();
};
// 验证字段
// 验证字段
const validateFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
> = {};
let isValid = true;
// 递归校验节点(包括子节点)
const checkNode = (node: TreeTableNode): boolean => {
const { name, description, method, type } = node;
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
let nodeIsValid = true;
// 校验参数名称
if (!name?.trim()) {
nodeErrors.name = $t('message.cannotBeEmpty.name');
nodeIsValid = false;
isValid = false;
}
// 校验参数描述
if (!description?.trim()) {
nodeErrors.description = $t('message.cannotBeEmpty.description');
nodeIsValid = false;
isValid = false;
}
// 校验传入方法(仅根节点+输入参数)
if (isRootNode(node) && !method && !props.isEditOutput) {
nodeErrors.method = $t('message.cannotBeEmpty.method');
nodeIsValid = false;
isValid = false;
}
// 校验参数类型
if (!type) {
nodeErrors.type = $t('message.cannotBeEmpty.type');
nodeIsValid = false;
isValid = false;
}
// 记录当前节点的错误
if (Object.keys(nodeErrors).length > 0) {
newErrors[node.key] = nodeErrors;
}
// 递归校验子节点
if (node.children) {
node.children.forEach((child) => {
if (!checkNode(child)) {
nodeIsValid = false;
isValid = false;
}
});
}
return nodeIsValid;
};
// 校验所有根节点
data.value.forEach((node) => {
checkNode(node);
});
// 更新错误信息
errors.value = newErrors;
return isValid;
};
// 判断是否为根节点
const isRootNode = (record: TreeTableNode): boolean => {
return !record.key.includes('-');
};
const handleSubmitParams = () => {
// 全量校验所有字段
const isFormValid = validateFields();
if (!isFormValid) {
ElMessage.error($t('message.cannotBeEmpty.all'));
// 找到第一个错误的输入框/选择器
const firstErrorInput = document.querySelector('.error-border');
if (firstErrorInput) {
// 滚动到错误元素位置
firstErrorInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 给输入框添加焦点
if ((firstErrorInput as HTMLInputElement).focus) {
(firstErrorInput as HTMLInputElement).focus();
} else {
// 处理选择器的焦点
const selectInput = firstErrorInput.querySelector('.el-input__inner');
if (selectInput) (selectInput as HTMLInputElement).focus();
}
}
throw new Error($t('message.cannotBeEmpty.error'));
}
// 校验通过,提交数据
emit('submit', data.value);
};
// 暴露方法给父组件
defineExpose({
handleSubmitParams,
});
// 输入框失焦时清除对应字段的错误提示
const handleInputBlur = (row: TreeTableNode, field: keyof TreeTableNode) => {
if (
errors.value &&
row &&
field &&
(errors.value[row.key] as Record<string, unknown>)
) {
delete (errors.value[row.key] as Record<string, unknown>)[field];
}
};
</script>
<template>
<div class="tree-table-container">
<ElTable
:data="data"
row-key="key"
:border="true"
size="default"
:expand-row-keys="expandedKeys"
@expand-change="onExpand"
style="width: 100%; overflow-x: auto"
>
<!-- 参数名称列 -->
<ElTableColumn prop="name" class-name="first-column">
<template #header>
<div class="header-with-asterisk">
{{ $t('pluginItem.parameterName') }}
<span class="required-asterisk">*</span>
</div>
</template>
<template #default="{ row }">
<div class="name-cell">
<div
v-if="!props.editable"
:style="{ paddingLeft: `${getIndentWidth(row)}px` }"
>
{{ row.name || '' }}
</div>
<div v-else>
<div class="name-input-wrapper">
<div :style="{ width: `${getIndentWidth(row)}px` }"></div>
<ElInput
v-model="row.name"
:disabled="row.name === 'arrayItem'"
@input="handleDataChange"
@blur="handleInputBlur(row, 'name')"
:class="{ 'error-border': errors[row.key]?.name }"
/>
<div v-if="errors[row.key]?.name" class="error-message">
{{ errors[row.key]?.name }}
</div>
</div>
</div>
</div>
</template>
</ElTableColumn>
<!-- 参数描述列 -->
<ElTableColumn prop="description">
<template #header>
<div class="header-with-asterisk">
{{ $t('pluginItem.parameterDescription') }}
<span class="required-asterisk">*</span>
</div>
</template>
<template #default="{ row }">
<div class="description-cell">
<span v-if="!props.editable">{{ row.description || '' }}</span>
<div v-else>
<ElInput
v-model="row.description"
@input="handleDataChange"
@blur="handleInputBlur(row, 'description')"
:class="{ 'error-border': errors[row.key]?.description }"
/>
<div v-if="errors[row.key]?.description" class="error-message">
{{ errors[row.key]?.description }}
</div>
</div>
</div>
</template>
</ElTableColumn>
<!-- 参数类型列 -->
<ElTableColumn
prop="type"
:label="$t('pluginItem.parameterType')"
width="150px"
>
<template #default="{ row }">
<span v-if="!props.editable">{{ row.type || '' }}</span>
<div v-else>
<ElSelect
v-model="row.type"
@change="handleTypeChange(row, $event)"
>
<ElOption
v-for="option in getTypeOptions(row)"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<div v-if="errors[row.key]?.type" class="error-message">
{{ errors[row.key]?.type }}
</div>
</div>
</template>
</ElTableColumn>
<!-- 传入方法列 (仅输入参数显示) -->
<ElTableColumn
v-if="!props.isEditOutput"
prop="method"
:label="$t('pluginItem.inputMethod')"
width="120px"
>
<template #default="{ row }">
<span v-if="row.name === 'arrayItem'"></span>
<span v-else-if="!props.editable">{{ row.method || '' }}</span>
<div v-else>
<ElSelect v-model="row.method" @change="handleDataChange">
<ElOption label="Query" value="Query" />
<ElOption label="Body" value="Body" />
<ElOption label="Path" value="Path" />
<ElOption label="Header" value="Header" />
</ElSelect>
<div v-if="errors[row.key]?.method" class="error-message">
{{ errors[row.key]?.method }}
</div>
</div>
</template>
</ElTableColumn>
<!-- 是否必填列 (仅输入参数显示) -->
<ElTableColumn
v-if="!props.isEditOutput"
prop="required"
:label="$t('pluginItem.required')"
width="120px"
>
<template #default="{ row }">
<ElSwitch
v-model="row.required"
@change="handleDataChange"
:disabled="!props.editable"
/>
</template>
</ElTableColumn>
<!-- 默认值列 (仅输入参数显示) -->
<ElTableColumn
v-if="!props.isEditOutput"
prop="defaultValue"
:label="$t('pluginItem.defaultValue')"
width="150px"
>
<template #default="{ row }">
<span v-if="row.type === 'Object'"></span>
<span v-else-if="!props.editable">{{ row.defaultValue || '' }}</span>
<ElInput
v-else
v-model="row.defaultValue"
@input="handleDataChange"
:disabled="!props.editable"
/>
</template>
</ElTableColumn>
<!-- 启用状态列 -->
<ElTableColumn
prop="enabled"
:label="$t('pluginItem.enabledStatus')"
width="120px"
>
<template #default="{ row }">
<ElSwitch
v-model="row.enabled"
@change="handleDataChange"
:disabled="!props.editable"
/>
</template>
</ElTableColumn>
<!-- 操作列 (仅可编辑时显示) -->
<ElTableColumn
v-if="props.editable"
:label="$t('common.handle')"
width="130px"
>
<template #default="{ row }">
<div class="action-buttons">
<ElButton
v-if="row.type === 'Object' || row.type === 'Array[Object]'"
type="primary"
link
:icon="Plus"
@click="handleAddChild(row.key)"
:title="$t('pluginItem.addChildNode')"
/>
<ElButton
type="danger"
link
:icon="Delete"
@click="deleteNode(row.key)"
>
{{ $t('button.delete') }}
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<!-- 新增参数按钮 -->
<div v-if="props.editable" class="add-button-container">
<ElButton type="default" @click="addNewRootNode" :icon="Plus">
{{ $t('pluginItem.addParameter') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.tree-table-container {
width: 100%;
overflow-x: auto;
}
.name-cell {
position: relative;
min-width: 100%;
}
.editable-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-input-wrapper {
display: flex;
flex-direction: column;
justify-content: flex-start;
width: 100%;
}
.error-message {
margin-top: 2px;
font-size: 12px;
line-height: 1.2;
color: #ff4d4f;
}
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
.action-buttons .el-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
padding: 0;
}
.add-button-container {
margin-top: 16px;
text-align: left;
}
.description-cell {
position: relative;
width: 100%;
}
:deep(.el-table td.el-table__cell.first-column > div) {
display: flex;
gap: 2px;
align-items: center;
}
.el-table__header-wrapper,
.el-table__body-wrapper {
min-width: 100%;
}
.header-with-asterisk {
position: relative;
display: inline-flex;
align-items: center;
}
.required-asterisk {
position: absolute;
right: -8px;
font-size: 12px;
font-weight: bold;
line-height: 1;
color: #ff4d4f;
}
/* 输入框/选择器错误样式 */
:deep(.el-input__inner.error-border),
:deep(.el-select .el-input__inner.error-border) {
border-color: #ff4d4f !important;
box-shadow: 0 0 0 2px rgb(255 77 79 / 20%) !important;
}
/* 下拉选择器的触发框错误样式 */
:deep(.el-select__wrapper.error-border) {
border-color: #ff4d4f !important;
box-shadow: 0 0 0 2px rgb(255 77 79 / 20%) !important;
}
.name-input-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
}
</style>

View File

@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ElInput, ElMessage, ElTable, ElTableColumn } from 'element-plus';
import { $t } from '#/locales';
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
editable: false,
isEditOutput: false,
});
const emit = defineEmits<Emits>();
export interface TreeTableNode {
key: string;
name: string;
description: string;
method?: 'Body' | 'Header' | 'Path' | 'Query';
required?: boolean;
defaultValue?: string;
enabled?: boolean;
type?: string;
children?: TreeTableNode[];
}
interface Props {
modelValue?: TreeTableNode[];
editable?: boolean;
isEditOutput?: boolean;
}
const data = ref<TreeTableNode[]>([]);
const expandedKeys = ref<string[]>(['1']);
const errors = ref<
Record<string, Partial<Record<keyof TreeTableNode, string>>>
>({});
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
data.value = newVal;
}
},
{ immediate: true, deep: true },
);
// 计算缩进宽度
const getIndentWidth = (record: TreeTableNode): number => {
const level = String(record.key).split('-').length - 1;
const indentSize = 20;
return level > 0 ? level * indentSize : 0;
};
// 数据变化处理
const handleDataChange = () => {
emit('update:modelValue', data.value);
};
// 展开/折叠处理
const onExpand = (_row: TreeTableNode, expandedRows: TreeTableNode[]) => {
expandedKeys.value = expandedRows.map((item) => item.key);
};
// 验证字段
const validateFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
> = {};
let isValid = true;
const checkNode = (node: TreeTableNode): boolean => {
const { name, description, method, type } = node;
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
if (!name?.trim()) {
nodeErrors.name = $t('message.cannotBeEmpty.name');
isValid = false;
}
if (!description?.trim()) {
nodeErrors.description = $t('message.cannotBeEmpty.description');
isValid = false;
}
if (isRootNode(node) && !method && !props.isEditOutput) {
nodeErrors.method = $t('message.cannotBeEmpty.method');
isValid = false;
}
if (!type) {
nodeErrors.type = $t('message.cannotBeEmpty.type');
isValid = false;
}
if (Object.keys(nodeErrors).length > 0) {
newErrors[node.key] = nodeErrors;
}
if (node.children) {
node.children.forEach((child) => {
if (!checkNode(child)) isValid = false;
});
}
return isValid;
};
data.value.forEach((node) => {
if (!checkNode(node)) isValid = false;
});
errors.value = newErrors;
return isValid;
};
// 判断是否为根节点
const isRootNode = (record: TreeTableNode): boolean => {
return !record.key.includes('-');
};
// 提交参数
const handleSubmitParams = () => {
if (!validateFields()) {
ElMessage.error($t('message.completeForm'));
return;
}
return data.value;
};
defineExpose({
handleSubmitParams,
});
interface Emits {
(e: 'update:modelValue', value: TreeTableNode[]): void;
(e: 'submit', value: TreeTableNode[]): void;
}
</script>
<template>
<div class="tree-table-container">
<ElTable
:data="data"
row-key="key"
:border="true"
size="default"
:expand-row-keys="expandedKeys"
@expand-change="onExpand"
style="width: 100%; overflow-x: auto"
>
<ElTableColumn
prop="name"
:label="$t('pluginItem.parameterName')"
class-name="first-column"
>
<template #default="{ row }">
<div class="name-cell">
<div
v-if="!props.editable"
:style="{ paddingLeft: `${getIndentWidth(row)}px` }"
>
{{ row.name || '' }}
</div>
<div v-else class="editable-name">
<div class="name-input-wrapper">
<div :style="{ width: `${getIndentWidth(row)}px` }"></div>
<ElInput
v-model="row.name"
:disabled="row.name === 'arrayItem'"
@input="handleDataChange"
/>
</div>
<div
v-if="errors[row.key]?.name"
class="error-message"
:style="{ marginLeft: `${getIndentWidth(row)}px` }"
>
{{ errors[row.key]?.name }}
</div>
</div>
</div>
</template>
</ElTableColumn>
<!-- 参数值-->
<ElTableColumn
prop="defaultValue"
:label="$t('plugin.parameterValue')"
width="150px"
>
<template #default="{ row }">
<span v-if="row.type === 'Object'"></span>
<span v-else-if="!props.editable">{{ row.defaultValue || '' }}</span>
<ElInput
v-else
v-model="row.defaultValue"
@input="handleDataChange"
:disabled="!props.editable"
/>
</template>
</ElTableColumn>
</ElTable>
</div>
</template>
<style scoped>
.tree-table-container {
box-sizing: border-box;
width: 100%;
overflow-x: auto;
}
.name-cell {
position: relative;
min-width: 100%;
}
.editable-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-input-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.name-input-wrapper .el-input {
box-sizing: border-box;
width: 100%;
}
.error-message {
margin-top: 2px;
font-size: 12px;
line-height: 1.2;
color: #ff4d4f;
}
.action-buttons .el-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
padding: 0;
}
:deep(.el-table td.el-table__cell.first-column div) {
display: flex;
gap: 2px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { VideoPlay } from '@element-plus/icons-vue';
import { ElButton, ElDialog, ElMenu, ElMenuItem } from 'element-plus';
import { JsonViewer } from 'vue3-json-viewer';
import { api } from '#/api/request';
import PluginRunParams from '#/views/ai/plugin/PluginRunParams.vue';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
const props = defineProps<{
pluginToolId: string;
}>();
const themeMode = ref(preferences.theme.mode);
watch(
() => preferences.theme.mode,
(newVal) => {
themeMode.value = newVal;
},
);
const dialogVisible = ref(false);
const openDialog = () => {
getPluginToolInfo();
runResultResponse.value = null;
dialogVisible.value = true;
};
const runTitle = ref('');
const runResult = ref('');
const inputDataParams = ref<any>(null);
const runResultResponse = ref<any>(null);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: props.pluginToolId,
})
.then((res) => {
if (res.errorCode === 0) {
runTitle.value = `${res.data.aiPlugin.title} - ${res.data.data.name} ${$t(
'pluginItem.inputData',
)}`;
runResult.value = `${$t('pluginItem.pluginToolEdit.runResult')}`;
inputDataParams.value = JSON.parse(res.data.data.inputData || '[]');
}
});
}
const activeIndex = ref('1');
defineExpose({
openDialog,
});
function handleSelect(index: string) {
activeIndex.value = index;
}
const runParamsRef = ref();
const runLoading = ref(false);
function handleSubmitRun() {
runLoading.value = true;
const runParams = runParamsRef.value.handleSubmitParams();
api
.post('/api/v1/pluginItem/test', {
pluginToolId: props.pluginToolId,
inputData: JSON.stringify(runParams),
})
.then((res) => {
if (res.errorCode === 0) {
runResultResponse.value = res.data;
activeIndex.value = '2';
}
runLoading.value = false;
});
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:close-on-click-modal="false"
width="80%"
align-center
class="run-test-dialog"
:title="$t('pluginItem.pluginToolEdit.trialRun')"
>
<div class="run-test-container">
<div class="run-test-params">
<div class="run-title-style">
{{ runTitle }}
</div>
<div>
<PluginRunParams
v-model="inputDataParams"
:editable="true"
:is-edit-output="true"
ref="runParamsRef"
/>
</div>
</div>
<div class="run-test-result">
<div class="run-title-style">
{{ runResult }}
</div>
<div>
<ElMenu
:default-active="activeIndex"
class="el-menu-demo"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
>
<ElMenuItem index="1">Request</ElMenuItem>
<ElMenuItem index="2">Response</ElMenuItem>
</ElMenu>
</div>
<div class="run-res-json">
<JsonViewer
v-if="activeIndex === '1'"
:value="inputDataParams || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<JsonViewer
v-if="activeIndex === '2'"
:value="runResultResponse || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
</div>
</div>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:icon="VideoPlay"
@click="handleSubmitRun"
:loading="runLoading"
>
{{ $t('pluginItem.pluginToolEdit.run') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.run-test-container {
display: flex;
gap: 16px;
width: 100%;
height: calc(100vh - 161px);
}
.run-test-dialog {
}
.run-test-params {
width: 100%;
overflow: auto;
flex: 1;
}
.run-res-json {
width: 100%;
flex: 1;
overflow: auto;
}
.run-test-result {
flex: 1;
display: flex;
flex-direction: column;
}
.name-cell {
position: relative;
min-width: 100%;
}
.run-title-style {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
}
.editable-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-input-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.name-input-wrapper .el-input {
box-sizing: border-box;
width: 100%;
}
.error-message {
color: #ff4d4f;
font-size: 12px;
margin-top: 2px;
line-height: 1.2;
}
:deep(.el-table td.el-table__cell.first-column div) {
display: flex;
align-items: center;
gap: 2px;
}
</style>

View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template></template>
<style scoped></style>

View File

@@ -0,0 +1,763 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { ArrowDown, Back, VideoPlay } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.vue';
import PluginRunTestModal from '#/views/ai/plugin/PluginRunTestModal.vue';
const route = useRoute();
const router = useRouter();
const toolId = ref<string>((route.query.id as string) || '');
onMounted(() => {
if (!toolId.value) {
return;
}
getPluginToolInfo();
});
const pluginToolInfo = ref<any>({
name: '',
englishName: '',
description: '',
basePath: '',
requestMethod: '',
});
const pluginInfo = ref<any>({});
const pluginInputData = ref<any[]>([]);
const pluginOutputData = ref<any[]>([]);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: toolId.value,
})
.then((res) => {
if (res.errorCode === 0) {
pluginToolInfo.value = res.data.data;
pluginInfo.value = res.data.aiPlugin;
pluginInputData.value = JSON.parse(res.data.data.inputData || '[]');
pluginOutputData.value = JSON.parse(res.data.data.outputData || '[]');
}
});
}
const pluginInputParamsEditable = ref(false);
const pluginOutputParamsEditable = ref(false);
const pluginBasicCollapse = ref({
title: $t('pluginItem.pluginToolEdit.basicInfo'),
isOpen: true,
isEdit: false,
});
const pluginBasicCollapseInputParams = ref({
title: $t('pluginItem.pluginToolEdit.configureInputParameters'),
isOpen: false,
isEdit: false,
});
const pluginBasicCollapseOutputParams = ref({
title: $t('pluginItem.pluginToolEdit.configureOutputParameters'),
isOpen: false,
isEdit: false,
});
const pluginInputParamsRef = ref();
const pluginOutputParamsRef = ref();
const handleClickHeader = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isOpen = !pluginBasicCollapse.value.isOpen;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isOpen =
!pluginBasicCollapseInputParams.value.isOpen;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isOpen =
!pluginBasicCollapseOutputParams.value.isOpen;
break;
}
// No default
}
};
const back = () => {
router.back();
};
const rules = reactive({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
requestMethod: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
basePath: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
englishName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
description: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
whiteSpace: true,
},
],
});
const saveForm = ref();
const updatePluginTool = (index: number) => {
if (index === 1) {
if (!saveForm.value) return;
saveForm.value.validate((valid: boolean) => {
if (valid) {
updatePluginToolInfo(index);
}
});
} else {
updatePluginToolInfo(index);
}
};
const updatePluginToolInfo = (index: number) => {
api
.post('/api/v1/pluginItem/tool/update', {
id: toolId.value,
name: pluginToolInfo.value.name,
englishName: pluginToolInfo.value.englishName,
description: pluginToolInfo.value.description,
basePath: pluginToolInfo.value.basePath,
requestMethod: pluginToolInfo.value.requestMethod,
inputData: JSON.stringify(pluginInputData.value),
outputData: JSON.stringify(pluginOutputData.value),
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
break;
}
// No default
}
}
});
};
const handleEdit = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = true;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = true;
pluginBasicCollapseInputParams.value.isOpen = true;
pluginInputParamsEditable.value = true;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = true;
pluginBasicCollapseOutputParams.value.isOpen = true;
pluginOutputParamsEditable.value = true;
break;
}
// No default
}
};
const handleSave = (index: number) => {
if (index === 2) {
try {
// 调用校验方法,若抛异常则进入 catch
pluginInputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
if (index === 3) {
try {
pluginOutputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
pluginInputParamsEditable.value = false;
updatePluginTool(index);
};
const handleCancel = (index: number) => {
getPluginToolInfo();
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
pluginInputParamsEditable.value = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
pluginOutputParamsEditable.value = false;
break;
}
// No default
}
};
const requestMethodOptions = [
{
label: 'POST',
value: 'POST',
},
{
label: 'GET',
value: 'GET',
},
{
label: 'PUT',
value: 'PUT',
},
{
label: 'DELETE',
value: 'DELETE',
},
{
label: 'PATCH',
value: 'PATCH',
},
];
const runTestRef = ref();
const handleOpenRunModal = () => {
runTestRef.value.openDialog();
};
</script>
<template>
<div class="accordion-container">
<div class="controls-header">
<ElButton @click="back" :icon="Back">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" :icon="VideoPlay" @click="handleOpenRunModal">
{{ $t('pluginItem.pluginToolEdit.trialRun') }}
</ElButton>
</div>
<!-- 折叠面板列表 -->
<div class="accordion-list">
<!-- 基本信息-->
<div
class="accordion-item"
:class="{ 'accordion-item--active': pluginBasicCollapse.isOpen }"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(1)">
<div class="column-header-container">
<div
class="accordion-icon"
:class="{ 'accordion-icon--rotated': pluginBasicCollapse.isOpen }"
>
<ElIcon size="16">
<ArrowDown />
</ElIcon>
</div>
<h3 class="accordion-title">{{ pluginBasicCollapse.title }}</h3>
</div>
<div>
<ElButton
@click.stop="handleEdit(1)"
type="primary"
v-if="!pluginBasicCollapse.isEdit"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(1)"
v-if="pluginBasicCollapse.isEdit"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(1)"
type="primary"
v-if="pluginBasicCollapse.isEdit"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!-- 面板内容 -->
<div
class="accordion-content"
:class="{ 'accordion-content--open': pluginBasicCollapse.isOpen }"
>
<div class="accordion-content-inner">
<!--编辑基本信息-->
<div v-show="pluginBasicCollapse.isEdit">
<div class="plugin-tool-info-edit-container">
<ElForm
ref="saveForm"
:model="pluginToolInfo"
label-width="80px"
status-icon
:rules="rules"
>
<ElFormItem :label="$t('pluginItem.name')" prop="name">
<ElInput v-model.trim="pluginToolInfo.name" />
</ElFormItem>
<ElFormItem
:label="$t('pluginItem.englishName')"
prop="englishName"
>
<ElInput v-model.trim="pluginToolInfo.englishName" />
</ElFormItem>
<ElFormItem
:label="$t('pluginItem.pluginToolEdit.toolPath')"
prop="basePath"
>
<ElInput v-model.trim="pluginToolInfo.basePath">
<template #prepend>{{ pluginInfo.baseUrl }}</template>
</ElInput>
</ElFormItem>
<ElFormItem
:label="$t('pluginItem.description')"
prop="description"
>
<ElInput
v-model.trim="pluginToolInfo.description"
type="textarea"
:rows="4"
/>
</ElFormItem>
<ElFormItem
:label="$t('pluginItem.pluginToolEdit.requestMethod')"
prop="requestMethod"
>
<ElSelect
v-model="pluginToolInfo.requestMethod"
:placeholder="$t('ui.placeholder.select')"
>
<ElOption
v-for="option in requestMethodOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</div>
</div>
<!--显示基本信息-->
<div
v-show="!pluginBasicCollapse.isEdit"
class="plugin-tool-info-view-container"
>
<div class="plugin-tool-view-item">
<div class="view-item-title">{{ $t('pluginItem.name') }}:</div>
<div>{{ pluginToolInfo.name }}</div>
</div>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.englishName') }}:
</div>
<div>{{ pluginToolInfo.englishName }}</div>
</div>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.description') }}:
</div>
<div>{{ pluginToolInfo.description }}</div>
</div>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.toolPath') }}:
</div>
<div>{{ pluginInfo.baseUrl }}{{ pluginToolInfo.basePath }}</div>
</div>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.requestMethod') }}:
</div>
<div>
{{ pluginToolInfo.requestMethod }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseInputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(2)">
<div class="column-header-container">
<div
class="accordion-icon"
:class="{
'accordion-icon--rotated':
pluginBasicCollapseInputParams.isOpen,
}"
>
<ElIcon size="16">
<ArrowDown />
</ElIcon>
</div>
<h3 class="accordion-title">
{{ pluginBasicCollapseInputParams.title }}
</h3>
</div>
<div>
<ElButton
@click.stop="handleEdit(2)"
type="primary"
v-if="!pluginBasicCollapseInputParams.isEdit"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(2)"
v-if="pluginBasicCollapseInputParams.isEdit"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(2)"
type="primary"
v-if="pluginBasicCollapseInputParams.isEdit"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输入参数-->
<div
class="accordion-content"
:class="{
'accordion-content--open': pluginBasicCollapseInputParams.isOpen,
}"
>
<div class="accordion-content-inner">
<PluginInputAndOutParams
ref="pluginInputParamsRef"
v-model="pluginInputData"
:editable="pluginInputParamsEditable"
:is-edit-output="false"
/>
</div>
</div>
</div>
<!-- 输出参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseOutputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(3)">
<div class="column-header-container">
<div
class="accordion-icon"
:class="{
'accordion-icon--rotated':
pluginBasicCollapseOutputParams.isOpen,
}"
>
<ElIcon size="16">
<ArrowDown />
</ElIcon>
</div>
<h3 class="accordion-title">
{{ pluginBasicCollapseOutputParams.title }}
</h3>
</div>
<div>
<ElButton
@click.stop="handleEdit(3)"
type="primary"
v-if="!pluginBasicCollapseOutputParams.isEdit"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(3)"
v-if="pluginBasicCollapseOutputParams.isEdit"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(3)"
type="primary"
v-if="pluginBasicCollapseOutputParams.isEdit"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输出参数-->
<div
class="accordion-content"
:class="{
'accordion-content--open': pluginBasicCollapseOutputParams.isOpen,
}"
>
<div class="accordion-content-inner">
<PluginInputAndOutParams
v-model="pluginOutputData"
ref="pluginOutputParamsRef"
:editable="pluginOutputParamsEditable"
:is-edit-output="true"
/>
</div>
</div>
</div>
</div>
<!-- 试运行模态框-->
<PluginRunTestModal ref="runTestRef" :plugin-tool-id="toolId" />
</div>
</template>
<style scoped>
/* 响应式设计 */
@media (max-width: 768px) {
.accordion-container {
padding: 15px;
}
.controls {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.control-group {
justify-content: center;
}
.title {
font-size: 1.5rem;
}
.accordion-header {
padding: 14px 16px;
}
.accordion-title {
font-size: 1rem;
}
}
.accordion-container {
max-width: 100%;
padding: 20px;
margin: 0 auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.controls-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
margin-bottom: 8px;
font-size: 2rem;
font-weight: 600;
color: var(--el-text-color-secondary);
text-align: center;
}
.subtitle {
margin-bottom: 30px;
font-size: 1.1rem;
color: var(--el-text-color-secondary);
text-align: center;
}
/* 控制面板样式 */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
margin-bottom: 30px;
background: var(--el-bg-color);
border: 1px solid #e9ecef;
border-radius: 8px;
}
.control-group {
display: flex;
gap: 15px;
align-items: center;
}
.checkbox-label {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
}
.control-btn {
padding: 8px 16px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-bg-color);
border-radius: 4px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: #3498db;
background: var(--el-color-primary-light-9);
}
/* 折叠面板列表 */
.accordion-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
}
.accordion-item {
overflow: hidden;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition: all 0.3s ease;
}
.accordion-item:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
}
.accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
cursor: pointer;
user-select: none;
background: hsl(var(--background));
transition: background-color 0.3s ease;
}
.accordion-title {
padding-left: 12px;
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.accordion-icon {
font-size: 12px;
color: #7f8c8d;
transition: transform 0.3s ease;
}
.accordion-icon--rotated {
transform: rotate(180deg);
}
.accordion-content {
max-height: 0;
overflow: hidden;
background: hsl(var(--background));
transition: max-height 0.4s ease;
}
.accordion-content--open {
max-height: 2000px;
}
.accordion-content-inner {
padding: 20px;
border-top: 1px solid hsl(var(--border));
}
.accordion-content-inner p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.column-header-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.plugin-tool-info-view-container {
display: flex;
flex-direction: column;
gap: 25px;
}
.plugin-tool-view-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
}
.view-item-title {
width: 70px;
/* text-align: right; */
/* margin-right: 12px; */
}
</style>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Delete, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
import AiPluginToolModal from '#/views/ai/plugin/AiPluginToolModal.vue';
const props = defineProps({
pluginId: {
required: true,
type: String,
},
});
const router = useRouter();
defineExpose({
openPluginToolModal() {
aiPluginToolRef.value.openDialog();
},
reload: () => {
pageDataRef.value.setQuery({ pluginId: props.pluginId });
},
handleSearch: (params: string) => {
pageDataRef.value.setQuery({
pluginId: props.pluginId,
isQueryOr: true,
name: params,
});
},
});
const pageDataRef = ref();
const handleEdit = (row: any) => {
router.push({
path: '/ai/plugin/tool/edit',
query: {
id: row.id,
pageKey: '/ai/plugin',
},
});
};
const handleDelete = (row: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
}).then(() => {
api.post('/api/v1/pluginItem/remove', { id: row.id }).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({ pluginId: props.pluginId });
}
});
});
};
const aiPluginToolRef = ref();
const pluginToolReload = () => {
pageDataRef.value.setQuery({ pluginId: props.pluginId });
};
</script>
<template>
<PageData
page-url="/api/v1/pluginItem/page"
ref="pageDataRef"
:page-size="10"
:extra-query-params="{ pluginId: props.pluginId }"
>
<template #default="{ pageList }">
<ElTable :data="pageList" style="width: 100%" size="large">
<ElTableColumn prop="name" :label="$t('pluginItem.name')" />
<ElTableColumn
prop="description"
:label="$t('pluginItem.description')"
/>
<ElTableColumn prop="created" :label="$t('pluginItem.created')" />
<ElTableColumn
fixed="right"
:label="$t('common.handle')"
width="100"
align="right"
>
<template #default="scope">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="handleEdit(scope.row)">
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleDelete(scope.row)">
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
<AiPluginToolModal
ref="aiPluginToolRef"
:plugin-id="pluginId"
@reload="pluginToolReload"
/>
</template>
<style scoped>
.time-container {
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { markRaw, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Back, Plus } from '@element-plus/icons-vue';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
const route = useRoute();
const router = useRouter();
const pluginId = ref<string>((route.query.id as string) || '');
const headerButtons = [
{
key: 'back',
text: $t('button.back'),
icon: markRaw(Back),
data: { action: 'back' },
},
{
key: 'createTool',
text: $t('pluginItem.createPluginTool'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'createTool' },
},
];
const handleSearch = (params: any) => {
pluginToolRef.value.handleSearch(params);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
switch (event.key) {
case 'back': {
router.push({ path: '/ai/plugin' });
break;
}
case 'createTool': {
pluginToolRef.value.openPluginToolModal({});
break;
}
}
};
const pluginToolRef = ref();
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<PluginToolTable :plugin-id="pluginId" ref="pluginToolRef" />
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ElButton, ElDialog } from 'element-plus';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import ResourceCardList from '#/views/ai/resource/ResourceCardList.vue';
const props = defineProps({
attrName: {
type: String,
required: true,
},
});
const emit = defineEmits(['choose']);
const pageDataRef = ref();
const dialogVisible = ref(false);
const chooseResources = ref([]);
const currentChoose = ref<any>({});
function openDialog() {
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
}
function confirm() {
emit('choose', currentChoose.value, props.attrName);
closeDialog();
}
watch(
() => chooseResources.value,
(newValue) => {
currentChoose.value = newValue.length > 0 ? newValue[0] : {};
},
);
</script>
<template>
<div>
<ElDialog
v-model="dialogVisible"
draggable
:title="$t('aiResource.choose')"
:before-close="closeDialog"
:close-on-click-modal="false"
width="80%"
destroy-on-close
>
<PageData
ref="pageDataRef"
page-url="/api/v1/resource/page"
:page-size="8"
:page-sizes="[8, 12, 16, 20]"
>
<template #default="{ pageList }">
<ResourceCardList v-model="chooseResources" :data="pageList" />
</template>
</PageData>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="confirm">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
<ElButton @click="openDialog()">
{{ $t('button.choose') }}
</ElButton>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog, ElImage } from 'element-plus';
defineExpose({
openDialog,
});
const dialogVisible = ref(false);
const data = ref<any>();
function openDialog(row: any) {
data.value = row;
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="$t('message.preview')"
:before-close="closeDialog"
:close-on-click-modal="false"
destroy-on-close
>
<div class="flex justify-center">
<ElImage
v-if="data.resourceType === 0"
style="width: 200px"
:preview-src-list="[data.resourceUrl]"
:src="data.resourceUrl"
/>
<video v-if="data.resourceType === 1" controls width="640" height="360">
<source :src="data.resourceUrl" type="video/mp4" />
{{ $t('message.notVideo') }}
</video>
<audio v-if="data.resourceType === 2" controls :src="data.resourceUrl">
{{ $t('message.notAudio') }}
</audio>
</div>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
ElCard,
ElCheckbox,
ElCol,
ElImage,
ElRadio,
ElRow,
ElText,
ElTooltip,
} from 'element-plus';
import Tag from '#/components/tag/Tag.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
import {
getResourceOriginColor,
getResourceTypeColor,
getSrc,
} from '#/utils/resource';
import PreviewModal from '#/views/ai/resource/PreviewModal.vue';
export interface ResourceCardProps {
data: any[];
multiple?: boolean;
valueProp?: string;
}
const props = withDefaults(defineProps<ResourceCardProps>(), {
multiple: false,
valueProp: 'id',
});
const emit = defineEmits(['update:modelValue']);
onMounted(() => {
initDict();
});
const dictStore = useDictStore();
function initDict() {
dictStore.fetchDictionary('resourceType');
dictStore.fetchDictionary('resourceOriginType');
}
const previewDialog = ref();
const radioValue = ref('');
const checkAll = ref(false);
function choose() {
const arr = [];
if (props.multiple) {
for (const data of props.data) {
if (data.checkboxValue) {
arr.push(data);
}
}
} else {
if (radioValue.value) {
for (const data of props.data) {
if (data[props.valueProp] === radioValue.value) {
arr.push(data);
}
}
}
}
emit('update:modelValue', arr);
}
function handleCheckAllChange(val: any) {
if (val) {
for (const data of props.data) {
data.checkboxValue = data[props.valueProp];
}
} else {
for (const data of props.data) {
data.checkboxValue = '';
}
}
}
function preview(row: any) {
previewDialog.value.openDialog({ ...row });
}
watch(
[() => radioValue.value, () => props.data],
() => {
choose();
},
{ deep: true },
);
</script>
<template>
<div>
<PreviewModal ref="previewDialog" />
<ElCheckbox
v-if="multiple"
:label="$t('button.selectAll')"
v-model="checkAll"
@change="handleCheckAllChange"
/>
<ElRow :gutter="20">
<ElCol :span="6" v-for="item in data" :key="item.id" class="mb-5">
<ElCard
:body-style="{ padding: '12px', height: '285px' }"
shadow="hover"
>
<div>
<div>
<ElCheckbox
v-if="multiple"
v-model="item.checkboxValue"
:true-value="item[valueProp]"
false-value=""
/>
<ElRadio v-else v-model="radioValue" :value="item[valueProp]" />
</div>
<div>
<ElImage
@click="preview(item)"
:src="getSrc(item)"
style="width: 100%; height: 150px; cursor: pointer"
/>
</div>
<div>
<ElTooltip
:content="`${item.resourceName}.${item.suffix}`"
placement="top"
>
<ElText truncated>
{{ item.resourceName }}.{{ item.suffix }}
</ElText>
</ElTooltip>
</div>
<div class="flex gap-1.5">
<Tag
size="small"
:background-color="`${getResourceOriginColor(item)}15`"
:text-color="getResourceOriginColor(item)"
:text="
dictStore.getDictLabel('resourceOriginType', item.origin)
"
/>
<Tag
size="small"
:background-color="`${getResourceTypeColor(item)}15`"
:text-color="getResourceTypeColor(item)"
:text="
dictStore.getDictLabel('resourceType', item.resourceType)
"
/>
</div>
</div>
</ElCard>
</ElCol>
</ElRow>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,467 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { formatBytes } from '@easyflow/utils';
import {
Delete,
Download,
Edit,
MoreFilled,
Plus,
} from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
ElText,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import Tag from '#/components/tag/Tag.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
import {
getResourceOriginColor,
getResourceTypeColor,
getSrc,
} from '#/utils/resource';
import PreviewModal from '#/views/ai/resource/PreviewModal.vue';
import ResourceModal from './ResourceModal.vue';
onMounted(() => {
initDict();
getSideList();
});
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const saveDialog = ref();
const previewDialog = ref();
const formInline = ref({
resourceName: '',
resourceType: '',
});
const dictStore = useDictStore();
function initDict() {
dictStore.fetchDictionary('resourceType');
dictStore.fetchDictionary('resourceOriginType');
}
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
pageDataRef.value.setQuery(formInline.value);
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/resource/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function preview(row: any) {
previewDialog.value.openDialog({ ...row });
}
function download(row: any) {
window.open(row.resourceUrl, '_blank');
}
const fieldDefinitions = ref<any[]>([
{
prop: 'categoryName',
label: $t('aiWorkflowCategory.categoryName'),
type: 'input',
required: true,
placeholder: $t('aiWorkflowCategory.categoryName'),
},
{
prop: 'sortNo',
label: $t('aiWorkflowCategory.sortNo'),
type: 'number',
required: false,
placeholder: $t('aiWorkflowCategory.sortNo'),
},
]);
const sideList = ref<any>([]);
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row: any) {
showControlDialog(row);
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row: any) {
removeCategory(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
showControlDialog({});
},
};
const sideDialogVisible = ref(false);
const sideFormData = ref<any>({});
const sideFormRef = ref<FormInstance>();
const sideFormRules = computed(() => {
const rules: Record<string, any[]> = {};
fieldDefinitions.value.forEach((field) => {
const fieldRules = [];
if (field.required) {
fieldRules.push({
required: true,
message: `${$t('message.required')}`,
trigger: 'blur',
});
}
if (fieldRules.length > 0) {
rules[field.prop] = fieldRules;
}
});
return rules;
});
const sideSaveLoading = ref(false);
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
}
function showControlDialog(item: any) {
sideFormRef.value?.resetFields();
sideFormData.value = { ...item };
sideDialogVisible.value = true;
}
function removeCategory(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/resourceCategory/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
done();
getSideList();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function handleSideSubmit() {
formRef.value?.validate((valid) => {
if (valid) {
sideSaveLoading.value = true;
const url = sideFormData.value.id
? '/api/v1/resourceCategory/update'
: '/api/v1/resourceCategory/save';
api.post(url, sideFormData.value).then((res) => {
sideSaveLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
sideDialogVisible.value = false;
getSideList();
}
});
}
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/resourceCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
if (res && res.errorCode === 0) {
sideList.value = [
{
id: '',
categoryName: $t('common.allCategories'),
},
...res.data,
];
}
};
</script>
<template>
<div class="flex h-full flex-col gap-1.5 p-6">
<PreviewModal ref="previewDialog" />
<ResourceModal ref="saveDialog" @reload="reset" />
<div class="flex items-center justify-between">
<ElForm ref="formRef" inline :model="formInline">
<ElFormItem prop="resourceType" class="!mr-3">
<DictSelect
v-model="formInline.resourceType"
dict-code="resourceType"
:placeholder="$t('aiResource.resourceType')"
/>
</ElFormItem>
<ElFormItem prop="resourceName" class="!mr-3">
<ElInput
v-model="formInline.resourceName"
:placeholder="$t('aiResource.resourceName')"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }}
</ElButton>
<ElButton @click="reset(formRef)">
{{ $t('button.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
<div class="handle-div">
<ElButton
v-access:code="'/api/v1/resource/save'"
@click="showDialog({})"
type="primary"
>
<ElIcon class="mr-1">
<Plus />
</ElIcon>
{{ $t('button.add') }}
</ElButton>
</div>
</div>
<div class="flex max-h-[calc(100vh-191px)] flex-1 gap-6">
<PageSide
label-key="categoryName"
value-key="id"
:menus="sideList"
:control-btns="controlBtns"
:footer-button="footerButton"
@change="changeCategory"
/>
<div class="bg-background h-full flex-1 overflow-auto rounded-lg p-5">
<PageData
ref="pageDataRef"
page-url="/api/v1/resource/page"
:page-size="10"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn
prop="resourceName"
:label="$t('aiResource.resourceName')"
width="300"
>
<template #default="{ row }">
<div class="flex items-center gap-2.5">
<ElAvatar :src="getSrc(row)" shape="square" :size="32" />
<ElText truncated>
{{ row.resourceName }}
</ElText>
</div>
</template>
</ElTableColumn>
<ElTableColumn
align="center"
prop="suffix"
:label="$t('aiResource.suffix')"
width="60"
>
<template #default="{ row }">
{{ row.suffix }}
</template>
</ElTableColumn>
<ElTableColumn
align="center"
prop="fileSize"
:label="$t('aiResource.fileSize')"
>
<template #default="{ row }">
{{ formatBytes(row.fileSize) }}
</template>
</ElTableColumn>
<ElTableColumn
align="center"
prop="origin"
:label="$t('aiResource.origin')"
>
<template #default="{ row }">
<Tag
size="small"
:background-color="`${getResourceOriginColor(row)}15`"
:text-color="getResourceOriginColor(row)"
:text="
dictStore.getDictLabel('resourceOriginType', row.origin)
"
/>
</template>
</ElTableColumn>
<ElTableColumn
align="center"
prop="resourceType"
:label="$t('aiResource.resourceType')"
>
<template #default="{ row }">
<Tag
size="small"
:background-color="`${getResourceTypeColor(row)}15`"
:text-color="getResourceTypeColor(row)"
:text="
dictStore.getDictLabel('resourceType', row.resourceType)
"
/>
</template>
</ElTableColumn>
<ElTableColumn prop="created" :label="$t('aiResource.created')">
<template #default="{ row }">
{{ row.created }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('common.handle')"
width="140"
align="right"
>
<template #default="{ row }">
<div class="flex items-center gap-3">
<div class="flex items-center">
<ElButton link type="primary" @click="preview(row)">
{{ $t('button.view') }}
</ElButton>
<ElButton link type="primary" @click="showDialog(row)">
{{ $t('button.edit') }}
</ElButton>
</div>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="download(row)">
<ElButton :icon="Download" link>
{{ $t('button.download') }}
</ElButton>
</ElDropdownItem>
<div v-access:code="'/api/v1/resource/remove'">
<ElDropdownItem @click="remove(row)">
<ElButton type="danger" :icon="Delete" link>
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
</div>
<ElDialog
v-model="sideDialogVisible"
:title="sideFormData.id ? `${$t('button.edit')}` : `${$t('button.add')}`"
:close-on-click-modal="false"
>
<ElForm
ref="sideFormRef"
:model="sideFormData"
:rules="sideFormRules"
label-width="120px"
>
<!-- 动态生成表单项 -->
<ElFormItem
v-for="field in fieldDefinitions"
:key="field.prop"
:label="field.label"
:prop="field.prop"
>
<ElInput
v-if="!field.type || field.type === 'input'"
v-model="sideFormData[field.prop]"
:placeholder="field.placeholder"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
v-model="sideFormData[field.prop]"
:placeholder="field.placeholder"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="sideDialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="handleSideSubmit"
:loading="sideSaveLoading"
>
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { getResourceType } from '@easyflow/utils';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import Upload from '#/components/upload/Upload.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
deptId: '',
resourceType: '',
resourceName: '',
suffix: '',
resourceUrl: '',
origin: '',
status: '',
options: '',
fileSize: '',
});
const btnLoading = ref(false);
const rules = ref({
deptId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
resourceType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
resourceName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
suffix: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
resourceUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
origin: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
status: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
}
entity.value = row;
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
api
.post(
isAdd.value ? 'api/v1/resource/save' : 'api/v1/resource/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
dialogVisible.value = false;
}
function beforeUpload(f: any) {
const fName = f?.name?.split('.')[0];
const fExt = f?.name?.split('.')[1];
entity.value.resourceType = getResourceType(fExt);
entity.value.resourceName = fName;
entity.value.suffix = fExt;
entity.value.fileSize = f.size;
entity.value.origin = 0;
}
function uploadSuccess(res: any) {
entity.value.resourceUrl = res;
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
>
<ElForm
label-width="120px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem prop="resourceUrl" :label="$t('aiResource.resourceUrl')">
<Upload @before-upload="beforeUpload" @success="uploadSuccess" />
</ElFormItem>
<ElFormItem prop="origin" :label="$t('aiResource.origin')">
<DictSelect v-model="entity.origin" dict-code="resourceOriginType" />
</ElFormItem>
<ElFormItem prop="resourceType" :label="$t('aiResource.resourceType')">
<DictSelect v-model="entity.resourceType" dict-code="resourceType" />
</ElFormItem>
<ElFormItem prop="resourceName" :label="$t('aiResource.resourceName')">
<ElInput v-model.trim="entity.resourceName" />
</ElFormItem>
<ElFormItem prop="categoryId" :label="$t('aiResource.categoryId')">
<DictSelect
v-model="entity.categoryId"
dict-code="aiResourceCategory"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { sortNodes } from '@easyflow/utils';
import { ArrowLeft } from '@element-plus/icons-vue';
import { ElAvatar, ElButton, ElCard, ElCol, ElRow } from 'element-plus';
import { api } from '#/api/request';
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
import { $t } from '#/locales';
import { router } from '#/router';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
onMounted(async () => {
pageLoading.value = true;
await Promise.all([getWorkflowInfo(workflowId.value), getRunningParams()]);
pageLoading.value = false;
});
const pageLoading = ref(false);
const route = useRoute();
const workflowId = ref(route.query.id);
const workflowInfo = ref<any>({});
const runParams = ref<any>(null);
const initState = ref(false);
const tinyFlowData = ref<any>(null);
const workflowForm = ref();
async function getWorkflowInfo(workflowId: any) {
api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
workflowInfo.value = res.data;
tinyFlowData.value = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content)
: {};
});
}
async function getRunningParams() {
api
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
.then((res) => {
runParams.value = res.data;
});
}
function onSubmit() {
initState.value = !initState.value;
}
function resumeChain(data: any) {
workflowForm.value?.resume(data);
}
const chainInfo = ref<any>(null);
function onAsyncExecute(info: any) {
chainInfo.value = info;
}
</script>
<template>
<div
v-loading="pageLoading"
class="bg-background-deep flex h-full max-h-[calc(100vh-90px)] w-full flex-col gap-6 overflow-hidden p-6"
>
<div>
<ElButton :icon="ArrowLeft" @click="router.back()">
{{ $t('button.back') }}
</ElButton>
</div>
<div
class="flex h-[150px] shrink-0 items-center gap-6 rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] pl-11"
>
<ElAvatar
class="shrink-0"
:src="workflowInfo.icon ?? workflowIcon"
:size="72"
/>
<div class="flex flex-col gap-5">
<span class="text-2xl font-medium">{{ workflowInfo.title }}</span>
<span class="text-base text-[#75808d]">{{
workflowInfo.description
}}</span>
</div>
</div>
<ElRow class="h-full overflow-hidden" :gutter="10">
<ElCol :span="10" class="h-full overflow-hidden">
<div class="grid h-full grid-rows-2 gap-2.5">
<ElCard shadow="never" style="height: 100%; overflow: auto">
<div class="mb-2.5 font-semibold">
{{ $t('aiWorkflow.params') }}
</div>
<WorkflowForm
v-if="runParams && tinyFlowData"
ref="workflowForm"
:workflow-id="workflowId"
:workflow-params="runParams"
:on-submit="onSubmit"
:on-async-execute="onAsyncExecute"
:tiny-flow-data="tinyFlowData"
/>
</ElCard>
<ElCard shadow="never" style="height: 100%; overflow: auto">
<div class="mb-2.5 font-semibold">
{{ $t('aiWorkflow.steps') }}
</div>
<WorkflowSteps
v-if="tinyFlowData"
:workflow-id="workflowId"
:node-json="sortNodes(tinyFlowData)"
:init-signal="initState"
:polling-data="chainInfo"
@resume="resumeChain"
/>
</ElCard>
</div>
</ElCol>
<ElCol :span="14">
<ElCard shadow="never" style="height: 100%; overflow: auto">
<div class="mb-2.5 mt-2.5 font-semibold">
{{ $t('aiWorkflow.result') }}
</div>
<ExecResult
v-if="tinyFlowData"
:workflow-id="workflowId"
:node-json="sortNodes(tinyFlowData)"
:init-signal="initState"
:polling-data="chainInfo"
/>
</ElCard>
</ElCol>
</ElRow>
</div>
</template>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { getOptions, sortNodes } from '@easyflow/utils';
import { ArrowLeft, Position } from '@element-plus/icons-vue';
import { Tinyflow } from '@tinyflow-ai/vue';
import { ElButton, ElDrawer, ElMessage, ElSkeleton } from 'element-plus';
import { api } from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import { getCustomNode } from './customNode/index';
import nodeNames from './customNode/nodeNames';
import '@tinyflow-ai/vue/dist/index.css';
const route = useRoute();
// vue
onMounted(async () => {
document.addEventListener('keydown', handleKeydown);
await Promise.all([
loadCustomNode(),
getLlmList(),
getKnowledgeList(),
getWorkflowInfo(workflowId.value),
]);
showTinyFlow.value = true;
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
// variables
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
const workflowId = ref(route.query.id);
const workflowInfo = ref<any>({});
const runParams = ref<any>(null);
const tinyFlowData = ref<any>(null);
const llmList = ref<any>([]);
const knowledgeList = ref<any>([]);
const provider = computed(() => ({
llm: () => getOptions('title', 'id', llmList.value),
knowledge: () => getOptions('title', 'id', knowledgeList.value),
searchEngine: (): any => [
{
value: 'bocha-search',
label: $t('aiWorkflow.bochaSearch'),
},
],
}));
const customNode = ref();
const showTinyFlow = ref(false);
const saveLoading = ref(false);
const handleKeydown = (event: KeyboardEvent) => {
// 检查是否是 Ctrl+S
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault(); // 阻止浏览器默认保存行为
if (!saveLoading.value) {
handleSave(true);
}
}
};
const drawerVisible = ref(false);
const initState = ref(false);
const singleNode = ref<any>();
const singleRunVisible = ref(false);
const workflowForm = ref();
const workflowSelectRef = ref();
const updateWorkflowNode = ref<any>(null);
const pluginSelectRef = ref();
const updatePluginNode = ref<any>(null);
const pageLoading = ref(false);
const chainInfo = ref<any>(null);
// functions
async function loadCustomNode() {
customNode.value = await getCustomNode({
handleChosen: (nodeName: string, updateNodeData: any, value: string) => {
const v = [];
if (value) {
v.push(value);
}
if (nodeName === nodeNames.workflowNode) {
workflowSelectRef.value.openDialog(v);
updateWorkflowNode.value = updateNodeData;
}
if (nodeName === nodeNames.pluginNode) {
pluginSelectRef.value.openDialog(v);
updatePluginNode.value = updateNodeData;
}
},
});
}
async function runWorkflow() {
if (!saveLoading.value) {
await handleSave().then(() => {
getWorkflowInfo(workflowId.value);
getRunningParams();
});
}
}
async function handleSave(showMsg: boolean = false) {
saveLoading.value = true;
await api
.post('/api/v1/workflow/update', {
id: workflowId.value,
content: tinyflowRef.value?.getData(),
})
.then((res) => {
saveLoading.value = false;
if (res.errorCode === 0 && showMsg) {
ElMessage.success(res.message);
}
});
}
async function getWorkflowInfo(workflowId: any) {
api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
workflowInfo.value = res.data;
tinyFlowData.value = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content)
: {};
});
}
async function getLlmList() {
api.get('/api/v1/model/list').then((res) => {
llmList.value = res.data;
});
}
async function getKnowledgeList() {
api.get('/api/v1/documentCollection/list').then((res) => {
knowledgeList.value = res.data;
});
}
function getRunningParams() {
api
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
.then((res) => {
if (res.errorCode === 0) {
runParams.value = res.data;
drawerVisible.value = true;
}
});
}
function onSubmit() {
initState.value = !initState.value;
}
async function runIndependently(node: any) {
if (node.type === 'loopNode') {
ElMessage.warning($t('message.notSupported'));
return;
}
await handleSave();
singleNode.value = node;
singleRunVisible.value = true;
}
function resumeChain(data: any) {
workflowForm.value?.resume(data);
}
function handleChoose(nodeName: string, value: any) {
if (nodeName === nodeNames.workflowNode) {
handleWorkflowNodeUpdate(value[0]);
}
if (nodeName === nodeNames.pluginNode) {
handlePluginNodeUpdate(value[0]);
}
}
function handleWorkflowNodeUpdate(chooseId: any) {
pageLoading.value = true;
api
.get('/api/v1/workflowNode/getChainParams', {
params: {
currentId: workflowId.value,
workflowId: chooseId,
},
})
.then((res) => {
pageLoading.value = false;
updateWorkflowNode.value(res.data);
});
}
function handlePluginNodeUpdate(chooseId: any) {
pageLoading.value = true;
api
.get('/api/v1/pluginItem/getTinyFlowData', {
params: {
id: chooseId,
},
})
.then((res) => {
pageLoading.value = false;
updatePluginNode.value(res.data);
});
}
function onAsyncExecute(info: any) {
chainInfo.value = info;
}
</script>
<template>
<div class="head-div h-full w-full" v-loading="pageLoading">
<CommonSelectDataModal
ref="workflowSelectRef"
page-url="/api/v1/workflow/page"
@get-data="(v) => handleChoose(nodeNames.workflowNode, v)"
/>
<CommonSelectDataModal
:title="$t('menus.ai.plugin')"
width="730"
ref="pluginSelectRef"
page-url="/api/v1/plugin/page"
:has-parent="true"
single-select
@get-data="(v) => handleChoose(nodeNames.pluginNode, v)"
/>
<ElDrawer
v-model="singleRunVisible"
:title="singleNode?.data?.title"
destroy-on-close
size="600px"
>
<SingleRun :node="singleNode" :workflow-id="workflowId" />
</ElDrawer>
<ElDrawer v-model="drawerVisible" :title="$t('button.run')" size="600px">
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.params') }}</div>
<WorkflowForm
ref="workflowForm"
:workflow-id="workflowId"
:workflow-params="runParams"
:on-submit="onSubmit"
:on-async-execute="onAsyncExecute"
:tiny-flow-data="tinyFlowData"
/>
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.steps') }}</div>
<WorkflowSteps
:workflow-id="workflowId"
:node-json="sortNodes(tinyFlowData)"
:init-signal="initState"
:polling-data="chainInfo"
@resume="resumeChain"
/>
<div class="mb-2.5 mt-2.5 font-semibold">
{{ $t('aiWorkflow.result') }}
</div>
<ExecResult
:workflow-id="workflowId"
:node-json="sortNodes(tinyFlowData)"
:init-signal="initState"
:polling-data="chainInfo"
/>
</ElDrawer>
<div class="flex items-center justify-between border-b p-2.5">
<div>
<ElButton :icon="ArrowLeft" link @click="router.back()">
<span
class="max-w-[500px] overflow-hidden text-ellipsis text-nowrap text-base"
style="font-size: 14px"
:title="workflowInfo.title"
>
{{ workflowInfo.title }}
</span>
</ElButton>
</div>
<div>
<ElButton :disabled="saveLoading" :icon="Position" @click="runWorkflow">
{{ $t('button.runTest') }}
</ElButton>
<ElButton
type="primary"
:disabled="saveLoading"
@click="handleSave(true)"
>
{{ $t('button.save') }}(ctrl+s)
</ElButton>
</div>
</div>
<Tinyflow
ref="tinyflowRef"
v-if="showTinyFlow"
class="tiny-flow-container"
:data="JSON.parse(JSON.stringify(tinyFlowData))"
:provider="provider"
:custom-nodes="customNode"
:on-node-execute="runIndependently"
/>
<ElSkeleton class="load-div" v-else :rows="5" animated />
</div>
</template>
<style scoped>
:deep(.tf-toolbar-container-body) {
height: calc(100vh - 365px) !important;
overflow-y: auto;
}
:deep(.agentsflow) {
height: calc(100vh - 130px) !important;
}
.head-div {
background-color: var(--el-bg-color);
}
.tiny-flow-container {
width: 100%;
height: calc(100vh - 150px);
}
.load-div {
margin: 20px;
}
</style>

View File

@@ -0,0 +1,451 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { ActionButton } from '#/components/page/CardList.vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import {
CopyDocument,
Delete,
Download,
Edit,
Plus,
Tickets,
Upload,
VideoPlay,
} from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
// import workflowSvg from '#/assets/workflow.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import DesignIcon from '#/components/icons/DesignIcon.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import { useDictStore } from '#/store';
import WorkflowModal from './WorkflowModal.vue';
interface FieldDefinition {
// 字段名称
prop: string;
// 字段标签
label: string;
// 字段类型input, number, select, radio, checkbox, switch, date, datetime
type?: 'input' | 'number';
// 是否必填
required?: boolean;
// 占位符
placeholder?: string;
}
const actions: ActionButton[] = [
{
icon: Edit,
text: $t('button.edit'),
className: '',
permission: '/api/v1/workflow/save',
onClick: (row: any) => {
showDialog(row);
},
},
{
icon: DesignIcon,
text: $t('button.design'),
className: '',
permission: '/api/v1/workflow/save',
onClick: (row: any) => {
toDesignPage(row);
},
},
{
icon: VideoPlay,
text: $t('button.run'),
className: '',
permission: '',
onClick: (row: any) => {
router.push({
name: 'RunPage',
query: {
id: row.id,
},
});
},
},
{
icon: Tickets,
text: $t('aiWorkflowExecRecord.moduleName'),
className: '',
permission: '/api/v1/workflow/save',
onClick: (row: any) => {
router.push({
name: 'ExecRecord',
query: {
workflowId: row.id,
},
});
},
},
{
icon: Download,
text: $t('button.export'),
className: '',
permission: '',
onClick: (row: any) => {
exportJson(row);
},
},
{
icon: CopyDocument,
text: $t('button.copy'),
className: '',
permission: '',
onClick: (row: any) => {
showDialog({
title: `${row.title}Copy`,
content: row.content,
});
},
},
{
icon: Delete,
text: $t('button.delete'),
className: 'item-danger',
permission: '',
onClick: (row: any) => {
remove(row);
},
},
];
onMounted(() => {
initDict();
getSideList();
});
const pageDataRef = ref();
const saveDialog = ref();
const dictStore = useDictStore();
const headerButtons = [
{
key: 'create',
text: $t('button.add'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'create' },
permission: '/api/v1/workflow/save',
},
{
key: 'import',
text: $t('button.import'),
icon: markRaw(Upload),
type: 'default',
data: { action: 'import' },
permission: '/api/v1/workflow/save',
},
];
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
function reset() {
pageDataRef.value.setQuery({});
}
function showDialog(row: any, importMode = false) {
saveDialog.value.openDialog({ ...row }, importMode);
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/workflow/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset();
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function toDesignPage(row: any) {
router.push({
name: 'WorkflowDesign',
query: {
id: row.id,
},
});
}
function exportJson(row: any) {
api
.get('/api/v1/workflow/exportWorkFlow', {
params: {
id: row.id,
},
})
.then((res) => {
const text = res.data;
const element = document.createElement('a');
element.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(text)}`,
);
element.setAttribute('download', `${row.title}.json`);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
ElMessage.success($t('message.downloadSuccess'));
});
}
const fieldDefinitions = ref<FieldDefinition[]>([
{
prop: 'categoryName',
label: $t('aiWorkflowCategory.categoryName'),
type: 'input',
required: true,
placeholder: $t('aiWorkflowCategory.categoryName'),
},
{
prop: 'sortNo',
label: $t('aiWorkflowCategory.sortNo'),
type: 'number',
required: false,
placeholder: $t('aiWorkflowCategory.sortNo'),
},
]);
const formData = ref<any>({});
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const saveLoading = ref(false);
const sideList = ref<any[]>([]);
const controlBtns = [
{
icon: Edit,
label: $t('button.edit'),
onClick(row: any) {
showControlDialog(row);
},
},
{
type: 'danger',
icon: Delete,
label: $t('button.delete'),
onClick(row: any) {
removeCategory(row);
},
},
];
const footerButton = {
icon: Plus,
label: $t('button.add'),
onClick() {
showControlDialog({});
},
};
const formRules = computed(() => {
const rules: Record<string, any[]> = {};
fieldDefinitions.value.forEach((field) => {
const fieldRules = [];
if (field.required) {
fieldRules.push({
required: true,
message: `${$t('message.required')}`,
trigger: 'blur',
});
}
if (fieldRules.length > 0) {
rules[field.prop] = fieldRules;
}
});
return rules;
});
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
}
function showControlDialog(item: any) {
formRef.value?.resetFields();
formData.value = { ...item };
dialogVisible.value = true;
}
function removeCategory(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/workflowCategory/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
done();
getSideList();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function handleSubmit() {
formRef.value?.validate((valid) => {
if (valid) {
saveLoading.value = true;
const url = formData.value.id
? '/api/v1/workflowCategory/update'
: '/api/v1/workflowCategory/save';
api.post(url, formData.value).then((res) => {
saveLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
dialogVisible.value = false;
getSideList();
}
});
}
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/workflowCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
if (res && res.errorCode === 0) {
sideList.value = [
{
id: '',
categoryName: $t('common.allCategories'),
},
...res.data,
];
}
};
function handleHeaderButtonClick(data: any) {
if (data.data.action === 'import') {
showDialog({}, true);
} else {
showDialog({});
}
}
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<WorkflowModal ref="saveDialog" @reload="reset" />
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleHeaderButtonClick"
/>
<div class="flex max-h-[calc(100vh-191px)] flex-1 gap-6">
<PageSide
label-key="categoryName"
value-key="id"
:menus="sideList"
:control-btns="controlBtns"
:footer-button="footerButton"
@change="changeCategory"
/>
<div class="h-full flex-1 overflow-auto">
<PageData
ref="pageDataRef"
page-url="/api/v1/workflow/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
>
<template #default="{ pageList }">
<CardList
:default-icon="workflowIcon"
:data="pageList"
:actions="actions"
/>
</template>
</PageData>
</div>
</div>
<ElDialog
v-model="dialogVisible"
:title="formData.id ? `${$t('button.edit')}` : `${$t('button.add')}`"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<!-- 动态生成表单项 -->
<ElFormItem
v-for="field in fieldDefinitions"
:key="field.prop"
:label="field.label"
:prop="field.prop"
>
<ElInput
v-if="!field.type || field.type === 'input'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="saveLoading">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import type { FormInstance, UploadInstance, UploadProps } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElUpload,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
// import Cropper from '#/components/upload/Cropper.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const isImport = ref(false);
const jsonFile = ref<any>(null);
const uploadFileList = ref<any[]>([]);
const uploadRef = ref<UploadInstance>();
const entity = ref<any>({
alias: '',
deptId: '',
title: '',
description: '',
icon: '',
content: '',
englishName: '',
});
const btnLoading = ref(false);
const jsonFileModel = computed({
get: () => (uploadFileList.value.length > 0 ? uploadFileList.value[0] : null),
set: (value: any) => {
if (!value) {
uploadFileList.value = [];
}
},
});
const rules = computed(() => ({
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
...(isImport.value && {
jsonFile: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
}),
}));
// functions
function openDialog(row: any, importMode = false) {
isImport.value = importMode;
if (row.id) {
isAdd.value = false;
}
entity.value = row;
dialogVisible.value = true;
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
jsonFile.value = file;
uploadFileList.value = [file];
saveForm.value?.clearValidate('jsonFile');
return false;
};
const handleChange: UploadProps['onChange'] = (file, fileList) => {
jsonFile.value = file.raw;
uploadFileList.value = fileList.slice(-1);
saveForm.value?.clearValidate('jsonFile');
};
const handleRemove: UploadProps['onRemove'] = () => {
jsonFile.value = null;
uploadFileList.value = [];
saveForm.value?.clearValidate('jsonFile');
};
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
if (isImport.value) {
const formData = new FormData();
formData.append('jsonFile', jsonFile.value!);
Object.keys(entity.value).forEach((key) => {
if (entity.value[key] !== null && entity.value[key] !== undefined) {
formData.append(key, entity.value[key]);
}
});
api
.post('/api/v1/workflow/importWorkFlow', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
} else {
api
.post(
isAdd.value ? '/api/v1/workflow/save' : '/api/v1/workflow/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
}
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
uploadRef.value?.clearFiles();
uploadFileList.value = [];
jsonFile.value = null;
isAdd.value = true;
isImport.value = false;
entity.value = {};
dialogVisible.value = false;
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="
isImport
? $t('button.import')
: isAdd
? $t('button.add')
: $t('button.edit')
"
:before-close="closeDialog"
:close-on-click-modal="false"
>
<ElForm
label-width="120px"
ref="saveForm"
:model="isImport ? { ...entity, jsonFile: jsonFileModel } : entity"
status-icon
:rules="rules"
>
<ElFormItem v-if="isImport" prop="jsonFile" label="JSON文件" required>
<ElUpload
class="w-full"
ref="uploadRef"
v-model:file-list="uploadFileList"
:limit="1"
:auto-upload="false"
:on-change="handleChange"
:before-upload="beforeUpload"
:on-remove="handleRemove"
accept=".json"
drag
>
<div class="el-upload__text w-full">
json 文件拖到此处<em>点击上传</em>
</div>
</ElUpload>
</ElFormItem>
<ElFormItem prop="icon" :label="$t('aiWorkflow.icon')">
<!-- <Cropper v-model="entity.icon" crop /> -->
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="title" :label="$t('aiWorkflow.title')">
<ElInput v-model.trim="entity.title" />
</ElFormItem>
<ElFormItem prop="categoryId" :label="$t('aiWorkflow.categoryId')">
<DictSelect
v-model="entity.categoryId"
dict-code="aiWorkFlowCategory"
/>
</ElFormItem>
<ElFormItem prop="alias" :label="$t('aiWorkflow.alias')">
<ElInput v-model.trim="entity.alias" />
</ElFormItem>
<ElFormItem prop="englishName" :label="$t('aiWorkflow.englishName')">
<ElInput v-model.trim="entity.englishName" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('aiWorkflow.description')">
<ElInput v-model.trim="entity.description" />
</ElFormItem>
<ElFormItem prop="status" :label="$t('aiWorkflow.status')">
<DictSelect v-model="entity.status" dict-code="showOrNot" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
// 请确保路径正确,或者将图片放在 public 目录下引用
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值
modelValue: {
type: [String, Number, Object],
default: null,
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue === item;
};
// 切换选中状态
const changeValue = (item: any) => {
if (props.modelValue === item) {
// 如果点击已选中的,则取消选中
emit('update:modelValue', null);
emit('change', null); // 触发 Element Plus 表单验证
} else {
emit('update:modelValue', item);
emit('change', item); // 触发 Element Plus 表单验证
}
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<audio
controls
:src="item"
style="width: 100%; height: 44px; margin-top: 8px"
></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号,如果没有安装 Element Plus可以用普通的 span + css -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box; /* 确保 padding 不会撑大宽度 */
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 4px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex; /* 为了对齐图标 */
align-items: center;
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值,多选版本这里是数组
modelValue: {
type: Array as () => any[],
default: () => [],
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue && props.modelValue.includes(item);
};
// 切换选中状态 (多选逻辑)
const changeValue = (item: any) => {
// 复制一份当前数组,避免直接修改 prop
const currentValues = props.modelValue ? [...props.modelValue] : [];
const index = currentValues.indexOf(item);
if (index === -1) {
// 如果不存在,则添加
currentValues.push(item);
} else {
// 如果已存在,则移除
currentValues.splice(index, 1);
}
// 更新 v-model
emit('update:modelValue', currentValues);
// 触发 Element Plus 表单验证
emit('change', currentValues);
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 300px; flex-shrink: 0"
@click="changeValue(item)"
>
<audio controls :src="item" style="width: 100%; height: 40px"></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号 -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* 这里复用之前的 CSS样式完全一致 */
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box;
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 5px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { ElEmpty, ElMessage, ElRow } from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import ExecResultItem from '#/views/ai/workflow/components/ExecResultItem.vue';
export interface ExecResultProps {
workflowId: any;
nodeJson: any;
initSignal?: boolean;
pollingData?: any;
}
const props = defineProps<ExecResultProps>();
const finalNode = computed(() => {
const nodes = props.nodeJson;
if (nodes.length > 0) {
let endNode = nodes[nodes.length - 1].original;
for (const node of nodes) {
if (node.original.type === 'endNode') {
endNode = node.original;
}
}
return endNode;
}
return {};
});
const result = ref('');
const success = ref(false);
watch(
() => props.initSignal,
() => {
result.value = '';
},
);
watch(
() => props.pollingData,
(newVal) => {
if (newVal.status === 20) {
ElMessage.success($t('message.success'));
result.value = newVal.result;
success.value = true;
}
if (newVal.status === 21) {
ElMessage.error($t('message.fail'));
result.value = newVal.message;
success.value = false;
}
},
{ deep: true },
);
function getResultCount(res: any[]) {
if (res.length > 1 || finalNode.value.data.outputDefs.length > 1) {
return 2;
}
return 1;
}
function getResult(res: any) {
return Array.isArray(res) ? res : [res];
}
</script>
<template>
<div>
<div v-if="finalNode.type === 'endNode' && success">
<ElRow :gutter="12" v-if="finalNode.data.outputDefs && result">
<template
v-for="outputDef in finalNode.data.outputDefs"
:key="outputDef.id"
>
<ExecResultItem
:result="getResult(result[outputDef.name])"
:result-count="getResultCount(getResult(result[outputDef.name]))"
:content-type="outputDef.contentType || 'text'"
:def-name="outputDef.name"
/>
</template>
</ElRow>
</div>
<div v-if="finalNode.type !== 'endNode' && !success">
<ShowJson :value="result" />
</div>
<div>
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
v-if="!result"
/>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ElCard, ElCol, ElImage } from 'element-plus';
import fileIcon from '#/assets/ai/workflow/fileIcon.png';
const props = defineProps({
defName: {
type: String,
required: true,
},
contentType: {
type: String,
required: true,
},
resultCount: {
type: Number,
required: true,
},
result: {
type: Array,
required: true,
},
});
function makeItem(item: any, index: number) {
const name = `${props.defName}-${index + 1}`;
// 保存需要用
return {
resourceName: name,
resourceUrl: item,
title: name,
filePath: item,
content: typeof item === 'string' ? item : JSON.stringify(item),
};
}
</script>
<template>
<ElCol
:span="resultCount === 1 ? 24 : 12"
v-for="(item, idx) of result"
:key="idx"
>
<ElCard shadow="hover" class="mb-3">
<template #header>
<div>
<div class="font-medium">
{{ makeItem(item, idx).resourceName }}
</div>
</div>
</template>
<div class="h-40 w-full overflow-auto break-words">
<ElImage
v-if="contentType === 'image'"
:src="`${item}`"
:preview-src-list="[`${item}`]"
class="h-36 w-full"
fit="contain"
/>
<video
v-if="contentType === 'video'"
controls
:src="`${item}`"
class="h-36 w-full"
></video>
<audio
v-if="contentType === 'audio'"
controls
:src="`${item}`"
class="h-3/5 w-full"
></audio>
<div v-if="contentType === 'text'">
{{ typeof item === 'string' ? item : JSON.stringify(item) }}
</div>
<div v-if="contentType === 'other' || contentType === 'file'">
<div class="mt-5 flex justify-center">
<img :src="fileIcon" alt="" class="h-20 w-20" />
</div>
<div class="mt-3 text-center">
<a :href="`${item}`" target="_blank">{{ $t('button.download') }}</a>
</div>
</div>
</div>
</ElCard>
</ElCol>
</template>
<style scoped></style>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
interface Props {
workflowId: any;
node: any;
}
const props = defineProps<Props>();
const singleRunForm = ref<FormInstance>();
const runParams = ref<any>({});
const submitLoading = ref(false);
const result = ref<any>('');
function submit() {
singleRunForm.value?.validate((valid) => {
if (valid) {
const params = {
workflowId: props.workflowId,
nodeId: props.node.id,
variables: runParams.value,
};
submitLoading.value = true;
api.post('/api/v1/workflow/singleRun', params).then((res) => {
submitLoading.value = false;
result.value = res.data;
if (res.errorCode === 0) {
ElMessage.success(res.message);
} else {
ElMessage.error(res.message);
}
});
}
});
}
</script>
<template>
<div>
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
<ElFormItem
v-for="(item, idx) in node?.data.parameters"
:prop="item.name"
:key="idx"
:label="item.description || item.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ElInput
v-if="item.formType === 'input' || !item.formType"
v-model="runParams[item.name]"
:placeholder="item.formPlaceholder"
/>
</ElFormItem>
<ElFormItem>
<ElButton
type="primary"
@click="submit"
:loading="submitLoading"
:icon="Position"
>
{{ $t('button.run') }}
</ElButton>
</ElFormItem>
</ElForm>
<div class="mb-2.5 mt-2.5 font-semibold">
{{ $t('workflow.result') }}
</div>
<ShowJson :value="result" />
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { computed, onUnmounted, ref } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import WorkflowFormItem from './WorkflowFormItem.vue';
export type WorkflowFormProps = {
onAsyncExecute?: (values: any) => void;
onSubmit?: (values: any) => void;
tinyFlowData: any;
workflowId: any;
workflowParams: any;
};
const props = withDefaults(defineProps<WorkflowFormProps>(), {
onExecuting: () => {
console.warn('no execute method');
},
onSubmit: () => {
console.warn('no submit method');
},
onAsyncExecute: () => {
console.warn('no async execute method');
},
});
defineExpose({
resume,
});
const runForm = ref<FormInstance>();
const runParams = ref<any>({});
const submitLoading = ref(false);
const parameters = computed(() => {
return props.workflowParams.parameters;
});
const executeId = ref('');
function resume(data: any) {
data.executeId = executeId.value;
submitLoading.value = true;
api.post('/api/v1/workflow/resume', data).then((res) => {
if (res.errorCode === 0) {
startPolling(executeId.value);
}
});
}
function submitV2() {
runForm.value?.validate((valid) => {
if (valid) {
const data = {
id: props.workflowId,
variables: {
...runParams.value,
},
};
props.onSubmit?.(runParams.value);
submitLoading.value = true;
api.post('/api/v1/workflow/runAsync', data).then((res) => {
if (res.errorCode === 0 && res.data) {
// executeId
executeId.value = res.data;
startPolling(res.data);
}
});
}
});
}
const timer = ref();
const nodes = ref(
props.tinyFlowData.nodes.map((node: any) => ({
nodeId: node.id,
nodeName: node.data.title,
})),
);
// 轮询执行结果
function startPolling(executeId: any) {
if (timer.value) return;
timer.value = setInterval(() => executePolling(executeId), 1000);
}
function executePolling(executeId: any) {
api
.post('/api/v1/workflow/getChainStatus', {
executeId,
nodes: nodes.value,
})
.then((res) => {
// 5 是挂起状态
if (res.data.status !== 1 || res.data.status === 5) {
stopPolling();
}
props.onAsyncExecute?.(res.data);
});
}
function stopPolling() {
submitLoading.value = false;
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<div>
<ElForm label-position="top" ref="runForm" :model="runParams">
<WorkflowFormItem
v-model:run-params="runParams"
:parameters="parameters"
/>
<ElFormItem>
<ElButton
type="primary"
@click="submitV2"
:loading="submitLoading"
:icon="Position"
>
{{ $t('button.run') }}
</ElButton>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
ElAlert,
ElCheckboxGroup,
ElFormItem,
ElInput,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
const props = defineProps({
parameters: {
type: Array<any>,
required: true,
},
runParams: {
type: Object,
required: true,
},
propPrefix: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:runParams']);
function getContentType(item: any) {
return item.contentType || 'text';
}
function isResource(contentType: any) {
return ['audio', 'file', 'image', 'video'].includes(contentType);
}
function getCheckboxOptions(item: any) {
if (item.enums) {
return (
item.enums?.map((option: any) => ({
label: option,
value: option,
})) || []
);
}
return [];
}
function updateParam(name: string, value: any) {
const newValue = { ...props.runParams, [name]: value };
emit('update:runParams', newValue);
}
function choose(data: any, propName: string) {
updateParam(propName, data.resourceUrl);
}
</script>
<template>
<ElFormItem
v-for="(item, idx) in parameters"
:prop="`${propPrefix}${item.name}`"
:key="idx"
:label="item.formLabel || item.name"
:rules="
item.required ? [{ required: true, message: $t('message.required') }] : []
"
>
<template v-if="getContentType(item) === 'text'">
<ElInput
v-if="item.formType === 'input' || !item.formType"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
/>
<ElSelect
v-if="item.formType === 'select'"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
:options="getCheckboxOptions(item)"
clearable
/>
<ElInput
v-if="item.formType === 'textarea'"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
:rows="3"
type="textarea"
/>
<ElRadioGroup
v-if="item.formType === 'radio'"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)"
/>
<ElCheckboxGroup
v-if="item.formType === 'checkbox'"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)"
/>
</template>
<template v-if="getContentType(item) === 'other'">
<ElInput
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
/>
</template>
<template v-if="isResource(getContentType(item))">
<ElInput
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
/>
<ChooseResource :attr-name="item.name" @choose="choose" />
</template>
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
{{ item.formDescription }}
</ElAlert>
</ElFormItem>
</template>
<style scoped></style>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { computed, ref, watch } from 'vue';
import {
CircleCloseFilled,
SuccessFilled,
VideoPause,
WarningFilled,
} from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElIcon,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
export interface WorkflowStepsProps {
workflowId: any;
nodeJson: any;
initSignal?: boolean;
pollingData?: any;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
watch(
() => props.pollingData,
(newVal) => {
const nodes = newVal.nodes;
if (newVal.status === 21) {
isChainError.value = true;
chainErrMsg.value = newVal.message;
}
if (![20, 21].includes(newVal.status)) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
activeName.value = nodeId;
}
}
},
{ deep: true },
);
watch(
() => props.initSignal,
() => {
nodeStatusMap.value = {};
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
},
);
watch(
() => props.nodeJson,
(newVal) => {
if (newVal) {
nodes.value = [...newVal];
}
},
{ immediate: true },
);
const displayNodes = computed(() => {
return nodes.value.map((node) => ({
...node,
...nodeStatusMap.value[node.key],
}));
});
const activeName = ref('1');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
// 动态设置 Ref 的辅助函数
const setFormRef = (el: any, key: string) => {
if (el) {
formRefs.value[key] = el as FormInstance;
}
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
}
function handleConfirm(node: any) {
const nodeKey = node.key;
// 根据 key 获取具体的 form 实例
const form = formRefs.value[nodeKey];
if (!form) {
console.warn(`Form instance for ${nodeKey} not found`);
return;
}
const confirmKey = node.suspendForParameters[0].name;
form.validate((valid) => {
if (valid) {
const value = {
confirmParams: {
[confirmKey]: 'yes',
...confirmParams.value,
},
};
confirmBtnLoading.value = true;
emit('resume', value);
}
});
}
</script>
<template>
<div>
<div class="mb-1">
<ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" />
</div>
<ElCollapse v-model="activeName" accordion expand-icon-position="left">
<ElCollapseItem
v-for="node in displayNodes"
:key="node.key"
:title="`${node.label}-${node.status}`"
:name="node.key"
>
<template #title>
<div class="flex items-center justify-between">
<div>
{{ node.label }}
</div>
<div class="flex items-center">
<ElIcon
v-if="node.status === 20 && !isChainError"
color="green"
size="20"
>
<SuccessFilled />
</ElIcon>
<div v-if="node.status === 1" class="spinner"></div>
<ElIcon
v-if="node.status === 21 && !isChainError"
color="red"
size="20"
>
<CircleCloseFilled />
</ElIcon>
<ElIcon
v-if="node.status === 5 && !isChainError"
color="orange"
size="20"
>
<VideoPause />
</ElIcon>
<ElIcon v-if="isChainError" color="orange" size="20">
<WarningFilled />
</ElIcon>
</div>
</div>
</template>
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
<ElForm
:ref="(el) => setFormRef(el, node.key)"
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</div>
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</template>
<style scoped>
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgb(255 255 255 / 30%);
border-top-color: var(--el-color-primary);
border-right-color: var(--el-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.header-container {
display: flex;
align-items: center;
font-weight: bold;
word-break: break-all;
}
.blue-bar {
display: inline-block;
width: 2px;
height: 16px;
margin-right: 16px;
background-color: var(--el-color-primary);
border-radius: 1px;
}
.description-container {
margin-bottom: 16px;
color: #969799;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,37 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export default {
[nodeNames.documentNode]: {
title: $t('aiWorkflow.fileContentExtraction'),
group: 'base',
description: $t('aiWorkflow.descriptions.fileContentExtraction'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 4V18.7215C21 18.9193 20.8833 19.0986 20.7024 19.1787L12 23.0313L3.29759 19.1787C3.11667 19.0986 3 18.9193 3 18.7215V4H1V2H23V4H21ZM5 4V17.7451L12 20.8441L19 17.7451V4H5ZM8 8H16V10H8V8ZM8 12H16V14H8V12Z"></path></svg>',
sortNo: 801,
parametersAddEnable: false,
outputDefsAddEnable: false,
parameters: [
{
name: 'fileUrl',
nameDisabled: true,
title: $t('aiWorkflow.documentAddress'),
dataType: 'File',
required: true,
description: $t('aiWorkflow.descriptions.documentAddress'),
},
],
outputDefs: [
{
name: 'content',
title: $t('aiWorkflow.parsedText'),
dataType: 'String',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.parsedText'),
deleteDisabled: true,
},
],
},
};

View File

@@ -0,0 +1,90 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export default {
[nodeNames.downloadNode]: {
title: $t('aiWorkflow.resourceSync'),
group: 'base',
description: $t('aiWorkflow.descriptions.resourceSync'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 12H16L12 16L8 12H11V8H13V12ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>',
sortNo: 811,
parametersAddEnable: false,
outputDefsAddEnable: false,
parameters: [
{
name: 'originUrl',
nameDisabled: true,
title: $t('aiWorkflow.originUrl'),
dataType: 'String',
required: true,
description: $t('aiWorkflow.descriptions.originUrl'),
},
],
outputDefs: [
{
name: 'resourceUrl',
title: $t('aiWorkflow.savedUrl'),
dataType: 'String',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.savedUrl'),
deleteDisabled: true,
},
],
forms: [
// 节点表单
{
// 'input' | 'textarea' | 'select' | 'slider' | 'heading' | 'chosen'
type: 'heading',
label: $t('aiWorkflow.saveOptions'),
},
{
type: 'select',
label: $t('aiResource.resourceType'),
description: $t('aiWorkflow.descriptions.resourceType'),
name: 'resourceType', // 属性名称
defaultValue: '99',
options: [
{
label: $t('aiWorkflow.image'),
value: '0',
},
{
label: $t('aiWorkflow.video'),
value: '1',
},
{
label: $t('aiWorkflow.audio'),
value: '2',
},
{
label: $t('aiWorkflow.document'),
value: '3',
},
{
label: $t('aiWorkflow.other'),
value: '99',
},
],
},
// {
// // 用法可参考插件节点的代码
// type: 'chosen',
// label: '插件选择',
// chosen: {
// // 节点自定义属性
// labelDataKey: 'pluginName',
// valueDataKey: 'pluginId',
// // updateNodeData 可动态更新节点属性
// // value 为选中的 value
// // label 为选中的 label
// onChosen: ((updateNodeData: (data: Record<string, any>) => void, value?: string, label?: string, event?: Event) => {
// console.warn('No onChosen handler provided for plugin-node');
// })
// }
// }
],
},
};

View File

@@ -0,0 +1,29 @@
import docNode from './documentNode';
import downloadNode from './downloadNode';
import makeFileNode from './makeFileNode';
import nodeNames from './nodeNames';
import { PluginNode } from './pluginNode';
import { SaveToDatacenterNode } from './saveToDatacenter';
import { SearchDatacenterNode } from './searchDatacenter';
import sqlNode from './sqlNode';
import { WorkflowNode } from './workflowNode';
export interface CustomNodeOptions {
handleChosen?: (nodeType: string, updateNodeData: any, value: string) => void;
}
export const getCustomNode = async (options: CustomNodeOptions) => {
const pluginNode = PluginNode({ onChosen: options.handleChosen });
const workflowNode = WorkflowNode({ onChosen: options.handleChosen });
const searchDatacenterNode = await SearchDatacenterNode();
const saveToDatacenterNode = await SaveToDatacenterNode();
return {
...docNode,
...makeFileNode,
...downloadNode,
...sqlNode,
[nodeNames.pluginNode]: pluginNode,
[nodeNames.workflowNode]: workflowNode,
[nodeNames.searchDatacenterNode]: searchDatacenterNode,
[nodeNames.saveToDatacenterNode]: saveToDatacenterNode,
};
};

View File

@@ -0,0 +1,58 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export default {
[nodeNames.makeFileNode]: {
title: $t('aiWorkflow.fileGeneration'),
group: 'base',
description: $t('aiWorkflow.descriptions.fileGeneration'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
sortNo: 802,
parametersAddEnable: true,
outputDefsAddEnable: true,
forms: [
{
type: 'heading',
label: $t('aiWorkflow.fileSettings'),
},
{
type: 'select',
label: $t('documentCollection.splitterDoc.fileType'),
description: $t('aiWorkflow.descriptions.fileType'),
name: 'suffix',
defaultValue: 'docx',
options: [
{
label: 'docx',
value: 'docx',
},
],
},
],
parameters: [
{
name: 'content',
nameDisabled: true,
title: $t('preferences.content'),
dataType: 'String',
required: true,
description: $t('preferences.content'),
deleteDisabled: true,
},
],
outputDefs: [
{
name: 'url',
nameDisabled: true,
title: $t('aiWorkflow.fileDownloadURL'),
dataType: 'String',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.fileDownloadURL'),
deleteDisabled: true,
},
],
},
};

View File

@@ -0,0 +1,10 @@
export default {
documentNode: 'document-node',
makeFileNode: 'make-file',
downloadNode: 'download-node',
sqlNode: 'sql-node',
pluginNode: 'plugin-node',
workflowNode: 'workflow-node',
searchDatacenterNode: 'search-datacenter-node',
saveToDatacenterNode: 'save-to-datacenter-node',
};

View File

@@ -0,0 +1,30 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export interface PluginNodeOptions {
onChosen?: (nodeType: string, updateNodeData: any, value: string) => void;
}
export const PluginNode = (options: PluginNodeOptions = {}) => ({
title: $t('menus.ai.plugin'),
group: 'base',
description: $t('aiWorkflow.descriptions.plugin'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6H19C19.5523 6 20 6.44772 20 7V14C20 16.2091 18.2091 18 16 18H13ZM8 16H16C17.1046 16 18 15.1046 18 14V11H6V14C6 15.1046 6.89543 16 8 16ZM18 8H6V9H18V8ZM12 14.5C11.4477 14.5 11 14.0523 11 13.5C11 12.9477 11.4477 12.5 12 12.5C12.5523 12.5 13 12.9477 13 13.5C13 14.0523 12.5523 14.5 12 14.5Z"></path></svg>',
sortNo: 810,
parametersAddEnable: false,
outputDefsAddEnable: false,
forms: [
{
type: 'chosen',
label: $t('aiWorkflow.pluginSelect'),
chosen: {
labelDataKey: 'pluginName',
valueDataKey: 'pluginId',
onChosen: (updateNodeData: any, value: any) => {
options.onChosen?.(nodeNames.pluginNode, updateNodeData, value);
},
},
},
],
});

View File

@@ -0,0 +1,58 @@
import { getOptions } from '@easyflow/utils';
import { api } from '#/api/request';
import { $t } from '#/locales';
export const SaveToDatacenterNode = async () => {
const res = await api.get('/api/v1/datacenterTable/list');
return {
title: $t('aiWorkflow.saveData'),
group: 'base',
description: $t('aiWorkflow.descriptions.saveData'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11 19V9H4V19H11ZM11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM13 5V19H20V5H13ZM5 16H10V18H5V16ZM14 16H19V18H14V16ZM14 13H19V15H14V13ZM14 10H19V12H14V10ZM5 13H10V15H5V13Z"></path></svg>',
sortNo: 812,
parametersAddEnable: false,
outputDefsAddEnable: false,
parameters: [
{
name: 'saveList',
title: $t('aiWorkflow.dataToBeSaved'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.dataToBeSaved'),
deleteDisabled: true,
nameDisabled: true,
},
],
outputDefs: [
{
name: 'successRows',
title: $t('aiWorkflow.successInsertedRecords'),
dataType: 'Number',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.successInsertedRecords'),
deleteDisabled: true,
nameDisabled: true,
},
],
forms: [
{
type: 'heading',
label: $t('aiWorkflow.dataTable'),
},
{
type: 'select',
label: '',
description: $t('aiWorkflow.descriptions.dataTable'),
name: 'tableId',
defaultValue: '',
options: getOptions('tableName', 'id', res.data),
},
],
};
};

View File

@@ -0,0 +1,68 @@
import { getOptions } from '@easyflow/utils';
import { api } from '#/api/request';
import { $t } from '#/locales';
export const SearchDatacenterNode = async () => {
const res = await api.get('/api/v1/datacenterTable/list');
return {
title: $t('aiWorkflow.queryData'),
group: 'base',
description: $t('aiWorkflow.descriptions.queryData'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11 2C15.968 2 20 6.032 20 11C20 15.968 15.968 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2ZM11 18C14.8675 18 18 14.8675 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18ZM19.4853 18.0711L22.3137 20.8995L20.8995 22.3137L18.0711 19.4853L19.4853 18.0711Z"></path></svg>',
sortNo: 813,
parametersAddEnable: true,
outputDefsAddEnable: false,
parameters: [],
outputDefs: [
{
name: 'rows',
title: $t('aiWorkflow.queryResult'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.queryResult'),
deleteDisabled: true,
nameDisabled: false,
},
],
forms: [
{
type: 'heading',
label: $t('aiWorkflow.dataTable'),
},
{
type: 'select',
label: '',
description: $t('aiWorkflow.descriptions.dataTable'),
name: 'tableId',
defaultValue: '',
options: getOptions('tableName', 'id', res.data),
},
{
type: 'heading',
label: $t('aiWorkflow.filterConditions'),
},
{
type: 'textarea',
label: "如name='张三' and age=21 or field = {{流程变量}}",
description: '',
name: 'where',
defaultValue: '',
},
{
type: 'heading',
label: $t('aiWorkflow.limit'),
},
{
type: 'input',
label: '',
description: '',
name: 'limit',
defaultValue: '10',
},
],
};
};

View File

@@ -0,0 +1,37 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export default {
[nodeNames.sqlNode]: {
title: $t('aiWorkflow.sqlQuery'),
group: 'base',
description: $t('aiWorkflow.descriptions.sqlQuery'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgba(37,99,235,1)"><path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z"></path></svg>',
sortNo: 803,
parametersAddEnable: true,
outputDefsAddEnable: true,
parameters: [],
forms: [
{
name: 'sql',
type: 'textarea',
label: 'SQL',
placeholder: $t('aiWorkflow.descriptions.enterSQL'),
},
],
outputDefs: [
{
name: 'queryData',
title: $t('aiWorkflow.queryResult'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.queryResultJson'),
deleteDisabled: true,
nameDisabled: true,
},
],
},
};

View File

@@ -0,0 +1,30 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export interface WorkflowNodeOptions {
onChosen?: (nodeType: string, updateNodeData: any, value: string) => void;
}
export const WorkflowNode = (options: WorkflowNodeOptions = {}) => ({
title: $t('aiWorkflow.subProcess'),
group: 'base',
description: $t('aiWorkflow.descriptions.subProcess'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path></svg>',
sortNo: 815,
parametersAddEnable: false,
outputDefsAddEnable: false,
forms: [
{
type: 'chosen',
label: $t('aiWorkflow.workflowSelect'),
chosen: {
labelDataKey: 'workflowName',
valueDataKey: 'workflowId',
onChosen: (updateNodeData: any, value: any) => {
options.onChosen?.(nodeNames.workflowNode, updateNodeData, value);
},
},
},
],
});

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft, DeleteFilled, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
const router = useRouter();
const $route = useRoute();
onMounted(() => {
initDict();
});
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const formInline = ref({
execKey: '',
});
const dictStore = useDictStore();
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
pageDataRef.value.setQuery(formInline.value);
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.get('/api/v1/workflowExecResult/del', { params: { id: row.id } })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function toStepPage(row: any) {
router.push({
name: 'RecordStep',
query: {
recordId: row.id,
},
});
}
function getTagType(row: any) {
switch (row.status) {
case 1: {
return 'primary';
}
case 5: {
return 'warning';
}
case 10: {
return 'danger';
}
case 20: {
return 'success';
}
case 21: {
return 'danger';
}
default: {
return 'info';
}
}
}
</script>
<template>
<div class="page-container border-border border">
<div class="mb-3">
<ElButton :icon="ArrowLeft" @click="router.back()">
{{ $t('button.back') }}
</ElButton>
</div>
<ElForm ref="formRef" :inline="true" :model="formInline">
<ElFormItem class="w-full max-w-[300px]" prop="execKey">
<ElInput
v-model="formInline.execKey"
:placeholder="$t('aiWorkflowExecRecord.execKey')"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }}
</ElButton>
<ElButton @click="reset(formRef)">
{{ $t('button.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
<div class="handle-div"></div>
<PageData
ref="pageDataRef"
page-url="/api/v1/workflowExecResult/page"
:page-size="10"
:extra-query-params="{
workflowId: $route.query.workflowId,
}"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn
prop="execKey"
show-overflow-tooltip
:label="$t('aiWorkflowExecRecord.execKey')"
>
<template #default="{ row }">
{{ row.execKey }}
</template>
</ElTableColumn>
<ElTableColumn prop="title" :label="$t('aiWorkflowExecRecord.title')">
<template #default="{ row }">
{{ row.title }}
</template>
</ElTableColumn>
<ElTableColumn
prop="description"
:label="$t('aiWorkflowExecRecord.description')"
>
<template #default="{ row }">
{{ row.description }}
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="input"
:label="$t('aiWorkflowExecRecord.input')"
>
<template #default="{ row }">
{{ row.input }}
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="output"
:label="$t('aiWorkflowExecRecord.output')"
>
<template #default="{ row }">
{{ row.output }}
</template>
</ElTableColumn>
<ElTableColumn
prop="startTime"
:label="$t('aiWorkflowExecRecord.execTime')"
>
<template #default="{ row }">
{{ row.execTime || '-' }} ms
</template>
</ElTableColumn>
<ElTableColumn
prop="status"
:label="$t('aiWorkflowExecRecord.status')"
>
<template #default="{ row }">
<ElTag :type="getTagType(row)">
{{ $t(`aiWorkflowExecRecord.status${row.status}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="errorInfo"
:label="$t('aiWorkflowExecRecord.errorInfo')"
>
<template #default="{ row }">
{{ row.errorInfo }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.handle')" width="110" align="right">
<template #default="{ row }">
<div class="flex items-center gap-1">
<ElButton link type="primary" @click="toStepPage(row)">
{{ $t('aiWorkflowRecordStep.moduleName') }}
</ElButton>
<ElDropdown>
<ElButton :icon="MoreFilled" link />
<template #dropdown>
<ElDropdownMenu>
<div v-access:code="'/api/v1/workflow/save'">
<ElDropdownItem @click="remove(row)">
<ElButton type="danger" :icon="DeleteFilled" link>
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
const router = useRouter();
const $route = useRoute();
onMounted(() => {
initDict();
});
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const formInline = ref({
nodeName: '',
});
const dictStore = useDictStore();
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
pageDataRef.value.setQuery(formInline.value);
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function getTagType(row: any) {
switch (row.status) {
case 1: {
return 'primary';
}
case 6: {
return 'warning';
}
case 10: {
return 'danger';
}
case 20: {
return 'success';
}
case 21: {
return 'danger';
}
default: {
return 'info';
}
}
}
</script>
<template>
<div class="page-container border-border border">
<div class="mb-3">
<ElButton :icon="ArrowLeft" @click="router.back()">
{{ $t('button.back') }}
</ElButton>
</div>
<ElForm ref="formRef" :inline="true" :model="formInline">
<ElFormItem class="w-full max-w-[300px]" prop="nodeName">
<ElInput
v-model="formInline.nodeName"
:placeholder="$t('aiWorkflowRecordStep.nodeName')"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }}
</ElButton>
<ElButton @click="reset(formRef)">
{{ $t('button.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
<div class="handle-div"></div>
<PageData
ref="pageDataRef"
page-url="/api/v1/workflowExecStep/page"
:page-size="10"
:extra-query-params="{
recordId: $route.query.recordId,
sortKey: 'startTime',
sortType: 'asc',
}"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn
show-overflow-tooltip
prop="execKey"
:label="$t('aiWorkflowRecordStep.execKey')"
>
<template #default="{ row }">
{{ row.execKey }}
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="nodeId"
:label="$t('aiWorkflowRecordStep.nodeId')"
>
<template #default="{ row }">
{{ row.nodeId }}
</template>
</ElTableColumn>
<ElTableColumn
prop="nodeName"
:label="$t('aiWorkflowRecordStep.nodeName')"
>
<template #default="{ row }">
{{ row.nodeName }}
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="input"
:label="$t('aiWorkflowRecordStep.input')"
>
<template #default="{ row }">
{{ row.input }}
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="output"
:label="$t('aiWorkflowRecordStep.output')"
>
<template #default="{ row }">
{{ row.output }}
</template>
</ElTableColumn>
<ElTableColumn
prop="execTime"
:label="$t('aiWorkflowRecordStep.execTime')"
>
<template #default="{ row }">
{{ row.execTime || '-' }} ms
</template>
</ElTableColumn>
<ElTableColumn
prop="status"
:label="$t('aiWorkflowRecordStep.status')"
>
<template #default="{ row }">
<ElTag :type="getTagType(row)">
{{ $t(`aiWorkflowRecordStep.status${row.status}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
show-overflow-tooltip
prop="errorInfo"
:label="$t('aiWorkflowRecordStep.errorInfo')"
>
<template #default="{ row }">
{{ row.errorInfo }}
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog } from 'element-plus';
import SysApiKeyList from '#/views/config/apikey/SysApiKeyList.vue';
import SysApiKeyResourcePermissionList from '#/views/config/apikey/SysApiKeyResourcePermissionList.vue';
const dialogVisible = ref(false);
</script>
<template>
<div class="sys-apikey-container">
<SysApiKeyList />
<ElDialog
v-model="dialogVisible"
draggable
:close-on-click-modal="false"
align-center
>
<SysApiKeyResourcePermissionList />
</ElDialog>
</div>
</template>
<style scoped>
.sys-apikey-container {
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { markRaw, ref } from 'vue';
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import SysApiKeyResourcePermissionList from '#/views/config/apikey/SysApiKeyResourcePermissionList.vue';
import SysApiKeyModal from './SysApiKeyModal.vue';
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const saveDialog = ref();
const headerButtons = [
{
key: 'addApiKey',
text: $t('sysApiKey.addApiKey'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'create' },
permission: '',
},
];
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ apiKey: params, isQueryOr: true });
};
const headerButtonClick = (action: any) => {
if (action.key === 'addApiKey') {
addNewApiKey();
} else if (action.key === 'addPermission') {
showAddPermissionDialog({});
}
};
function reset(formEl?: FormInstance) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function showAddPermissionDialog(_row: any) {
dialogVisible.value = true;
}
const dialogVisible = ref(false);
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/sysApiKey/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
function addNewApiKey() {
ElMessageBox.confirm(
$t('sysApiKey.addApiKeyNotice'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
},
).then(() => {
api.post('/api/v1/sysApiKey/key/save', {}).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.saveOkMessage'));
pageDataRef.value.setQuery({});
}
});
});
}
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<SysApiKeyModal ref="saveDialog" @reload="reset" />
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="headerButtonClick"
/>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<PageData
ref="pageDataRef"
page-url="/api/v1/sysApiKey/page"
:page-size="10"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn
prop="apiKey"
:label="$t('sysApiKey.apiKey')"
width="280"
>
<template #default="{ row }">
{{ row.apiKey }}
</template>
</ElTableColumn>
<ElTableColumn prop="created" :label="$t('sysApiKey.created')">
<template #default="{ row }">
{{ row.created }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" :label="$t('sysApiKey.status')">
<template #default="{ row }">
<ElTag type="primary" v-if="row.status === 1">
{{ $t('sysApiKey.actions.enable') }}
</ElTag>
<ElTag type="danger" v-else-if="row.status === 0">
{{ $t('sysApiKey.actions.disable') }}
</ElTag>
<ElTag type="warning" v-else>
{{ $t('sysApiKey.actions.failure') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="expiredAt" :label="$t('sysApiKey.expiredAt')">
<template #default="{ row }">
{{ row.expiredAt }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('common.handle')"
width="90"
align="right"
>
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="showDialog(row)">
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="remove(row)">
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
<ElDialog
v-model="dialogVisible"
:title="$t('sysApiKeyResourcePermission.addPermission')"
draggable
:close-on-click-modal="false"
>
<SysApiKeyResourcePermissionList />
</ElDialog>
</div>
</template>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
ElAlert,
ElButton,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElDialog,
ElForm,
ElFormItem,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales';
// 定义权限接口
interface ResourcePermission {
id: number;
title: string;
requestInterface: string;
}
// 定义表单数据接口
interface Entity {
apiKey: string;
status: number | string;
deptId: number | string;
expiredAt: Date | null | string;
permissionIds: (number | string)[]; // 绑定值:权限 ID 数组
id?: number; // 编辑时的主键
}
const emit = defineEmits(['reload']);
// 表单实例
const saveForm = ref<FormInstance>();
// 对话框状态
const dialogVisible = ref(false);
const isAdd = ref(true);
// 表单数据(初始化默认值)
const entity = ref<Entity>({
apiKey: '',
status: '',
deptId: '',
expiredAt: null,
permissionIds: [],
});
// 加载状态
const btnLoading = ref(false);
// 资源权限列表
const resourcePermissionList = ref<ResourcePermission[]>([]);
// 表单校验规则(必填项校验)
const rules = ref({
status: [
{
required: true,
message: $t('message.pleaseSelect', { name: $t('sysApiKey.status') }),
trigger: 'change',
},
],
expiredAt: [
{
required: true,
message: $t('message.pleaseSelect', { name: $t('sysApiKey.expiredAt') }),
trigger: 'change',
},
],
});
function openDialog(row: Partial<Entity> = {}) {
saveForm.value?.resetFields();
entity.value = {
apiKey: '',
status: '',
deptId: '',
expiredAt: null,
permissionIds: [],
...row,
};
isAdd.value = !row.id;
dialogVisible.value = true;
}
// 获取资源权限列表
function getResourcePermissionList() {
api
.get('/api/v1/sysApiKeyResourcePermission/list')
.then((res) => {
if (res.errorCode === 0) {
resourcePermissionList.value = res.data;
} else {
ElMessage.error(res.message || $t('message.getDataError'));
}
})
.catch(() => {
ElMessage.error($t('message.getDataError'));
});
}
// 保存表单
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
const url = isAdd.value
? 'api/v1/sysApiKey/save'
: 'api/v1/sysApiKey/update';
api
.post(url, entity.value)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('message.saveFailMessage'));
}
})
.catch(() => {
btnLoading.value = false;
ElMessage.error($t('message.saveFailMessage'));
});
}
});
}
// 关闭对话框
function closeDialog() {
saveForm.value?.resetFields();
// 重置表单数据
entity.value = {
apiKey: '',
status: '',
deptId: '',
expiredAt: null,
permissionIds: [],
};
isAdd.value = true;
dialogVisible.value = false;
}
onMounted(() => {
getResourcePermissionList();
});
defineExpose({
openDialog,
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
width="50%"
>
<ElForm
label-width="120px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
class="form-container"
>
<!-- 状态选择 -->
<ElFormItem prop="status" :label="$t('sysApiKey.status')">
<DictSelect
v-model="entity.status"
dict-code="dataStatus"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem prop="expiredAt" :label="$t('sysApiKey.expiredAt')">
<ElDatePicker
v-model="entity.expiredAt"
type="datetime"
:placeholder="
$t('message.pleaseSelect', { name: $t('sysApiKey.expiredAt') })
"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem
prop="permissions"
:label="$t('sysApiKey.permissions')"
class="permission-form-item"
>
<ElAlert type="info">
接口信息请运行tech.easyflow.publicapi.SyncApis main
方法同步到数据库
</ElAlert>
<ElCheckboxGroup
v-model="entity.permissionIds"
class="permission-checkbox-group"
>
<ElCheckbox
v-for="item in resourcePermissionList"
:key="item.id"
:value="item.id"
class="permission-checkbox"
>
{{ item.requestInterface }} - {{ item.title }}
</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.form-container {
max-height: 60vh;
padding-right: 10px;
overflow-y: auto;
}
.permission-form-item .el-form-item__content {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.permission-checkbox {
display: flex;
align-items: flex-start;
margin: 4px 0;
}
.form-container::-webkit-scrollbar {
width: 6px;
}
.form-container::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import SysApiKeyResourcePermissionModal from './SysApiKeyResourcePermissionModal.vue';
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const saveDialog = ref();
const formInline = ref({
id: '',
});
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
pageDataRef.value.setQuery(formInline.value);
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/sysApiKeyResourcePermission/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
</script>
<template>
<div class="page-container !m-0 !pl-0">
<SysApiKeyResourcePermissionModal ref="saveDialog" @reload="reset" />
<div class="flex items-center justify-between">
<ElForm ref="formRef" :inline="true" :model="formInline">
<ElFormItem prop="title" class="!mr-3">
<ElInput
v-model="formInline.title"
:placeholder="$t('sysApiKeyResourcePermission.title')"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }}
</ElButton>
<ElButton @click="reset(formRef)">
{{ $t('button.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
<div class="mb-5">
<ElButton @click="showDialog({})" type="primary">
<ElIcon class="mr-1">
<Plus />
</ElIcon>
{{ $t('button.add') }}
</ElButton>
</div>
</div>
<PageData
ref="pageDataRef"
page-url="/api/v1/sysApiKeyResourcePermission/page"
:page-size="10"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTableColumn
prop="requestInterface"
:label="$t('sysApiKeyResourcePermission.requestInterface')"
>
<template #default="{ row }">
{{ row.requestInterface }}
</template>
</ElTableColumn>
<ElTableColumn
prop="title"
:label="$t('sysApiKeyResourcePermission.title')"
>
<template #default="{ row }">
{{ row.title }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.handle')" width="90" align="right">
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="showDialog(row)">
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="remove(row)">
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
requestInterface: '',
title: '',
});
const btnLoading = ref(false);
const rules = ref({});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
}
entity.value = row;
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
api
.post(
isAdd.value
? 'api/v1/sysApiKeyResourcePermission/save'
: 'api/v1/sysApiKeyResourcePermission/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
dialogVisible.value = false;
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
>
<ElForm
label-width="120px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem
prop="requestInterface"
:label="$t('sysApiKeyResourcePermission.requestInterface')"
>
<ElInput v-model.trim="entity.requestInterface" />
</ElFormItem>
<ElFormItem prop="title" :label="$t('sysApiKeyResourcePermission.title')">
<ElInput v-model.trim="entity.title" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
ElButton,
ElDatePicker,
ElDialog,
ElForm,
ElFormItem,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
apiKey: '',
status: '',
deptId: '',
expiredAt: '',
});
const btnLoading = ref(false);
const rules = ref({});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
}
entity.value = row;
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
api
.post(
isAdd.value ? 'api/v1/sysApiKey/save' : 'api/v1/sysApiKey/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
dialogVisible.value = false;
}
const resourcePermissionList = ref([]);
function getResourcePermissionList() {
api.get('/api/v1/sysApiKeyResourcePermission/list').then((res) => {
console.log('资源');
console.log(res);
if (res.errorCode === 0) {
resourcePermissionList.value = res.data;
}
});
}
onMounted(() => {
getResourcePermissionList();
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
:close-on-click-modal="false"
>
<ElForm
label-width="120px"
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
>
<ElFormItem prop="status" :label="$t('sysApiKey.status')">
<DictSelect v-model="entity.status" dict-code="dataStatus" />
</ElFormItem>
<ElFormItem prop="expiredAt" :label="$t('sysApiKey.expiredAt')">
<ElDatePicker v-model="entity.expiredAt" type="datetime" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
>
{{ $t('button.save') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request.js';
import providerList from '#/views/ai/model/modelUtils/providerList.json';
const providerOptions =
ref<Array<{ label: string; options: any; value: string }>>(providerList);
const brands = ref([]);
const llmOptions = ref([]);
// 获取品牌接口数据
function getBrands() {
api.get('/api/v1/modelProvider/list').then((res) => {
if (res.errorCode === 0) {
brands.value = res.data;
llmOptions.value = formatLlmList(res.data);
}
});
}
function getOptions() {
api
.get(
'/api/v1/sysOption/list?keys=model_of_chat&keys=chatgpt_endpoint&keys=chatgpt_chatPath&keys=chatgpt_api_key&keys=chatgpt_model_name',
)
.then((res) => {
if (res.errorCode === 0) {
entity.value = res.data;
}
});
}
onMounted(() => {
getOptions();
getBrands();
});
const entity = ref({
model_of_chat: '',
chatgpt_api_key: '',
chatgpt_chatPath: '',
chatgpt_endpoint: '',
chatgpt_model_name: '',
});
function formatLlmList(data) {
return data.map((item) => {
const extra = new Map([
['chatPath', item.options?.chatPath],
['llmEndpoint', item.options?.llmEndpoint],
]);
return {
label: item.title,
value: item.key,
extra,
};
});
}
function handleChangeModel(value) {
const extra = providerList.find((item) => item.value === value);
entity.value.chatgpt_chatPath = extra.options.chatPath;
entity.value.chatgpt_endpoint = extra.options.llmEndpoint;
}
function handleSave() {
api.post('/api/v1/sysOption/save', entity.value).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.saveOkMessage'));
}
});
}
</script>
<template>
<div class="settings-container">
<div class="settings-config-container border-border border">
<div class="mb-6">
{{ $t('settingsConfig.systemAIFunctionSettings') }}
</div>
<ElAlert
class="!mb-5"
:title="$t('settingsConfig.note')"
type="warning"
/>
<ElForm :model="entity" class="demo-form-inline" label-width="150px">
<ElFormItem :label="$t('settingsConfig.modelOfChat')">
<ElSelect
v-model="entity.model_of_chat"
clearable
@change="handleChangeModel"
>
<ElOption
v-for="item in providerOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('settingsConfig.modelName')">
<ElInput v-model="entity.chatgpt_model_name" clearable />
</ElFormItem>
<ElFormItem label="Endpoint">
<ElInput v-model="entity.chatgpt_endpoint" clearable />
</ElFormItem>
<ElFormItem label="ChatPath">
<ElInput v-model="entity.chatgpt_chatPath" clearable />
</ElFormItem>
<ElFormItem label="ApiKey">
<ElInput v-model="entity.chatgpt_api_key" clearable />
</ElFormItem>
</ElForm>
<div class="settings-button-container">
<ElButton type="primary" @click="handleSave">
{{ $t('button.save') }}
</ElButton>
</div>
</div>
</div>
</template>
<style scoped>
.settings-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 30px 143px;
}
.settings-config-container {
width: 100%;
padding: 20px;
background-color: var(--el-bg-color);
border-radius: 10px;
}
:deep(.el-form-item) {
margin-bottom: 25px;
}
.settings-notice {
margin-bottom: 20px;
color: var(--el-color-danger);
}
.settings-button-container {
display: flex;
justify-content: flex-end;
}
</style>

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