Переглянути джерело

feat(frontend): 技能管理对接新接口,工作台加载态居中

- capabilityApi / types:技能接口去掉 caseId 与 path(技能与案件无关,库里没有磁盘路径)
- 技能名规则对齐官方 SkillUtil.isValidSkillName:只允许小写字母/数字/短横线,
  输入框提示与校验文案同步(原来是中英文/._-)
- 附属文件只保留 scripts/ references/ 两个规范目录,移除 assets/ testcases/ 页签;
  后端仍接受任意相对路径(zip 里的其他目录会归到「规范外文件」)
- 弹窗文案改为「技能库」口径,不再提磁盘目录
- 修复工作台内容区加载态:列表为空时渲染占位块,并在只有占位块时把 spin 容器/覆盖层
  撑满面板、flex 居中,同时解除 Ant 覆盖层默认的 max-height:400px(原来转圈贴左上角)
  覆盖研判方案 / 知识库 / 模型管理 / 插件与技能三个页签
cc 4 днів тому
батько
коміт
ee756d64ae

+ 7 - 17
ai-electron/frontend/src/ai/api/capabilityApi.ts

@@ -21,21 +21,14 @@ export function listTools(): Promise<AgentTool[]> {
   return aiHttp.get<AgentTool[]>('/capability/tools');
 }
 
