agent-Specialization/static/src/components/overlay/ConversationReviewDialog.vue
JOJO b4030c386e refactor(frontend): 颜色 token 系统两层重构 + 三主题填色 + 去半透明
将前端配色从散落的硬编码/双命名体系(--claude-* / --theme-*)重构为
两层 token 结构(原始层 + 中性语义层),并按"经典抄 Claude、浅色抄
ChatGPT、深色自研"的定位完成三主题填色,同时建立 stylelint 防回退栏杆。
代码大量由 AI 生成的历史遗留配色问题(同语义多值、跨主题塌缩、强调色
撞文字色、fallback 第二真相源等)在此一并收口。

== 一、Token 结构重构(_tokens.scss)==
- 重写为两层:原始层 + 语义层;组件只引用中性语义名
  (--surface-* / --text-* / --border-* / --accent* / --state-* 等)。
- --claude-* / --theme-* 降级为兼容别名(41 个转发),迁移完成后删除。
- 三主题(classic / light / dark)+ 首屏回退(:root:not([data-theme]),
  与经典同值)全部补齐,结构对齐、不再缺项。

== 二、三主题填色 ==
- 经典 = Claude 官网亮色盘:暖奶油表面阶梯
  canvas #faf9f5 < soft #f5f0e8 < card #efe9de < cream-strong #e8e0d2
  < 纯白浮起;文字 #141413;hairline #e6dfd8;暖橙 primary #cc785c。
- 浅色 = ChatGPT 亮色盘:冷白 #ffffff / 侧栏 #f9f9f9 / 嵌套 #f3f3f3,
  中性灰文字,近黑 primary #181818。
- 深色 = 自研中性灰阶,本轮完全不动(原样保留)。
- 修复塌缩 bug:强调色在 light/dark 原被误填成灰(CTA 失去品牌色),
  现统一回品牌 primary;light 表面原全塌成 #ffffff(过于平面),
  现按灰阶拉开层次。
- 强调色克制(CTA-only voltage):primary 仅用于发送按钮/logo 等极少
  数处;hover / 选中 / 运行态一律走中性灰(运行态指示器从 amber 改灰)。
- 对话区背景从纯白改为暖奶油 canvas;个人空间下拉菜单、git 状态条、
  二级菜单等收口到统一语义色。

== 三、全面去半透明 ==
- 所有实体面板/二级菜单背景改为不透明(surface token 由半透明白改实色,
  --mobile-menu 三亮色主题改实色)。
- 移除已不透明面板上失效的 backdrop-filter 磨砂(移动端菜单、右键菜单、
  模型模式下拉等)。
- 遮罩 scrim(--overlay-scrim)与刻意玻璃质感装饰保留半透明。

== 四、块展开底色统一 ==
- 思考/工具块展开区底色统一为对话背景色:.collapsible-block 及其 header
  背景由 --surface-soft 改 transparent,与堆叠块(stacked-block)一致,
  消除"单块奶油底 vs 多块透明"的不一致。

== 五、布局修复 ==
- 工作区隐藏后残留空白:.workspace-panel--collapsed 补 flex-basis:0 +
  width/min-width:0,修正 flex-basis:auto 被子元素内容宽度撑开导致内联
  width:0 失效、collapsed 后仍占 ~300px 的问题。

== 六、防回退栏杆(新增)==
- 新增 .stylelintrc.cjs:三条规则拦截裸 hex / rgb()|hsl() 字面色 /
  var(--x, fallback) 兜底 / prefers-color-scheme;存量未清理文件用
  BASELINE_EXEMPT 临时豁免(现仅剩 5 个 admin 页面,本轮按用户要求不处理),
  清理一个移除一个、不再回退。
- package.json:build 脚本接入 stylelint(tsc → stylelint → vite),
  新增 lint:css 脚本及 stylelint/postcss-scss/postcss-html 依赖。
- 修复 25 处 \!important 笔误(zsh 历史扩展污染导致的无效 CSS)。

== 七、辅助脚本(新增 scripts/)==
- lint_check.py:Node 环境不可用时的 stylelint 替身(复刻三规则 + 括号
  校验,支持多行声明检测)。
