feat: 支持 Skill 仓库扫描与批量导入
- 统一支持本地单包、多包和 Git 候选最多 50 项导入 - 固定提交并限制 SSRF、DNS、并发、频率、超时和仓库资源 - 增加导入对话框、接口契约和安全回归测试
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCreateDialog from './SkillCreateDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
cancelSkillImport: vi.fn(),
|
||||
importSkillConfirmBatch: vi.fn(),
|
||||
importSkillPreviews: vi.fn(),
|
||||
prepareSkillGitCandidates: vi.fn(),
|
||||
saveSkill: vi.fn(),
|
||||
scanSkillGitRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
|
||||
describe('skill Git repository import dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.scanSkillGitRepository.mockResolvedValue({
|
||||
data: {
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'a'.repeat(64),
|
||||
description: '聚合热门 AI 内容',
|
||||
issues: [],
|
||||
name: 'aihot',
|
||||
path: 'aihot',
|
||||
resourceCount: 2,
|
||||
status: 'IMPORTABLE',
|
||||
totalBytes: 2048,
|
||||
},
|
||||
],
|
||||
expiresAt: '2026-08-15T14:00:00Z',
|
||||
repository: {
|
||||
commitSha: 'b'.repeat(40),
|
||||
defaultBranch: 'main',
|
||||
name: 'khazix-skills',
|
||||
url: 'https://github.com/KKKKhazix/khazix-skills',
|
||||
},
|
||||
scanToken: 'c'.repeat(32),
|
||||
summary: { discovered: 1, importable: 1, needsAttention: 0 },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.prepareSkillGitCandidates.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
importToken: 'd'.repeat(32),
|
||||
issues: [],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name: 'aihot',
|
||||
packageId: 'aihot',
|
||||
packageRoot: 'aihot',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.importSkillConfirmBatch.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
importToken: 'd'.repeat(32),
|
||||
skills: [{ id: 101, name: 'aihot' }],
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('scans, selects and imports one repository candidate through the standard pipeline', async () => {
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDialog: {
|
||||
props: ['modelValue', 'title'],
|
||||
template:
|
||||
'<section><h2>{{ title }}</h2><slot/><footer><slot name="footer"/></footer></section>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
categories: [],
|
||||
defaultMode: 'import',
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const sourceButtons = wrapper.findAllComponents({ name: 'ElRadioButton' });
|
||||
sourceButtons
|
||||
.find((item) => item.props('value') === 'git')
|
||||
?.vm.$emit('click');
|
||||
sourceButtons
|
||||
.find((item) => item.props('value') === 'git')
|
||||
?.vm.$emit('update:modelValue', 'git');
|
||||
const sourceGroup = wrapper.findAllComponents({ name: 'ElRadioGroup' })[1];
|
||||
sourceGroup?.vm.$emit('update:modelValue', 'git');
|
||||
await flushPromises();
|
||||
|
||||
const repositoryInput = wrapper.find(
|
||||
'input[placeholder="输入 HTTPS Git 仓库地址"]',
|
||||
);
|
||||
await repositoryInput.setValue(
|
||||
'https://github.com/KKKKhazix/khazix-skills',
|
||||
);
|
||||
const scanButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '扫描仓库');
|
||||
await scanButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.scanSkillGitRepository).toHaveBeenCalledWith(
|
||||
'https://github.com/KKKKhazix/khazix-skills',
|
||||
);
|
||||
expect(wrapper.text()).toContain('khazix-skills');
|
||||
expect(wrapper.text()).toContain('aihot');
|
||||
|
||||
await wrapper
|
||||
.get('.skill-create-dialog__candidate-toggle')
|
||||
.trigger('click');
|
||||
const importButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('导入所选'));
|
||||
await importButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.prepareSkillGitCandidates).toHaveBeenCalledWith(
|
||||
'c'.repeat(32),
|
||||
['a'.repeat(64)],
|
||||
);
|
||||
expect(apiMocks.importSkillConfirmBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
conflictStrategy: 'REJECT',
|
||||
importToken: 'd'.repeat(32),
|
||||
visibilityScope: 'PRIVATE',
|
||||
}),
|
||||
]);
|
||||
expect(wrapper.emitted('imported')).toEqual([[1]]);
|
||||
});
|
||||
|
||||
it('renders and confirms every Skill returned from one multi-Skill ZIP', async () => {
|
||||
const archive = new File(['fixture'], 'skill-bundle.zip', {
|
||||
type: 'application/zip',
|
||||
});
|
||||
apiMocks.importSkillPreviews.mockResolvedValue({
|
||||
data: ['skill-1', 'skill-2'].map((name, index) => ({
|
||||
importToken: `${index + 1}`.repeat(32),
|
||||
issues: [],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name,
|
||||
packageId: name,
|
||||
packageRoot: name,
|
||||
},
|
||||
],
|
||||
sourceName: archive.name,
|
||||
})),
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.importSkillConfirmBatch.mockResolvedValue({
|
||||
data: ['skill-1', 'skill-2'].map((name, index) => ({
|
||||
importToken: `${index + 1}`.repeat(32),
|
||||
skills: [{ id: index + 1, name }],
|
||||
success: true,
|
||||
})),
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDialog: {
|
||||
props: ['modelValue', 'title'],
|
||||
template:
|
||||
'<section><h2>{{ title }}</h2><slot/><footer><slot name="footer"/></footer></section>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
categories: [],
|
||||
defaultMode: 'import',
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const fileInput = wrapper.get('input[type="file"]');
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
configurable: true,
|
||||
value: [archive],
|
||||
});
|
||||
await fileInput.trigger('change');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.importSkillPreviews).toHaveBeenCalledWith([archive]);
|
||||
expect(wrapper.text()).toContain('skill-1');
|
||||
expect(wrapper.text()).toContain('skill-2');
|
||||
expect(wrapper.text()).toContain('skill-bundle.zip');
|
||||
|
||||
const importButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '导入');
|
||||
await importButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.importSkillConfirmBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ importToken: '1'.repeat(32) }),
|
||||
expect.objectContaining({ importToken: '2'.repeat(32) }),
|
||||
]);
|
||||
expect(wrapper.emitted('imported')).toEqual([[2]]);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ describe('skill create and import contract', () => {
|
||||
expect(dialogSource).toContain('value="import">批量导入');
|
||||
expect(dialogSource).toContain('accept=".zip,application/zip"');
|
||||
expect(dialogSource).toContain('multiple');
|
||||
expect(dialogSource).toContain('单次最多导入 20 个技能');
|
||||
expect(dialogSource).toContain('const MAX_IMPORT_SKILLS = 50');
|
||||
expect(dialogSource).toContain('支持单技能或多技能 ZIP');
|
||||
expect(dialogSource).toContain('response.data.map((preview, index)');
|
||||
expect(dialogSource).toContain('importSkillConfirmBatch');
|
||||
expect(dialogSource).not.toContain('.efskill');
|
||||
});
|
||||
@@ -18,4 +20,14 @@ describe('skill create and import contract', () => {
|
||||
expect(dialogSource).toContain('v-model="form.visibilityScope"');
|
||||
expect(dialogSource).toContain('visibilityScope: form.visibilityScope');
|
||||
});
|
||||
|
||||
it('supports scanning arbitrary HTTPS Git repositories and selecting candidates', () => {
|
||||
expect(dialogSource).toContain('value="git">Git 仓库');
|
||||
expect(dialogSource).toContain('scanSkillGitRepository');
|
||||
expect(dialogSource).toContain('prepareSkillGitCandidates');
|
||||
expect(dialogSource).toContain('搜索技能名称、用途或路径');
|
||||
expect(dialogSource).toContain('toggleGitCandidate(candidate)');
|
||||
expect(dialogSource).toContain('next.size >= MAX_IMPORT_SKILLS');
|
||||
expect(dialogSource).not.toContain('github.com/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type {
|
||||
SkillCategory,
|
||||
SkillGitCandidate,
|
||||
SkillGitScanResult,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillVisibilityScope,
|
||||
@@ -10,7 +12,14 @@ import type {
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
Check,
|
||||
Document,
|
||||
Link,
|
||||
Plus,
|
||||
Search,
|
||||
UploadFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
@@ -30,7 +39,9 @@ import {
|
||||
cancelSkillImport,
|
||||
importSkillConfirmBatch,
|
||||
importSkillPreviews,
|
||||
prepareSkillGitCandidates,
|
||||
saveSkill,
|
||||
scanSkillGitRepository,
|
||||
} from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
@@ -38,9 +49,11 @@ import { buildInitialSkillDraft } from './skill-create';
|
||||
|
||||
type CreateMode = 'import' | 'manual';
|
||||
type ConflictStrategy = 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
type ImportSource = 'git' | 'zip';
|
||||
|
||||
interface ImportRow {
|
||||
file: File;
|
||||
key: string;
|
||||
label: string;
|
||||
message?: string;
|
||||
preview?: SkillImportPreview;
|
||||
rename: string;
|
||||
@@ -69,14 +82,21 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const MAX_IMPORT_SKILLS = 50;
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const mode = ref<CreateMode>('manual');
|
||||
const importSource = ref<ImportSource>('zip');
|
||||
const busy = ref(false);
|
||||
const actionError = ref('');
|
||||
const importRows = ref<ImportRow[]>([]);
|
||||
const importFinished = ref(false);
|
||||
const gitRepositoryUrl = ref('');
|
||||
const gitSearch = ref('');
|
||||
const gitScan = ref<SkillGitScanResult>();
|
||||
const selectedGitCandidateIds = ref<Set<string>>(new Set());
|
||||
const form = reactive({
|
||||
categoryId: '' as number | string,
|
||||
displayName: '',
|
||||
@@ -102,28 +122,44 @@ const rules: FormRules = {
|
||||
{ max: 128, message: '技能名称不能超过 128 个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
const confirmText = computed(() =>
|
||||
mode.value === 'manual' ? '创建并进入编辑' : '导入',
|
||||
const confirmText = computed(() => {
|
||||
if (mode.value === 'manual') return '创建并进入编辑';
|
||||
if (importSource.value !== 'git' || importRows.value.length > 0)
|
||||
return '导入';
|
||||
const count = selectedGitCandidateIds.value.size;
|
||||
return `导入所选${count > 0 ? `(${count})` : ''}`;
|
||||
});
|
||||
const dialogWidth = computed(() =>
|
||||
mode.value === 'import' && importSource.value === 'git'
|
||||
? 'min(920px, calc(100vw - 32px))'
|
||||
: 'min(720px, calc(100vw - 32px))',
|
||||
);
|
||||
const importReady = computed(
|
||||
() =>
|
||||
importRows.value.length > 0 &&
|
||||
importRows.value.every((row) => {
|
||||
const item = row.preview?.skills[0];
|
||||
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||
return false;
|
||||
if (!item.conflict) return true;
|
||||
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||
return false;
|
||||
}),
|
||||
() => importRows.value.length > 0 && rowsReady(importRows.value),
|
||||
);
|
||||
const filteredGitCandidates = computed(() => {
|
||||
const keyword = normalizeSearch(gitSearch.value);
|
||||
if (!keyword) return gitScan.value?.candidates || [];
|
||||
return (gitScan.value?.candidates || []).filter((candidate) =>
|
||||
normalizeSearch(
|
||||
`${candidate.name} ${candidate.description || ''} ${candidate.path}`,
|
||||
).includes(keyword),
|
||||
);
|
||||
});
|
||||
const submitDisabled = computed(() => {
|
||||
if (mode.value === 'manual') return false;
|
||||
if (importRows.value.length > 0) return !importReady.value;
|
||||
return (
|
||||
importSource.value === 'zip' || selectedGitCandidateIds.value.size === 0
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
mode.value = props.defaultMode;
|
||||
importSource.value = 'zip';
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
@@ -131,6 +167,10 @@ watch(
|
||||
});
|
||||
importRows.value = [];
|
||||
importFinished.value = false;
|
||||
gitRepositoryUrl.value = '';
|
||||
gitSearch.value = '';
|
||||
gitScan.value = undefined;
|
||||
selectedGitCandidateIds.value = new Set();
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
if (mode.value === 'manual')
|
||||
@@ -141,7 +181,13 @@ watch(
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return;
|
||||
await (mode.value === 'manual' ? createManualSkill() : confirmImports());
|
||||
if (mode.value === 'manual') {
|
||||
await createManualSkill();
|
||||
} else if (importSource.value === 'git' && importRows.value.length === 0) {
|
||||
await prepareGitImports();
|
||||
} else {
|
||||
await confirmImports();
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualSkill() {
|
||||
@@ -185,8 +231,8 @@ async function handleDrop(event: DragEvent) {
|
||||
|
||||
async function previewFiles(files: File[]) {
|
||||
if (busy.value || files.length === 0) return;
|
||||
if (files.length > 20) {
|
||||
ElMessage.warning('单次最多导入 20 个技能');
|
||||
if (files.length > MAX_IMPORT_SKILLS) {
|
||||
ElMessage.warning(`单次最多选择 ${MAX_IMPORT_SKILLS} 个 ZIP`);
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => !file.name.toLowerCase().endsWith('.zip'))) {
|
||||
@@ -202,11 +248,14 @@ async function previewFiles(files: File[]) {
|
||||
actionError.value = response.message || '导入预检失败';
|
||||
return;
|
||||
}
|
||||
importRows.value = files.map((file, index) => {
|
||||
const preview = response.data[index];
|
||||
importRows.value = response.data.map((preview, index) => {
|
||||
const item = preview?.skills[0];
|
||||
const sourceName = preview?.sourceName || `导入包 ${index + 1}`;
|
||||
return {
|
||||
file,
|
||||
key:
|
||||
preview?.importToken ||
|
||||
`${sourceName}-${item?.packageRoot || item?.packageId || 'invalid'}-${index}`,
|
||||
label: sourceName,
|
||||
preview,
|
||||
rename: item ? `${item.name}-copy` : '',
|
||||
strategy: item?.conflict ? 'RENAME' : 'REJECT',
|
||||
@@ -227,11 +276,15 @@ async function confirmImports() {
|
||||
ElMessage.warning('请先处理导入错误和名称冲突');
|
||||
return;
|
||||
}
|
||||
await confirmImportRows(importRows.value);
|
||||
}
|
||||
|
||||
async function confirmImportRows(rows: ImportRow[]) {
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await importSkillConfirmBatch(
|
||||
importRows.value.map((row) => {
|
||||
rows.map((row) => {
|
||||
const item = row.preview!.skills[0]!;
|
||||
return {
|
||||
categoryId: form.categoryId || undefined,
|
||||
@@ -251,7 +304,7 @@ async function confirmImports() {
|
||||
}
|
||||
let succeeded = 0;
|
||||
response.data.forEach((result, index) => {
|
||||
const row = importRows.value[index];
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
row.result = result.success ? 'success' : 'failed';
|
||||
row.message = result.message;
|
||||
@@ -259,7 +312,7 @@ async function confirmImports() {
|
||||
});
|
||||
importFinished.value = true;
|
||||
emit('imported', succeeded);
|
||||
if (succeeded === importRows.value.length) {
|
||||
if (succeeded === rows.length) {
|
||||
ElMessage.success(`已导入 ${succeeded} 个技能`);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
@@ -270,6 +323,101 @@ async function confirmImports() {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanGitRepo() {
|
||||
if (busy.value) return;
|
||||
const repositoryUrl = gitRepositoryUrl.value.trim();
|
||||
if (!repositoryUrl) {
|
||||
ElMessage.warning('请输入 Git 仓库地址');
|
||||
return;
|
||||
}
|
||||
await cancelPendingImports();
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
importRows.value = [];
|
||||
importFinished.value = false;
|
||||
selectedGitCandidateIds.value = new Set();
|
||||
try {
|
||||
const response = await scanSkillGitRepository(repositoryUrl);
|
||||
if (response.errorCode !== 0 || !response.data?.scanToken) {
|
||||
actionError.value = response.message || '仓库扫描失败';
|
||||
gitScan.value = undefined;
|
||||
return;
|
||||
}
|
||||
gitScan.value = response.data;
|
||||
if (response.data.summary.discovered === 0) {
|
||||
ElMessage.info('仓库中未发现 SKILL.md');
|
||||
}
|
||||
} catch (error) {
|
||||
gitScan.value = undefined;
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'仓库扫描失败,请检查地址后重试',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGitCandidate(candidate: SkillGitCandidate) {
|
||||
if (candidate.status !== 'IMPORTABLE' || busy.value) return;
|
||||
const next = new Set(selectedGitCandidateIds.value);
|
||||
if (next.has(candidate.candidateId)) {
|
||||
next.delete(candidate.candidateId);
|
||||
} else if (next.size >= MAX_IMPORT_SKILLS) {
|
||||
ElMessage.warning(`单次最多导入 ${MAX_IMPORT_SKILLS} 个技能`);
|
||||
return;
|
||||
} else {
|
||||
next.add(candidate.candidateId);
|
||||
}
|
||||
selectedGitCandidateIds.value = next;
|
||||
}
|
||||
|
||||
async function prepareGitImports() {
|
||||
const scan = gitScan.value;
|
||||
const selected = scan?.candidates.filter((candidate) =>
|
||||
selectedGitCandidateIds.value.has(candidate.candidateId),
|
||||
);
|
||||
if (!scan || !selected?.length) {
|
||||
ElMessage.warning('请先扫描并选择要导入的技能');
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await prepareSkillGitCandidates(
|
||||
scan.scanToken,
|
||||
selected.map((candidate) => candidate.candidateId),
|
||||
);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '准备导入失败';
|
||||
return;
|
||||
}
|
||||
const rows = selected.map((candidate, index): ImportRow => {
|
||||
const preview = response.data[index];
|
||||
const item = preview?.skills[0];
|
||||
return {
|
||||
key: candidate.candidateId,
|
||||
label: candidate.path,
|
||||
preview,
|
||||
rename: item ? `${item.name}-copy` : '',
|
||||
strategy: item?.conflict ? 'RENAME' : 'REJECT',
|
||||
};
|
||||
});
|
||||
importRows.value = rows;
|
||||
if (rowsReady(rows)) {
|
||||
busy.value = false;
|
||||
await confirmImportRows(rows);
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'准备导入失败,请重新扫描',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestClose() {
|
||||
if (busy.value) return;
|
||||
await cancelPendingImports();
|
||||
@@ -291,6 +439,28 @@ function importErrorCount(preview?: SkillImportPreview) {
|
||||
.length;
|
||||
}
|
||||
|
||||
function rowsReady(rows: ImportRow[]) {
|
||||
return rows.every((row) => {
|
||||
const item = row.preview?.skills[0];
|
||||
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||
return false;
|
||||
if (!item.conflict) return true;
|
||||
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.trim().toLocaleLowerCase().replaceAll(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function canonicalName(value: string) {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim());
|
||||
}
|
||||
@@ -304,7 +474,7 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
class="skill-create-dialog"
|
||||
width="min(720px, calc(100vw - 32px))"
|
||||
:width="dialogWidth"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
:show-close="!busy"
|
||||
@@ -371,36 +541,157 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
</ElForm>
|
||||
|
||||
<template v-if="mode === 'import'">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="skill-create-dialog__file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-create-dialog__dropzone"
|
||||
:disabled="busy"
|
||||
@click="chooseFiles"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
<ElRadioGroup
|
||||
v-model="importSource"
|
||||
class="skill-create-dialog__source"
|
||||
:disabled="busy || importRows.length > 0"
|
||||
>
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
<span>选择或拖入标准 Skill ZIP</span>
|
||||
<small>最多 20 个,每个 ZIP 包含一个技能</small>
|
||||
</button>
|
||||
<ElRadioButton value="zip">本地 ZIP</ElRadioButton>
|
||||
<ElRadioButton value="git">Git 仓库</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
|
||||
<template v-if="importSource === 'zip'">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="skill-create-dialog__file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-create-dialog__dropzone"
|
||||
:disabled="busy"
|
||||
@click="chooseFiles"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
<span>选择或拖入标准 Skill ZIP</span>
|
||||
<small>最多导入 50 个技能,支持单技能或多技能 ZIP</small>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section v-else class="skill-create-dialog__git">
|
||||
<div class="skill-create-dialog__git-url">
|
||||
<ElInput
|
||||
v-model="gitRepositoryUrl"
|
||||
clearable
|
||||
:disabled="busy"
|
||||
placeholder="输入 HTTPS Git 仓库地址"
|
||||
@keyup.enter="scanGitRepo"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Link /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton :loading="busy" :disabled="busy" @click="scanGitRepo">
|
||||
扫描仓库
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<template v-if="gitScan">
|
||||
<div class="skill-create-dialog__git-summary">
|
||||
<div>
|
||||
<strong>{{ gitScan.repository.name }}</strong>
|
||||
<small>
|
||||
{{ gitScan.repository.defaultBranch }} ·
|
||||
{{ gitScan.repository.commitSha.slice(0, 8) }}
|
||||
</small>
|
||||
</div>
|
||||
<span>发现 {{ gitScan.summary.discovered }} 个</span>
|
||||
<span>可导入 {{ gitScan.summary.importable }} 个</span>
|
||||
<span v-if="gitScan.summary.needsAttention">
|
||||
需处理 {{ gitScan.summary.needsAttention }} 个
|
||||
</span>
|
||||
</div>
|
||||
<ElInput
|
||||
v-model="gitSearch"
|
||||
class="skill-create-dialog__git-search"
|
||||
clearable
|
||||
placeholder="搜索技能名称、用途或路径"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="skill-create-dialog__candidates">
|
||||
<article
|
||||
v-for="candidate in filteredGitCandidates"
|
||||
:key="candidate.candidateId"
|
||||
:class="{
|
||||
'is-selected': selectedGitCandidateIds.has(
|
||||
candidate.candidateId,
|
||||
),
|
||||
}"
|
||||
>
|
||||
<div class="skill-create-dialog__candidate-main">
|
||||
<strong>{{ candidate.name }}</strong>
|
||||
<p>{{ candidate.description || '暂无用途说明' }}</p>
|
||||
<small>
|
||||
{{ candidate.path }} · {{ candidate.resourceCount }} 个资源 ·
|
||||
{{ formatBytes(candidate.totalBytes) }}
|
||||
</small>
|
||||
<small
|
||||
v-if="candidate.status === 'NEEDS_ATTENTION'"
|
||||
class="skill-create-dialog__candidate-issue"
|
||||
>
|
||||
{{ candidate.issues[0]?.message || '内容需要处理' }}
|
||||
</small>
|
||||
</div>
|
||||
<ElTag
|
||||
:type="
|
||||
candidate.status === 'IMPORTABLE' ? 'success' : 'warning'
|
||||
"
|
||||
effect="plain"
|
||||
>
|
||||
{{ candidate.status === 'IMPORTABLE' ? '可导入' : '需处理' }}
|
||||
</ElTag>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-create-dialog__candidate-toggle"
|
||||
:class="{
|
||||
'is-selected': selectedGitCandidateIds.has(
|
||||
candidate.candidateId,
|
||||
),
|
||||
}"
|
||||
:disabled="candidate.status !== 'IMPORTABLE' || busy"
|
||||
:aria-label="
|
||||
selectedGitCandidateIds.has(candidate.candidateId)
|
||||
? `取消选择 ${candidate.name}`
|
||||
: `选择 ${candidate.name}`
|
||||
"
|
||||
@click="toggleGitCandidate(candidate)"
|
||||
>
|
||||
<ElIcon>
|
||||
<Check
|
||||
v-if="selectedGitCandidateIds.has(candidate.candidateId)"
|
||||
/>
|
||||
<Plus v-else />
|
||||
</ElIcon>
|
||||
</button>
|
||||
</article>
|
||||
<div
|
||||
v-if="filteredGitCandidates.length === 0"
|
||||
class="skill-create-dialog__candidate-empty"
|
||||
>
|
||||
没有匹配的技能
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div v-if="importRows.length > 0" class="skill-create-dialog__imports">
|
||||
<article v-for="row in importRows" :key="row.file.name">
|
||||
<article v-for="row in importRows" :key="row.key">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
<div>
|
||||
<strong>{{ row.preview?.skills[0]?.name || row.file.name }}</strong>
|
||||
<strong>{{ row.preview?.skills[0]?.name || row.label }}</strong>
|
||||
<small v-if="importErrorCount(row.preview)">
|
||||
{{ row.preview?.issues?.[0]?.message || '包内容不符合规范' }}
|
||||
</small>
|
||||
<small v-else-if="row.result === 'failed'">{{ row.message }}</small>
|
||||
<small v-else>{{ row.file.name }}</small>
|
||||
<small v-else>{{ row.label }}</small>
|
||||
</div>
|
||||
<ElTag v-if="row.result === 'success'" type="success" effect="plain">
|
||||
成功
|
||||
@@ -448,7 +739,7 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="mode === 'import' && !importReady"
|
||||
:disabled="submitDisabled"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmText }}
|
||||
@@ -493,6 +784,10 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__source {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -523,6 +818,141 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-url {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary > div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary small,
|
||||
.skill-create-dialog__git-summary span {
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates {
|
||||
display: grid;
|
||||
max-height: 336px;
|
||||
overflow: auto;
|
||||
border-block: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 36px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 88px;
|
||||
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
transition:
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article:hover,
|
||||
.skill-create-dialog__candidates article.is-selected {
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-secondary));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main small {
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-muted));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main .skill-create-dialog__candidate-issue {
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: hsl(var(--text-secondary));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-default));
|
||||
border: 1px solid hsl(var(--line-strong));
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:hover,
|
||||
.skill-create-dialog__candidate-toggle:focus-visible,
|
||||
.skill-create-dialog__candidate-toggle.is-selected {
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:focus-visible {
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 20%);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:disabled {
|
||||
color: hsl(var(--text-disabled));
|
||||
cursor: not-allowed;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-color: hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-empty {
|
||||
display: grid;
|
||||
min-height: 112px;
|
||||
color: hsl(var(--text-muted));
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -574,5 +1004,21 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
.skill-create-dialog__imports article {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-url {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article {
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article > .el-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SkillCategoryDraft,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillGitScanResult,
|
||||
SkillImportBatchResult,
|
||||
SkillImportConfirmPayload,
|
||||
SkillImportPreview,
|
||||
@@ -261,6 +262,23 @@ export function importSkillPreviews(files: File[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function scanSkillGitRepository(repositoryUrl: string) {
|
||||
return api.post<RequestResult<SkillGitScanResult>>(
|
||||
'/api/v1/skill/import/repository/scan',
|
||||
{ repositoryUrl },
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSkillGitCandidates(
|
||||
scanToken: string,
|
||||
candidateIds: string[],
|
||||
) {
|
||||
return api.post<RequestResult<SkillImportPreview[]>>(
|
||||
'/api/v1/skill/import/repository/prepare',
|
||||
{ candidateIds, scanToken },
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirmBatch(payload: SkillImportConfirmPayload[]) {
|
||||
return api.post<RequestResult<SkillImportBatchResult[]>>(
|
||||
'/api/v1/skill/import/confirmBatch',
|
||||
|
||||
@@ -157,6 +157,7 @@ export interface SkillImportPreview {
|
||||
expiresAt?: string;
|
||||
importToken?: string;
|
||||
issues?: SkillValidationIssue[];
|
||||
sourceName?: string;
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
@@ -175,6 +176,42 @@ export interface SkillImportBatchResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export type SkillGitCandidateStatus = 'IMPORTABLE' | 'NEEDS_ATTENTION';
|
||||
|
||||
export interface SkillGitCandidateIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface SkillGitCandidate {
|
||||
candidateId: string;
|
||||
description?: string;
|
||||
issues: SkillGitCandidateIssue[];
|
||||
name: string;
|
||||
path: string;
|
||||
resourceCount: number;
|
||||
status: SkillGitCandidateStatus;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface SkillGitScanResult {
|
||||
candidates: SkillGitCandidate[];
|
||||
expiresAt: string;
|
||||
repository: {
|
||||
commitSha: string;
|
||||
defaultBranch: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
scanToken: string;
|
||||
summary: {
|
||||
discovered: number;
|
||||
importable: number;
|
||||
needsAttention: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillFileBuffer {
|
||||
content: string;
|
||||
conflict?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user