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>
This commit is contained in:
JOJO 2026-09-02 01:07:57 +08:00
parent e6d8f194d2
commit b3158913ef
38 changed files with 317 additions and 508 deletions

View File

@ -49,7 +49,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="banner.message" class="banner" :class="banner.type"> <div v-if="banner.message" class="banner" :class="banner.type">
<span>{{ banner.message }}</span> <span>{{ banner.message }}</span>
<button type="button" class="banner-close" @click="banner.message = ''">×</button> <CloseButton :label="$t('common.close')" @click="banner.message = ''" />
</div> </div>
</transition> </transition>
@ -300,6 +300,7 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue'; import SecondaryGate from './SecondaryGate.vue';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
type TargetType = 'global' | 'role' | 'user' | 'invite'; type TargetType = 'global' | 'role' | 'user' | 'invite';
@ -1061,14 +1062,6 @@ onBeforeUnmount(() => {
border-color: rgba(189, 93, 58, 0.35); border-color: rgba(189, 93, 58, 0.35);
} }
.banner-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.panel { .panel {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(118, 103, 84, 0.2); border: 1px solid rgba(118, 103, 84, 0.2);

View File

@ -2,8 +2,9 @@
import katex from 'katex'; import katex from 'katex';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { t } from '@/locales'; import { t, i18n } from '@/locales';
import ShowFileCard from '../components/chat/ShowFileCard.vue'; import ShowFileCard from '../components/chat/ShowFileCard.vue';
import { useUiStore } from '@/stores/ui';
import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox'; import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
import { import {
openShowHtmlFullscreen, openShowHtmlFullscreen,
@ -174,11 +175,16 @@ function normalizeShowImageSrc(src: string) {
trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, ''); trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, '');
} }
if (trimmed.startsWith('/user_upload/')) return trimmed; if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx // 兼容容器内部绝对路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
// 注意:.astrion/user_upload/xxx 是工作区相对路径,不在此特判,
// 让它落到下方通用分支走 /api/file/content与 _validate_path 同源解析,
// 避免依赖 /user_upload/ 后端路由在宿主机免登录模式下的工作区解析)
if (trimmed.startsWith('/')) {
const idx = trimmed.toLowerCase().indexOf('/user_upload/'); const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) { if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length); return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
} }
}
// /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理 // /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
if (trimmed === '/workspace') return ''; if (trimmed === '/workspace') return '';
if (trimmed.startsWith('/workspace/')) { if (trimmed.startsWith('/workspace/')) {
@ -1256,6 +1262,36 @@ function buildNodePathKey(node: Element) {
return parts.reverse().join('/'); return parts.reverse().join('/');
} }
// ===== 内容图片点击预览document 级事件委托) =====
// 覆盖三类无 Vue 事件绑定的注入图片:
// 1. <show_image> 卡片figure.chat-inline-imagerenderShowImages 动态创建)
// 2. Markdown ![]() 正文图片(.text-content 内的 img
// 3. 工具结果图片(.tool-result-image如 view_image / ocr_image
// 不会误伤:消息气泡图片与 CitationPopover 缩略图已用 @click.stop 阻断冒泡;
// ShowFileCard 卡片自带 @click在此显式排除头像/图标为 SVG 或不在命中容器内。
let imagePreviewDelegationBound = false;
export function setupImagePreviewDelegation() {
if (imagePreviewDelegationBound) return;
imagePreviewDelegationBound = true;
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement | null;
if (!target || typeof target.closest !== 'function') return;
const img = target.closest('img') as HTMLImageElement | null;
if (!img) return;
// ShowFileCard 内部已自行打开灯箱预览,避免双重触发
if (img.closest('.show-file-card')) return;
const hit =
img.closest('figure.chat-inline-image') ||
img.closest('.tool-result-image') ||
img.closest('.text-content');
if (!hit) return;
const url = img.currentSrc || img.src;
if (!url) return;
useUiStore().openImagePreview({ url, name: img.alt || '' });
});
}
export function setupShowImageObserver() { export function setupShowImageObserver() {
if (showImageObserver || showContainerObserver) return; if (showImageObserver || showContainerObserver) return;
setupLayoutDebugObservers(); setupLayoutDebugObservers();
@ -1471,6 +1507,9 @@ function renderShowFileCard(node: Element) {
node.replaceChildren(mountEl); node.replaceChildren(mountEl);
const app = createApp(ShowFileCard, { path }); const app = createApp(ShowFileCard, { path });
// 独立 createApp 实例不继承主应用的插件,必须显式安装 i18n
// 否则组件模板里的 $t 不可用(报 "j.$t is not a function"
app.use(i18n);
app.mount(mountEl); app.mount(mountEl);
showFileAppMap.set(node, app); showFileAppMap.set(node, app);
} }

View File

@ -2,7 +2,7 @@
import { useChatActionStore } from '../stores/chatActions'; import { useChatActionStore } from '../stores/chatActions';
import { useSandboxSetupStore } from '../stores/sandboxSetup'; import { useSandboxSetupStore } from '../stores/sandboxSetup';
import { normalizeScrollLock } from '../composables/useScrollControl'; import { normalizeScrollLock } from '../composables/useScrollControl';
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap'; import { setupShowImageObserver, teardownShowImageObserver, setupImagePreviewDelegation } from './bootstrap';
import { debugLog } from './methods/common'; import { debugLog } from './methods/common';
export function created() { export function created() {
@ -41,6 +41,7 @@ export async function mounted() {
normalizeScrollLock(this); normalizeScrollLock(this);
}); });
setupShowImageObserver(); setupShowImageObserver();
setupImagePreviewDelegation();
// 立即加载初始数据(并行获取状态,优先同步运行模式) // 立即加载初始数据(并行获取状态,优先同步运行模式)
const initialDataPromise = this.loadInitialData(); const initialDataPromise = this.loadInitialData();

