feat: 支持 Skill 仓库扫描与批量导入

- 统一支持本地单包、多包和 Git 候选最多 50 项导入

- 固定提交并限制 SSRF、DNS、并发、频率、超时和仓库资源

- 增加导入对话框、接口契约和安全回归测试
This commit is contained in:
2026-08-19 22:39:55 +08:00
parent 4e8640dcaf
commit 9579619384
28 changed files with 3949 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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