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) => {
|
.catch((err) => {
|
||||||
console.warn('[show_file] 下载失败:', err);
|
console.warn('[show_file] 下载失败:', err);
|
||||||
// 兜底:直接跳转
|
// 失败不再兜底跳转(window.open 会把后端 JSON 错误整页展示),改为错误弹窗提示
|
||||||
window.open(url, '_blank');
|
useUiStore().pushToast({
|
||||||
|
message: err?.message || t('common.downloadFailed'),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,7 @@ function openMenu(e: MouseEvent, row: Row) {
|
|||||||
key: row.path,
|
key: row.path,
|
||||||
left: rect.left, // 与按钮左对齐、向下展开
|
left: rect.left, // 与按钮左对齐、向下展开
|
||||||
top: rect.bottom + 6,
|
top: rect.bottom + 6,
|
||||||
|
btnTop: rect.top,
|
||||||
alignRight: false
|
alignRight: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,25 +17,34 @@
|
|||||||
<!-- 详情面板(fixed 浮在列左侧) -->
|
<!-- 详情面板(fixed 浮在列左侧) -->
|
||||||
<RunnerDetailPanel />
|
<RunnerDetailPanel />
|
||||||
|
|
||||||
<!-- 全局 ⋯ 菜单(fixed 单例) -->
|
<!-- 全局 ⋯ 菜单(fixed 单例);Transition 提供进入/离开动画,
|
||||||
<div v-if="menu" class="qd-menu menu-enter" :style="menuStyle" @click.stop>
|
离开期间用 effectiveMenu 快照保持内容与定位不变 -->
|
||||||
<template v-if="menu.type === 'runner'">
|
<Transition name="qd-menu">
|
||||||
<button
|
<div
|
||||||
class="qd-menu__item qd-menu__item--danger"
|
v-if="menu"
|
||||||
:disabled="!menuTargetRunning"
|
class="qd-menu"
|
||||||
@click="killRunner"
|
:class="{ 'qd-menu--above': menuFlipped }"
|
||||||
>
|
:style="menuStyle"
|
||||||
{{ $t('quickdock.menuForceStop') }}
|
@click.stop
|
||||||
</button>
|
>
|
||||||
</template>
|
<template v-if="effectiveMenu?.type === 'runner'">
|
||||||
<template v-else>
|
<button
|
||||||
<button class="qd-menu__item" @click="downloadFile">{{ $t('common.download') }}</button>
|
class="qd-menu__item qd-menu__item--danger"
|
||||||
<button v-if="hostMode" class="qd-menu__item" @click="revealInManager">
|
:disabled="!menuTargetRunning"
|
||||||
{{ $t('quickdock.menuRevealInManager') }}
|
@click="killRunner"
|
||||||
</button>
|
>
|
||||||
<button class="qd-menu__item" @click="copyPath">{{ $t('quickdock.menuCopyPath') }}</button>
|
{{ $t('quickdock.menuForceStop') }}
|
||||||
</template>
|
</button>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -46,7 +55,8 @@ import { t } from '@/locales';
|
|||||||
import {
|
import {
|
||||||
useQuickDockStore,
|
useQuickDockStore,
|
||||||
persistQuickDockHadContent,
|
persistQuickDockHadContent,
|
||||||
persistQuickDockConvContent
|
persistQuickDockConvContent,
|
||||||
|
type QuickDockMenuState
|
||||||
} from '@/stores/quickDock';
|
} from '@/stores/quickDock';
|
||||||
import { useSubAgentStore } from '@/stores/subAgent';
|
import { useSubAgentStore } from '@/stores/subAgent';
|
||||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||||
@ -67,7 +77,7 @@ import FileWindow from './FileWindow.vue';
|
|||||||
* 同时负责:全局 ⋯ 菜单、Esc 分层关闭、列表轮询、对话切换重置。
|
* 同时负责:全局 ⋯ 菜单、Esc 分层关闭、列表轮询、对话切换重置。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defineProps<{ hostMode: boolean }>();
|
const props = defineProps<{ hostMode: boolean }>();
|
||||||
|
|
||||||
const quickDock = useQuickDockStore();
|
const quickDock = useQuickDockStore();
|
||||||
const subAgentStore = useSubAgentStore();
|
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 menuStyle = computed(() => {
|
||||||
const m = menu.value;
|
const m = effectiveMenu.value;
|
||||||
if (!m) {
|
if (!m) {
|
||||||
return {};
|
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) {
|
if (m.alignRight) {
|
||||||
return { right: `${window.innerWidth - m.left}px`, top: `${top}px` };
|
return { right: `${window.innerWidth - m.left}px`, top: `${top}px` };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
v-if="renderVisible"
|
v-if="renderVisible"
|
||||||
|
ref="rootRef"
|
||||||
class="qd-detail"
|
class="qd-detail"
|
||||||
:class="{ 'panel-enter': entering, 'panel-leave': leaving }"
|
:class="{ 'panel-enter': entering, 'panel-leave': leaving }"
|
||||||
|
:style="{ right: `${panelRight}px` }"
|
||||||
>
|
>
|
||||||
<header class="qd-detail__header">
|
<header class="qd-detail__header">
|
||||||
<span class="qd-detail__dot" :class="`is-${stateClass}`"></span>
|
<span class="qd-detail__dot" :class="`is-${stateClass}`"></span>
|
||||||
@ -65,12 +67,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
import { useQuickDockStore } from '@/stores/quickDock';
|
import { useQuickDockStore } from '@/stores/quickDock';
|
||||||
import { useSubAgentStore } from '@/stores/subAgent';
|
import { useSubAgentStore } from '@/stores/subAgent';
|
||||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情面板(fixed 浮在快捷窗口列左侧)
|
* 详情面板(fixed 浮在快捷窗口列左侧)
|
||||||
@ -139,6 +142,35 @@ const entering = ref(false);
|
|||||||
const leaving = ref(false);
|
const leaving = ref(false);
|
||||||
const bodyFading = ref(false);
|
const bodyFading = ref(false);
|
||||||
const bodyRef = ref<HTMLElement | null>(null);
|
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(() => {
|
const currentStatus = computed(() => {
|
||||||
|
|||||||
@ -169,6 +169,7 @@ function openMenu(e: MouseEvent, row: Row) {
|
|||||||
key: row.id,
|
key: row.id,
|
||||||
left: rect.right, // alignRight:菜单右缘对齐按钮右缘
|
left: rect.right, // alignRight:菜单右缘对齐按钮右缘
|
||||||
top: rect.bottom + 6,
|
top: rect.bottom + 6,
|
||||||
|
btnTop: rect.top,
|
||||||
alignRight: true
|
alignRight: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -453,7 +453,9 @@
|
|||||||
.qd-detail {
|
.qd-detail {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: calc(294px + 12px); /* 快捷窗口列宽 + gap */
|
/* right 由组件内联样式动态计算:跟随快捷栏实际位置(右侧可能还有预览/终端面板),
|
||||||
|
兜底值与旧写法一致(快捷窗口列宽 294 + gap 12) */
|
||||||
|
right: calc(294px + 12px);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 360px);
|
max-width: calc(100vw - 360px);
|
||||||
height: 460px;
|
height: 460px;
|
||||||
@ -988,19 +990,43 @@
|
|||||||
box-shadow: var(--shadow-card);
|
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 {
|
.qd-menu--above.qd-menu-enter-active,
|
||||||
from {
|
.qd-menu--above.qd-menu-leave-active {
|
||||||
opacity: 0;
|
transform-origin: bottom center;
|
||||||
transform: translateY(-3px) scale(0.97);
|
}
|
||||||
}
|
|
||||||
to {
|
.qd-menu-leave-active {
|
||||||
opacity: 1;
|
transition-duration: 0.13s;
|
||||||
transform: translateY(0) scale(1);
|
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 {
|
.qd-menu__item {
|
||||||
|
|||||||
@ -509,26 +509,68 @@ function normalizeShowTagsPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拦截 markdown 链接里的 download:// 协议,标记为下载链接。
|
* 拦截 markdown 链接里的下载协议与工作区相对路径,标记为下载链接。
|
||||||
* 输入:[文件名.pdf](download:///output/file.pdf) 或 (download://output/file.pdf)
|
* 输入:[文件名.pdf](download:///output/file.pdf)、[报告](research/result.md)
|
||||||
* 输出:<a class="md-download-link" data-path="output/file.pdf" href="download://output/file.pdf">文件名.pdf</a>
|
* 输出:<a class="md-download-link" data-path="..." href="download://..." title="...">文件名.pdf</a>
|
||||||
* 点击事件由 bootstrap.ts 统一拦截。
|
* 点击事件由 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() {
|
function transformDownloadLinksPlugin() {
|
||||||
return (tree: any) => {
|
return (tree: any) => {
|
||||||
visit(tree, 'element', (node: any) => {
|
visit(tree, 'element', (node: any) => {
|
||||||
if (!node || node.tagName !== 'a' || !node.properties) return;
|
if (!node || node.tagName !== 'a' || !node.properties) return;
|
||||||
const href = node.properties.href;
|
const href = node.properties.href;
|
||||||
if (typeof href !== 'string') return;
|
if (typeof href !== 'string') return;
|
||||||
|
let path: string;
|
||||||
// 兼容 download:///path(三个斜杠,旧写法)和 download://path(两个斜杠,推荐写法)
|
// 兼容 download:///path(三个斜杠,旧写法)和 download://path(两个斜杠,推荐写法)
|
||||||
const match = href.match(/^download:\/\/\/?(.*)$/i);
|
const dlMatch = href.match(/^download:\/{2,}(.*)$/i);
|
||||||
if (!match) return;
|
if (dlMatch) {
|
||||||
const rawPath = match[1];
|
// 必须走多层解码:micromark 会把中文等非 ASCII 字符规范化为 percent 编码,
|
||||||
const path = rawPath.replace(/^\/+/, '');
|
// 不解码直接当路径会再被 encodeURIComponent 编一层,服务器拿到字面 %XX 文件名
|
||||||
|
path = extractWorkspacePath(dlMatch[1]);
|
||||||
|
} else if (isWorkspaceFileHref(href)) {
|
||||||
|
path = extractWorkspacePath(href);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
node.properties.href = `download://${path}`;
|
node.properties.href = `download://${path}`;
|
||||||
node.properties['data-path'] = path;
|
node.properties['data-path'] = path;
|
||||||
node.properties['data-download'] = '1';
|
node.properties['data-download'] = '1';
|
||||||
|
// hover 提示完整路径
|
||||||
|
node.properties.title = path;
|
||||||
const existingClass = node.properties.className;
|
const existingClass = node.properties.className;
|
||||||
if (Array.isArray(existingClass)) {
|
if (Array.isArray(existingClass)) {
|
||||||
if (!existingClass.includes('md-download-link')) {
|
if (!existingClass.includes('md-download-link')) {
|
||||||
@ -576,7 +618,7 @@ const sanitizedSchema: Record<string, any> = {
|
|||||||
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
||||||
'dataFootnoteBackref', 'dataFootnoteRef',
|
'dataFootnoteBackref', 'dataFootnoteRef',
|
||||||
'data-path', 'data-download',
|
'data-path', 'data-download',
|
||||||
'href',
|
'href', 'title',
|
||||||
// className: 合并默认的 data-footnote-backref 和我们的 md-download-link
|
// className: 合并默认的 data-footnote-backref 和我们的 md-download-link
|
||||||
['className', 'data-footnote-backref', 'md-download-link']
|
['className', 'data-footnote-backref', 'md-download-link']
|
||||||
],
|
],
|
||||||
|
|||||||
@ -105,9 +105,11 @@ export interface QuickDockMenuState {
|
|||||||
kind?: 'agent' | 'cmd';
|
kind?: 'agent' | 'cmd';
|
||||||
/** runner: task_id / command_id;file: 相对路径 */
|
/** runner: task_id / command_id;file: 相对路径 */
|
||||||
key: string;
|
key: string;
|
||||||
/** fixed 定位坐标 */
|
/** fixed 定位坐标(top = 按钮下缘 + 间距) */
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
|
/** 按钮上缘坐标:下方空间不足时菜单翻转到按钮上方展开 */
|
||||||
|
btnTop: number;
|
||||||
/** 菜单与触发按钮右对齐(runner 的 ⋯ 在行右侧) */
|
/** 菜单与触发按钮右对齐(runner 的 ⋯ 在行右侧) */
|
||||||
alignRight: boolean;
|
alignRight: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2469,15 +2469,43 @@ show-html[data-rendered='1'] {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== download:// 链接样式 =====
|
// ===== 文件下载链接样式(download:// 与工作区相对路径链接统一) =====
|
||||||
|
// 版式对齐 ChatGPT:文件图标 + 链接文字 + 虚线下划线;颜色全部走三主题语义 token
|
||||||
a.md-download-link {
|
a.md-download-link {
|
||||||
color: var(--accent-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline dotted;
|
||||||
|
text-decoration-color: var(--text-tertiary);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
/* 行高 2.5(正文 1.6):撑高所在行盒,与上下两行拉开明显间距 */
|
||||||
|
line-height: 2.5;
|
||||||
cursor: pointer;
|
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 {
|
&:hover {
|
||||||
opacity: 0.8;
|
color: var(--accent);
|
||||||
|
text-decoration-color: var(--accent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user