View File

@ -27,7 +27,12 @@
</div> </div>
<div v-if="single.url" class="pop-url">{{ single.url }}</div> <div v-if="single.url" class="pop-url">{{ single.url }}</div>
<div v-if="isImageFile(single)" class="pop-image"> <div v-if="isImageFile(single)" class="pop-image">
<img :src="fileContentUrl(single)" :alt="single.file_name || ''" loading="lazy" /> <img
:src="fileContentUrl(single)"
:alt="single.file_name || ''"
loading="lazy"
@click.stop="openCitationPreview(single)"
/>
</div> </div>
<div v-else-if="displaySnippet(single)" class="pop-snippet">{{ displaySnippet(single) }}</div> <div v-else-if="displaySnippet(single)" class="pop-snippet">{{ displaySnippet(single) }}</div>
</div> </div>
@ -132,6 +137,14 @@ function fileContentUrl(ann: CitationAnnotation): string {
return `/api/file/content?path=${encodeURIComponent(ann.file_path || '')}`; return `/api/file/content?path=${encodeURIComponent(ann.file_path || '')}`;
} }
/** 点击引用图片缩略图:打开全局灯箱预览 */
function openCitationPreview(ann: CitationAnnotation) {
uiStore.openImagePreview({
url: fileContentUrl(ann),
name: ann.file_name || ''
});
}
function shortDomain(domain?: string) { function shortDomain(domain?: string) {
return (domain || '').replace(/^www\./, ''); return (domain || '').replace(/^www\./, '');
} }

View File