-/**
- * 技能清单。
- *
- * @param caseId 案件ID,可选;传了才会额外带上「该案件 agent 配置的技能仓库」里的只读技能
- */
-export function listSkills(caseId?: string | number | null): Promise<Skill[]> {
-  return aiHttp.get<Skill[]>('/capability/skills', caseId ? { caseId: String(caseId) } : undefined);
+/** 技能清单(当前登录用户可管理的技能) */
+export function listSkills(): Promise<Skill[]> {
+  return aiHttp.get<Skill[]>('/capability/skills');
 }
 
 /** 技能详情(SKILL.md 正文 + 附属文件清单) */
-export function readSkill(name: string, caseId?: string | number | null): Promise<SkillDetail> {
-  return aiHttp.get<SkillDetail>(
-    `/capability/skills/${encodeURIComponent(name)}`,
-    caseId ? { caseId: String(caseId) } : undefined,
-  );
+export function readSkill(name: string): Promise<SkillDetail> {
+  return aiHttp.get<SkillDetail>(`/capability/skills/${encodeURIComponent(name)}`);
 }
 
 /**
@@ -43,11 +36,8 @@ export function readSkill(name: string, caseId?: string | number | null): Promis
  *
  * <p>文本返回 content;图片等二进制返回 base64(供预览);超过预览上限只回体积。
  */
-export function readSkillFile(name: string, path: string, caseId?: string | number | null): Promise<SkillFileContent> {
-  return aiHttp.get<SkillFileContent>(`/capability/skills/${encodeURIComponent(name)}/file`, {
-    path,
-    ...(caseId ? { caseId: String(caseId) } : {}),
-  });
+export function readSkillFile(name: string, path: string): Promise<SkillFileContent> {
+  return aiHttp.get<SkillFileContent>(`/capability/skills/${encodeURIComponent(name)}/file`, { path });
 }
 
 /**

+ 3 - 5
ai-electron/frontend/src/ai/api/types.ts

@@ -155,7 +155,7 @@ export interface SaveSkillParams {
   description?: string;
   /** SKILL.md 正文(Markdown;自带 frontmatter 时后端原样保留) */
   content: string;
-  /** 附属文件:scripts/ references/ assets/ testcases/ 下的文本文件 */
+  /** 附属文件:scripts/ references/ 下的文本文件 */
   files?: SaveSkillFile[];
   /** 要删除的附属文件相对路径(编辑时用;未提交的文件保持原样) */
   deletePaths?: string[];
@@ -635,16 +635,14 @@ export interface AgentTool {
   updatedAt?: string;
 }
 
-/** 技能(磁盘上一个含 SKILL.md 的目录) */
+/** 技能(服务端技能仓库里的一条技能,按登录用户隔离) */
 export interface Skill {
   name: string;
   description?: string;
-  /** user = 用户技能目录(可管理);repo = agent 配置的技能仓库(只读) */
+  /** 来源标识,当前固定 user */
   source?: string;
   managed?: boolean;
   enabled?: boolean;
-  /** 技能所在目录(服务端本机路径) */
-  path?: string;
 }
 
 /** 技能附属文件 */

+ 2 - 2
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -313,8 +313,8 @@
         toolPackApi.listToolPacks().catch(() => [] as ToolPack[]),
         knowledgeApi.listKnowledgeBases().catch(() => [] as KnowledgeBase[]),
       ]);
-      // 白名单只对用户技能目录生效:repo 来源是只读技能,不参与勾选
-      skills.value = (skillList || []).filter((s) => s.source === 'user');
+      // 技能清单已按登录用户过滤(服务端保证),不需要再按来源筛
+      skills.value = skillList || [];
       packs.value = packList || [];
       knowledgeBases.value = kbList || [];
       // 工具包清单就位后,把旧工具组映射成同名内置包勾选

+ 5 - 7
ai-electron/frontend/src/ai/components/SkillDetailModal.vue

@@ -11,8 +11,8 @@
     <a-spin :spinning="loading">
       <div v-if="detail" class="skill-detail">
         <p class="skill-detail__sub">
-          技能是一个目录:<b>SKILL.md</b> 必需(元数据 + 执行指令);<b>scripts/</b> <b>references/</b> <b>assets/</b>
-          <b>testcases/</b> 可选。下面是磁盘上的实际内容,只读。
+          技能由 <b>SKILL.md</b>(元数据 + 执行指令)和可选附属文件 <b>scripts/</b> <b>references/</b>
+          组成。下面是技能库里的实际内容,只读。
         </p>
 
         <div class="skill-detail__field">
@@ -80,7 +80,7 @@
 <script lang="ts" setup name="SkillDetailModal">
   /**
    * 技能详情(只读)—— 与「新增 / 编辑技能」弹框同构:
-   * 同样的 900 宽弹框、同样的 SKILL.md + scripts/references/assets/testcases 页签,
+   * 同样的 900 宽弹框、同样的 SKILL.md + scripts/references 页签,
    * 只是所有控件都只读、底部没有确认按钮。
    *
    * 打开时读一次详情(SKILL.md 正文 + 附属文件清单),再并发把每个附属文件的内容拉回来
@@ -115,8 +115,6 @@
     open: boolean;
     /** 要查看的技能;为空时不请求 */
     skill?: Skill | null;
-    /** 案件ID,可选:只影响是否附带该案件配置的只读技能仓库 */
-    caseId?: string | number | null;
   }>();
   const emit = defineEmits<{
     (e: 'update:open', value: boolean): void;
@@ -147,14 +145,14 @@
     contents.value = {};
     activeTab.value = 'SKILL.md';
     try {
-      const data = await readSkill(skill.name, props.caseId);
+      const data = await readSkill(skill.name);
       detail.value = data;
       body.value = splitSkillMd(data?.content || '').body;
 
       const files = data?.files ?? [];
       const loaded = await Promise.all(
         files.map(async (f) => {
-          const content = await readSkillFile(skill.name, f.path, props.caseId).catch(() => null);
+          const content = await readSkillFile(skill.name, f.path).catch(() => null);
           return [f.path, content] as const;
         }),
       );

+ 6 - 6
ai-electron/frontend/src/ai/components/SkillEditorModal.vue

@@ -14,8 +14,8 @@
     <a-spin :spinning="loading">
       <div class="skill-create">
         <p class="skill-create__sub">
-          技能是一个目录:<b>SKILL.md</b> 必需(元数据 + 执行指令);<b>scripts/</b> <b>references/</b> <b>assets/</b>
-          <b>testcases/</b> 可选。保存即写入当前账号的技能目录,下一轮对话就能被模型调用。
+          技能由 <b>SKILL.md</b>(元数据 + 执行指令)和可选附属文件 <b>scripts/</b> <b>references/</b>
+          组成。保存即写入当前账号的技能库,下一轮对话就能被模型调用。
         </p>
 
         <div class="skill-create__field">
@@ -27,7 +27,7 @@
             v-model:value="name"
             :disabled="isEdit"
             :maxlength="64"
-            placeholder="中英文 / 数字 / ._-,最多 64 字符"
+            placeholder="小写字母 / 数字 / 短横线,最多 64 字符"
           />
         </div>
         <div class="skill-create__field">
@@ -105,7 +105,7 @@
    * 技能编辑器(新增 / 编辑共用)—— 「插件与技能 → 技能」页的顶栏「新增技能」与卡片「编辑」。
    *
    * 按技能目录规范落地:SKILL.md 必需(技能名 + 描述 + 正文),
-   * scripts/ references/ assets/ testcases/ 可选(各目录下的文本文件)。
+   * scripts/ references/ 可选(各目录下的文本文件)。
    * 保存走 capabilityApi.createSkill / updateSkill,落到当前账号的技能目录。
    *
    * 编辑已有技能时:读详情后逐个拉文本文件内容(二进制只读展示),
@@ -145,7 +145,7 @@
 
   interface EditorFile {
     uid: number;
-    /** 所属规范目录(scripts / references / assets / testcases) */
+    /** 所属规范目录(scripts / references) */
     dir: string;
     /** 目录内相对路径(可含子目录) */
     name: string;
@@ -256,7 +256,7 @@
     const skillName = (props.editing?.name || name.value).trim();
     if (!isEdit.value) {
       if (!skillName) return showMessage('请输入技能名', 'warning');
-      if (!SKILL_NAME_RE.test(skillName)) return showMessage('技能名只能含中英文、数字和 ._-,最多 64 字符', 'warning');
+      if (!SKILL_NAME_RE.test(skillName)) return showMessage('技能名只能含小写字母、数字和短横线,且以字母或数字开头,最多 64 字符', 'warning');
       if (props.existing?.includes(skillName)) return showMessage('同名技能已存在', 'warning');
     }
     if (!content.value.trim()) return showMessage('请输入 SKILL.md 正文', 'warning');

+ 31 - 0
ai-electron/frontend/src/ai/styles/ai.less

@@ -507,3 +507,34 @@
   color: var(--ai-text-2);
   font-size: 13px;
 }
+
+/*
+ * 内容区加载占位:Ant 的 Spin 覆盖层在「空容器」里会贴左上角。
+ * 列表加载中先渲染一个占位块;容器里只有这个占位块时让它撑满面板,
+ * 转圈就落在内容区正中(加载完成后换成空态/列表,尺寸不受影响)。
+ */
+.ai-spin-placeholder {
+  min-height: 300px;
+  height: 100%;
+}
+
+.ant-spin-nested-loading:has(> .ant-spin-container > .ai-spin-placeholder:only-child) {
+  position: relative;
+  height: 100%;
+}
+
+.ant-spin-nested-loading:has(> .ant-spin-container > .ai-spin-placeholder:only-child) > .ant-spin-container,
+.ant-spin-nested-loading:has(> .ant-spin-container > .ai-spin-placeholder:only-child) > div {
+  height: 100%;
+}
+
+/* 覆盖层默认只高 400px、贴着顶部,这里改成撑满内容并居中 */
+.ant-spin-nested-loading:has(> .ant-spin-container > .ai-spin-placeholder:only-child) > div > .ant-spin {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  max-height: none; /* Ant 默认 max-height: 400px,会把覆盖层截断在上半区 */
+}

+ 3 - 5
ai-electron/frontend/src/ai/utils/skill.ts

@@ -7,19 +7,17 @@
  */
 import type { SkillFile } from '../api/types';
 
-/** 四个可选规范子目录(顺序即页面展示顺序) */
+/** 可选规范子目录(顺序即页面展示顺序) */
 export const SKILL_STD_DIRS = [
   { key: 'scripts', desc: '自动化脚本或工具调用逻辑' },
   { key: 'references', desc: '长文档、规范、API 说明' },
-  { key: 'assets', desc: '模板、示例、图片、配置' },
-  { key: 'testcases', desc: '测试用例、验证数据' },
 ] as const;
 
 /** 规范目录名集合:用来判断某个附属文件是否落在规范目录内 */
 export const SKILL_STD_DIR_KEYS: readonly string[] = SKILL_STD_DIRS.map((d) => d.key);
 
-/** 技能名合法字符(与后端 SkillStore.NAME_PATTERN 对齐:中英文/数字/._-,1~64) */
-export const SKILL_NAME_RE = /^[一-龥A-Za-z0-9._-]{1,64}$/;
+/** 技能名合法字符(与后端 SkillUtil.isValidSkillName 对齐:小写字母/数字/短横线,1~64) */
+export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
 
 /** 文件大小展示 */
 export function formatFileSize(size?: number): string {

+ 2 - 1
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -88,9 +88,10 @@
               </div>
             </article>
           </div>
+          <div v-else-if="loading" class="ai-spin-placeholder" aria-hidden="true"></div>
 
           <EmptyState
-            v-else-if="!loading"
+            v-else
             :title="experts.length ? '没有匹配的研判方案' : '还没有研判方案'"
             :description="
               experts.length

+ 2 - 1
ai-electron/frontend/src/ai/views/aiKnowledge/index.vue

@@ -85,9 +85,10 @@
               </div>
             </article>
           </div>
+          <div v-else-if="loading" class="ai-spin-placeholder" aria-hidden="true"></div>
 
           <EmptyState
-            v-else-if="!loading"
+            v-else
             :title="bases.length ? '没有匹配的知识库' : '还没有知识库'"
             :description="
               bases.length

+ 2 - 1
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -103,8 +103,9 @@
               </div>
             </article>
           </div>
+          <div v-else-if="loading" class="ai-spin-placeholder" aria-hidden="true"></div>
           <EmptyState
-            v-else-if="!loading"
+            v-else
             :title="activeTab === 'embedding' ? '还没有向量模型' : '还没有对话模型'"
             :description="
               activeTab === 'embedding'

+ 9 - 11
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -83,7 +83,7 @@
         <!-- ── ① 技能(SKILL.md 包) ──────────────────────────────── -->
         <a-spin v-if="activeTab === 'skill'" :spinning="loading">
           <div v-if="skills.length" class="ai-plugin__grid">
-            <article v-for="skill in skills" :key="skill.path || skill.name" class="ai-plugin__card">
+            <article v-for="skill in skills" :key="skill.name" class="ai-plugin__card">
               <div class="ai-plugin__card-head">
                 <span class="ai-plugin__card-icon">
                   <Icon icon="mdi:script-text-outline" :size="18" />
@@ -120,6 +120,7 @@
               </div>
             </article>
           </div>
+          <div v-else-if="loading" class="ai-spin-placeholder" aria-hidden="true"></div>
           <EmptyState
             v-else
             title="还没有技能"
@@ -170,6 +171,7 @@
               </div>
             </article>
           </div>
+          <div v-else-if="packsLoading" class="ai-spin-placeholder" aria-hidden="true"></div>
           <EmptyState
             v-else
             title="还没有工具包"
@@ -213,6 +215,7 @@
               </div>
             </article>
           </div>
+          <div v-else-if="loading" class="ai-spin-placeholder" aria-hidden="true"></div>
           <EmptyState
             v-else
             :title="tools.length ? '没有匹配的工具' : '还没有同步到工具清单'"
@@ -363,7 +366,7 @@
       </div>
     </a-modal>
 
-    <!-- 弹框:新增 / 编辑技能(SKILL.md + scripts/ references/ assets/ testcases/ 附属文件) -->
+    <!-- 弹框:新增 / 编辑技能(SKILL.md + scripts/ references/ 附属文件) -->
     <SkillEditorModal
       v-model:open="skillCreateOpen"
       :existing="skillNames"
@@ -372,7 +375,7 @@
     />
 
     <!-- 弹框:技能详情(只读,与「新增 / 编辑技能」同构) -->
-    <SkillDetailModal v-model:open="skillOpen" :skill="skillViewing" :case-id="caseId" />
+    <SkillDetailModal v-model:open="skillOpen" :skill="skillViewing" />
   </div>
 </template>
 
@@ -394,7 +397,6 @@
   import { EmptyState } from '@/core/components/EmptyState';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
-  import { useUserStore } from '@/core/store/modules/case';
   import { deleteSkill, installSkillZip, listSkills, listTools, toggleSkill } from '../../api/capabilityApi';
   import { createToolPack, deleteToolPack, listToolPacks, updateToolPack } from '../../api/toolPackApi';
   // 工具名 → 中文展示名。工具名是模型调用用的标识(英文),页面只显示中文,两者互不影响。
@@ -411,7 +413,6 @@
   const ASwitch = Switch;
 
   const { showMessage } = useMessage();
-  const userStore = useUserStore();
 
   type PluginTab = 'skill' | 'pack' | 'tool';
   const activeTab = ref<PluginTab>('skill');
@@ -438,7 +439,6 @@
   const fileRef = ref<HTMLInputElement>();
 
   /** 当前案件ID:只用于附带展示「该案件 agent 配置的只读技能仓库」,技能本身与案件无关 */
-  const caseId = computed(() => (userStore.getCaseInfo?.id ? String(userStore.getCaseInfo.id) : null));
 
   const summary = computed(
     () => `${skills.value.length} 个技能 · ${packs.value.length} 个工具包 · ${tools.value.length} 个工具`,
@@ -526,7 +526,7 @@
     try {
       const [toolList, skillList] = await Promise.all([
         listTools().catch(() => [] as AgentTool[]),
-        listSkills(caseId.value).catch(() => [] as Skill[]),
+        listSkills().catch(() => [] as Skill[]),
         loadPacks(),
       ]);
       tools.value = toolList || [];
@@ -553,9 +553,7 @@
   /* ---------------- 技能 ---------------- */
 
   function sourceMeta(skill: Skill) {
-    return skill.source === 'repo'
-      ? { label: '配置仓库', cls: '' }
-      : { label: skill.managed ? '用户技能' : '技能', cls: '' };
+    return { label: skill.managed ? '我的技能' : '技能', cls: '' };
   }
 
   /**
@@ -619,7 +617,7 @@
   function confirmRemove(skill: Skill) {
     Modal.confirm({
       title: '卸载技能',
-      content: `确认删除技能「${skill.name}」?该技能目录会被删除,且不可恢复。`,
+      content: `确认删除技能「${skill.name}」?技能与其附属文件会被删除,且不可恢复。`,
       okText: '卸载',
       okType: 'primary',
       okButtonProps: { danger: true },