feat(web): 统一前端设计风格并重写回顾/版本管理弹窗

- 将 8 条前端设计风格原则写入 CLAUDE.md 与 AGENTS.md
- 重写对话回顾弹窗:扁平化去圆角套娃、移除彩色光晕、固定高度、
  自定义关闭按钮、滚动条隐藏、颜色全走三模式变量
- 回顾弹窗改用独立列表 state,加载更多改为追加不刷新、不复位滚动,
  按钮样式对齐侧边栏(全宽/文字靠左/固定高度)
- 后端 get_conversation_list 新增 non_empty 参数,回顾弹窗仅显示
  有内容的对话且分页数量一致(侧边栏不受影响)
- 重写版本管理弹窗:原生 select/checkbox 换自定义下拉与开关、
  拆圆角套娃、固定高度、颜色走变量(diff 红绿语义色保留)
- 版本管理开关旁文字随状态显示开启/关闭
- icons 注册 refreshCw 键

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-05-31 11:30:08 +08:00
parent 3154cd8bc6
commit fc70179b02
12 changed files with 1057 additions and 313 deletions

View File

@ -96,6 +96,21 @@
- 思考内容当前默认隐藏,只显示“思考中 / 思考完成”标题;相关折叠代码保留,后续可继续修。
- 不要在未获得用户要求的情况下运行交互式 TUI 压测或长时间模拟输入,以免刷屏占满上下文。
## 5.5) 前端设计风格统一规范(强制)
> 适用范围:所有前端 UI`static/src` Web 为主;`cli/src` 在视觉可类比处同样适用;以及未来新增的任何界面)。
> 约束级别:写新 UI 或改动 UI 时**必须遵守**;遇到存量违规应在最小改动允许范围内顺手修正。
> 配色基础设施:三模式 CSS 变量定义在 `static/src/styles/base/_tokens.scss``:root[data-theme='classic'|'light'|'dark']`),切换逻辑见 `static/src/utils/theme.ts`
1. **禁止边缘光晕**:不使用任何 glow / 外发光 / 彩色光晕效果(大范围彩色 `box-shadow` 扩散、`filter: drop-shadow` 光圈、`::before/::after` 模糊光晕等)。已有的要移除。允许的只是中性、克制的投影——沿用 `--claude-shadow` / `--theme-shadow-*` 既有 token不自造发光阴影。
2. **禁止原生浏览器组件**:不直接使用浏览器默认外观的 `<select>`、`<input type=checkbox/radio/range/date/color>`、`alert/confirm/prompt`、原生右键菜单、原生 tooltip 等,一律换成与项目风格统一的自定义组件(下拉 / 开关 / 单选 / 滑块 / 模态 / Toast
3. **禁止圆角套娃**:不允许「圆角矩形套圆角矩形套圆角矩形」的多层嵌套卡片——典型垃圾审美,且极大占据有效显示面积。容器层级要扁平,优先用分隔线 / 留白 / 底色区分,不要多包一层带边框圆角的盒子。
4. **图标外框对齐**:同一组图标按钮的外框(点击热区 / 容器)必须统一对齐,不能一会居中、一会左对齐、一会右对齐;同组固定相同尺寸与对齐方式。
5. **图标视觉对齐而非理论对齐**:按图标视觉重心对齐而非几何边界盒;对重心偏移的图标(三角形 / 播放键 / 放大镜等)做微调,使其「看起来居中」。
6. **颜色遵循三模式切换**:所有颜色走原生三模式(经典 `classic` / 明亮 `light` / 夜间 `dark`),禁止写死颜色字面量,一律用 CSS 变量(`--claude-*` / `--theme-*`)。新增颜色必须在三套主题里都补齐。
7. **带文字容器固定高度**:所有含内部文字的选项 / 按钮 / 标签 / 容器固定高度,禁止因文字多少被撑大撑高;过长文字用省略号或内部滚动处理。
8. **窗口设最大尺寸 + 内部滚动**:所有弹窗 / 面板 / 列表设置 `max-height` / `max-width`,超出由内部容器滚动,不顶大整个窗口;滚动条要么隐藏,要么做样式适配,不暴露原生粗滚动条。
## 6) Git 工作流(开发 + Review
### 6.1 核心原则

View File

@ -237,6 +237,27 @@ terminal.search_conversations(query="bug fix")
terminal.delete_conversation(conversation_id)
```
## 前端设计风格统一规范(强制)
> 适用范围:所有前端 UI`static/src` Web 为主;`cli/src` 在视觉可类比处同样适用;以及未来新增的任何界面)。
> 约束级别:写新 UI 或改动 UI 时**必须遵守**;遇到存量违规应在最小改动允许范围内顺手修正。
1. **禁止边缘光晕**:不使用任何 glow / 外发光 / 彩色光晕效果(大范围彩色 `box-shadow` 扩散、用 `filter: drop-shadow` 做光圈、`::before/::after` 模糊光晕等)。已有的要移除。允许的只是中性、克制的投影——沿用 `--claude-shadow` / `--theme-shadow-*` 这类已有 token不要自造发光阴影。
2. **禁止原生浏览器组件**:不直接使用浏览器默认外观的 `<select>`、`<input type=checkbox/radio/range/date/color>`、`alert/confirm/prompt`、原生右键菜单、原生 tooltip 等。一律替换为与项目风格统一的自定义组件下拉、开关、单选、滑块、模态、Toast 等)。
3. **禁止圆角套娃**:不允许「圆角矩形里再套圆角矩形再套圆角矩形」的多层嵌套卡片——这是典型的垃圾审美,且会极大占据有效显示面积。容器层级要扁平:能用分隔线 / 留白 / 底色区分的,就不要再多包一层带边框圆角的盒子。
4. **图标外框对齐**:同一组图标按钮的外框(点击热区 / 容器)必须对齐统一,不能一个居中、一个左对齐、一个右对齐。同组按钮固定相同尺寸与对齐方式。
5. **图标视觉对齐而非理论对齐**:按图标的视觉重心对齐,而不是几何边界盒。对视觉重心偏移的图标(三角形 / 播放键 / 放大镜等做微调micro-nudge让它「看起来居中」而非「数值上居中」。
6. **颜色遵循三模式切换**:所有颜色必须走原生三模式(经典 `classic` / 明亮 `light` / 夜间 `dark`)。禁止写死颜色字面量,一律使用 CSS 变量(`--claude-*` / `--theme-*`,定义在 `static/src/styles/base/_tokens.scss`,由 `:root[data-theme='classic'|'light'|'dark']` 提供;切换逻辑见 `static/src/utils/theme.ts`)。新增颜色必须在三套主题里都补齐。
7. **带文字容器固定高度**:所有含内部文字的选项 / 按钮 / 标签 / 容器必须固定高度,禁止因文字多少被撑大撑高。文字过长用省略号或内部滚动处理,保持行高与块高稳定。
8. **窗口设最大尺寸 + 内部滚动**:所有弹窗 / 面板 / 列表必须设置 `max-height` / `max-width`,超出时由内部容器滚动,而不是把整个窗口顶大。滚动条要么隐藏,要么做样式适配(贴合整体设计,不暴露原生粗滚动条)。
## Security Considerations
- 所有用户操作在独立容器中执行,与宿主机隔离

View File

@ -273,10 +273,10 @@ class WebTerminal(MainTerminal):
"message": f"加载对话异常: {e}"
}
def get_conversations_list(self, limit: int = 20, offset: int = 0) -> Dict:
def get_conversations_list(self, limit: int = 20, offset: int = 0, non_empty: bool = False) -> Dict:
"""获取对话列表Web版本"""
try:
result = self.context_manager.get_conversation_list(limit=limit, offset=offset)
result = self.context_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty)
return {
"success": True,
"data": result

View File

@ -421,12 +421,13 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username:
# 获取查询参数
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
non_empty = request.args.get('non_empty', '0') in ('1', 'true', 'True')
# 限制参数范围
limit = max(1, min(limit, 10000)) # 限制在1-10000之间
offset = max(0, offset)
result = terminal.get_conversations_list(limit=limit, offset=offset)
result = terminal.get_conversations_list(limit=limit, offset=offset, non_empty=non_empty)
if result["success"]:
return jsonify({

View File

@ -419,12 +419,12 @@
<ConversationReviewDialog
v-if="reviewDialogOpen"
:open="reviewDialogOpen"
:conversations="conversations"
:conversations="reviewConversations"
:current-conversation-id="currentConversationId"
:selected-id="reviewSelectedConversationId"
:loading="conversationsLoading"
:loading-more="loadingMoreConversations"
:has-more="hasMoreConversations"
:loading="reviewListLoading"
:loading-more="reviewListLoadingMore"
:has-more="reviewListHasMore"
:submitting="reviewSubmitting"
:preview="reviewPreviewLines"
:preview-loading="reviewPreviewLoading"
@ -432,9 +432,10 @@
:preview-limit="reviewPreviewLimit"
:send-to-model="reviewSendToModel"
:generated-path="reviewGeneratedPath"
:icon-style="iconStyle"
@close="reviewDialogOpen = false"
@select="handleReviewSelect"
@load-more="loadMoreConversations"
@load-more="loadMoreReviewConversations"
@toggle-send="reviewSendToModel = $event"
@confirm="handleConfirmReview"
/>
@ -469,6 +470,7 @@
:restoring="versioningRestoring"
:restore-mode="versioningRestoreMode"
:workspace-matched="versioningWorkspaceMatched"
:icon-style="iconStyle"
@close="versioningDialogOpen = false"
@refresh="refreshVersioningDialog"
@toggle-enabled="toggleConversationVersioning"

View File

@ -2182,25 +2182,70 @@ export const uiMethods = {
});
return;
}
if (!this.conversations.length && !this.conversationsLoading) {
this.loadConversationsList();
}
const fallback = this.conversations.find((c) => c.id !== this.currentConversationId);
if (!fallback) {
this.uiPushToast({
title: '暂无可用对话',
message: '没有可供回顾的其他对话记录',
type: 'info'
});
return;
}
this.reviewSelectedConversationId = fallback.id;
this.reviewDialogOpen = true;
this.reviewSelectedConversationId = null;
this.reviewPreviewLines = [];
this.reviewPreviewError = null;
this.reviewGeneratedPath = null;
this.loadReviewPreview(fallback.id);
this.closeQuickMenu();
// 弹窗使用独立列表(仅含有内容的对话),加载完成后自动选中首个可用项
this.loadReviewConversations();
},
async loadReviewConversations() {
if (this.reviewListLoading) return;
this.reviewListLoading = true;
this.reviewListOffset = 0;
try {
const limit = this.reviewListLimit;
const resp = await fetch(`/api/conversations?limit=${limit}&offset=0&non_empty=1`);
const data = await resp.json().catch(() => ({}));
if (data?.success) {
this.reviewConversations = data.data.conversations || [];
this.reviewListHasMore = Boolean(data.data.has_more);
this.autoSelectReviewConversation();
} else {
this.reviewConversations = [];
this.reviewListHasMore = false;
}
} catch (error) {
console.error('加载回顾对话列表异常:', error);
this.reviewConversations = [];
this.reviewListHasMore = false;
} finally {
this.reviewListLoading = false;
}
},
async loadMoreReviewConversations() {
if (this.reviewListLoadingMore || !this.reviewListHasMore) return;
this.reviewListLoadingMore = true;
try {
const limit = this.reviewListLimit;
const offset = this.reviewListOffset + limit;
const resp = await fetch(`/api/conversations?limit=${limit}&offset=${offset}&non_empty=1`);
const data = await resp.json().catch(() => ({}));
if (data?.success) {
// 追加到现有列表,不整体替换,避免滚动位置复位
this.reviewConversations.push(...(data.data.conversations || []));
this.reviewListHasMore = Boolean(data.data.has_more);
this.reviewListOffset = offset;
this.autoSelectReviewConversation();
}
} catch (error) {
console.error('加载更多回顾对话异常:', error);
} finally {
this.reviewListLoadingMore = false;
}
},
autoSelectReviewConversation() {
if (this.reviewSelectedConversationId) return;
const fallback = this.reviewConversations.find((c) => c.id !== this.currentConversationId);
if (fallback && fallback.id) {
this.reviewSelectedConversationId = fallback.id;
this.loadReviewPreview(fallback.id);
}
},
closeHeaderMenu() {

View File

@ -229,6 +229,13 @@ export function dataState() {
reviewPreviewLimit: 20,
reviewSendToModel: true,
reviewGeneratedPath: null,
// 回顾弹窗独立的对话列表(只含有内容的对话,不污染侧边栏)
reviewConversations: [],
reviewListLoading: false,
reviewListLoadingMore: false,
reviewListHasMore: false,
reviewListOffset: 0,
reviewListLimit: 20,
// 新手教程首次引导弹窗
tutorialPromptVisible: false,

View File

@ -2,25 +2,36 @@
<div class="review-overlay" @click.self="$emit('close')">
<div class="review-window">
<div class="review-header">
<div>
<div class="header-titles">
<div class="title">对话回顾</div>
<div class="subtitle">选择要生成回顾文件的对话</div>
</div>
<div class="header-actions">
<div v-if="generatedPath" class="hint">已生成 {{ generatedPath }}</div>
<button type="button" class="ghost-btn" @click="$emit('close')" :disabled="submitting">
关闭
<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="list-head">
<span>对话列表</span>
<span class="counts" v-if="conversations.length"> {{ conversations.length }} </span>
<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="conversation-list" :class="{ loading }">
<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>
@ -37,7 +48,7 @@
:disabled="submitting || conv.id === currentConversationId"
>
<div class="row">
<span class="title">{{ conv.title || '未命名对话' }}</span>
<span class="item-title">{{ conv.title || '未命名对话' }}</span>
<span v-if="conv.id === currentConversationId" class="tag current-tag">当前</span>
</div>
<div class="meta">
@ -49,10 +60,10 @@
</div>
</button>
</template>
<div class="list-footer" v-if="hasMore || conversations.length">
<div class="list-footer" v-if="conversations.length || hasMore">
<button
type="button"
class="ghost-btn"
class="load-more-btn"
@click="$emit('load-more')"
:disabled="loadingMore || !hasMore || submitting"
>
@ -63,23 +74,23 @@
</div>
<div class="review-right">
<div class="preview-header">
<span>预览 {{ previewLimit }} </span>
<span v-if="preview && preview.length" class="preview-count"
>{{ preview.length }} </span
>
<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="preview-box" :class="{ loading: previewLoading }">
<div class="scroll-area preview-box" :class="{ loading: previewLoading }">
<div v-if="previewLoading" class="placeholder">
<img :src="icons.loading" alt="" class="big-icon" />
<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">
<img :src="icons.error" alt="" class="big-icon" />
<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">
<img :src="icons.empty" alt="" class="big-icon" />
<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>
@ -118,7 +129,7 @@
<script setup lang="ts">
defineOptions({ name: 'ConversationReviewDialog' });
defineProps<{
const props = defineProps<{
open: boolean;
conversations: Array<{
id: string;
@ -139,19 +150,17 @@ defineProps<{
previewLimit?: number;
sendToModel: boolean;
generatedPath?: string | null;
iconStyle?: (key: string) => Record<string, string>;
}>();
const icons = {
loading: new URL('../../../icons/clock.svg', import.meta.url).href,
error: new URL('../../../icons/triangle-alert.svg', import.meta.url).href,
empty: new URL('../../../icons/file.svg', import.meta.url).href
};
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) => {
@ -164,6 +173,7 @@ const formatUpdatedAt = (value: string | number) => {
</script>
<style scoped>
/* ===== 遮罩层 ===== */
.review-overlay {
position: fixed;
inset: 0;
@ -176,35 +186,48 @@ const formatUpdatedAt = (value: string | number) => {
padding: 24px;
}
/* ===== 窗口:单层圆角,最大尺寸 + 内部滚动 ===== */
.review-window {
width: min(1100px, 96vw);
height: min(720px, 88vh);
background: var(--theme-surface-card, var(--claude-panel));
border-radius: 20px;
box-shadow: var(--theme-shadow-strong, 0 28px 80px rgba(15, 23, 42, 0.25));
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;
border: 1px solid var(--theme-control-border, var(--claude-border));
}
.review-header,
.review-footer {
padding: 16px 20px;
/* ===== 头/脚:靠分隔线区分,不再套盒子 ===== */
.review-header {
flex: 0 0 auto;
height: 64px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--theme-surface-muted, var(--claude-left-rail));
gap: 12px;
border-bottom: 1px solid var(--claude-border);
}
.header-titles {
min-width: 0;
}
.review-header .title {
font-size: 18px;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
color: var(--claude-text);
letter-spacing: -0.01em;
}
.review-header .subtitle {
font-size: 13px;
font-size: 12px;
line-height: 1.2;
color: var(--claude-text-secondary);
margin-top: 4px;
}
@ -213,100 +236,158 @@ const formatUpdatedAt = (value: string | number) => {
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: 6px 10px;
border-radius: 10px;
background: var(--theme-surface-soft, var(--claude-panel));
border: 1px solid var(--theme-control-border, var(--claude-border));
padding: 0 10px;
border-radius: 8px;
background: var(--theme-tab-active, rgba(0, 0, 0, 0.05));
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, rgba(0, 0, 0, 0.05));
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: 420px 1fr;
grid-template-columns: 360px 1fr;
min-height: 0;
}
.review-left {
border-right: 1px solid var(--theme-control-border, var(--claude-border));
padding: 12px 14px 14px;
border-right: 1px solid var(--claude-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.list-head {
.review-right {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--claude-text);
margin-bottom: 8px;
flex-direction: column;
min-height: 0;
}
.counts {
/* 栏头:固定高度 */
.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);
}
.conversation-list {
/* ===== 通用滚动容器:隐藏滚动条,无边框盒子 ===== */
.scroll-area {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--theme-control-border, var(--claude-border));
border-radius: 12px;
background: var(--theme-surface-card, var(--claude-panel));
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.conversation-list::-webkit-scrollbar {
.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%;
max-width: 360px;
align-self: center;
height: 60px;
flex: 0 0 60px;
box-sizing: border-box;
text-align: left;
background: var(--theme-surface-card, var(--claude-panel));
border: 1px solid var(--theme-control-border, var(--claude-border));
background: transparent;
border: 0;
border-radius: 10px;
padding: 14px 14px;
padding: 0 12px;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
overflow: hidden;
min-height: 64px;
line-height: 1.4;
}
.conversation-item:hover {
border-color: var(--claude-accent);
box-shadow: var(--theme-shadow-soft, 0 6px 18px rgba(15, 23, 42, 0.12));
transform: translateY(-1px);
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 {
border-color: var(--claude-accent-strong);
box-shadow: 0 8px 22px rgba(218, 119, 86, 0.22);
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
}
.conversation-item.current {
border-color: var(--claude-accent, #d14b31);
/* 选中态:左侧强调条提示,不靠彩色光晕 */
.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 {
@ -317,166 +398,177 @@ const formatUpdatedAt = (value: string | number) => {
color: var(--claude-text);
}
.conversation-item .title {
.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 {
padding: 2px 8px;
flex: 0 0 auto;
height: 20px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: var(--theme-surface-soft, rgba(79, 70, 229, 0.12));
color: var(--claude-text-secondary);
font-size: 12px;
}
.current-tag {
background: rgba(218, 119, 86, 0.18);
color: var(--claude-accent, #d14b31);
line-height: 1;
background: var(--theme-chip-bg, rgba(118, 103, 84, 0.12));
color: var(--claude-text-secondary);
}
.conversation-item .meta {
margin-top: 6px;
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 {
margin-top: 10px;
display: flex;
justify-content: center;
flex: 0 0 auto;
padding: 2px 0;
}
.review-right {
display: flex;
flex-direction: column;
padding: 12px;
min-height: 0;
background: var(--theme-surface-card, var(--claude-panel));
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--claude-text);
padding: 8px 0;
}
.preview-count {
font-size: 12px;
color: var(--claude-text-secondary);
}
.preview-box {
flex: 1 1 auto;
border: 1px solid var(--theme-control-border, var(--claude-border));
border-radius: 12px;
background: var(--theme-surface-card, var(--claude-panel));
padding: 10px;
overflow: auto;
/* 加载更多按钮:对齐侧边栏对话记录的样式(全宽、文字靠左、固定高度) */
.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, rgba(0, 0, 0, 0.055));
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: 6px;
gap: 1px;
font-family: Menlo, Consolas, 'SFMono-Regular', monospace;
font-size: 13px;
color: var(--claude-text);
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
}
/* 预览行:扁平,靠交替/留白分隔,不套边框圆角盒 */
.preview-line {
padding: 6px 8px;
padding: 7px 10px;
border-radius: 8px;
background: var(--theme-surface-soft, var(--claude-panel));
border: 1px solid var(--theme-control-border, var(--claude-border));
line-height: 1.5;
}
.preview-line:nth-child(odd) {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.04));
}
.placeholder {
height: 100%;
min-height: 220px;
text-align: center;
color: var(--claude-text-secondary);
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
padding: 20px;
align-items: center;
justify-content: center;
}
.big-icon {
width: 42px;
height: 42px;
object-fit: contain;
opacity: 0.85;
.placeholder-icon {
color: var(--claude-text-tertiary);
opacity: 0.9;
}
.text-main {
font-weight: 600;
font-size: 15px;
}
.text-sub {
font-size: 13px;
font-size: 14px;
color: var(--claude-text-secondary);
}
.text-sub {
font-size: 12px;
color: var(--claude-text-tertiary);
}
.placeholder.error .text-main {
color: #dc2626;
color: var(--claude-warning, #d99845);
}
/* ===== 底部操作栏 ===== */
.review-footer {
gap: 14px;
justify-content: space-between;
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);
}
.ghost-btn,
.primary-btn {
border: none;
outline: none;
cursor: pointer;
border-radius: 10px;
padding: 10px 18px;
font-weight: 600;
font-size: 14px;
transition:
transform 0.12s ease,
box-shadow 0.12s ease,
background 0.12s ease;
}
/* 自定义开关(非原生 checkbox 外观),固定高度 */
.toggle-send {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 10px;
height: 38px;
font-size: 14px;
color: var(--claude-text);
cursor: pointer;
user-select: none;
}
.toggle-send input {
display: none;
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-control-border, var(--claude-border));
background: var(--theme-switch-track, #d7d1c5);
position: relative;
transition: background 0.2s ease;
}
@ -487,8 +579,7 @@ const formatUpdatedAt = (value: string | number) => {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--theme-surface-card, #ffffff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
background: var(--theme-surface-strong, #ffffff);
top: 2px;
left: 2px;
transition: transform 0.2s ease;
@ -503,44 +594,55 @@ const formatUpdatedAt = (value: string | number) => {
}
.toggle-send .label {
user-select: none;
}
.ghost-btn {
background: var(--theme-surface-soft, var(--claude-panel));
color: var(--claude-text);
border: 1px solid var(--theme-control-border, var(--claude-border));
}
.ghost-btn:hover:not(:disabled) {
background: var(--theme-surface-muted, var(--claude-left-rail));
white-space: nowrap;
}
/* ===== 主操作按钮:固定高度,无彩色光晕 ===== */
.primary-btn {
background: linear-gradient(135deg, var(--claude-accent), var(--claude-accent-strong));
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: #fffaf0;
box-shadow: 0 10px 24px rgba(212, 75, 49, 0.28);
transition:
background 140ms ease,
color 140ms ease;
}
.primary-btn:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--claude-button-hover);
}
.ghost-btn:disabled,
.primary-btn:disabled,
.conversation-item:disabled {
opacity: 0.55;
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
@media (max-width: 900px) {
.conversation-item.current {
cursor: default;
}
/* 暗色模式下主按钮文字保持白色accent 在暗色为灰,文字仍需高对比) */
:global(:root[data-theme='dark']) .primary-btn,
:global(body[data-theme='dark']) .primary-btn {
color: #ffffff;
}
/* ===== 窄屏:上下堆叠 ===== */
@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(--theme-control-border, var(--claude-border));
border-bottom: 1px solid var(--claude-border);
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div class="versioning-dialog" @click.self="$emit('close')">
<div class="versioning-card">
<div class="versioning-card" @click="closeScopeMenu">
<header class="versioning-header">
<div class="versioning-header-left">
<h3>版本管理</h3>
@ -11,35 +11,64 @@
:disabled="loading"
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
/>
<span>开启</span>
<span class="switch"></span>
<span class="switch-text">{{ enabled ? '开启' : '关闭' }}</span>
</label>
<label class="scope-line">
<span>管理范围</span>
<select
:value="trackingMode"
:disabled="!hostMode || loading || restoring"
@change="$emit('update:tracking-mode', ($event.target as HTMLSelectElement).value)"
<div class="scope-line">
<span class="scope-label">管理范围</span>
<div
class="scope-select"
:class="{ open: scopeMenuOpen, disabled: !hostMode || loading || restoring }"
@click.stop
>
<option value="workspace_and_conversation" :disabled="!hostMode">
管理工作区和对话
</option>
<option value="conversation_only">仅管理对话</option>
</select>
</label>
<button
type="button"
class="scope-select-button"
:disabled="!hostMode || loading || restoring"
@click="toggleScopeMenu"
>
<span class="scope-select-value">{{ trackingModeLabel }}</span>
<span class="scope-chevron" aria-hidden="true"></span>
</button>
<div v-if="scopeMenuOpen" class="scope-menu">
<button
v-for="opt in scopeOptions"
:key="opt.value"
type="button"
class="scope-menu-option"
:class="{ selected: trackingMode === opt.value, disabled: opt.disabled }"
:disabled="opt.disabled"
@click="selectScope(opt.value)"
>
<span class="scope-option-label">{{ opt.label }}</span>
<span
v-if="trackingMode === opt.value"
class="icon icon-sm scope-option-check"
:style="iconStyle('check')"
aria-hidden="true"
></span>
</button>
</div>
</div>
</div>
</div>
<div class="versioning-header-actions">
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
刷新
<span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span>
<span>刷新</span>
</button>
<button type="button" class="icon-close-btn" aria-label="关闭" @click="$emit('close')">
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
</button>
<button type="button" class="close-btn" @click="$emit('close')">×</button>
</div>
</header>
<div class="versioning-body">
<aside class="checkpoint-list">
<div
<aside class="checkpoint-list scroll-area">
<button
v-for="item in orderedItems"
:key="item.seq"
type="button"
class="checkpoint-item"
:class="{ active: Number(selectedSeq) === Number(item.seq) }"
@click="$emit('select', item.seq)"
@ -54,20 +83,20 @@
<span class="minus">-{{ item.deletions || 0 }}</span>
<span class="files">{{ item.files_changed || 0 }} 文件</span>
</div>
</div>
</button>
<div v-if="!items.length" class="empty">暂无版本点</div>
</aside>
<section class="checkpoint-detail">
<section class="checkpoint-detail scroll-area">
<div v-if="detailLoading" class="loading">加载详情中...</div>
<template v-else-if="detail">
<h4>提交详情 #{{ detail.seq }}</h4>
<div class="summary">
<span class="plus">+{{ detail.insertions || 0 }}</span>
<span class="minus">-{{ detail.deletions || 0 }}</span>
<span>{{ detail.files_changed || 0 }} 文件</span>
<span class="files">{{ detail.files_changed || 0 }} 文件</span>
</div>
<div class="files">
<div class="file-list">
<template v-for="(file, idx) in detail.files || []" :key="`${file.status}:${file.path}:${idx}`">
<div class="file-row" :class="{ expanded: isFileExpanded(file, idx) }" @click="toggleFileExpand(file, idx)">
<span class="status">{{ file.status }}</span>
@ -77,7 +106,7 @@
<span class="minus">-{{ file.deletions || 0 }}</span>
</span>
</div>
<div v-if="isFileExpanded(file, idx)" class="tool-result-diff scrollable versioning-diff">
<div v-if="isFileExpanded(file, idx)" class="tool-result-diff scroll-area versioning-diff">
<div v-if="!(file.patch_lines || []).length" class="empty">暂无可展示的文本变更行</div>
<div
v-for="(line, lineIdx) in file.patch_lines || []"
@ -100,7 +129,7 @@
<div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致无法执行回溯
</div>
<label>回溯模式覆盖当前对话</label>
<span class="restore-mode-label">回溯模式覆盖当前对话</span>
<button
type="button"
class="confirm-btn"
@ -129,9 +158,10 @@ const props = defineProps<{
restoring: boolean;
restoreMode: string;
workspaceMatched?: boolean;
iconStyle?: (key: string) => Record<string, string>;
}>();
defineEmits([
const emit = defineEmits([
'close',
'refresh',
'toggle-enabled',
@ -141,8 +171,34 @@ defineEmits([
'update:restore-mode'
]);
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
const scopeOptions = computed(() => [
{ value: 'workspace_and_conversation', label: '管理工作区和对话', disabled: !props.hostMode },
{ value: 'conversation_only', label: '仅管理对话', disabled: false }
]);
const trackingModeLabel = computed(() => {
const found = scopeOptions.value.find((o) => o.value === props.trackingMode);
return found ? found.label : '仅管理对话';
});
const scopeMenuOpen = ref(false);
const toggleScopeMenu = () => {
if (!props.hostMode || props.loading || props.restoring) return;
scopeMenuOpen.value = !scopeMenuOpen.value;
};
const closeScopeMenu = () => {
scopeMenuOpen.value = false;
};
const selectScope = (value: string) => {
scopeMenuOpen.value = false;
if (value === props.trackingMode) return;
emit('update:tracking-mode', value);
};
const orderedItems = computed(() => {
const src = Array.isArray(props.items) ? props.items : [];
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
@ -179,57 +235,537 @@ const formatTime = (value: string) => {
</script>
<style scoped>
.versioning-dialog { position: fixed; inset: 0; z-index: 1200; background: var(--theme-overlay-scrim); display: flex; align-items: center; justify-content: center; padding: 20px; backdrop-filter: blur(8px); }
.versioning-card { width: min(1120px, 96vw); height: 600px; overflow: hidden; border-radius: 14px; background: var(--theme-surface-card); border: 1px solid var(--theme-control-border); box-shadow: var(--theme-shadow-strong); display: flex; flex-direction: column; color: var(--claude-text); }
.versioning-header { padding: 14px 18px; border-bottom: 1px solid var(--theme-control-border); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.versioning-header-left { display: inline-flex; align-items: center; gap: 14px; min-width: 0; }
.versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.scope-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--claude-text-secondary); }
.scope-line select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 5px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
.refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; }
.versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); }
.checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }
.checkpoint-item { border: 1px solid var(--theme-control-border); border-radius: 10px; padding: 10px; cursor: pointer; margin-bottom: 8px; background: var(--theme-surface-soft); }
.checkpoint-item.active { border-color: var(--claude-accent); background: var(--theme-tab-active); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.row.top { font-size: 12px; color: var(--claude-text-secondary); }
.seq { font-weight: 700; color: var(--claude-text); }
.preview { margin-top: 6px; font-size: 13px; color: var(--claude-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.stats { margin-top: 6px; font-size: 12px; }
.plus { color: #16a34a; }
.minus { color: #dc2626; }
.files { color: var(--claude-text-secondary); }
.checkpoint-detail { overflow: auto; padding: 12px 14px; }
.summary { display: flex; gap: 12px; font-size: 13px; margin-bottom: 10px; }
.file-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 10px; align-items: center; border-bottom: 1px dashed var(--theme-control-border); padding: 8px 0; font-size: 13px; cursor: pointer; }
.file-row.expanded { background: color-mix(in srgb, var(--theme-tab-active) 45%, transparent); }
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--claude-text-secondary); }
.path { word-break: break-all; color: var(--claude-text); }
.delta { display: inline-flex; gap: 8px; }
.loading, .empty { color: var(--claude-text-secondary); font-size: 13px; padding: 12px 4px; }
.versioning-footer { padding: 12px 18px; display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
.workspace-mismatch-tip { margin-right: auto; color: #dc2626; font-size: 13px; font-weight: 600; }
.versioning-footer select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
.confirm-btn { border: 0; background: var(--claude-accent); color: #fff; border-radius: 8px; padding: 8px 14px; cursor: pointer; }
.confirm-btn:disabled { opacity: .5; cursor: not-allowed; }
/* ===== 遮罩 + 窗口(单层圆角,最大尺寸) ===== */
.versioning-dialog {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--theme-overlay-scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(8px);
}
/* 与文件编辑差异展示保持一致 */
.versioning-card {
width: min(1100px, 96vw);
max-width: 96vw;
height: min(620px, 88vh);
max-height: 88vh;
overflow: hidden;
border-radius: 16px;
background: var(--claude-panel);
border: 1px solid var(--claude-border);
box-shadow: var(--theme-shadow-strong);
display: flex;
flex-direction: column;
color: var(--claude-text);
}
/* ===== 头部:固定高度,靠分隔线 ===== */
.versioning-header {
flex: 0 0 auto;
height: 60px;
padding: 0 18px;
border-bottom: 1px solid var(--claude-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.versioning-header-left {
display: inline-flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.versioning-header h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}
.versioning-header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 自定义开关 */
.switch-line {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.switch-line input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch-line .switch {
flex: 0 0 auto;
width: 38px;
height: 22px;
border-radius: 999px;
background: var(--theme-switch-track, #d7d1c5);
position: relative;
transition: background 0.2s ease;
}
.switch-line .switch::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--theme-surface-strong, #ffffff);
top: 2px;
left: 2px;
transition: transform 0.2s ease;
}
.switch-line input:checked + .switch {
background: var(--claude-accent-strong);
}
.switch-line input:checked + .switch::after {
transform: translateX(16px);
}
.switch-line input:disabled + .switch {
opacity: 0.5;
}
.switch-text {
white-space: nowrap;
}
/* ===== 自定义下拉(管理范围) ===== */
.scope-line {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--claude-text-secondary);
}
.scope-label {
white-space: nowrap;
}
.scope-select {
position: relative;
}
.scope-select-button {
height: 32px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--claude-border);
border-radius: 9px;
background: transparent;
color: var(--claude-text);
font-size: 13px;
cursor: pointer;
transition:
background 140ms ease,
border-color 140ms ease;
}
.scope-select-button:hover:not(:disabled) {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.05));
}
.scope-select.disabled .scope-select-button,
.scope-select-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.scope-select-value {
white-space: nowrap;
}
/* chevron纯 CSS 箭头,视觉居中 */
.scope-chevron {
width: 10px;
height: 10px;
flex: 0 0 auto;
position: relative;
}
.scope-chevron::before {
content: '';
position: absolute;
top: 1px;
left: 1px;
width: 6px;
height: 6px;
border-right: 1.5px solid var(--claude-text-secondary);
border-bottom: 1.5px solid var(--claude-text-secondary);
transform: rotate(45deg);
transition: transform 160ms ease;
}
.scope-select.open .scope-chevron::before {
transform: rotate(-135deg);
top: 4px;
}
.scope-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 100%;
width: max-content;
z-index: 20;
padding: 6px;
border: 1px solid var(--claude-border);
border-radius: 12px;
background: var(--theme-surface-strong, #fff);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
gap: 2px;
}
.scope-menu-option {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 10px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--claude-text);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 140ms ease;
}
.scope-menu-option:hover:not(:disabled) {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.05));
}
.scope-menu-option.disabled,
.scope-menu-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.scope-option-check {
flex: 0 0 auto;
color: var(--claude-text-secondary);
}
/* ===== 头部按钮 ===== */
.refresh-btn {
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid var(--claude-border);
border-radius: 9px;
background: transparent;
color: var(--claude-text);
font-size: 13px;
cursor: pointer;
transition: background 140ms ease;
}
.refresh-btn:hover:not(:disabled) {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.05));
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-close-btn {
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 {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.05));
color: var(--claude-text);
}
/* ===== 主体:左右分栏靠竖线 ===== */
.versioning-body {
display: grid;
grid-template-columns: 360px 1fr;
min-height: 0;
flex: 1 1 auto;
}
/* 通用滚动容器:隐藏滚动条 */
.scroll-area {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-area::-webkit-scrollbar {
width: 0;
height: 0;
}
.checkpoint-list {
border-right: 1px solid var(--claude-border);
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* 版本点:扁平无边框,固定高度,靠 hover/active 底色区分 */
.checkpoint-item {
width: 100%;
height: 78px;
flex: 0 0 78px;
box-sizing: border-box;
text-align: left;
border: 0;
border-radius: 10px;
padding: 0 12px;
background: transparent;
color: var(--claude-text);
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
transition: background 140ms ease;
}
.checkpoint-item:hover,
.checkpoint-item.active {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
}
.checkpoint-item.active {
position: relative;
}
.checkpoint-item.active::before {
content: '';
position: absolute;
left: 4px;
top: 16px;
bottom: 16px;
width: 3px;
border-radius: 999px;
background: var(--claude-text-secondary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.row.top {
font-size: 12px;
color: var(--claude-text-secondary);
}
.seq {
font-weight: 700;
color: var(--claude-text);
}
.preview {
font-size: 13px;
line-height: 1.3;
color: var(--claude-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.stats {
font-size: 12px;
}
/* diff 红绿增删色:通用语义色,保留固定值,三模式下含义一致 */
.plus {
color: #16a34a;
}
.minus {
color: #dc2626;
}
.files {
color: var(--claude-text-secondary);
}
.checkpoint-detail {
padding: 14px 16px;
}
.checkpoint-detail h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
}
.summary {
display: flex;
gap: 12px;
font-size: 13px;
margin-bottom: 12px;
}
.file-list {
display: flex;
flex-direction: column;
}
/* 文件行:固定高度,靠虚线分隔 */
.file-row {
display: grid;
grid-template-columns: 52px 1fr auto;
gap: 10px;
align-items: center;
min-height: 40px;
border-bottom: 1px solid var(--claude-border);
padding: 0 4px;
font-size: 13px;
cursor: pointer;
border-radius: 8px;
transition: background 140ms ease;
}
.file-row:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.04));
}
.file-row.expanded {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
}
.status {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--claude-text-secondary);
}
.path {
word-break: break-all;
color: var(--claude-text);
}
.delta {
display: inline-flex;
gap: 8px;
}
.loading,
.empty {
color: var(--claude-text-secondary);
font-size: 13px;
padding: 12px 4px;
}
/* ===== 底部:固定高度,靠分隔线 ===== */
.versioning-footer {
flex: 0 0 auto;
height: 60px;
padding: 0 18px;
border-top: 1px solid var(--claude-border);
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
}
.restore-mode-label {
font-size: 13px;
color: var(--claude-text-secondary);
}
.workspace-mismatch-tip {
margin-right: auto;
color: var(--claude-warning, #d99845);
font-size: 13px;
font-weight: 600;
}
.confirm-btn {
height: 38px;
border: 0;
border-radius: 10px;
padding: 0 18px;
font-size: 14px;
font-weight: 600;
background: var(--claude-accent);
color: #fffaf0;
cursor: pointer;
transition: background 140ms ease;
}
.confirm-btn:hover:not(:disabled) {
background: var(--claude-button-hover);
}
.confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 暗色下主按钮文字保持高对比 */
:global(:root[data-theme='dark']) .confirm-btn,
:global(body[data-theme='dark']) .confirm-btn {
color: #ffffff;
}
/* ===== diff 展示:与文件编辑差异保持一致 ===== */
.tool-result-diff {
margin-top: 12px;
margin-top: 10px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
}
.tool-result-diff.scrollable {
.tool-result-diff.scroll-area {
max-height: calc(8 * 1.5em + 16px);
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
border: 1px solid var(--claude-border);
border-radius: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.01);
background: var(--theme-surface-soft, transparent);
}
.diff-line {
@ -239,18 +775,19 @@ const formatTime = (value: string) => {
word-wrap: break-word;
}
/* diff 增删色:通用语义色,保留固定值 */
.diff-remove {
background: rgba(255, 0, 0, 0.1);
color: #c00;
background: rgba(220, 38, 38, 0.12);
color: #dc2626;
}
.diff-add {
background: rgba(0, 255, 0, 0.1);
color: #0a0;
background: rgba(22, 163, 74, 0.12);
color: #16a34a;
}
.diff-context {
background: color-mix(in srgb, var(--theme-surface-soft) 70%, transparent);
background: transparent;
color: var(--claude-text-secondary);
}
@ -260,18 +797,14 @@ const formatTime = (value: string) => {
color: var(--claude-text-secondary);
}
/* 隐藏弹窗内部所有竖向滚动条(保留滚动能力) */
.checkpoint-list,
.checkpoint-detail,
.tool-result-diff.scrollable {
scrollbar-width: none;
-ms-overflow-style: none;
}
.checkpoint-list::-webkit-scrollbar,
.checkpoint-detail::-webkit-scrollbar,
.tool-result-diff.scrollable::-webkit-scrollbar {
width: 0;
height: 0;
@media (max-width: 860px) {
.versioning-body {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.checkpoint-list {
border-right: none;
border-bottom: 1px solid var(--claude-border);
}
}
</style>

View File

@ -32,6 +32,7 @@ export const ICONS = Object.freeze({
pencil: '/static/icons/pencil.svg',
python: '/static/icons/python.svg',
recycle: '/static/icons/recycle.svg',
refreshCw: '/static/icons/refresh-cw.svg',
save: '/static/icons/save.svg',
search: '/static/icons/search.svg',
settings: '/static/icons/settings.svg',

View File

@ -1117,9 +1117,9 @@ class ContextManager:
except Exception as e:
print(f"⌘ 自动保存异常: {e}")
def get_conversation_list(self, limit: int = 50, offset: int = 0) -> Dict:
def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False) -> Dict:
"""获取对话列表"""
return self.conversation_manager.get_conversation_list(limit=limit, offset=offset)
return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty)
def delete_conversation_by_id(self, conversation_id: str) -> bool:
"""删除指定对话"""

View File

@ -986,34 +986,51 @@ class ConversationManager:
return 0
return stats.get("current_context_tokens", 0)
def get_conversation_list(self, limit: int = 50, offset: int = 0) -> Dict:
def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False) -> Dict:
"""
获取对话列表
Args:
limit: 限制数量
offset: 偏移量
non_empty: 仅返回有内容total_messages > 0的对话分页基于过滤后的结果
Returns:
Dict: 包含对话列表和统计信息
"""
try:
# 总对话数按文件数统计,防止初始索引截断导致“没有更多”按钮消失
total_files = len(self._iter_conversation_files(sort_by_mtime=False))
if non_empty:
# 过滤模式:全量加载后剔除空对话,再在过滤结果上分页,
# 保证 total / has_more 与"有内容对话"的真实数量一致。
index = self._ensure_index_covering(limit=10000, offset=0)
sorted_conversations = sorted(
index.items(),
key=lambda x: x[1].get("updated_at") or "",
reverse=True
)
sorted_conversations = [
item for item in sorted_conversations
if (item[1].get("total_messages", 0) or 0) > 0
]
total = len(sorted_conversations)
conversations = sorted_conversations[offset:offset + limit]
else:
# 总对话数按文件数统计,防止初始索引截断导致"没有更多"按钮消失
total_files = len(self._iter_conversation_files(sort_by_mtime=False))
index = self._ensure_index_covering(limit=limit, offset=offset)
# 按更新时间倒序排列(处理 None 值)
sorted_conversations = sorted(
index.items(),
key=lambda x: x[1].get("updated_at") or "",
reverse=True
)
# 分页
total = max(len(sorted_conversations), total_files)
conversations = sorted_conversations[offset:offset+limit]
index = self._ensure_index_covering(limit=limit, offset=offset)
# 按更新时间倒序排列(处理 None 值)
sorted_conversations = sorted(
index.items(),
key=lambda x: x[1].get("updated_at") or "",
reverse=True
)
# 分页
total = max(len(sorted_conversations), total_files)
conversations = sorted_conversations[offset:offset+limit]
# 格式化结果
result = []
for conv_id, metadata in conversations:
@ -1029,7 +1046,7 @@ class ConversationManager:
"total_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active")
})
return {
"conversations": result,
"total": total,