fix(frontend): 快捷菜单翻转动画与详情面板修复,md 文件链接统一下载通道

- QuickDock ⋯ 菜单:下方空间不足时翻转到按钮上方展开;进出动画按
  翻转状态分方向(下方展开从上到下、翻转到上方时从下到上),补离开动画
- 子智能体详情面板:补 CloseButton 导入修复关闭按钮缺失;right 定位
  改为实时跟随快捷栏实际位置(右侧开预览/终端面板时不再盖住按钮)
- md 相对路径链接识别为工作区文件链接,与 download:// 统一走
  /api/download/file 下载(web blob / Android 原生桥接);修复中文路径
  因 micromark 规范化编码未解码导致的双重编码 404
- 文件链接样式重构:文件图标 + 中性文字 + 虚线下划线(对齐 ChatGPT),
  图标跟随文字色、下缘对齐虚线,行高 2.5;下载失败改弹 toast 不再跳转错误页

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-09 18:09:10 +08:00
parent aa50a72d86
commit f2676caa80
9 changed files with 242 additions and 54 deletions

View File

@ -1475,8 +1475,11 @@ function dispatchShowFileDownload(path: string) {
})
.catch((err) => {
console.warn('[show_file] 下载失败:', err);
// 兜底:直接跳转
window.open(url, '_blank');
// 失败不再兜底跳转window.open 会把后端 JSON 错误整页展示),改为错误弹窗提示
useUiStore().pushToast({
message: err?.message || t('common.downloadFailed'),
type: 'error'
});
});
}

View File

@ -78,6 +78,7 @@ function openMenu(e: MouseEvent, row: Row) {
key: row.path,
left: rect.left, //
top: rect.bottom + 6,
btnTop: rect.top,
alignRight: false
});
}

View File

