feat: 增加技能管理模块试验性功能,等待优化
This commit is contained in:
164
easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue
Normal file
164
easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import {onBeforeUnmount, onMounted, ref, shallowRef, watch} from 'vue';
|
||||
|
||||
import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands';
|
||||
import {javascript} from '@codemirror/lang-javascript';
|
||||
import {python} from '@codemirror/lang-python';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
indentOnInput,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting
|
||||
} from '@codemirror/language';
|
||||
import {shell} from '@codemirror/legacy-modes/mode/shell';
|
||||
import {EditorState, type Extension} from '@codemirror/state';
|
||||
import {
|
||||
drawSelection,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
lineNumbers
|
||||
} from '@codemirror/view';
|
||||
|
||||
type EditorLanguage = 'javascript' | 'markdown' | 'python' | 'shell' | 'text';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
language?: EditorLanguage;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
language: 'text',
|
||||
readonly: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const hostRef = ref<HTMLElement>();
|
||||
const viewRef = shallowRef<EditorView>();
|
||||
let syncingFromOutside = false;
|
||||
|
||||
function languageExtension(): Extension[] {
|
||||
if (props.language === 'python') {
|
||||
return [python()];
|
||||
}
|
||||
if (props.language === 'javascript') {
|
||||
return [javascript({ jsx: true, typescript: true })];
|
||||
}
|
||||
if (props.language === 'shell') {
|
||||
return [StreamLanguage.define(shell)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function createState(doc: string) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.editable.of(!props.readonly),
|
||||
EditorState.readOnly.of(props.readonly),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || syncingFromOutside) return;
|
||||
emit('update:modelValue', update.state.doc.toString());
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'var(--el-bg-color)',
|
||||
color: 'var(--el-text-color-primary)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--el-fill-color-lighter)',
|
||||
borderRight: '1px solid var(--el-border-color-lighter)',
|
||||
color: 'var(--el-text-color-secondary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--el-fill-color-light)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--el-fill-color-light)',
|
||||
},
|
||||
}),
|
||||
...languageExtension(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountEditor() {
|
||||
if (!hostRef.value) return;
|
||||
viewRef.value = new EditorView({
|
||||
state: createState(props.modelValue || ''),
|
||||
parent: hostRef.value,
|
||||
});
|
||||
}
|
||||
|
||||
function recreateEditor() {
|
||||
const currentDoc = viewRef.value?.state.doc.toString() ?? props.modelValue ?? '';
|
||||
viewRef.value?.destroy();
|
||||
viewRef.value = undefined;
|
||||
if (!hostRef.value) return;
|
||||
viewRef.value = new EditorView({
|
||||
state: createState(currentDoc),
|
||||
parent: hostRef.value,
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const view = viewRef.value;
|
||||
if (!view) return;
|
||||
const nextValue = value || '';
|
||||
const currentValue = view.state.doc.toString();
|
||||
if (nextValue === currentValue) return;
|
||||
syncingFromOutside = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: nextValue },
|
||||
});
|
||||
syncingFromOutside = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.language, props.readonly],
|
||||
() => recreateEditor(),
|
||||
);
|
||||
|
||||
onMounted(mountEditor);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
viewRef.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="hostRef" class="code-mirror-editor"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-mirror-editor {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
18
easyflow-ui-admin/app/src/router/routes/modules/skill.ts
Normal file
18
easyflow-ui-admin/app/src/router/routes/modules/skill.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {RouteRecordRaw} from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'SkillDetail',
|
||||
path: '/ai/skill/detail/:id',
|
||||
component: () => import('#/views/ai/skill/SkillDetail.vue'),
|
||||
meta: {
|
||||
title: '技能详情',
|
||||
openInNewWindow: true,
|
||||
hideInMenu: true,
|
||||
activePath: '/ai/skill',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
5
easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts
vendored
Normal file
5
easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '@codemirror/legacy-modes/mode/shell.js' {
|
||||
import type {StreamParser} from '@codemirror/language';
|
||||
|
||||
export const shell: StreamParser<unknown>;
|
||||
}
|
||||
2
easyflow-ui-admin/app/src/types/markdown-it.d.ts
vendored
Normal file
2
easyflow-ui-admin/app/src/types/markdown-it.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'markdown-it';
|
||||
|
||||
398
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue
Normal file
398
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import type {SkillFileContent, SkillFileNode, SkillInfo} from './types';
|
||||
|
||||
import {computed, onMounted, ref, watch} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import {ArrowLeft, Check, Download, Promotion} from '@element-plus/icons-vue';
|
||||
import {ElButton, ElEmpty, ElImage, ElMessage, ElTag, ElTree,} from 'element-plus';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
import CodeMirrorEditor from '#/components/editor/CodeMirrorEditor.vue';
|
||||
|
||||
import {
|
||||
getSkillDetail,
|
||||
getSkillFileContent,
|
||||
getSkillFileTree,
|
||||
resolveSkillAssetUrl,
|
||||
saveSkill,
|
||||
saveSkillFile,
|
||||
submitSkillPublishApproval,
|
||||
} from './api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const skillId = computed(() => String(route.params.id || 'new'));
|
||||
const isNew = computed(() => skillId.value === 'new');
|
||||
const skill = ref<SkillInfo>({
|
||||
displayName: '新建技能',
|
||||
skillContent: '---\nname: new-skill\ndescription: 新技能\n---\n\n# 新技能\n',
|
||||
visibilityScope: 'PRIVATE',
|
||||
enabled: true,
|
||||
});
|
||||
const fileTree = ref<SkillFileNode[]>([]);
|
||||
const selectedFile = ref<SkillFileContent>();
|
||||
const editorContent = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
if (!isMarkdownFile.value) return '';
|
||||
return DOMPurify.sanitize(markdown.render(editorContent.value || ''));
|
||||
});
|
||||
|
||||
const isMarkdownFile = computed(() =>
|
||||
selectedFile.value?.type === 'SKILL' || selectedFile.value?.type === 'REFERENCE',
|
||||
);
|
||||
const isScriptFile = computed(() => selectedFile.value?.type === 'SCRIPT');
|
||||
const isAssetFile = computed(() => selectedFile.value?.type === 'ASSET');
|
||||
const assetUrl = computed(() =>
|
||||
selectedFile.value?.path && !isNew.value
|
||||
? resolveSkillAssetUrl(skillId.value, selectedFile.value.path)
|
||||
: '',
|
||||
);
|
||||
const isImageAsset = computed(() =>
|
||||
String(selectedFile.value?.mediaType || '').startsWith('image/') &&
|
||||
selectedFile.value?.mediaType !== 'image/svg+xml',
|
||||
);
|
||||
const isPdfAsset = computed(() => selectedFile.value?.mediaType === 'application/pdf');
|
||||
|
||||
onMounted(initPage);
|
||||
|
||||
watch(skillId, initPage);
|
||||
|
||||
async function initPage() {
|
||||
if (isNew.value) {
|
||||
const defaultContent = '---\nname: new-skill\ndescription: 新技能\n---\n\n# 新技能\n';
|
||||
skill.value = {
|
||||
displayName: '新建技能',
|
||||
skillContent: defaultContent,
|
||||
visibilityScope: 'PRIVATE',
|
||||
enabled: true,
|
||||
};
|
||||
selectedFile.value = { path: 'SKILL.md', type: 'SKILL', content: defaultContent };
|
||||
editorContent.value = defaultContent;
|
||||
fileTree.value = [
|
||||
{ key: 'SKILL.md', path: 'SKILL.md', name: 'SKILL.md', type: 'SKILL' },
|
||||
{ key: 'references', path: 'references', name: 'references/', type: 'DIRECTORY', children: [] },
|
||||
{ key: 'scripts', path: 'scripts', name: 'scripts/', type: 'DIRECTORY', children: [] },
|
||||
{ key: 'assets', path: 'assets', name: 'assets/', type: 'DIRECTORY', children: [] },
|
||||
];
|
||||
return;
|
||||
}
|
||||
await loadDetail();
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const detailRes = await getSkillDetail(skillId.value);
|
||||
if (detailRes.errorCode === 0) {
|
||||
skill.value = detailRes.data;
|
||||
}
|
||||
await loadTree();
|
||||
await selectFile('SKILL.md');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
const res = await getSkillFileTree(skillId.value);
|
||||
if (res.errorCode === 0) {
|
||||
fileTree.value = res.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(path: string) {
|
||||
if (isNew.value) return;
|
||||
const res = await getSkillFileContent(skillId.value, path);
|
||||
if (res.errorCode === 0) {
|
||||
selectedFile.value = res.data;
|
||||
editorContent.value = res.data.content || '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleTreeNodeClick(node: SkillFileNode) {
|
||||
if (node.type === 'DIRECTORY') return;
|
||||
selectFile(node.path);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isNew.value) {
|
||||
skill.value.skillContent = editorContent.value;
|
||||
const res = await saveSkill(skill.value);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success('保存成功');
|
||||
await router.replace(`/ai/skill/detail/${res.data.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!selectedFile.value) return;
|
||||
if (isMarkdownFile.value || isScriptFile.value) {
|
||||
const res = await saveSkillFile(skillId.value, selectedFile.value.path, editorContent.value);
|
||||
if (res.errorCode === 0) {
|
||||
selectedFile.value = res.data;
|
||||
ElMessage.success('保存成功');
|
||||
await loadTree();
|
||||
if (selectedFile.value.path === 'SKILL.md') {
|
||||
await loadDetail();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (isNew.value) {
|
||||
ElMessage.warning('请先保存技能');
|
||||
return;
|
||||
}
|
||||
const res = await submitSkillPublishApproval(skillId.value);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '操作成功');
|
||||
await loadDetail();
|
||||
}
|
||||
}
|
||||
|
||||
function editorLanguage() {
|
||||
const language = selectedFile.value?.language;
|
||||
if (language === 'PYTHON') return 'python';
|
||||
if (language === 'JAVASCRIPT') return 'javascript';
|
||||
if (language === 'SHELL') return 'shell';
|
||||
if (isMarkdownFile.value) return 'markdown';
|
||||
return 'text';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-detail-page" v-loading="loading">
|
||||
<header class="skill-detail-page__header">
|
||||
<div class="skill-detail-page__title">
|
||||
<ElButton :icon="ArrowLeft" text @click="router.push('/ai/skill')" />
|
||||
<div>
|
||||
<h1>{{ skill.displayName || skill.name || '技能详情' }}</h1>
|
||||
<p>{{ skill.description || '未填写描述' }}</p>
|
||||
</div>
|
||||
<ElTag v-if="skill.displayPublishStatus || skill.publishStatus" size="small" effect="plain">
|
||||
{{ skill.displayPublishStatus || skill.publishStatus }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="skill-detail-page__actions">
|
||||
<ElButton :icon="Check" type="primary" :loading="saving" @click="handleSave">
|
||||
保存
|
||||
</ElButton>
|
||||
<ElButton :icon="Promotion" @click="handlePublish">发布</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="skill-detail-page__body">
|
||||
<aside class="skill-detail-page__tree">
|
||||
<ElTree
|
||||
node-key="path"
|
||||
:data="fileTree"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
default-expand-all
|
||||
highlight-current
|
||||
@node-click="handleTreeNodeClick"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section class="skill-detail-page__workspace">
|
||||
<template v-if="selectedFile">
|
||||
<div class="skill-detail-page__filebar">
|
||||
<strong>{{ selectedFile.path }}</strong>
|
||||
<span v-if="selectedFile.size">{{ selectedFile.size }} bytes</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isMarkdownFile" class="skill-detail-page__split">
|
||||
<CodeMirrorEditor
|
||||
v-model="editorContent"
|
||||
class="skill-detail-page__editor"
|
||||
:language="editorLanguage()"
|
||||
/>
|
||||
<div class="skill-detail-page__preview markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
|
||||
<CodeMirrorEditor
|
||||
v-else-if="isScriptFile"
|
||||
v-model="editorContent"
|
||||
class="skill-detail-page__editor"
|
||||
:language="editorLanguage()"
|
||||
/>
|
||||
|
||||
<div v-else-if="isAssetFile" class="skill-detail-page__asset">
|
||||
<ElImage v-if="isImageAsset" :src="assetUrl" fit="contain" />
|
||||
<iframe v-else-if="isPdfAsset" :src="assetUrl"></iframe>
|
||||
<div v-else class="skill-detail-page__asset-info">
|
||||
<p>{{ selectedFile.mediaType || 'application/octet-stream' }}</p>
|
||||
<p>{{ selectedFile.size || 0 }} bytes</p>
|
||||
</div>
|
||||
<ElButton :icon="Download" tag="a" :href="assetUrl" target="_blank">
|
||||
下载
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty v-else />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skill-detail-page__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skill-detail-page__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-detail-page__title h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.skill-detail-page__title p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skill-detail-page__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skill-detail-page__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skill-detail-page__tree {
|
||||
width: 280px;
|
||||
min-width: 240px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skill-detail-page__workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skill-detail-page__filebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.skill-detail-page__filebar span {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skill-detail-page__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skill-detail-page__editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.skill-detail-page__split .skill-detail-page__editor {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skill-detail-page__preview {
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skill-detail-page__asset {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skill-detail-page__asset :deep(.el-image) {
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.skill-detail-page__asset iframe {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skill-detail-page__asset-info {
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
335
easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue
Normal file
335
easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import type {SkillInfo} from './types';
|
||||
import type {ActionButton, CardPrimaryAction} from '#/components/page/CardList.vue';
|
||||
import CardList from '#/components/page/CardList.vue';
|
||||
|
||||
import {markRaw, onMounted, ref} from 'vue';
|
||||
import {useRouter} from 'vue-router';
|
||||
|
||||
import {Delete, Edit, Plus, Promotion, Upload} from '@element-plus/icons-vue';
|
||||
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
|
||||
import {tryit} from 'radash';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
canAiResourceOffline,
|
||||
canAiResourcePublish,
|
||||
canAiResourceRepublish,
|
||||
isAiResourceApprovalPending,
|
||||
resolveAiResourceDisplayStatus,
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
|
||||
import {
|
||||
getSkillCategories,
|
||||
importSkillConfirm,
|
||||
importSkillPreview,
|
||||
submitSkillDeleteApproval,
|
||||
submitSkillOfflineApproval,
|
||||
submitSkillPublishApproval,
|
||||
} from './api';
|
||||
|
||||
const router = useRouter();
|
||||
const pageDataRef = ref();
|
||||
const sideList = ref<any[]>([]);
|
||||
const importInputRef = ref<HTMLInputElement>();
|
||||
const selectedCategoryId = ref<string>('');
|
||||
const SKILL_TAB_PAGE_KEY = '/ai/skill';
|
||||
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'create',
|
||||
text: '新建技能',
|
||||
icon: markRaw(Plus),
|
||||
type: 'primary',
|
||||
data: { action: 'create' },
|
||||
permission: '/api/v1/skill/save',
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
text: '导入',
|
||||
icon: markRaw(Upload),
|
||||
type: 'default',
|
||||
data: { action: 'import' },
|
||||
permission: '/api/v1/skill/save',
|
||||
},
|
||||
];
|
||||
|
||||
const primaryAction: CardPrimaryAction = {
|
||||
icon: Edit,
|
||||
text: '编辑',
|
||||
permission: '/api/v1/skill/update',
|
||||
onClick(row: SkillInfo) {
|
||||
router.push({
|
||||
path: `/ai/skill/detail/${row.id}`,
|
||||
query: {
|
||||
pageKey: SKILL_TAB_PAGE_KEY,
|
||||
navTitle: row.displayName || row.name || '技能详情',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const actions: ActionButton[] = [
|
||||
{
|
||||
icon: Promotion,
|
||||
text: (row: SkillInfo) =>
|
||||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
|
||||
? '重新发布'
|
||||
: '发布',
|
||||
permission: '/api/v1/skill/save',
|
||||
placement: 'inline',
|
||||
visible: (row: SkillInfo) =>
|
||||
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
|
||||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
|
||||
onClick: handlePublishAction,
|
||||
},
|
||||
{
|
||||
icon: Promotion,
|
||||
text: '下线',
|
||||
permission: '/api/v1/skill/save',
|
||||
placement: 'menu',
|
||||
visible: (row: SkillInfo) =>
|
||||
canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
|
||||
onClick: handleOfflineAction,
|
||||
},
|
||||
{
|
||||
icon: Delete,
|
||||
text: '删除',
|
||||
permission: '/api/v1/skill/remove',
|
||||
placement: 'menu',
|
||||
tone: 'danger',
|
||||
visible: (row: SkillInfo) =>
|
||||
canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
|
||||
onClick: handleDeleteAction,
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(loadCategories);
|
||||
|
||||
function handleSearch(keyword: string) {
|
||||
pageDataRef.value?.setQuery({
|
||||
isQueryOr: true,
|
||||
name: keyword,
|
||||
displayName: keyword,
|
||||
description: keyword,
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonClick(payload: any) {
|
||||
if (payload?.key === 'create' || payload?.data?.action === 'create') {
|
||||
router.push({
|
||||
path: '/ai/skill/detail/new',
|
||||
query: { pageKey: SKILL_TAB_PAGE_KEY, navTitle: '新建技能' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload?.key === 'import' || payload?.data?.action === 'import') {
|
||||
importInputRef.value?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function changeCategory(category: any) {
|
||||
selectedCategoryId.value = category.id || '';
|
||||
pageDataRef.value?.setQuery({ categoryId: category.id });
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const [, res] = await tryit(getSkillCategories)();
|
||||
if (res?.errorCode === 0) {
|
||||
sideList.value = [
|
||||
{ id: '', categoryName: '全部分类' },
|
||||
...(res.data || []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
const preview = await importSkillPreview(file);
|
||||
if (preview.errorCode !== 0) return;
|
||||
const conflictCount = preview.data?.skills?.filter((item) => item.conflict).length || 0;
|
||||
const ok = await confirmAction(
|
||||
conflictCount > 0
|
||||
? `检测到 ${conflictCount} 个同名草稿,确认覆盖草稿并导入?`
|
||||
: '确认导入该 Skill zip?',
|
||||
conflictCount > 0 ? 'warning' : 'info',
|
||||
);
|
||||
if (!ok) return;
|
||||
const res = await importSkillConfirm(
|
||||
file,
|
||||
selectedCategoryId.value || undefined,
|
||||
conflictCount > 0,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success('导入完成');
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePublishStatusMeta(displayPublishStatus?: string, publishStatus?: string) {
|
||||
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
|
||||
case 'DELETE_PENDING':
|
||||
return { label: '删除中', type: 'danger' as const };
|
||||
case 'OFFLINE':
|
||||
return { label: '已下线', type: 'info' as const };
|
||||
case 'OFFLINE_PENDING':
|
||||
return { label: '下线中', type: 'warning' as const };
|
||||
case 'PUBLISH_PENDING':
|
||||
return { label: '发布中', type: 'warning' as const };
|
||||
case 'PUBLISHED':
|
||||
return { label: '已发布', type: 'success' as const };
|
||||
default:
|
||||
return { label: '草稿', type: 'info' as const };
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAction(message: string, type: 'info' | 'warning' = 'info') {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, '提示', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishAction(row: SkillInfo) {
|
||||
if (isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)) {
|
||||
ElMessage.warning('当前技能正在审批中');
|
||||
return;
|
||||
}
|
||||
const ok = await confirmAction(
|
||||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
|
||||
? '确认提交重新发布审批?'
|
||||
: '确认提交发布审批?',
|
||||
);
|
||||
if (!ok) return;
|
||||
const res = await submitSkillPublishApproval(String(row.id));
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '操作成功');
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOfflineAction(row: SkillInfo) {
|
||||
const ok = await confirmAction('确认提交下线审批?', 'warning');
|
||||
if (!ok) return;
|
||||
const res = await submitSkillOfflineApproval(String(row.id));
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '操作成功');
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: SkillInfo) {
|
||||
const ok = await confirmAction('确认提交删除审批?', 'warning');
|
||||
if (!ok) return;
|
||||
const res = await submitSkillDeleteApproval(String(row.id));
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '操作成功');
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-list-page">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
<input
|
||||
ref="importInputRef"
|
||||
class="skill-list-page__file"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
<div class="skill-list-page__body">
|
||||
<PageSide
|
||||
label-key="categoryName"
|
||||
value-key="id"
|
||||
:menus="sideList"
|
||||
@change="changeCategory"
|
||||
/>
|
||||
<div class="skill-list-page__content">
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/skill/page"
|
||||
:page-sizes="[12, 18, 24]"
|
||||
:page-size="12"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<CardList
|
||||
title-field="displayName"
|
||||
desc-field="description"
|
||||
:data="pageList"
|
||||
:default-icon="''"
|
||||
:primary-action="primaryAction"
|
||||
:actions="actions"
|
||||
>
|
||||
<template #corner="{ item }">
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
round
|
||||
:type="
|
||||
resolvePublishStatusMeta(
|
||||
item.displayPublishStatus,
|
||||
item.publishStatus,
|
||||
).type
|
||||
"
|
||||
>
|
||||
{{
|
||||
resolvePublishStatusMeta(
|
||||
item.displayPublishStatus,
|
||||
item.publishStatus,
|
||||
).label
|
||||
}}
|
||||
</ElTag>
|
||||
</template>
|
||||
</CardList>
|
||||
</template>
|
||||
</PageData>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skill-list-page__file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skill-list-page__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skill-list-page__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: calc(100vh - 192px);
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
106
easyflow-ui-admin/app/src/views/ai/skill/api.ts
Normal file
106
easyflow-ui-admin/app/src/views/ai/skill/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
RequestResult,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
} from './types';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
|
||||
export function getSkillDetail(id: number | string) {
|
||||
return api.get<RequestResult<SkillInfo>>('/api/v1/skill/getDetail', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/save', skill);
|
||||
}
|
||||
|
||||
export function updateSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/update', skill);
|
||||
}
|
||||
|
||||
export function getSkillCategories() {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/skillCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function submitSkillPublishApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
'/api/v1/skill/submitPublishApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillOfflineApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
'/api/v1/skill/submitOfflineApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillDeleteApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
'/api/v1/skill/submitDeleteApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillFileTree(skillId: number | string) {
|
||||
return api.get<RequestResult<SkillFileNode[]>>('/api/v1/skill/file/tree', {
|
||||
params: { skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkillFileContent(skillId: number | string, path: string) {
|
||||
return api.get<RequestResult<SkillFileContent>>('/api/v1/skill/file/content', {
|
||||
params: { skillId, path },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
content: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
|
||||
skillId,
|
||||
path,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export function importSkillPreview(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.postFile<RequestResult<SkillImportPreview>>(
|
||||
'/api/v1/skill/import/preview',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirm(
|
||||
file: File,
|
||||
categoryId?: number | string,
|
||||
overwriteDraft = false,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (categoryId) {
|
||||
formData.append('categoryId', String(categoryId));
|
||||
}
|
||||
formData.append('overwriteDraft', String(overwriteDraft));
|
||||
return api.postFile<RequestResult<SkillInfo[]>>(
|
||||
'/api/v1/skill/import/confirm',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSkillAssetUrl(skillId: number | string, path: string) {
|
||||
return `/api/v1/skill/file/asset?skillId=${encodeURIComponent(
|
||||
String(skillId),
|
||||
)}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
99
easyflow-ui-admin/app/src/views/ai/skill/types.ts
Normal file
99
easyflow-ui-admin/app/src/views/ai/skill/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export interface RequestResult<T = any> {
|
||||
data: T;
|
||||
errorCode: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
categoryId?: number | string;
|
||||
metadataJson?: Record<string, any>;
|
||||
skillContent?: string;
|
||||
enabled?: boolean;
|
||||
visibilityScope?: string;
|
||||
sourceType?: string;
|
||||
referenceCount?: number;
|
||||
scriptCount?: number;
|
||||
assetCount?: number;
|
||||
publishStatus?: string;
|
||||
displayPublishStatus?: string;
|
||||
approvalPending?: boolean;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
created?: string;
|
||||
createdByName?: string;
|
||||
references?: SkillReference[];
|
||||
scripts?: SkillScript[];
|
||||
assets?: SkillAsset[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SkillReference {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillScript {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
language?: string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillAsset {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
mediaType?: string;
|
||||
contentRef?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
key: string;
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'ASSET' | 'DIRECTORY' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
size?: number;
|
||||
children?: SkillFileNode[];
|
||||
}
|
||||
|
||||
export interface SkillFileContent {
|
||||
path: string;
|
||||
type: 'ASSET' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
content?: string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
size?: number;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface SkillImportPreviewItem {
|
||||
packageId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
referenceCount: number;
|
||||
scriptCount: number;
|
||||
assetCount: number;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
export interface SkillImportPreview {
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user