agent-Specialization/static/src/components/chat/quickdock/FilePreviewPanel.vue
JOJO b3158913ef feat(frontend): 内容图片全站接入灯箱预览,关闭按钮统一为 CloseButton 组件
- 新增 document 级事件委托(bootstrap.ts setupImagePreviewDelegation),
  show_image 卡片 / Markdown 正文图 / view_image / ocr_image 工具结果
  统一支持点击灯箱预览;可预览图片加 zoom-in 光标
- show_file 图片卡片由打开新标签页改为灯箱预览,并解除 Android 端禁用
- CitationPopover 引用缩略图支持点击预览
- renderShowFileCard 独立 app 实例补挂 i18n 插件(修复 $t 不可用隐患)
- 新建 common/CloseButton.vue(boxed 圆角方 hover 底 / bare 裸字形双变体),
  替换全站 25 处形态各异的关闭按钮(圆形/圆角方/裸字符/macOS 红点等 8 种风格),
  修复 ImagePicker/VideoPicker/路径授权弹窗原本无 hover 反馈的问题
- showHtmlFullscreen 顶栏按钮 CSS 对齐 boxed 形态(动态 DOM 无法复用组件)
- 清理 10+ 份散落重复的关闭按钮旧样式与 .mobile-overlay-close 死代码

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-02 01:07:57 +08:00

