SkillDetailModal.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. <template>
  2. <BasicModal
  3. :open="open"
  4. :title="detail?.name || skill?.name || '技能详情'"
  5. :width="900"
  6. wrap-class-name="skill-detail-modal"
  7. :footer="null"
  8. :body-style="{ maxHeight: '64vh', overflowY: 'auto' }"
  9. @cancel="close"
  10. >
  11. <a-spin :spinning="loading">
  12. <div v-if="detail" class="skill-detail">
  13. <p class="skill-detail__sub">
  14. 技能是一个目录:<b>SKILL.md</b> 必需(元数据 + 执行指令);<b>scripts/</b> <b>references/</b> <b>assets/</b>
  15. <b>testcases/</b> 可选。下面是磁盘上的实际内容,只读。
  16. </p>
  17. <div class="skill-detail__field">
  18. <label class="skill-detail__label">技能名</label>
  19. <a-input :value="detail.name" readonly />
  20. </div>
  21. <div class="skill-detail__field">
  22. <label class="skill-detail__label">描述</label>
  23. <a-input :value="detail.description || '(未填写描述)'" readonly />
  24. </div>
  25. <a-tabs v-model:active-key="activeTab" size="small">
  26. <a-tab-pane key="SKILL.md">
  27. <template #tab>
  28. <span>SKILL.md</span>
  29. <span class="skill-detail__badge is-need">必需</span>
  30. </template>
  31. <div class="skill-detail__field">
  32. <label class="skill-detail__label">正文(Markdown)</label>
  33. <a-textarea :value="body" readonly :rows="14" />
  34. </div>
  35. </a-tab-pane>
  36. <a-tab-pane v-for="dir in SKILL_STD_DIRS" :key="dir.key">
  37. <template #tab>
  38. <span>{{ dir.key }}/</span>
  39. <span class="skill-detail__badge">{{ filesOf(dir.key).length }}</span>
  40. </template>
  41. <p class="skill-detail__dir-desc">{{ dir.desc }}</p>
  42. <p v-if="!filesOf(dir.key).length" class="skill-detail__empty">该目录未使用</p>
  43. <div v-for="file in filesOf(dir.key)" :key="file.path" class="skill-detail__file">
  44. <div class="skill-detail__file-head">
  45. <Icon icon="mdi:file-outline" :size="14" />
  46. <span class="skill-detail__file-name" :title="file.path">{{ leafPath(file.path, dir.key) }}</span>
  47. <span class="skill-detail__badge">{{ formatFileSize(file.size) }}</span>
  48. </div>
  49. <img
  50. v-if="isImagePath(file.path) && contents[file.path]?.base64"
  51. class="skill-detail__img"
  52. :src="`data:${imageMimeOf(file.path)};base64,${contents[file.path].base64}`"
  53. :alt="file.path"
  54. />
  55. <p v-else-if="contents[file.path]?.truncated" class="skill-detail__note">
  56. 文件较大({{ formatFileSize(contents[file.path]?.size) }}),超过预览上限,未展示内容。
  57. </p>
  58. <p v-else-if="contents[file.path]?.binary" class="skill-detail__note">
  59. 二进制文件({{ formatFileSize(contents[file.path]?.size) }}),不支持在线预览。
  60. </p>
  61. <a-textarea v-else :value="contents[file.path]?.content ?? ''" readonly :rows="7" />
  62. </div>
  63. </a-tab-pane>
  64. </a-tabs>
  65. <p v-if="foreignFiles.length" class="skill-detail__foreign">
  66. 规范目录之外的文件({{ foreignFiles.length }} 个):{{ foreignFiles.join('、') }}
  67. </p>
  68. </div>
  69. </a-spin>
  70. </BasicModal>
  71. </template>
  72. <script lang="ts" setup name="SkillDetailModal">
  73. /**
  74. * 技能详情(只读)—— 与「新增 / 编辑技能」弹框同构:
  75. * 同样的 900 宽弹框、同样的 SKILL.md + scripts/references/assets/testcases 页签,
  76. * 只是所有控件都只读、底部没有确认按钮。
  77. *
  78. * 打开时读一次详情(SKILL.md 正文 + 附属文件清单),再并发把每个附属文件的内容拉回来
  79. * (文本走 content,图片走 base64)。技能文件通常个位数,一次拉完比点开再等更顺手;
  80. * 单文件超过后端预览上限时只回体积,这里给明确提示。
  81. */
  82. import { computed, ref, watch } from 'vue';
  83. import { Input, Spin, Tabs, Textarea } from 'ant-design-vue';
  84. import { BasicModal } from '@/core/components/Modal';
  85. import Icon from '@/core/components/Icon/src/Icon.vue';
  86. import { useMessage } from '@/core/hooks/web/useMessage';
  87. import { readSkill, readSkillFile } from '../api/capabilityApi';
  88. import type { Skill, SkillDetail, SkillFileContent } from '../api/types';
  89. import {
  90. SKILL_STD_DIRS,
  91. SKILL_STD_DIR_KEYS,
  92. formatFileSize,
  93. imageMimeOf,
  94. isImagePath,
  95. leafPath,
  96. splitSkillMd,
  97. topDirOf,
  98. } from '../utils/skill';
  99. const AInput = Input;
  100. const ATextarea = Textarea;
  101. const ATabs = Tabs;
  102. const ATabPane = Tabs.TabPane;
  103. const ASpin = Spin;
  104. const props = defineProps<{
  105. open: boolean;
  106. /** 要查看的技能;为空时不请求 */
  107. skill?: Skill | null;
  108. /** 案件ID,可选:只影响是否附带该案件配置的只读技能仓库 */
  109. caseId?: string | number | null;
  110. }>();
  111. const emit = defineEmits<{
  112. (e: 'update:open', value: boolean): void;
  113. }>();
  114. const { showMessage } = useMessage();
  115. const detail = ref<SkillDetail | null>(null);
  116. /** SKILL.md 正文(已剥掉 frontmatter,与编辑弹框展示的是同一段) */
  117. const body = ref('');
  118. /** 附属文件内容:相对路径 → 内容 */
  119. const contents = ref<Record<string, SkillFileContent>>({});
  120. const activeTab = ref<string>('SKILL.md');
  121. const loading = ref(false);
  122. function filesOf(dir: string) {
  123. return (detail.value?.files ?? []).filter((f) => f.path.startsWith(`${dir}/`));
  124. }
  125. /** 规范目录之外的文件(例如根目录的 readme.txt):不在页签里,只在底部提示 */
  126. const foreignFiles = computed(() =>
  127. (detail.value?.files ?? []).map((f) => f.path).filter((p) => !SKILL_STD_DIR_KEYS.includes(topDirOf(p))),
  128. );
  129. async function load(skill: Skill) {
  130. loading.value = true;
  131. detail.value = null;
  132. contents.value = {};
  133. activeTab.value = 'SKILL.md';
  134. try {
  135. const data = await readSkill(skill.name, props.caseId);
  136. detail.value = data;
  137. body.value = splitSkillMd(data?.content || '').body;
  138. const files = data?.files ?? [];
  139. const loaded = await Promise.all(
  140. files.map(async (f) => {
  141. const content = await readSkillFile(skill.name, f.path, props.caseId).catch(() => null);
  142. return [f.path, content] as const;
  143. }),
  144. );
  145. const map: Record<string, SkillFileContent> = {};
  146. for (const [path, content] of loaded) {
  147. if (content) map[path] = content;
  148. }
  149. contents.value = map;
  150. } catch (e: any) {
  151. showMessage(e?.message || '读取技能失败', 'error');
  152. } finally {
  153. loading.value = false;
  154. }
  155. }
  156. watch(
  157. () => props.open,
  158. (opened) => {
  159. if (!opened || !props.skill) return;
  160. void load(props.skill);
  161. },
  162. );
  163. function close() {
  164. emit('update:open', false);
  165. }
  166. </script>
  167. <style lang="less" scoped>
  168. /* 与 SkillEditorModal 保持同一套视觉:label 在上、控件在下 */
  169. .skill-detail {
  170. display: flex;
  171. flex-direction: column;
  172. gap: 10px;
  173. &__sub {
  174. margin: 0;
  175. font-size: 12.5px;
  176. line-height: 1.7;
  177. color: var(--ai-text-2, #656d76);
  178. b {
  179. font-weight: 600;
  180. color: var(--ai-text, #24292f);
  181. }
  182. }
  183. &__field {
  184. display: flex;
  185. flex-direction: column;
  186. gap: 4px;
  187. }
  188. &__label {
  189. font-size: 12.5px;
  190. color: var(--ai-text-2, #656d76);
  191. }
  192. /* 只读态:输入框 / 文本域底纹置灰(沿用项目的输入框底色 token),
  193. 一眼区分「看」与「改」;hover / focus 也不再变色,避免像可编辑。 */
  194. :deep(.ant-input) {
  195. &[readonly] {
  196. color: var(--ai-text, #24292f);
  197. cursor: default;
  198. background: var(--ai-bg-subtle, #f5f5f5);
  199. border-color: var(--ai-border, #eaecef);
  200. &:hover,
  201. &:focus {
  202. background: var(--ai-bg-subtle, #f5f5f5);
  203. border-color: var(--ai-border, #eaecef);
  204. box-shadow: none;
  205. }
  206. }
  207. }
  208. &__badge {
  209. margin-left: 4px;
  210. padding: 0 5px;
  211. font-size: 11px;
  212. line-height: 16px;
  213. color: var(--ai-text-3, #9aa1ac);
  214. background: var(--ai-fill-2, #f2f3f5);
  215. border-radius: 8px;
  216. &.is-need {
  217. color: #d4380d;
  218. background: #fff2e8;
  219. }
  220. }
  221. &__dir-desc {
  222. margin: 0 0 8px;
  223. font-size: 12px;
  224. color: var(--ai-text-3, #9aa1ac);
  225. }
  226. &__empty {
  227. margin: 0;
  228. font-size: 12.5px;
  229. color: var(--ai-text-3, #9aa1ac);
  230. }
  231. &__file {
  232. margin-bottom: 10px;
  233. padding: 8px 10px;
  234. background: var(--ai-fill-1, #fafafa);
  235. border: 1px solid var(--ai-border, #eaecef);
  236. border-radius: 6px;
  237. }
  238. &__file-head {
  239. display: flex;
  240. gap: 6px;
  241. align-items: center;
  242. margin-bottom: 6px;
  243. color: var(--ai-text-2, #656d76);
  244. }
  245. &__file-name {
  246. flex: 1;
  247. min-width: 0;
  248. overflow: hidden;
  249. font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  250. font-size: 12.5px;
  251. color: var(--ai-text, #24292f);
  252. white-space: nowrap;
  253. text-overflow: ellipsis;
  254. }
  255. &__img {
  256. display: block;
  257. max-width: 100%;
  258. max-height: 420px;
  259. object-fit: contain;
  260. background: #fff;
  261. border: 1px solid var(--ai-border, #eaecef);
  262. border-radius: 4px;
  263. }
  264. &__note {
  265. margin: 0;
  266. font-size: 12px;
  267. color: var(--ai-text-3, #9aa1ac);
  268. }
  269. &__foreign {
  270. margin: 0;
  271. font-size: 12px;
  272. line-height: 1.7;
  273. color: var(--ai-text-3, #9aa1ac);
  274. }
  275. }
  276. </style>