@ -17,25 +17,34 @@
<!-- 详情面板fixed 浮在列左侧 -->
<RunnerDetailPanel />
<!-- 全局 菜单fixed 单例 -->
<div v-if="menu" class="qd-menu menu-enter" :style="menuStyle" @click.stop>
<template v-if="menu.type === 'runner'">
<button
class="qd-menu__item qd-menu__item--danger"
:disabled="!menuTargetRunning"
@click="killRunner"
>
{{ $t('quickdock.menuForceStop') }}
</button>
</template>
<template v-else>
<button class="qd-menu__item" @click="downloadFile">{{ $t('common.download') }}</button>
<button v-if="hostMode" class="qd-menu__item" @click="revealInManager">
{{ $t('quickdock.menuRevealInManager') }}
</button>
<button class="qd-menu__item" @click="copyPath">{{ $t('quickdock.menuCopyPath') }}</button>
</template>
</div>
<!-- 全局 菜单fixed 单例Transition 提供进入/离开动画
离开期间用 effectiveMenu 快照保持内容与定位不变 -->
<Transition name="qd-menu">
<div
v-if="menu"
class="qd-menu"
:class="{ 'qd-menu--above': menuFlipped }"
:style="menuStyle"
@click.stop
>
<template v-if="effectiveMenu?.type === 'runner'">
<button
class="qd-menu__item qd-menu__item--danger"
:disabled="!menuTargetRunning"
@click="killRunner"
>
{{ $t('quickdock.menuForceStop') }}
</button>
</template>
<template v-else>
<button class="qd-menu__item" @click="downloadFile">{{ $t('common.download') }}</button>
<button v-if="hostMode" class="qd-menu__item" @click="revealInManager">
{{ $t('quickdock.menuRevealInManager') }}
</button>
<button class="qd-menu__item" @click="copyPath">{{ $t('quickdock.menuCopyPath') }}</button>
</template>
</div>
</Transition>
</aside>
</template>
@ -46,7 +55,8 @@ import { t } from '@/locales';
import {
useQuickDockStore,
persistQuickDockHadContent,
persistQuickDockConvContent
persistQuickDockConvContent,
type QuickDockMenuState
} from '@/stores/quickDock';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
@ -67,7 +77,7 @@ import FileWindow from './FileWindow.vue';
* 同时负责全局 菜单Esc 分层关闭列表轮询对话切换重置
*/
defineProps<{ hostMode: boolean }>();
const props = defineProps<{ hostMode: boolean }>();
const quickDock = useQuickDockStore();
const subAgentStore = useSubAgentStore();
@ -243,14 +253,57 @@ const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
/* ---------------- 菜单定位与目标状态 ---------------- */
const MENU_ESTIMATED_HEIGHT = 128;
const MENU_ITEM_HEIGHT = 30;
const MENU_CHROME = 10; // padding 4×2 + border 1×2
const MENU_GAP = 6;
const VIEWPORT_MARGIN = 8;
/** 离开动画期间沿用的最后一份菜单快照menu 关闭即 null直接读会让位置/内容在动画中跳变) */
const lastMenu = ref<QuickDockMenuState | null>(null);
watch(
menu,
(val) => {
if (val) {
lastMenu.value = val;
}
},
{ immediate: true }
);
const effectiveMenu = computed(() => menu.value || lastMenu.value);
/** 菜单高度估算:行高固定 30px按类型精确到项数翻转时才能贴住按钮 */
const menuEstimatedHeight = computed(() => {
const m = effectiveMenu.value;
if (!m) {
return 0;
}
if (m.type === 'runner') {
return MENU_ITEM_HEIGHT + MENU_CHROME;
}
return (props.hostMode ? 3 : 2) * MENU_ITEM_HEIGHT + MENU_CHROME;
});
/**
* 驱动定位与动画方向qd-menu--above从下到上淡入 */
const menuFlipped = computed(() => {
const m = effectiveMenu.value;
if (!m) {
return false;
}
const spaceBelow = window.innerHeight - VIEWPORT_MARGIN - m.top;
const spaceAbove = m.btnTop - MENU_GAP - VIEWPORT_MARGIN;
return spaceBelow < menuEstimatedHeight.value && spaceAbove > spaceBelow;
});
const menuStyle = computed(() => {
const m = menu.value;
const m = effectiveMenu.value;
if (!m) {
return {};
}
const top = Math.min(m.top, window.innerHeight - MENU_ESTIMATED_HEIGHT - 8);
const estHeight = menuEstimatedHeight.value;
const top = menuFlipped.value
? Math.max(VIEWPORT_MARGIN, m.btnTop - MENU_GAP - estHeight)
: Math.min(m.top, window.innerHeight - estHeight - VIEWPORT_MARGIN);
if (m.alignRight) {
return { right: `${window.innerWidth - m.left}px`, top: `${top}px` };
}

View File

@ -1,8 +1,10 @@
<template>
<section
v-if="renderVisible"
ref="rootRef"
class="qd-detail"
:class="{ 'panel-enter': entering, 'panel-leave': leaving }"
:style="{ right: `${panelRight}px` }"
>
<header class="qd-detail__header">
<span class="qd-detail__dot" :class="`is-${stateClass}`"></span>
@ -65,12 +67,13 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { t, currentLocale } from '@/locales';
import { useQuickDockStore } from '@/stores/quickDock';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
import CloseButton from '@/components/common/CloseButton.vue';
/**
* 详情面板fixed 浮在快捷窗口列左侧
@ -139,6 +142,35 @@ const entering = ref(false);
const leaving = ref(false);
const bodyFading = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const rootRef = ref<HTMLElement | null>(null);
/* ---------------- ----------------
CSS 里的 right 兜底值假设快捷栏贴视口最右缘右侧再开预览/终端面板时
快捷栏左移写死的 right 会让面板盖住快捷栏显示期间逐帧测量
快捷栏左缘顺带跟随面板开合过渡与拖拽调宽保持 12px 间距 */
const panelRight = ref(294 + 12);
let positionRafId = 0;
function trackDockPosition() {
const aside = rootRef.value?.parentElement;
if (aside) {
const next = Math.round(window.innerWidth - aside.getBoundingClientRect().left + 12);
if (next !== panelRight.value) {
panelRight.value = next;
}
}
positionRafId = requestAnimationFrame(trackDockPosition);
}
watch(renderVisible, (visible) => {
if (visible) {
trackDockPosition();
} else {
cancelAnimationFrame(positionRafId);
}
});
onBeforeUnmount(() => cancelAnimationFrame(positionRafId));
/** 当前条目状态与标题(关闭离开动画期间沿用最后快照) */
const currentStatus = computed(() => {

View File

@ -169,6 +169,7 @@ function openMenu(e: MouseEvent, row: Row) {
key: row.id,
left: rect.right, // alignRight
top: rect.bottom + 6,
btnTop: rect.top,
alignRight: true
});
}

View File

@ -453,7 +453,9 @@
.qd-detail {
position: fixed;
top: 20px;
right: calc(294px + 12px); /* 快捷窗口列宽 + gap */
/* right 由组件内联样式动态计算跟随快捷栏实际位置右侧可能还有预览/终端面板
兜底值与旧写法一致快捷窗口列宽 294 + gap 12 */
right: calc(294px + 12px);
width: 480px;
max-width: calc(100vw - 360px);
height: 460px;
@ -988,19 +990,43 @@
box-shadow: var(--shadow-card);
}
.qd-menu.menu-enter {
animation: qd-menu-in 0.16s ease-out;
/* 进入/离开淡入淡出 + 从按钮一侧滑入菜单在按钮下方展开时从上到下默认
翻转到按钮上方时从下到上--above动画始终以按钮为起点 */
.qd-menu-enter-active,
.qd-menu-leave-active {
transition:
opacity 0.16s ease-out,
transform 0.16s ease-out;
transform-origin: top center;
}
@keyframes qd-menu-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
.qd-menu--above.qd-menu-enter-active,
.qd-menu--above.qd-menu-leave-active {
transform-origin: bottom center;
}
.qd-menu-leave-active {
transition-duration: 0.13s;
transition-timing-function: ease-in;
pointer-events: none;
}
.qd-menu-enter-from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
.qd-menu--above.qd-menu-enter-from {
transform: translateY(4px) scale(0.98);
}
.qd-menu-leave-to {
opacity: 0;
transform: translateY(-3px) scale(0.98);
}
.qd-menu--above.qd-menu-leave-to {
transform: translateY(3px) scale(0.98);
}
.qd-menu__item {

View File

@ -509,26 +509,68 @@ function normalizeShowTagsPlugin() {
}
/**
* markdown download:// 协议,标记为下载链接。
* [.pdf](download:///output/file.pdf) 或 (download://output/file.pdf)
* <a class="md-download-link" data-path="output/file.pdf" href="download://output/file.pdf">.pdf</a>
* bootstrap.ts
* markdown
* [.pdf](download:///output/file.pdf)、[报告](research/result.md)
* <a class="md-download-link" data-path="..." href="download://..." title="...">.pdf</a>
* bootstrap.ts web fetch Android
*/
/** 带 scheme 的绝对 URIhttp/https/mailto/tel/ftp 等)都不是工作区文件 */
const ABSOLUTE_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
/** 判断 href 是否为工作区内文件的相对路径(排除协议链接、协议相对链接、页内锚点) */
function isWorkspaceFileHref(href: string): boolean {
const trimmed = href.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return false;
if (ABSOLUTE_SCHEME_RE.test(trimmed)) return false;
return true;
}
/** 从相对路径 href 提取工作区相对路径:去 query/hash、URL 解码、去前导斜杠 */
function extractWorkspacePath(href: string): string {
let path = href.trim().split('#')[0].split('?')[0];
// 模型输出的链接可能本身已 percent 编码,还可能叠加多层(如 micromark 规范化或
// 模型对已编码串二次编码);逐层解码直到没有合法 %XX 序列,上限 3 层防死循环
for (let i = 0; i < 3; i++) {
if (!/%[0-9a-fA-F]{2}/.test(path)) {
break;
}
try {
const decoded = decodeURIComponent(path);
if (decoded === path) {
break;
}
path = decoded;
} catch {
break; // 含非法编码序列时保留现状
}
}
return path.replace(/^\/+/, '');
}
function transformDownloadLinksPlugin() {
return (tree: any) => {
visit(tree, 'element', (node: any) => {
if (!node || node.tagName !== 'a' || !node.properties) return;
const href = node.properties.href;
if (typeof href !== 'string') return;
let path: string;
// 兼容 download:///path三个斜杠旧写法和 download://path两个斜杠推荐写法
const match = href.match(/^download:\/\/\/?(.*)$/i);
if (!match) return;
const rawPath = match[1];
const path = rawPath.replace(/^\/+/, '');
const dlMatch = href.match(/^download:\/{2,}(.*)$/i);
if (dlMatch) {
// 必须走多层解码micromark 会把中文等非 ASCII 字符规范化为 percent 编码,
// 不解码直接当路径会再被 encodeURIComponent 编一层,服务器拿到字面 %XX 文件名
path = extractWorkspacePath(dlMatch[1]);
} else if (isWorkspaceFileHref(href)) {
path = extractWorkspacePath(href);
} else {
return;
}
if (!path) return;
node.properties.href = `download://${path}`;
node.properties['data-path'] = path;
node.properties['data-download'] = '1';
// hover 提示完整路径
node.properties.title = path;
const existingClass = node.properties.className;
if (Array.isArray(existingClass)) {
if (!existingClass.includes('md-download-link')) {
@ -576,7 +618,7 @@ const sanitizedSchema: Record<string, any> = {
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
'dataFootnoteBackref', 'dataFootnoteRef',
'data-path', 'data-download',
'href',
'href', 'title',
// className: 合并默认的 data-footnote-backref 和我们的 md-download-link
['className', 'data-footnote-backref', 'md-download-link']
],

View File

@ -105,9 +105,11 @@ export interface QuickDockMenuState {
kind?: 'agent' | 'cmd';
/** runner: task_id / command_idfile: 相对路径 */
key: string;
/** fixed 定位坐标 */
/** fixed 定位坐标top = 按钮下缘 + 间距) */
left: number;
top: number;
/** 按钮上缘坐标:下方空间不足时菜单翻转到按钮上方展开 */
btnTop: number;
/** 菜单与触发按钮右对齐runner 的 ⋯ 在行右侧) */
alignRight: boolean;
}

View File

@ -2469,15 +2469,43 @@ show-html[data-rendered='1'] {
max-width: 100%;
}
// ===== download:// 链接样式 =====
// ===== 文件下载链接样式download:// 与工作区相对路径链接统一 =====
// 版式对齐 ChatGPT文件图标 + 链接文字 + 虚线下划线颜色全部走三主题语义 token
a.md-download-link {
color: var(--accent-primary);
text-decoration: underline;
color: var(--text-primary);
text-decoration: underline dotted;
text-decoration-color: var(--text-tertiary);
text-underline-offset: 3px;
/* 行高 2.5(正文 1.6):撑高所在行盒,与上下两行拉开明显间距 */
line-height: 2.5;
cursor: pointer;
transition: opacity 0.15s;
transition:
color 0.15s,
text-decoration-color 0.15s;
// 文件图标mask 上色跟随正文色深色模式近白浅色近黑
&::before {
content: '';
display: inline-block;
/* 图标大于字高:顶部略过帽高线,底部下移到虚线下划线处(基线下 3px */
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: -3px;
background-color: var(--text-primary);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.5 1.5h-5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V4.5L9.5 1.5z'/%3E%3Cpath d='M9.5 1.5v3h3'/%3E%3Cpath d='M6 8h4M6 10.5h4'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.5 1.5h-5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V4.5L9.5 1.5z'/%3E%3Cpath d='M9.5 1.5v3h3'/%3E%3Cpath d='M6 8h4M6 10.5h4'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
&:hover {
opacity: 0.8;
color: var(--accent);
text-decoration-color: var(--accent);
}
}