- color_map.py:硬编码色 → token 反查,判定可映射/野色。
- strip_fallback.py:安全剥离已定义 token 的冗余 fallback。

== 八、文档 ==
- CLAUDE.md / AGENTS.md 前端设计规范更新:颜色规则扩写为三主题定位 +
  两层 token + 表面层次 + 强调色克制 + color-mix 派生 + stylelint 栏杆;
  新增"实体面板禁止半透明"规则;"禁止边缘光晕"补充"仅在用户明确允许
  或要求时才可使用"。

注:前端构建验证(npm run build / lint:css)由用户本机完成;AI 工具
上下文 Node v24 段错误无法运行 node/npm/vite/stylelint。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:20:58 +08:00

643 lines
15 KiB
Vue

<template>
<div class="review-overlay" @click.self="$emit('close')">
<div class="review-window">
<div class="review-header">
<div class="header-titles">
<div class="title">对话回顾</div>
<div class="subtitle">选择要生成回顾文件的对话</div>
</div>
<div class="header-actions">
<div v-if="generatedPath" class="hint" :title="generatedPath">
已生成 {{ generatedPath }}
</div>
<button
type="button"
class="icon-close-btn"
aria-label="关闭"
title="关闭"
@click="$emit('close')"
:disabled="submitting"
>
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="review-body">
<div class="review-left">
<div class="pane-head">
<span class="pane-head-label">对话列表</span>
<span class="pane-head-meta" v-if="conversations.length">
{{ conversations.length }}
</span>
</div>
<div class="scroll-area conversation-list" :class="{ loading }">
<div v-if="loading" class="empty">正在加载...</div>
<div v-else-if="!conversations.length" class="empty">暂无对话</div>
<template v-else>
<button
v-for="conv in conversations"
:key="conv.id"
type="button"
class="conversation-item"
:class="{
active: conv.id === selectedId,
current: conv.id === currentConversationId
}"
@click="$emit('select', conv.id)"
:disabled="submitting || conv.id === currentConversationId"
>
<div class="row">
<span class="item-title">{{ conv.title || '未命名对话' }}</span>
<span v-if="conv.id === currentConversationId" class="tag current-tag">当前</span>
</div>
<div class="meta">
<span>{{ formatUpdatedAt(conv.updated_at) }}</span>
<span>
{{ conv.total_messages || 0 }}条
<span v-if="(conv.total_tools || 0) > 0"> · {{ conv.total_tools }}工具</span>
</span>
</div>
</button>
</template>
<div class="list-footer" v-if="conversations.length || hasMore">
<button
type="button"
class="load-more-btn"
@click="$emit('load-more')"
:disabled="loadingMore || !hasMore || submitting"
>
{{ loadingMore ? '载入中...' : hasMore ? '加载更多' : '没有更多了' }}
</button>
</div>
</div>
</div>
<div class="review-right">
<div class="pane-head">
<span class="pane-head-label">预览(前 {{ previewLimit }} 条)</span>
<span v-if="preview && preview.length" class="pane-head-meta">
{{ preview.length }} 条
</span>
</div>
<div class="scroll-area preview-box" :class="{ loading: previewLoading }">
<div v-if="previewLoading" class="placeholder">
<span class="icon icon-xl placeholder-icon" :style="iconStyle('clock')" aria-hidden="true"></span>
<div class="text-main">预览生成中...</div>
</div>
<div v-else-if="previewError" class="placeholder error">
<span class="icon icon-xl placeholder-icon" :style="iconStyle('triangleAlert')" aria-hidden="true"></span>
<div class="text-main">{{ previewError }}</div>
</div>
<div v-else-if="!preview || !preview.length" class="placeholder">
<span class="icon icon-xl placeholder-icon" :style="iconStyle('file')" aria-hidden="true"></span>
<div class="text-main">选择左侧对话以查看预览</div>
<div class="text-sub">最多展示前 {{ previewLimit }} 条</div>
</div>
<div v-else class="preview-list">
<div v-for="(line, idx) in preview" :key="idx" class="preview-line">
{{ line }}
</div>
</div>
</div>
</div>
</div>
<div class="review-footer">
<label class="toggle-send">
<input
type="checkbox"
:checked="sendToModel"
@change="$emit('toggle-send', ($event.target as HTMLInputElement).checked)"
/>
<span class="switch"></span>
<span class="label">是否发送给模型</span>
</label>
<button
type="button"
class="primary-btn"
@click="$emit('confirm')"
:disabled="!selectedId || submitting"
>
{{ submitting ? '生成中...' : '确认' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ConversationReviewDialog' });
const props = defineProps<{
open: boolean;
conversations: Array<{
id: string;
title: string;
updated_at: string | number;
total_messages?: number;
total_tools?: number;
}>;
selectedId: string | null;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
submitting: boolean;
currentConversationId: string | null;
preview: string[];
previewLoading: boolean;
previewError?: string | null;
previewLimit?: number;
sendToModel: boolean;
generatedPath?: string | null;
iconStyle?: (key: string) => Record<string, string>;
}>();
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
defineEmits<{
(event: 'close'): void;
(event: 'select', id: string): void;
(event: 'load-more'): void;
(event: 'confirm'): void;
(event: 'toggle-send', value: boolean): void;
}>();
const formatUpdatedAt = (value: string | number) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
</script>
<style scoped>
/* ===== 遮罩层 ===== */
.review-overlay {
position: fixed;
inset: 0;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 420;
padding: 24px;
}
/* ===== 窗口:单层圆角,最大尺寸 + 内部滚动 ===== */
.review-window {
width: min(1040px, 96vw);
max-width: 96vw;
height: min(700px, 88vh);
max-height: 88vh;
background: var(--claude-panel);
border: 1px solid var(--claude-border);
border-radius: 16px;
box-shadow: var(--theme-shadow-strong);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ===== 头/脚:靠分隔线区分,不再套盒子 ===== */
.review-header {
flex: 0 0 auto;
height: 64px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--claude-border);
}
.header-titles {
min-width: 0;
}
.review-header .title {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
color: var(--claude-text);
letter-spacing: -0.01em;
}
.review-header .subtitle {
font-size: 12px;
line-height: 1.2;
color: var(--claude-text-secondary);
margin-top: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.hint {
flex: 0 1 auto;
height: 28px;
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--claude-text-secondary);
padding: 0 10px;
border-radius: 8px;
background: var(--theme-tab-active);
max-width: 280px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
/* 自定义关闭按钮,固定尺寸、视觉居中 */
.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(--claude-text-secondary);
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.icon-close-btn:hover:not(:disabled) {
background: var(--theme-tab-active);
color: var(--claude-text);
}
.icon-close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== 主体:左右两栏,靠竖线分隔 ===== */
.review-body {
flex: 1 1 auto;
display: grid;
grid-template-columns: 360px 1fr;
min-height: 0;
}
.review-left {
border-right: 1px solid var(--claude-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.review-right {
display: flex;
flex-direction: column;
min-height: 0;
}
/* 栏头:固定高度 */
.pane-head {
flex: 0 0 auto;
height: 40px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.pane-head-label {
font-size: 13px;
font-weight: 600;
color: var(--claude-text);
}
.pane-head-meta {
font-size: 12px;
color: var(--claude-text-secondary);
}
/* ===== 通用滚动容器:隐藏滚动条,无边框盒子 ===== */
.scroll-area {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-area::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* ===== 对话列表项:扁平,固定高度,无套娃边框 ===== */
.conversation-list {
padding: 4px 8px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.conversation-item {
width: 100%;
height: 60px;
flex: 0 0 60px;
box-sizing: border-box;
text-align: left;
background: transparent;
border: 0;
border-radius: 10px;
padding: 0 12px;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
transition:
background 140ms ease,
color 140ms ease;
}
.conversation-item:hover:not(:disabled),
.conversation-item.active {
background: var(--theme-tab-active);
}
/* 选中态:左侧强调条提示,不靠彩色光晕 */
.conversation-item.active {
position: relative;
}
.conversation-item.active::before {
content: '';
position: absolute;
left: 4px;
top: 14px;
bottom: 14px;
width: 3px;
border-radius: 999px;
background: var(--claude-text-secondary);
}
.conversation-item .row {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--claude-text);
}
.conversation-item .item-title {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
line-height: 1.25;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item .tag {
flex: 0 0 auto;
height: 20px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
line-height: 1;
background: var(--theme-chip-bg);
color: var(--claude-text-secondary);
}
.conversation-item .meta {
font-size: 12px;
line-height: 1;
color: var(--claude-text-secondary);
display: flex;
gap: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
text-align: center;
color: var(--claude-text-secondary);
font-size: 13px;
padding: 20px 0;
}
.list-footer {
flex: 0 0 auto;
padding: 2px 0;
}
/* 加载更多按钮:对齐侧边栏对话记录的样式(全宽、文字靠左、固定高度) */
.load-more-btn {
width: 100%;
height: 34px;
border: 0;
border-radius: 10px;
padding: 0 12px;
background: transparent;
color: var(--claude-text-secondary);
font-size: 13px;
text-align: left;
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.load-more-btn:hover:not(:disabled) {
background: var(--theme-tab-active);
color: var(--claude-text);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===== 预览区 ===== */
.preview-box {
padding: 4px 12px 12px;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 1px;
font-family: Menlo, Consolas, 'SFMono-Regular', monospace;
font-size: 13px;
color: var(--claude-text);
white-space: pre-wrap;
word-break: break-word;
}
/* 预览行:扁平,靠交替/留白分隔,不套边框圆角盒 */
.preview-line {
padding: 7px 10px;
border-radius: 8px;
line-height: 1.5;
}
.preview-line:nth-child(odd) {
background: var(--theme-tab-active);
}
.placeholder {
height: 100%;
min-height: 220px;
text-align: center;
color: var(--claude-text-secondary);
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
align-items: center;
justify-content: center;
}
.placeholder-icon {
color: var(--claude-text-tertiary);
opacity: 0.9;
}
.text-main {
font-weight: 600;
font-size: 14px;
color: var(--claude-text-secondary);
}
.text-sub {
font-size: 12px;
color: var(--claude-text-tertiary);
}
.placeholder.error .text-main {
color: var(--claude-warning);
}
/* ===== 底部操作栏 ===== */
.review-footer {
flex: 0 0 auto;
height: 64px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
border-top: 1px solid var(--claude-border);
}
/* 自定义开关(非原生 checkbox 外观),固定高度 */
.toggle-send {
display: inline-flex;
align-items: center;
gap: 10px;
height: 38px;
font-size: 14px;
color: var(--claude-text);
cursor: pointer;
user-select: none;
}
.toggle-send input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-send .switch {
flex: 0 0 auto;
width: 38px;
height: 22px;
border-radius: 999px;
background: var(--theme-switch-track);
position: relative;
transition: background 0.2s ease;
}
.toggle-send .switch::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--theme-surface-strong);
top: 2px;
left: 2px;
transition: transform 0.2s ease;
}
.toggle-send input:checked + .switch {
background: var(--claude-accent-strong);
}
.toggle-send input:checked + .switch::after {
transform: translateX(16px);
}
.toggle-send .label {
white-space: nowrap;
}
/* ===== 主操作按钮:固定高度,无彩色光晕 ===== */
.primary-btn {
flex: 0 0 auto;
height: 38px;
border: 0;
border-radius: 10px;
padding: 0 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background: var(--claude-accent);
color: var(--on-accent);
transition:
background 140ms ease,
color 140ms ease;
}
.primary-btn:hover:not(:disabled) {
background: var(--claude-button-hover);
}
.primary-btn:disabled,
.conversation-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.conversation-item.current {
cursor: default;
}
/* ===== 窄屏:上下堆叠 ===== */
@media (max-width: 860px) {
.review-body {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.review-left {
border-right: none;
border-bottom: 1px solid var(--claude-border);
}
}
</style>