diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
index c27ca1b1..baf15ad1 100644
--- a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
+++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
@@ -15,12 +15,28 @@ describe('skill list contract', () => {
expect(listSource).toContain("text: '新建技能'");
});
- it('shows only the agreed business columns and omits technical source data', () => {
- for (const label of ['技能', '用途', '创建人', '范围', '状态', '操作']) {
+ it('shows only the agreed business columns and omits status and technical source data', () => {
+ for (const label of ['技能', '用途', '创建人', '范围', '操作']) {
expect(listSource).toContain(`label="${label}"`);
}
+ expect(listSource).not.toContain('label="状态"');
expect(listSource).not.toContain('label="来源"');
expect(listSource).not.toContain('label="能力"');
expect(listSource).not.toContain('
技能库
');
});
+
+ it('supports publishing, republishing, and taking published skills off shelf from the list', () => {
+ expect(listSource).toContain('canPublishRow(row)');
+ expect(listSource).toContain("? '重新发布'");
+ expect(listSource).toContain(':type="rowPublishButtonType(row)"');
+ expect(listSource).toContain("? 'warning'");
+ expect(listSource).toContain(": 'success'");
+ expect(listSource).toContain('command="offline"');
+ expect(listSource).toContain('skill-list-page__danger-action');
+ expect(listSource).toContain('color: hsl(var(--destructive))');
+ expect(listSource).toContain('validateSkillForPublish(row.id)');
+ expect(listSource).toContain(
+ 'submitSkillPublishApproval(target.id, reason)',
+ );
+ });
});
diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue
index c2c37623..ece40e3a 100644
--- a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue
+++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue
@@ -49,7 +49,8 @@ import { withListReturnTo } from '#/router/list-return-context';
import {
canAiResourceDelete,
canAiResourceOffline,
- resolveAiResourceDisplayStatus,
+ canAiResourcePublish,
+ canAiResourceRepublish,
} from '#/views/ai/shared/publish-status';
import {
@@ -59,6 +60,8 @@ import {
getSkillCategories,
submitSkillDeleteApproval,
submitSkillOfflineApproval,
+ submitSkillPublishApproval,
+ validateSkillForPublish,
} from './api';
import {
isSkillAccessDeniedError,
@@ -91,6 +94,9 @@ const editingCategory = ref();
const categoryAction = ref('');
const copyDialogOpen = ref(false);
const copyLoading = ref(false);
+const publishDialogOpen = ref(false);
+const publishReason = ref('');
+const publishTarget = ref();
const rowAction = ref('');
const copyForm = reactive({
categoryId: '' as number | string,
@@ -105,6 +111,9 @@ const canView = computed(() => hasPermission(['/api/v1/skill/getDetail']));
const canUpdate = computed(() => hasPermission(['/api/v1/skill/update']));
const canExport = computed(() => hasPermission(['/api/v1/skill/export']));
const canCopy = computed(() => canCreate.value);
+const canPublish = computed(() =>
+ hasPermission(['/api/v1/skill/submitPublishApproval']),
+);
const canOffline = computed(() =>
hasPermission(['/api/v1/skill/submitOfflineApproval']),
);
@@ -291,21 +300,6 @@ function handlePageStateChange(state: {
if (target.fullPath !== route.fullPath) void router.replace(target);
}
-function statusMeta(row: SkillInfo) {
- const status = resolveAiResourceDisplayStatus(
- row.displayPublishStatus,
- row.publishStatus,
- );
- return {
- DELETE_PENDING: { label: '删除中', type: 'danger' as const },
- DRAFT: { label: '草稿', type: 'info' as const },
- OFFLINE: { label: '已下线', type: 'info' as const },
- OFFLINE_PENDING: { label: '下线中', type: 'warning' as const },
- PUBLISHED: { label: '已发布', type: 'success' as const },
- PUBLISH_PENDING: { label: '待审核', type: 'warning' as const },
- }[status];
-}
-
function scopeMeta(scope?: string) {
return {
DEPT: { label: '部门', type: 'primary' as const },
@@ -318,6 +312,27 @@ function rowPrimaryLabel(row: SkillInfo) {
return row.manageable !== false && canUpdate.value ? '编辑' : '查看';
}
+function canPublishRow(row: SkillInfo) {
+ return (
+ row.manageable !== false &&
+ canPublish.value &&
+ (canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
+ canAiResourceRepublish(row.displayPublishStatus, row.publishStatus))
+ );
+}
+
+function rowPublishLabel(row: SkillInfo) {
+ return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
+ ? '重新发布'
+ : '发布';
+}
+
+function rowPublishButtonType(row: SkillInfo): 'success' | 'warning' {
+ return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
+ ? 'warning'
+ : 'success';
+}
+
function canOfflineRow(row: SkillInfo) {
return (
row.manageable !== false &&
@@ -334,8 +349,62 @@ function canDeleteRow(row: SkillInfo) {
);
}
-function isRowBusy(row: SkillInfo) {
- return rowAction.value.endsWith(`:${row.id}`);
+function isPublishRowBusy(row: SkillInfo) {
+ return (
+ rowAction.value === `publish:${row.id}` ||
+ rowAction.value === `validate:${row.id}`
+ );
+}
+
+function isMoreActionBusy(row: SkillInfo) {
+ return !isPublishRowBusy(row) && rowAction.value.endsWith(`:${row.id}`);
+}
+
+async function openPublishDialog(row: SkillInfo) {
+ if (!row.id || !canPublishRow(row) || rowAction.value) return;
+ rowAction.value = `validate:${row.id}`;
+ try {
+ const response = await validateSkillForPublish(row.id);
+ if (response.errorCode !== 0) return;
+ if (!response.data.valid) {
+ const issue = response.data.issues?.find(
+ (item) => item.severity === 'ERROR',
+ );
+ ElMessage.warning(
+ issue?.message
+ ? `发布校验未通过:${issue.message}`
+ : '发布校验未通过,请编辑技能后重试',
+ );
+ return;
+ }
+ publishTarget.value = row;
+ publishReason.value = '';
+ publishDialogOpen.value = true;
+ } finally {
+ rowAction.value = '';
+ }
+}
+
+async function confirmPublish() {
+ const target = publishTarget.value;
+ const reason = publishReason.value.trim();
+ if (!target?.id || rowAction.value) return;
+ if (!reason) {
+ ElMessage.warning('请输入发布说明');
+ return;
+ }
+ if (reason.length > 500) return;
+ rowAction.value = `publish:${target.id}`;
+ try {
+ const response = await submitSkillPublishApproval(target.id, reason);
+ if (response.errorCode !== 0) return;
+ publishDialogOpen.value = false;
+ publishTarget.value = undefined;
+ pageDataRef.value?.reload?.();
+ ElMessage.success(response.message || '已提交发布');
+ } finally {
+ rowAction.value = '';
+ }
}
async function handleRowCommand(command: string, row: SkillInfo) {
@@ -396,9 +465,9 @@ async function exportOne(row: SkillInfo) {
async function offline(row: SkillInfo) {
if (!row.id || rowAction.value) return;
try {
- await ElMessageBox.confirm('下线后将不可继续使用,确认下线?', '下线技能', {
+ await ElMessageBox.confirm('下架后将不可继续使用,确认下架?', '下架技能', {
cancelButtonText: '取消',
- confirmButtonText: '下线',
+ confirmButtonText: '下架',
type: 'warning',
});
} catch {
@@ -597,19 +666,22 @@ async function handleCategorySaved() {
-
-
-
- {{ statusMeta(row).label }}
-
-
-
-
+
{{ rowPrimaryLabel(row) }}
+
+ {{ rowPublishLabel(row) }}
+
@@ -646,7 +718,9 @@ async function handleCategorySaved() {
v-if="canOfflineRow(row)"
command="offline"
>
- 下线
+
+ 下架
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ publishTarget ? rowPublishLabel(publishTarget) : '发布' }}
+
+
+
@@ -848,6 +963,10 @@ async function handleCategorySaved() {
align-items: center;
}
+.skill-list-page__danger-action {
+ color: hsl(var(--destructive));
+}
+
@media (max-width: 900px) {
.skill-list-page {
padding: var(--space-4);
diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts
index 6c241bd0..ea7790e6 100644
--- a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts
+++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts
@@ -326,6 +326,58 @@ describe('skill resource workbench validation', () => {
expect(valid).toBe(true);
expect(apiMocks.validateSkillForPublish).toHaveBeenCalledWith(101);
+ expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe(
+ false,
+ );
+ });
+
+ it('keeps validation issues collapsed until the user opens them', async () => {
+ apiMocks.validateSkillForPublish.mockResolvedValue({
+ data: {
+ issues: [
+ {
+ line: 3,
+ message: '缺少必填字段',
+ path: 'SKILL.md',
+ severity: 'ERROR',
+ },
+ ],
+ valid: false,
+ },
+ errorCode: 0,
+ });
+ wrapper = mount(SkillResourceWorkbench, {
+ global: {
+ directives: { loading: {} },
+ stubs: {
+ MarkdownLiveEditor: {
+ template: '',
+ },
+ },
+ },
+ props: { skillId: 101 },
+ });
+ await flushPromises();
+
+ const valid = await (
+ wrapper.vm as unknown as {
+ validateAll: () => Promise;
+ }
+ ).validateAll();
+ await flushPromises();
+
+ expect(valid).toBe(false);
+ expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe(
+ false,
+ );
+ const issueButton = wrapper.get('button[aria-label="查看校验问题"]');
+ expect(issueButton.text()).toContain('校验问题 1');
+
+ await issueButton.trigger('click');
+
+ expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe(
+ true,
+ );
});
});
diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
index 5ecdbcbc..976be6c5 100644
--- a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
+++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
@@ -540,7 +540,6 @@ async function validateAll() {
if (res.errorCode !== 0) return false;
issues.value = res.data.issues || [];
emit('issues', issues.value);
- inspector.value = 'validation';
if (res.data.valid) ElMessage.success('发布级校验通过');
else ElMessage.warning('发现需要处理的校验问题');
return res.data.valid;
@@ -1246,6 +1245,16 @@ defineExpose({
size="small"
/>
+
+ 校验问题 {{ issues.length }}
+