@ -17,6 +17,7 @@
*/ */
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{ const props = defineProps<{
summary: any; summary: any;
@ -342,15 +343,12 @@ function lineNumber(line: any): string {
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span> <span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span> <span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
</span> </span>
<button <CloseButton
v-if="pinned || isMobile" v-if="pinned || isMobile"
type="button" :label="$t('common.close')"
class="es-modal__close" size="sm"
:aria-label="$t('common.close')"
@click.stop="close" @click.stop="close"
> />
<span class="icon icon-sm" :style="props.iconStyle('x')" aria-hidden="true"></span>
</button>
</div> </div>
<div class="es-modal__body"> <div class="es-modal__body">
<template v-if="(activeFile.lines || []).length"> <template v-if="(activeFile.lines || []).length">
@ -539,26 +537,6 @@ function lineNumber(line: any): string {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.es-modal__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
}
.es-modal__close:hover {
background: var(--surface-muted);
color: var(--text-primary);
}
.es-modal__body { .es-modal__body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@ -62,7 +62,7 @@
:class="{ 'sfc-image--android': isAndroidApp }" :class="{ 'sfc-image--android': isAndroidApp }"
:src="contentUrl" :src="contentUrl"
:alt="displayName" :alt="displayName"
@click="!isAndroidApp && openFullImage()" @click="openFullImage()"
@error="onImageError" @error="onImageError"
/> />
</template> </template>
@ -82,6 +82,7 @@ import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox';
import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen'; import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
import PdfPreview from './PdfPreview.vue'; import PdfPreview from './PdfPreview.vue';
import { t } from '@/locales'; import { t } from '@/locales';
import { useUiStore } from '@/stores/ui';
// fetch // fetch
const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>(); const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>();
@ -430,7 +431,9 @@ async function copyContent() {
} }
function openFullImage() { function openFullImage() {
if (contentUrl.value) window.open(contentUrl.value, '_blank'); if (!contentUrl.value) return;
// Android WebView
useUiStore().openImagePreview({ url: contentUrl.value, name: displayName.value || '' });
} }
function onImageError() { function onImageError() {

View File

@ -768,7 +768,9 @@ function renderViewImage(result: any, args: any): string {
if (imageUrl) { if (imageUrl) {
const safeUrl = escapeHtml(imageUrl); const safeUrl = escapeHtml(imageUrl);
html += `<div class="tool-result-image"><a href="${safeUrl}" target="_blank" rel="noopener" title="${escapeHtml(t('toolResults.media.viewImgTitle'))}"><img src="${safeUrl}" alt="${escapeHtml(t('toolResults.media.viewImgAlt'))}" loading="lazy" /></a></div>`; // 不再用 <a target="_blank"> 打开新标签页,直接渲染 <img>
// 由聊天消息区的事件委托统一承接点击灯箱预览。
html += `<div class="tool-result-image"><img src="${safeUrl}" alt="${escapeHtml(t('toolResults.media.viewImgAlt'))}" loading="lazy" /></div>`;
} }
return html; return html;

View File

@ -25,16 +25,7 @@
</svg> </svg>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span> <span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span> <span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<button class="qd-preview__close" :title="$t('common.close')" @click="close"> <CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
</header> </header>
<div class="qd-preview__body"> <div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div> <div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div>
@ -70,6 +61,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import CloseButton from '@/components/common/CloseButton.vue';
import { t } from '@/locales'; import { t } from '@/locales';
import { useQuickDockStore } from '@/stores/quickDock'; import { useQuickDockStore } from '@/stores/quickDock';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';

View File

@ -9,16 +9,9 @@
<span class="qd-detail__title" :title="title">{{ title }}</span> <span class="qd-detail__title" :title="title">{{ title }}</span>
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span> <span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
<span v-if="tokensText" class="qd-detail__tokens" :title="tokensTitle">{{ tokensText }}</span> <span v-if="tokensText" class="qd-detail__tokens" :title="tokensTitle">{{ tokensText }}</span>
<button class="qd-detail__close" :title="$t('common.close')" @click="close"> <span class="qd-detail__close">
<svg viewBox="0 0 16 16" fill="none"> <CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
<path </span>
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
</header> </header>
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }"> <div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
<!-- 子智能体工具调用 + 文本输出时间线 --> <!-- 子智能体工具调用 + 文本输出时间线 -->

View File

@ -581,30 +581,8 @@
.qd-detail__close { .qd-detail__close {
flex: none; flex: none;
margin-left: auto; margin-left: auto;
width: 26px;
height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-detail__close svg {
width: 13px;
height: 13px;
}
.qd-detail__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
} }
.qd-detail__body { .qd-detail__body {
@ -879,34 +857,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.qd-preview__close {
flex: none;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-preview__close svg {
width: 13px;
height: 13px;
}
.qd-preview__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
.qd-preview__body { .qd-preview__body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@ -0,0 +1,131 @@
<script setup lang="ts">
/**
* 全局统一关闭按钮X两种变体全站所有关闭语义按钮统一引用
*
* 变体
* - boxed默认用于一切有容器承载场景窗口/弹窗/抽屉/侧边栏/面板/通知卡片
* 28×28pxsm=24×24px圆角 7pxsm=6px透明底hover/active 出现 --hover-bg 底色块
* - bare用于悬空无容器场景当前仅 ImageLightbox 灯箱无背景/无圆角/无底色块
* 仅字形变色颜色通过 `--close-btn-color` / `--close-btn-color-hover` CSS 变量由父容器覆盖
*
* 接口
* - variant: 'boxed' | 'bare'默认 boxed
* - size: 'sm' | 'md'默认 md=28px
* - label: aria-label 文案必传调用方给 i18n 文案
* - emit: click
*
* 字形复用项目图标机制 iconStyle('x')等价 VersioningDialog 中的
* `<span class="icon icon-sm" :style="iconStyle('x')" />`通过在组件内直接由 ICONS
* 构造 `--icon-src` style 对象实现无需依赖父组件透传 iconStyle prop
*/
import { ICONS } from '../../utils/icons';
withDefaults(
defineProps<{
variant?: 'boxed' | 'bare';
size?: 'sm' | 'md';
label: string;
disabled?: boolean;
}>(),
{
variant: 'boxed',
size: 'md',
disabled: false
}
);
defineEmits<{
(e: 'click', evt: MouseEvent): void;
}>();
// iconStyle('x')mask 使 --icon-src x.svg
const iconStyle = { '--icon-src': `url(${ICONS.x})` };
</script>
<template>
<button
type="button"
class="close-btn"
:class="[`close-btn--${variant}`, `close-btn--${size}`]"
:aria-label="label"
:disabled="disabled"
v-bind="$attrs"
@click="$emit('click', $event)"
>
<span class="icon icon-sm" :style="iconStyle" aria-hidden="true"></span>
</button>
</template>
<style scoped>
.close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
}
.close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== boxed有容器承载 ===== */
.close-btn--boxed {
--_size: 28px;
--_radius: 7px;
width: var(--_size);
height: var(--_size);
border-radius: var(--_radius);
color: var(--text-secondary);
transition:
background 140ms ease,
color 140ms ease;
}
.close-btn--boxed.close-btn--sm {
--_size: 24px;
--_radius: 6px;
}
@media (hover: hover) {
.close-btn--boxed:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.close-btn--boxed:active {
background: var(--hover-bg);
color: var(--text-primary);
}
/* ===== bare悬空无容器 ===== */
.close-btn--bare {
width: 36px;
height: 36px;
color: var(--close-btn-color);
transition: color 140ms ease;
}
/* :where() ImageLightbox
--close-btn-color / --close-btn-color-hover 时始终能赢过默认值
注意禁止写成 var(--x, 兜底) 形式会被 stylelint 颜色规则拦截 */
:where(.close-btn--bare) {
--close-btn-color: var(--text-secondary);
--close-btn-color-hover: var(--text-primary);
}
.close-btn--bare .icon {
--icon-size: 20px;
}
@media (hover: hover) {
.close-btn--bare:hover {
color: var(--close-btn-color-hover);
}
}
</style>

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title"> <div class="subagent-activity-title">
{{ $t('overlay.bgCommandTitle', { id: activeCommand.command_id }) }} {{ $t('overlay.bgCommandTitle', { id: activeCommand.command_id }) }}
</div> </div>
<button type="button" class="subagent-activity-close" @click="close">×</button> <CloseButton :label="$t('common.close')" @click="close" />
</div> </div>
<div class="subagent-activity-meta"> <div class="subagent-activity-meta">
<span <span
@ -49,6 +49,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '@/locales'; import { t } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand'; import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
const commandStore = useBackgroundCommandStore(); const commandStore = useBackgroundCommandStore();

View File

@ -10,16 +10,12 @@
<div v-if="generatedPath" class="hint" :title="generatedPath"> <div v-if="generatedPath" class="hint" :title="generatedPath">
{{ $t('overlay.generatedHint', { path: generatedPath }) }} {{ $t('overlay.generatedHint', { path: generatedPath }) }}
</div> </div>
<button <CloseButton
type="button" :label="$t('common.close')"
class="icon-close-btn"
:aria-label="$t('common.close')"
:title="$t('common.close')" :title="$t('common.close')"
@click="$emit('close')"
:disabled="submitting" :disabled="submitting"
> @click="$emit('close')"
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span> />
</button>
</div> </div>
</div> </div>
@ -127,6 +123,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'ConversationReviewDialog' }); defineOptions({ name: 'ConversationReviewDialog' });
const props = defineProps<{ const props = defineProps<{
@ -256,32 +254,6 @@ const formatUpdatedAt = (value: string | number) => {
} }
/* 自定义关闭按钮,固定尺寸、视觉居中 */ /* 自定义关闭按钮,固定尺寸、视觉居中 */
.icon-close-btn {
flex: 0 0 auto;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.icon-close-btn:hover:not(:disabled) {
background: var(--theme-tab-active);
color: var(--text-primary);
}
.icon-close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== 主体:左右两栏,靠竖线分隔 ===== */ /* ===== 主体:左右两栏,靠竖线分隔 ===== */
.review-body { .review-body {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title"> <div class="subagent-activity-title">
{{ isDone ? $t('overlay.goalDoneTitle') : isStopped ? $t('overlay.goalStoppedTitle') : $t('overlay.goalRunningTitle') }} {{ isDone ? $t('overlay.goalDoneTitle') : isStopped ? $t('overlay.goalStoppedTitle') : $t('overlay.goalRunningTitle') }}
</div> </div>
<button type="button" class="subagent-activity-close" @click="$emit('close')">×</button> <CloseButton :label="$t('common.close')" @click="$emit('close')" />
</div> </div>
<div class="goal-progress-meta"> <div class="goal-progress-meta">
@ -49,6 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'GoalProgressDialog' }); defineOptions({ name: 'GoalProgressDialog' });

View File

@ -9,14 +9,12 @@
:aria-label="preview.name || $t('overlay.imagePreview')" :aria-label="preview.name || $t('overlay.imagePreview')"
@click.self="close" @click.self="close"
> >
<button <CloseButton
type="button" variant="bare"
class="image-lightbox__close" class="image-lightbox__close"
:aria-label="$t('overlay.closePreview')" :label="$t('overlay.closePreview')"
@click.stop="close" @click="close"
> />
×
</button>
<div class="image-lightbox__stage" @click.self="close"> <div class="image-lightbox__stage" @click.self="close">
<img <img
class="image-lightbox__img" class="image-lightbox__img"
@ -34,6 +32,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue'; import { computed, onBeforeUnmount, watch } from 'vue';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
const preview = computed(() => uiStore.imagePreview); const preview = computed(() => uiStore.imagePreview);
@ -125,28 +124,15 @@ onBeforeUnmount(() => syncLock(false));
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* bare CloseButton
灯箱遮罩永远为深色颜色不随主题变量默认约 72% 透明度的白hover 全亮 */
.image-lightbox__close { .image-lightbox__close {
position: absolute; position: absolute;
top: calc(10px + env(safe-area-inset-top, 0px)); top: calc(10px + env(safe-area-inset-top, 0px));
right: calc(10px + env(safe-area-inset-right, 0px)); right: calc(10px + env(safe-area-inset-right, 0px));
z-index: 1; z-index: 1;
width: 36px; --close-btn-color: color-mix(in srgb, var(--lightbox-text) 72%, transparent);
height: 36px; --close-btn-color-hover: var(--lightbox-text);
border-radius: 50%;
border: none;
background: var(--lightbox-btn-bg);
color: var(--lightbox-text);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease;
}
.image-lightbox__close:hover {
background: var(--lightbox-btn-bg-hover);
} }
.lightbox-fade-enter-active, .lightbox-fade-enter-active,

View File

@ -17,7 +17,7 @@
@change="onLocalChange" @change="onLocalChange"
/> />
</div> </div>
<button class="close-btn" @click="close">×</button> <CloseButton :label="$t('common.close')" @click="close" />
</div> </div>
<div class="body"> <div class="body">
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div> <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
@ -57,6 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, withDefaults } from 'vue'; import { computed, ref, watch, onMounted, withDefaults } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
interface ImageEntry { interface ImageEntry {
name: string; name: string;
@ -195,13 +196,6 @@ onMounted(() => {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.close-btn {
background: transparent;
color: var(--text-tertiary);
border: none;
font-size: 20px;
cursor: pointer;
}
.body { .body {
padding: 12px 16px; padding: 12px 16px;
overflow: auto; overflow: auto;

View File

@ -4,7 +4,7 @@
<div class="overlay-card"> <div class="overlay-card">
<div class="overlay-header"> <div class="overlay-header">
<h3>{{ $t('overlay.pathAuthTitle') }}</h3> <h3>{{ $t('overlay.pathAuthTitle') }}</h3>
<button type="button" @click="$emit('close')">×</button> <CloseButton :label="$t('common.close')" @click="$emit('close')" />
</div> </div>
<div class="mode-switch"> <div class="mode-switch">
<button <button
@ -51,6 +51,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import CloseButton from '@/components/common/CloseButton.vue';
defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>(); defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>();
defineEmits<{ defineEmits<{
(e: 'close'): void; (e: 'close'): void;
@ -65,7 +67,6 @@ defineEmits<{
.overlay-card { width: min(680px, 92vw); background: var(--theme-surface-soft); border: 1px solid var(--theme-control-border); border-radius: 12px; padding: 12px; } .overlay-card { width: min(680px, 92vw); background: var(--theme-surface-soft); border: 1px solid var(--theme-control-border); border-radius: 12px; padding: 12px; }
.overlay-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .overlay-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.overlay-header h3 { margin: 0; font-size: 16px; } .overlay-header h3 { margin: 0; font-size: 16px; }
.overlay-header button { border: none; background: transparent; font-size: 20px; cursor: pointer; }
.hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 8px 0; } .hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 8px 0; }
.mode-switch { display: inline-flex; border: 1px solid var(--theme-control-border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; } .mode-switch { display: inline-flex; border: 1px solid var(--theme-control-border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
.mode-btn { border: none; background: transparent; padding: 6px 10px; cursor: pointer; font-size: 12px; } .mode-btn { border: none; background: transparent; padding: 6px 10px; cursor: pointer; font-size: 12px; }

View File

@ -4,17 +4,13 @@
<section class="plan-approval-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.planApprovalAriaLabel')"> <section class="plan-approval-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.planApprovalAriaLabel')">
<header class="plan-approval-windowbar"> <header class="plan-approval-windowbar">
<div class="plan-approval-window-title">{{ $t('overlay.planApprovalTitle') }}</div> <div class="plan-approval-window-title">{{ $t('overlay.planApprovalTitle') }}</div>
<button <span class="plan-approval-close">
type="button" <CloseButton
class="plan-approval-close" :label="$t('overlay.planApprovalMinimize')"
:title="$t('overlay.planApprovalMinimize')" :title="$t('overlay.planApprovalMinimize')"
:aria-label="$t('overlay.planApprovalMinimize')"
@click="emit('minimize')" @click="emit('minimize')"
> />
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> </span>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</header> </header>
<header class="plan-approval-header"> <header class="plan-approval-header">
@ -77,6 +73,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'; import MarkdownRenderer from '../chat/MarkdownRenderer.vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@ -165,22 +162,9 @@ function reject() {
right: 10px; right: 10px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 26px;
height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.plan-approval-close:hover {
background: var(--hover-bg);
color: var(--text-primary);
} }
.plan-approval-window-title { .plan-approval-window-title {

View File

@ -9,17 +9,9 @@
> >
<header class="sandbox-setup-windowbar"> <header class="sandbox-setup-windowbar">
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div> <div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
<button <span class="sandbox-setup-close">
type="button" <CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="onClose" />
class="sandbox-setup-close" </span>
:title="$t('common.close')"
:aria-label="$t('common.close')"
@click="onClose"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</header> </header>
<div class="sandbox-setup-body"> <div class="sandbox-setup-body">
@ -144,6 +136,7 @@
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup'; import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'SandboxSetupDialog' }); defineOptions({ name: 'SandboxSetupDialog' });
@ -312,22 +305,9 @@ function onClose() {
right: 10px; right: 10px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 26px;
height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sandbox-setup-close:hover {
background: var(--hover-bg);
color: var(--text-primary);
} }
.sandbox-setup-body { .sandbox-setup-body {

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title"> <div class="subagent-activity-title">
{{ $t('overlay.subAgentProgressTitle', { id: activeAgent.agent_id || activeAgent.task_id }) }} {{ $t('overlay.subAgentProgressTitle', { id: activeAgent.agent_id || activeAgent.task_id }) }}
</div> </div>
<button type="button" class="subagent-activity-close" @click="close">×</button> <CloseButton :label="$t('common.close')" @click="close" />
</div> </div>
<div class="subagent-activity-meta"> <div class="subagent-activity-meta">
<span class="subagent-activity-status" :class="activeAgent.status || ''">{{ <span class="subagent-activity-status" :class="activeAgent.status || ''">{{
@ -59,6 +59,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import { useSubAgentStore } from '@/stores/subAgent'; import { useSubAgentStore } from '@/stores/subAgent';
type ActivityEntry = { type ActivityEntry = {

View File

@ -3,8 +3,8 @@
<div v-if="visible && questions.length" class="user-question-overlay" @click.self="emit('minimize')"> <div v-if="visible && questions.length" class="user-question-overlay" @click.self="emit('minimize')">
<section class="user-question-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.userQuestionAriaLabel')"> <section class="user-question-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.userQuestionAriaLabel')">
<header class="user-question-windowbar"> <header class="user-question-windowbar">
<div class="user-question-traffic" :aria-label="$t('overlay.windowControlAria')"> <div class="user-question-traffic">
<button type="button" class="traffic-dot traffic-dot--close" :aria-label="$t('overlay.minimizeAria')" @click="emit('minimize')"></button> <CloseButton :label="$t('overlay.minimizeAria')" @click="emit('minimize')" />
</div> </div>
<div class="user-question-window-title">{{ $t('overlay.userQuestionWindowTitle') }}</div> <div class="user-question-window-title">{{ $t('overlay.userQuestionWindowTitle') }}</div>
</header> </header>
@ -80,6 +80,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@ -208,16 +209,7 @@ function dismiss() {
align-items: center; align-items: center;
} }
.traffic-dot { /* 关闭按钮本体样式在 common/CloseButton.vueboxed 变体traffic-dot 红点样式已随统一下线 */
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--border-strong);
padding: 0;
cursor: pointer;
}
.traffic-dot--close { background: var(--mac-close); }
.user-question-window-title { .user-question-window-title {
text-align: center; text-align: center;

View File

@ -20,9 +20,7 @@
<span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span>
<span>{{ $t('overlay.refresh') }}</span> <span>{{ $t('overlay.refresh') }}</span>
</button> </button>
<button type="button" class="icon-close-btn" :aria-label="$t('common.close')" @click="$emit('close')"> <CloseButton :label="$t('common.close')" @click="$emit('close')" />
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
</button>
</div> </div>
</header> </header>
@ -155,6 +153,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{ const props = defineProps<{
hostMode: boolean; hostMode: boolean;
@ -498,26 +497,6 @@ const splitDiffContent = (content: any): string[] => {
cursor: not-allowed; cursor: not-allowed;
} }
.icon-close-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.icon-close-btn:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
}
/* ===== 主体:左右分栏靠竖线 ===== */ /* ===== 主体:左右分栏靠竖线 ===== */
.versioning-body { .versioning-body {
display: grid; display: grid;

View File

@ -16,7 +16,7 @@
@change="onLocalChange" @change="onLocalChange"
/> />
</div> </div>
<button class="close-btn" @click="close">×</button> <CloseButton :label="$t('common.close')" @click="close" />
</div> </div>
<div class="body"> <div class="body">
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div> <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
@ -59,6 +59,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, withDefaults } from 'vue'; import { computed, ref, watch, onMounted, withDefaults } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
interface VideoEntry { interface VideoEntry {
name: string; name: string;
@ -197,13 +198,6 @@ onMounted(() => {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.close-btn {
background: transparent;
color: var(--text-tertiary);
border: none;
font-size: 20px;
cursor: pointer;
}
.body { .body {
padding: 12px 16px; padding: 12px 16px;
overflow: auto; overflow: auto;

View File

@ -9,15 +9,11 @@
<span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span>
<span>{{ $t('shell.focusFilesCount', { n: focusedCount }) }}</span> <span>{{ $t('shell.focusFilesCount', { n: focusedCount }) }}</span>
</h3> </h3>
<button <CloseButton
v-if="showCloseButton" v-if="showCloseButton"
type="button" :label="$t('shell.closeFocusPanel')"
class="focus-close-btn"
:aria-label="$t('shell.closeFocusPanel')"
@click="$emit('close')" @click="$emit('close')"
> />
×
</button>
</div> </div>
<div class="focused-files" v-if="!collapsed"> <div class="focused-files" v-if="!collapsed">
<div v-if="!focusedCount" class="no-files">{{ $t('shell.noFocusFiles') }}</div> <div v-if="!focusedCount" class="no-files">{{ $t('shell.noFocusFiles') }}</div>
@ -40,6 +36,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useFocusStore } from '@/stores/focus'; import { useFocusStore } from '@/stores/focus';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'FocusPanel' }); defineOptions({ name: 'FocusPanel' });

View File

@ -9,9 +9,7 @@
<div class="git-changes-panel__summary"> <div class="git-changes-panel__summary">
<span class="git-changes-panel__add">+{{ additions }}</span> <span class="git-changes-panel__add">+{{ additions }}</span>
<span class="git-changes-panel__del">-{{ deletions }}</span> <span class="git-changes-panel__del">-{{ deletions }}</span>
<button type="button" class="git-changes-panel__close" :aria-label="$t('shell.closeGitPanel')" @click="$emit('close')"> <CloseButton :label="$t('shell.closeGitPanel')" @click="$emit('close')" />
×
</button>
</div> </div>
</header> </header>
@ -135,6 +133,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'GitChangesPanel' }); defineOptions({ name: 'GitChangesPanel' });

View File

@ -18,12 +18,7 @@
<span class="terminal-panel__tab-name">{{ name }}</span> <span class="terminal-panel__tab-name">{{ name }}</span>
</button> </button>
</div> </div>
<button <CloseButton :label="$t('shell.closeTerminalPanel')" @click="$emit('close')" />
type="button"
class="terminal-panel__close"
:aria-label="$t('shell.closeTerminalPanel')"
@click="$emit('close')"
>&times;</button>
</header> </header>
<!-- 终端容器 --> <!-- 终端容器 -->
@ -44,6 +39,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Terminal } from 'xterm'; import { Terminal } from 'xterm';
import { io as createSocketClient } from 'socket.io-client'; import { io as createSocketClient } from 'socket.io-client';
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'TerminalPanel' }); defineOptions({ name: 'TerminalPanel' });
@ -541,29 +537,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.terminal-panel__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
margin-left: 4px;
}
.terminal-panel__close:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
box-shadow: 0 1px 2px var(--shadow-color);
}
.terminal-panel__body { .terminal-panel__body {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;

View File

@ -6,9 +6,7 @@
> >
<div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }"> <div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }">
<h3 class="icon-label">{{ panelTitle }}</h3> <h3 class="icon-label">{{ panelTitle }}</h3>
<button type="button" class="approval-close-btn" :aria-label="$t('shell.closeApprovalPanel')" @click="handleCloseClick"> <CloseButton :label="$t('shell.closeApprovalPanel')" @click="handleCloseClick" />
×
</button>
</div> </div>
<div class="approval-panel-body" v-if="!collapsed"> <div class="approval-panel-body" v-if="!collapsed">
<div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">{{ $t('shell.noPendingApprovals') }}</div> <div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">{{ $t('shell.noPendingApprovals') }}</div>
@ -132,6 +130,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, computed } from 'vue'; import { onMounted, computed } from 'vue';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'ToolApprovalPanel' }); defineOptions({ name: 'ToolApprovalPanel' });

View File

@ -23,29 +23,19 @@
<div class="settings-mobile-bar"> <div class="settings-mobile-bar">
<span class="settings-mobile-bar-cell" aria-hidden="true"></span> <span class="settings-mobile-bar-cell" aria-hidden="true"></span>
<span class="settings-mobile-bar-title">{{ $t('common.settings') }}</span> <span class="settings-mobile-bar-title">{{ $t('common.settings') }}</span>
<button <span class="settings-mobile-bar-btn">
type="button" <CloseButton
class="settings-mobile-bar-btn" :label="$t('personalization.closePersonalSpaceAriaLabel')"
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')"
@click="personalization.closeDrawer()" @click="personalization.closeDrawer()"
> />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> </span>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div> </div>
<div class="settings-nav-head"> <div class="settings-nav-head">
<button <CloseButton
type="button"
class="settings-close-button"
data-tutorial="personal-close" data-tutorial="personal-close"
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')" :label="$t('personalization.closePersonalSpaceAriaLabel')"
@click="personalization.closeDrawer()" @click="personalization.closeDrawer()"
> />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div> </div>
<div class="settings-redesign-tabs"> <div class="settings-redesign-tabs">
<button <button
@ -164,6 +154,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide } from 'vue'; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import GeneralTab from './tabs/GeneralTab.vue'; import GeneralTab from './tabs/GeneralTab.vue';
import PreferencesTab from './tabs/PreferencesTab.vue'; import PreferencesTab from './tabs/PreferencesTab.vue';
import ModelTab from './tabs/ModelTab.vue'; import ModelTab from './tabs/ModelTab.vue';

View File

@ -33,31 +33,6 @@ body[data-theme='dark'] .settings-redesign-card {
padding: 12px 12px 4px; padding: 12px 12px 4px;
} }
.settings-close-button {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
display: grid;
place-items: center;
cursor: pointer;
}
@media (hover: hover) {
.settings-close-button:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.settings-close-button svg {
width: 17px;
height: 17px;
stroke-width: 2;
}
/* 移动端顶栏:桌面端隐藏 */ /* 移动端顶栏:桌面端隐藏 */
.settings-mobile-bar { .settings-mobile-bar {
display: none; display: none;

View File

@ -2,7 +2,7 @@
<transition name="quota-toast-fade"> <transition name="quota-toast-fade">
<div class="quota-toast" v-if="quotaToast"> <div class="quota-toast" v-if="quotaToast">
<span class="quota-toast-label">{{ quotaToast.message }}</span> <span class="quota-toast-label">{{ quotaToast.message }}</span>
<button type="button" class="toast-close" :aria-label="$t('shell.closeNotification')" @click="dismiss">×</button> <CloseButton size="sm" :label="$t('shell.closeNotification')" @click="dismiss" />
</div> </div>
</transition> </transition>
</template> </template>
@ -10,6 +10,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
const { quotaToast } = storeToRefs(uiStore); const { quotaToast } = storeToRefs(uiStore);

View File

@ -10,15 +10,12 @@
<div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div> <div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div>
<div class="app-toast-message">{{ toast.message }}</div> <div class="app-toast-message">{{ toast.message }}</div>
</div> </div>
<button <CloseButton
v-if="toast.closable !== false" v-if="toast.closable !== false"
type="button" size="sm"
class="toast-close" :label="$t('shell.closeNotification')"
:aria-label="$t('shell.closeNotification')"
@click="dismiss(toast.id)" @click="dismiss(toast.id)"
> />
×
</button>
</div> </div>
</transition-group> </transition-group>
</div> </div>
@ -27,6 +24,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
const { toastQueue: toasts } = storeToRefs(uiStore); const { toastQueue: toasts } = storeToRefs(uiStore);

View File

@ -1,15 +1,12 @@
<template> <template>
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer"> <div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
<div class="token-display-panel"> <div class="token-display-panel">
<button <CloseButton
class="token-close-btn" class="token-close-btn"
type="button"
data-tutorial="token-close" data-tutorial="token-close"
:label="$t('sidebar.collapseUsage')"
@click="emit('toggle')" @click="emit('toggle')"
:aria-label="$t('sidebar.collapseUsage')" />
>
<span class="sr-only">{{ $t('common.close') }}</span>
</button>
<div class="token-panel-content"> <div class="token-panel-content">
<div class="usage-dashboard"> <div class="usage-dashboard">
<div class="usage-cell usage-cell--left usage-cell--token panel-card"> <div class="usage-cell usage-cell--left usage-cell--token panel-card">
@ -127,6 +124,7 @@ defineOptions({ name: 'TokenDrawer' });
import { computed } from 'vue'; import { computed } from 'vue';
import { t, currentLocale } from '@/locales'; import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle'): void; (e: 'toggle'): void;

View File

@ -18,7 +18,7 @@
<div v-else class="wf-shell__loading">{{ $t('common.loading') }}</div> <div v-else class="wf-shell__loading">{{ $t('common.loading') }}</div>
<div v-if="errorMessage" class="wf-shell__error" role="alert"> <div v-if="errorMessage" class="wf-shell__error" role="alert">
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
<button type="button" class="wf-shell__error-close" :aria-label="$t('common.close')" @click="errorMessage = ''">×</button> <CloseButton :label="$t('common.close')" @click="errorMessage = ''" />
</div> </div>
</div> </div>
</template> </template>
@ -26,6 +26,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { t } from '@/locales'; import { t } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import WorkflowLibraryView from './WorkflowLibraryView.vue'; import WorkflowLibraryView from './WorkflowLibraryView.vue';
import WorkflowEditorView from './WorkflowEditorView.vue'; import WorkflowEditorView from './WorkflowEditorView.vue';
import { createEmptyWorkflow, type WorkflowDef } from './workflowModel'; import { createEmptyWorkflow, type WorkflowDef } from './workflowModel';

View File

@ -2007,11 +2007,13 @@ html.show-html-fullscreen-open body {
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 与卡片 ⋯ 按钮一致的纯文字风格无边框无背景hover 仅变色 */ /* 全屏预览顶栏按钮与全站统一关闭按钮common/CloseButton.vue boxed 变体同一形态
圆角方透明底hover/active 出现 --hover-bg 底色块动态 DOM 无法复用组件样式在此对齐 */
.show-html-fullscreen__btn { .show-html-fullscreen__btn {
width: 32px; width: 28px;
height: 32px; height: 28px;
border: 0; border: 0;
border-radius: 7px;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
display: inline-flex; display: inline-flex;
@ -2020,10 +2022,18 @@ html.show-html-fullscreen-open body {
font-size: 16px; font-size: 16px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: color 0.18s ease; transition: background 140ms ease, color 140ms ease;
} }
.show-html-fullscreen__btn:hover { @media (hover: hover) {
.show-html-fullscreen__btn:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.show-html-fullscreen__btn:active {
background: var(--hover-bg);
color: var(--text-primary); color: var(--text-primary);
} }
@ -2058,6 +2068,14 @@ html.show-html-fullscreen-open body {
@extend .chat-inline-card__caption; @extend .chat-inline-card__caption;
} }
/* 可点击预览的内容图片统一给放大镜光标提示
show_image 卡片 / Markdown 正文图 / 工具结果图点击由 bootstrap.ts 事件委托承接 */
.chat-inline-image img,
.text-content img,
.tool-result-image img {
cursor: zoom-in;
}
/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */ /* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
show_html:not([data-rendered='1']), show_html:not([data-rendered='1']),
show-html:not([data-rendered='1']) { show-html:not([data-rendered='1']) {

View File

@ -1479,15 +1479,6 @@ body[data-theme='light'] {
padding: 0 16px !important; padding: 0 16px !important;
} }
/* 移动端审批面板关闭按钮 - 移除圆形外框 */
.mobile-panel-sheet--approval .approval-close-btn {
background: transparent !important;
border-radius: 0 !important;
width: auto !important;
height: auto !important;
padding: 4px !important;
font-size: 24px !important;
}
} }
.user-question-mini-dot { .user-question-mini-dot {

View File

@ -1811,20 +1811,7 @@
background: var(--surface-panel); background: var(--surface-panel);
} }
.mobile-overlay-close { /* .mobile-overlay-close 为历史遗留死代码(全站无模板引用),统一关闭按钮改造时删除 */
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 16px;
border: none;
background: var(--border-default);
color: var(--text-primary);
font-size: 18px;
cursor: pointer;
z-index: 5;
}
.mobile-overlay-content { .mobile-overlay-content {
margin-top: 0; margin-top: 0;
@ -2085,20 +2072,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.subagent-activity-close {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
}
.subagent-activity-close:hover {
background: var(--theme-control-bg);
}
.subagent-activity-meta { .subagent-activity-meta {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -172,29 +172,11 @@ body[data-theme='dark'] {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* 按钮本体样式在 common/CloseButton.vueboxed 变体),此处仅保留抽屉左上角定位 */
.token-close-btn { .token-close-btn {
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 12px; left: 12px;
width: 14px;
height: 14px;
border: 1px solid var(--mac-close-border);
border-radius: 50%;
background: var(--mac-close);
display: block;
padding: 0;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
box-shadow: 0 2px 4px var(--shadow-color);
}
.token-close-btn:hover {
background: var(--mac-close-hover);
transform: translateY(-1px);
}
.token-close-btn:active {
transform: translateY(0);
} }
.sr-only { .sr-only {
@ -437,19 +419,7 @@ body[data-theme='dark'] {
line-height: 1.4; line-height: 1.4;
} }
.toast-close { /* .toast-close 已移除Toast 关闭按钮统一改用 common/CloseButton.vueboxed sm */
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
padding: 2px;
}
.toast-close:hover {
color: var(--text-primary);
}
.status-pill--running { .status-pill--running {
background: color-mix(in srgb, var(--state-success) 22%, transparent); background: color-mix(in srgb, var(--state-success) 22%, transparent);

View File

@ -40,28 +40,6 @@
overflow-y: auto; overflow-y: auto;
} }
.focus-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: var(--hover-bg);
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
}
.approval-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: var(--hover-bg);
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
}
.git-changes-panel { .git-changes-panel {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 340px; min-width: 340px;
@ -126,24 +104,6 @@
color: var(--git-diff-del-text); color: var(--git-diff-del-text);
} }
.git-changes-panel__close {
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.git-changes-panel__close:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
box-shadow: 0 1px 2px var(--shadow-color);
}
.git-changes-panel__body { .git-changes-panel__body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@ -843,19 +803,4 @@ body[data-theme='dark'] {
background: var(--surface-base); background: var(--surface-base);
} }
.focus-close-btn {
background: var(--badge-bg);
}
.focus-close-btn:hover {
background: var(--surface-muted);
}
.approval-close-btn {
background: var(--badge-bg);
}
.approval-close-btn:hover {
background: var(--surface-muted);
}
} }