277 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<transition name="qd-preview-slide">
<aside v-if="previewPath" class="qd-preview" :style="{ width: previewWidth + 'px' }">
<!-- 左缘拖拽手柄拖动调整面板宽度localStorage 持久化 -->
<div
class="qd-preview__resize"
:title="$t('quickdock.resizeWidthHint')"
@mousedown="startPreviewResize"
></div>
<section class="qd-preview__panel">
<header class="qd-preview__header">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path
d="M9.5 2.5V5H12"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
</header>
<div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div>
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
<template v-else>
<!-- 自动换行模式(设置项开启):逐行渲染,行号内嵌不会错位,行高亮走 CSS :hover -->
<div v-if="autoWrap" class="qd-code qd-code--wrap">
<div v-for="(lineHtml, idx) in wrappedLines" :key="idx" class="qd-code-line">
<span class="qd-code-line-no" aria-hidden="true">{{ idx + 1 }}</span>
<span class="qd-code-line-text" v-html="lineHtml"></span>
</div>
</div>
<!-- 不换行(默认):行号列 + 整段高亮 prePrism 的 token 可能跨行(多行注释/字符串), -->
<!-- 不能按行拆分 v-html行 hover 用绝对定位高亮条实现 -->
<div v-else class="qd-code" @mousemove="onCodeMouseMove" @mouseleave="hoverLine = -1">
<div
v-if="hoverLine >= 0"
class="qd-code-hoverline"
:style="{ top: `${hoverLine * LINE_HEIGHT}px` }"
></div>
<div class="qd-code-gutter" aria-hidden="true">
<span v-for="n in lineCount" :key="n">{{ n }}</span>
</div>
<pre class="qd-code-pre"><code v-html="highlightedHtml"></code></pre>
</div>
</template>
</div>
</section>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import CloseButton from '@/components/common/CloseButton.vue';
import { t } from '@/locales';
import { useQuickDockStore } from '@/stores/quickDock';
import { usePersonalizationStore } from '@/stores/personalization';
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
/** 与 quickdock.css 中 .qd-code 的 font-size(12px) × line-height(1.75) 保持同步 */
const LINE_HEIGHT = 21;
/** 超过该字符数的文件跳过语法高亮(仍显示纯文本与行号),避免大文件解析卡顿 */
const HIGHLIGHT_MAX_CHARS = 100 * 1024;
/**
* 文件预览侧边栏占位列位于快捷窗口列右侧、git/终端面板左侧)
* 内容走现有 /api/file/content仅支持白名单内的 UTF-8 文本类文件。
*/
const PREVIEWABLE_EXTS = new Set([
'.txt',
'.md',
'.markdown',
'.csv',
'.json',
'.json5',
'.yaml',
'.yml',
'.js',
'.ts',
'.jsx',
'.tsx',
'.vue',
'.py',
'.rb',
'.go',
'.rs',
'.java',
'.c',
'.h',
'.cpp',
'.hpp',
'.cs',
'.php',
'.swift',
'.kt',
'.sh',
'.bash',
'.zsh',
'.ps1',
'.bat',
'.cmd',
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
'.xml',
'.svg',
'.toml',
'.ini',
'.cfg',
'.conf',
'.log',
'.sql'
]);
const quickDock = useQuickDockStore();
const { previewPath } = storeToRefs(quickDock);
/** 预览面板宽度(含右侧 12px paddinglocalStorage 持久化 */
const PREVIEW_WIDTH_STORAGE_KEY = 'agents_qd_preview_width';
const PREVIEW_WIDTH_MIN = 320;
const PREVIEW_WIDTH_MAX = 860;
const PREVIEW_WIDTH_DEFAULT = 452;
const loadPreviewWidth = (): number => {
if (typeof window === 'undefined' || !window.localStorage) return PREVIEW_WIDTH_DEFAULT;
try {
const raw = Number(window.localStorage.getItem(PREVIEW_WIDTH_STORAGE_KEY));
if (!Number.isFinite(raw) || raw <= 0) return PREVIEW_WIDTH_DEFAULT;
return Math.max(PREVIEW_WIDTH_MIN, Math.min(PREVIEW_WIDTH_MAX, raw));
} catch {
return PREVIEW_WIDTH_DEFAULT;
}
};
const previewWidth = ref(loadPreviewWidth());
function startPreviewResize(event: MouseEvent) {
event.preventDefault();
const startX = event.clientX;
const startWidth = previewWidth.value;
const onMove = (e: MouseEvent) => {
// 手柄在面板左缘:向左拖变宽、向右拖变窄
const next = startWidth + (startX - e.clientX);
previewWidth.value = Math.max(PREVIEW_WIDTH_MIN, Math.min(PREVIEW_WIDTH_MAX, next));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
try {
window.localStorage.setItem(PREVIEW_WIDTH_STORAGE_KEY, String(previewWidth.value));
} catch {
/* 持久化失败不影响本次调整 */
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
const rawText = ref('');
const loading = ref(false);
const error = ref('');
const hoverLine = ref(-1);
const previewLang = computed(() => {
if (rawText.value.length > HIGHLIGHT_MAX_CHARS) return null;
return prismLangForPath(previewPath.value || '');
});
const highlightedHtml = computed(() => {
const html = highlightCode(rawText.value, previewLang.value);
// pre 中末尾的单个 \n 不产生视觉空行;文本以换行结尾时补一个,
// 保证行号列与代码视觉行一一对齐
return rawText.value.endsWith('\n') ? html + '\n' : html;
});
const lineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0));
const personalization = usePersonalizationStore();
/** 设置项「预览窗口自动换行显示」,默认关闭(长行横向滚动) */
const autoWrap = computed(() => personalization.form.file_preview_auto_wrap === true);
// 自动换行模式:逐行拆分 + 逐行高亮。
// 取舍:多行注释/字符串等跨行 token 在该模式下着色可能不准(逐行独立解析),换来行号与换行行的天然对齐。
const wrappedLines = computed<string[]>(() => {
if (!rawText.value) return [];
return rawText.value.split('\n').map((line) => highlightCode(line, previewLang.value));
});
const fileName = computed(() => {
const p = previewPath.value || '';
return p.split('/').pop() || p;
});
const dirName = computed(() => {
const p = previewPath.value || '';
const parts = p.split('/');
return parts.length > 1 ? `${parts.slice(0, -1).join('/')}/` : '';
});
function extOf(path: string): string {
const name = path.split('/').pop() || '';
const idx = name.lastIndexOf('.');
return idx >= 0 ? name.slice(idx).toLowerCase() : '';
}
let loadSeq = 0;
watch(
previewPath,
async (path) => {
const mySeq = ++loadSeq;
rawText.value = '';
error.value = '';
hoverLine.value = -1;
if (!path) {
return;
}
if (!PREVIEWABLE_EXTS.has(extOf(path))) {
error.value = t('quickdock.previewTypeUnsupported');
return;
}
loading.value = true;
try {
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
throw new Error(payload?.error || t('quickdock.loadFailedHttp', { status: resp.status }));
}
const text = await resp.text();
if (mySeq !== loadSeq) {
return;
}
rawText.value = text;
} catch (err: any) {
if (mySeq !== loadSeq) {
return;
}
error.value = err?.message || t('common.loadFailed');
} finally {
if (mySeq === loadSeq) {
loading.value = false;
}
}
},
{ immediate: true }
);
function onCodeMouseMove(event: MouseEvent) {
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const idx = Math.floor((event.clientY - rect.top) / LINE_HEIGHT);
hoverLine.value = idx >= 0 && idx < lineCount.value ? idx : -1;
}
function close() {
quickDock.closePreview();
}
</script>