发布 v1.10 #5
@@ -15,12 +15,28 @@ describe('skill list contract', () => {
|
|||||||
expect(listSource).toContain("text: '新建技能'");
|
expect(listSource).toContain("text: '新建技能'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows only the agreed business columns and omits technical source data', () => {
|
it('shows only the agreed business columns and omits status and technical source data', () => {
|
||||||
for (const label of ['技能', '用途', '创建人', '范围', '状态', '操作']) {
|
for (const label of ['技能', '用途', '创建人', '范围', '操作']) {
|
||||||
expect(listSource).toContain(`label="${label}"`);
|
expect(listSource).toContain(`label="${label}"`);
|
||||||
}
|
}
|
||||||
|
expect(listSource).not.toContain('label="状态"');
|
||||||
expect(listSource).not.toContain('label="来源"');
|
expect(listSource).not.toContain('label="来源"');
|
||||||
expect(listSource).not.toContain('label="能力"');
|
expect(listSource).not.toContain('label="能力"');
|
||||||
expect(listSource).not.toContain('<h1>技能库</h1>');
|
expect(listSource).not.toContain('<h1>技能库</h1>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ import { withListReturnTo } from '#/router/list-return-context';
|
|||||||
import {
|
import {
|
||||||
canAiResourceDelete,
|
canAiResourceDelete,
|
||||||
canAiResourceOffline,
|
canAiResourceOffline,
|
||||||
resolveAiResourceDisplayStatus,
|
canAiResourcePublish,
|
||||||
|
canAiResourceRepublish,
|
||||||
} from '#/views/ai/shared/publish-status';
|
} from '#/views/ai/shared/publish-status';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +60,8 @@ import {
|
|||||||
getSkillCategories,
|
getSkillCategories,
|
||||||
submitSkillDeleteApproval,
|
submitSkillDeleteApproval,
|
||||||
submitSkillOfflineApproval,
|
submitSkillOfflineApproval,
|
||||||
|
submitSkillPublishApproval,
|
||||||
|
validateSkillForPublish,
|
||||||
} from './api';
|
} from './api';
|
||||||
import {
|
import {
|
||||||
isSkillAccessDeniedError,
|
isSkillAccessDeniedError,
|
||||||
@@ -91,6 +94,9 @@ const editingCategory = ref<SkillCategory>();
|
|||||||
const categoryAction = ref('');
|
const categoryAction = ref('');
|
||||||
const copyDialogOpen = ref(false);
|
const copyDialogOpen = ref(false);
|
||||||
const copyLoading = ref(false);
|
const copyLoading = ref(false);
|
||||||
|
const publishDialogOpen = ref(false);
|
||||||
|
const publishReason = ref('');
|
||||||
|
const publishTarget = ref<SkillInfo>();
|
||||||
const rowAction = ref('');
|
const rowAction = ref('');
|
||||||
const copyForm = reactive({
|
const copyForm = reactive({
|
||||||
categoryId: '' as number | string,
|
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 canUpdate = computed(() => hasPermission(['/api/v1/skill/update']));
|
||||||
const canExport = computed(() => hasPermission(['/api/v1/skill/export']));
|
const canExport = computed(() => hasPermission(['/api/v1/skill/export']));
|
||||||
const canCopy = computed(() => canCreate.value);
|
const canCopy = computed(() => canCreate.value);
|
||||||
|
const canPublish = computed(() =>
|
||||||
|
hasPermission(['/api/v1/skill/submitPublishApproval']),
|
||||||
|
);
|
||||||
const canOffline = computed(() =>
|
const canOffline = computed(() =>
|
||||||
hasPermission(['/api/v1/skill/submitOfflineApproval']),
|
hasPermission(['/api/v1/skill/submitOfflineApproval']),
|
||||||
);
|
);
|
||||||
@@ -291,21 +300,6 @@ function handlePageStateChange(state: {
|
|||||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
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) {
|
function scopeMeta(scope?: string) {
|
||||||
return {
|
return {
|
||||||
DEPT: { label: '部门', type: 'primary' as const },
|
DEPT: { label: '部门', type: 'primary' as const },
|
||||||
@@ -318,6 +312,27 @@ function rowPrimaryLabel(row: SkillInfo) {
|
|||||||
return row.manageable !== false && canUpdate.value ? '编辑' : '查看';
|
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) {
|
function canOfflineRow(row: SkillInfo) {
|
||||||
return (
|
return (
|
||||||
row.manageable !== false &&
|
row.manageable !== false &&
|
||||||
@@ -334,8 +349,62 @@ function canDeleteRow(row: SkillInfo) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRowBusy(row: SkillInfo) {
|
function isPublishRowBusy(row: SkillInfo) {
|
||||||
return rowAction.value.endsWith(`:${row.id}`);
|
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) {
|
async function handleRowCommand(command: string, row: SkillInfo) {
|
||||||
@@ -396,9 +465,9 @@ async function exportOne(row: SkillInfo) {
|
|||||||
async function offline(row: SkillInfo) {
|
async function offline(row: SkillInfo) {
|
||||||
if (!row.id || rowAction.value) return;
|
if (!row.id || rowAction.value) return;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('下线后将不可继续使用,确认下线?', '下线技能', {
|
await ElMessageBox.confirm('下架后将不可继续使用,确认下架?', '下架技能', {
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
confirmButtonText: '下线',
|
confirmButtonText: '下架',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -597,19 +666,22 @@ async function handleCategorySaved() {
|
|||||||
</ElTag>
|
</ElTag>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn label="状态" width="96">
|
<ElTableColumn label="操作" width="208" fixed="right">
|
||||||
<template #default="{ row }">
|
|
||||||
<ElTag :type="statusMeta(row).type" effect="plain">
|
|
||||||
{{ statusMeta(row).label }}
|
|
||||||
</ElTag>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn label="操作" width="112" fixed="right">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="skill-list-page__actions">
|
<div class="skill-list-page__actions">
|
||||||
<ElButton text type="primary" @click="openDetail(row)">
|
<ElButton text type="primary" @click="openDetail(row)">
|
||||||
{{ rowPrimaryLabel(row) }}
|
{{ rowPrimaryLabel(row) }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="canPublishRow(row)"
|
||||||
|
text
|
||||||
|
:type="rowPublishButtonType(row)"
|
||||||
|
:disabled="Boolean(rowAction)"
|
||||||
|
:loading="isPublishRowBusy(row)"
|
||||||
|
@click="openPublishDialog(row)"
|
||||||
|
>
|
||||||
|
{{ rowPublishLabel(row) }}
|
||||||
|
</ElButton>
|
||||||
<ElDropdown
|
<ElDropdown
|
||||||
v-if="
|
v-if="
|
||||||
canExport ||
|
canExport ||
|
||||||
@@ -624,7 +696,7 @@ async function handleCategorySaved() {
|
|||||||
text
|
text
|
||||||
:icon="MoreFilled"
|
:icon="MoreFilled"
|
||||||
aria-label="更多操作"
|
aria-label="更多操作"
|
||||||
:loading="isRowBusy(row)"
|
:loading="isMoreActionBusy(row)"
|
||||||
/>
|
/>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
@@ -646,7 +718,9 @@ async function handleCategorySaved() {
|
|||||||
v-if="canOfflineRow(row)"
|
v-if="canOfflineRow(row)"
|
||||||
command="offline"
|
command="offline"
|
||||||
>
|
>
|
||||||
下线
|
<span class="skill-list-page__danger-action">
|
||||||
|
下架
|
||||||
|
</span>
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
<ElDropdownItem
|
<ElDropdownItem
|
||||||
v-if="canDeleteRow(row)"
|
v-if="canDeleteRow(row)"
|
||||||
@@ -749,6 +823,47 @@ async function handleCategorySaved() {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
|
|
||||||
|
<ElDialog
|
||||||
|
v-model="publishDialogOpen"
|
||||||
|
width="min(520px, calc(100vw - 32px))"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!rowAction.startsWith('publish:')"
|
||||||
|
:show-close="!rowAction.startsWith('publish:')"
|
||||||
|
:title="
|
||||||
|
publishTarget ? `${rowPublishLabel(publishTarget)}技能` : '发布技能'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="发布说明" required>
|
||||||
|
<ElInput
|
||||||
|
v-model="publishReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="填写本次发布内容"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton
|
||||||
|
:disabled="rowAction.startsWith('publish:')"
|
||||||
|
@click="publishDialogOpen = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:type="
|
||||||
|
publishTarget ? rowPublishButtonType(publishTarget) : 'success'
|
||||||
|
"
|
||||||
|
:loading="rowAction.startsWith('publish:')"
|
||||||
|
@click="confirmPublish"
|
||||||
|
>
|
||||||
|
{{ publishTarget ? rowPublishLabel(publishTarget) : '发布' }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -848,6 +963,10 @@ async function handleCategorySaved() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-list-page__danger-action {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.skill-list-page {
|
.skill-list-page {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
|
|||||||
@@ -326,6 +326,58 @@ describe('skill resource workbench validation', () => {
|
|||||||
|
|
||||||
expect(valid).toBe(true);
|
expect(valid).toBe(true);
|
||||||
expect(apiMocks.validateSkillForPublish).toHaveBeenCalledWith(101);
|
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: '<div data-testid="markdown-live-editor"></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { skillId: 101 },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const valid = await (
|
||||||
|
wrapper.vm as unknown as {
|
||||||
|
validateAll: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
).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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -540,7 +540,6 @@ async function validateAll() {
|
|||||||
if (res.errorCode !== 0) return false;
|
if (res.errorCode !== 0) return false;
|
||||||
issues.value = res.data.issues || [];
|
issues.value = res.data.issues || [];
|
||||||
emit('issues', issues.value);
|
emit('issues', issues.value);
|
||||||
inspector.value = 'validation';
|
|
||||||
if (res.data.valid) ElMessage.success('发布级校验通过');
|
if (res.data.valid) ElMessage.success('发布级校验通过');
|
||||||
else ElMessage.warning('发现需要处理的校验问题');
|
else ElMessage.warning('发现需要处理的校验问题');
|
||||||
return res.data.valid;
|
return res.data.valid;
|
||||||
@@ -1246,6 +1245,16 @@ defineExpose({
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<div class="skill-resource-workbench__toolbar-actions">
|
<div class="skill-resource-workbench__toolbar-actions">
|
||||||
|
<ElButton
|
||||||
|
v-if="issues.length > 0"
|
||||||
|
text
|
||||||
|
type="warning"
|
||||||
|
aria-label="查看校验问题"
|
||||||
|
title="查看校验问题"
|
||||||
|
@click="inspector = 'validation'"
|
||||||
|
>
|
||||||
|
校验问题 {{ issues.length }}
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="currentBuffer?.conflict"
|
v-if="currentBuffer?.conflict"
|
||||||
text
|
text
|
||||||
|
|||||||
Reference in New Issue
Block a user