发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
4 changed files with 229 additions and 33 deletions
Showing only changes of commit 7a5298c3fc - Show all commits

View File

@@ -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)',
);
});
}); });

View File

@@ -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);

View File

@@ -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,
);
}); });
}); });

View File

@@ -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