| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- <template>
- <BasicModal
- :open="open"
- :title="detail?.name || skill?.name || '技能详情'"
- :width="900"
- wrap-class-name="skill-detail-modal"
- :footer="null"
- :body-style="{ maxHeight: '64vh', overflowY: 'auto' }"
- @cancel="close"
- >
- <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> 可选。下面是磁盘上的实际内容,只读。
- </p>
- <div class="skill-detail__field">
- <label class="skill-detail__label">技能名</label>
- <a-input :value="detail.name" readonly />
- </div>
- <div class="skill-detail__field">
- <label class="skill-detail__label">描述</label>
- <a-input :value="detail.description || '(未填写描述)'" readonly />
- </div>
- <a-tabs v-model:active-key="activeTab" size="small">
- <a-tab-pane key="SKILL.md">
- <template #tab>
- <span>SKILL.md</span>
- <span class="skill-detail__badge is-need">必需</span>
- </template>
- <div class="skill-detail__field">
- <label class="skill-detail__label">正文(Markdown)</label>
- <a-textarea :value="body" readonly :rows="14" />
- </div>
- </a-tab-pane>
- <a-tab-pane v-for="dir in SKILL_STD_DIRS" :key="dir.key">
- <template #tab>
- <span>{{ dir.key }}/</span>
- <span class="skill-detail__badge">{{ filesOf(dir.key).length }}</span>
- </template>
- <p class="skill-detail__dir-desc">{{ dir.desc }}</p>
- <p v-if="!filesOf(dir.key).length" class="skill-detail__empty">该目录未使用</p>
- <div v-for="file in filesOf(dir.key)" :key="file.path" class="skill-detail__file">
- <div class="skill-detail__file-head">
- <Icon icon="mdi:file-outline" :size="14" />
- <span class="skill-detail__file-name" :title="file.path">{{ leafPath(file.path, dir.key) }}</span>
- <span class="skill-detail__badge">{{ formatFileSize(file.size) }}</span>
- </div>
- <img
- v-if="isImagePath(file.path) && contents[file.path]?.base64"
- class="skill-detail__img"
- :src="`data:${imageMimeOf(file.path)};base64,${contents[file.path].base64}`"
- :alt="file.path"
- />
- <p v-else-if="contents[file.path]?.truncated" class="skill-detail__note">
- 文件较大({{ formatFileSize(contents[file.path]?.size) }}),超过预览上限,未展示内容。
- </p>
- <p v-else-if="contents[file.path]?.binary" class="skill-detail__note">
- 二进制文件({{ formatFileSize(contents[file.path]?.size) }}),不支持在线预览。
- </p>
- <a-textarea v-else :value="contents[file.path]?.content ?? ''" readonly :rows="7" />
- </div>
- </a-tab-pane>
- </a-tabs>
- <p v-if="foreignFiles.length" class="skill-detail__foreign">
- 规范目录之外的文件({{ foreignFiles.length }} 个):{{ foreignFiles.join('、') }}
- </p>
- </div>
- </a-spin>
- </BasicModal>
- </template>
- <script lang="ts" setup name="SkillDetailModal">
- /**
- * 技能详情(只读)—— 与「新增 / 编辑技能」弹框同构:
- * 同样的 900 宽弹框、同样的 SKILL.md + scripts/references/assets/testcases 页签,
- * 只是所有控件都只读、底部没有确认按钮。
- *
- * 打开时读一次详情(SKILL.md 正文 + 附属文件清单),再并发把每个附属文件的内容拉回来
- * (文本走 content,图片走 base64)。技能文件通常个位数,一次拉完比点开再等更顺手;
- * 单文件超过后端预览上限时只回体积,这里给明确提示。
- */
- import { computed, ref, watch } from 'vue';
- import { Input, Spin, Tabs, Textarea } from 'ant-design-vue';
- import { BasicModal } from '@/core/components/Modal';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import { readSkill, readSkillFile } from '../api/capabilityApi';
- import type { Skill, SkillDetail, SkillFileContent } from '../api/types';
- import {
- SKILL_STD_DIRS,
- SKILL_STD_DIR_KEYS,
- formatFileSize,
- imageMimeOf,
- isImagePath,
- leafPath,
- splitSkillMd,
- topDirOf,
- } from '../utils/skill';
- const AInput = Input;
- const ATextarea = Textarea;
- const ATabs = Tabs;
- const ATabPane = Tabs.TabPane;
- const ASpin = Spin;
- const props = defineProps<{
- open: boolean;
- /** 要查看的技能;为空时不请求 */
- skill?: Skill | null;
- /** 案件ID,可选:只影响是否附带该案件配置的只读技能仓库 */
- caseId?: string | number | null;
- }>();
- const emit = defineEmits<{
- (e: 'update:open', value: boolean): void;
- }>();
- const { showMessage } = useMessage();
- const detail = ref<SkillDetail | null>(null);
- /** SKILL.md 正文(已剥掉 frontmatter,与编辑弹框展示的是同一段) */
- const body = ref('');
- /** 附属文件内容:相对路径 → 内容 */
- const contents = ref<Record<string, SkillFileContent>>({});
- const activeTab = ref<string>('SKILL.md');
- const loading = ref(false);
- function filesOf(dir: string) {
- return (detail.value?.files ?? []).filter((f) => f.path.startsWith(`${dir}/`));
- }
- /** 规范目录之外的文件(例如根目录的 readme.txt):不在页签里,只在底部提示 */
- const foreignFiles = computed(() =>
- (detail.value?.files ?? []).map((f) => f.path).filter((p) => !SKILL_STD_DIR_KEYS.includes(topDirOf(p))),
- );
- async function load(skill: Skill) {
- loading.value = true;
- detail.value = null;
- contents.value = {};
- activeTab.value = 'SKILL.md';
- try {
- const data = await readSkill(skill.name, props.caseId);
- 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);
- return [f.path, content] as const;
- }),
- );
- const map: Record<string, SkillFileContent> = {};
- for (const [path, content] of loaded) {
- if (content) map[path] = content;
- }
- contents.value = map;
- } catch (e: any) {
- showMessage(e?.message || '读取技能失败', 'error');
- } finally {
- loading.value = false;
- }
- }
- watch(
- () => props.open,
- (opened) => {
- if (!opened || !props.skill) return;
- void load(props.skill);
- },
- );
- function close() {
- emit('update:open', false);
- }
- </script>
- <style lang="less" scoped>
- /* 与 SkillEditorModal 保持同一套视觉:label 在上、控件在下 */
- .skill-detail {
- display: flex;
- flex-direction: column;
- gap: 10px;
- &__sub {
- margin: 0;
- font-size: 12.5px;
- line-height: 1.7;
- color: var(--ai-text-2, #656d76);
- b {
- font-weight: 600;
- color: var(--ai-text, #24292f);
- }
- }
- &__field {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- &__label {
- font-size: 12.5px;
- color: var(--ai-text-2, #656d76);
- }
- /* 只读态:输入框 / 文本域底纹置灰(沿用项目的输入框底色 token),
- 一眼区分「看」与「改」;hover / focus 也不再变色,避免像可编辑。 */
- :deep(.ant-input) {
- &[readonly] {
- color: var(--ai-text, #24292f);
- cursor: default;
- background: var(--ai-bg-subtle, #f5f5f5);
- border-color: var(--ai-border, #eaecef);
- &:hover,
- &:focus {
- background: var(--ai-bg-subtle, #f5f5f5);
- border-color: var(--ai-border, #eaecef);
- box-shadow: none;
- }
- }
- }
- &__badge {
- margin-left: 4px;
- padding: 0 5px;
- font-size: 11px;
- line-height: 16px;
- color: var(--ai-text-3, #9aa1ac);
- background: var(--ai-fill-2, #f2f3f5);
- border-radius: 8px;
- &.is-need {
- color: #d4380d;
- background: #fff2e8;
- }
- }
- &__dir-desc {
- margin: 0 0 8px;
- font-size: 12px;
- color: var(--ai-text-3, #9aa1ac);
- }
- &__empty {
- margin: 0;
- font-size: 12.5px;
- color: var(--ai-text-3, #9aa1ac);
- }
- &__file {
- margin-bottom: 10px;
- padding: 8px 10px;
- background: var(--ai-fill-1, #fafafa);
- border: 1px solid var(--ai-border, #eaecef);
- border-radius: 6px;
- }
- &__file-head {
- display: flex;
- gap: 6px;
- align-items: center;
- margin-bottom: 6px;
- color: var(--ai-text-2, #656d76);
- }
- &__file-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- font-size: 12.5px;
- color: var(--ai-text, #24292f);
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__img {
- display: block;
- max-width: 100%;
- max-height: 420px;
- object-fit: contain;
- background: #fff;
- border: 1px solid var(--ai-border, #eaecef);
- border-radius: 4px;
- }
- &__note {
- margin: 0;
- font-size: 12px;
- color: var(--ai-text-3, #9aa1ac);
- }
- &__foreign {
- margin: 0;
- font-size: 12px;
- line-height: 1.7;
- color: var(--ai-text-3, #9aa1ac);
- }
- }
- </style>
|