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:
parent
aa50a72d86
commit
f2676caa80
@ -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'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,9 +17,17 @@
|
||||
<!-- 详情面板(fixed 浮在列左侧) -->
|
||||
<RunnerDetailPanel />
|
||||
|
||||
<!-- 全局 ⋯ 菜单(fixed 单例) -->
|
||||
<div v-if="menu" class="qd-menu menu-enter" :style="menuStyle" @click.stop>
|
||||
<template v-if="menu.type === 'runner'">
|
||||
<!-- 全局 ⋯ 菜单(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"
|
||||
@ -36,6 +44,7 @@
|
||||
<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` };
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
.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(-3px) scale(0.97);
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
|
||||
.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 {
|
||||
|
||||
@ -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 的绝对 URI(http/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']
|
||||
],
|
||||
|
||||
@ -105,9 +105,11 @@ export interface QuickDockMenuState {
|
||||
kind?: 'agent' | 'cmd';
|
||||
/** runner: task_id / command_id;file: 相对路径 */
|
||||
key: string;
|
||||
/** fixed 定位坐标 */
|
||||
/** fixed 定位坐标(top = 按钮下缘 + 间距) */
|
||||
left: number;
|
||||
top: number;
|
||||
/** 按钮上缘坐标:下方空间不足时菜单翻转到按钮上方展开 */
|
||||
btnTop: number;
|
||||
/** 菜单与触发按钮右对齐(runner 的 ⋯ 在行右侧) */
|
||||
alignRight: boolean;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user