agent-Specialization/static/src/components/overlay/VersioningDialog.vue

805 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="versioning-dialog" @click.self="$emit('close')">
<div class="versioning-card" @click="closeScopeMenu">
<header class="versioning-header">
<div class="versioning-header-left">
<h3>版本管理</h3>
<label class="switch-line">
<input
type="checkbox"
:checked="enabled"
:disabled="loading"
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
/>
<span class="switch"></span>
<span class="switch-text">{{ enabled ? '开启' : '关闭' }}</span>
</label>
<div class="scope-line">
<span class="scope-label">管理范围</span>
<div
class="scope-select"
:class="{ open: scopeMenuOpen, disabled: !hostMode || loading || restoring }"
@click.stop
>
<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>
</div>
</header>
<div class="versioning-body">
<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)"
>
<div class="row top">
<span class="seq">#{{ item.seq }}</span>
<span class="time">{{ formatTime(item.timestamp) }}</span>
</div>
<div class="preview">{{ item.message_preview || '(空消息)' }}</div>
<div class="row stats">
<span class="plus">+{{ item.insertions || 0 }}</span>
<span class="minus">-{{ item.deletions || 0 }}</span>
<span class="files">{{ item.files_changed || 0 }} 文件</span>
</div>
</button>
<div v-if="!items.length" class="empty">暂无版本点</div>
</aside>
<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 class="files">{{ detail.files_changed || 0 }} 文件</span>
</div>
<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>
<span class="path">{{ file.path }}</span>
<span class="delta">
<span class="plus">+{{ file.insertions || 0 }}</span>
<span class="minus">-{{ file.deletions || 0 }}</span>
</span>
</div>
<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 || []"
:key="`line:${idx}:${lineIdx}`"
class="diff-line"
:class="line.type === 'add' ? 'diff-add' : (line.type === 'remove' ? 'diff-remove' : 'diff-context')"
>
{{ line.type === 'add' ? '+ ' : (line.type === 'remove' ? '- ' : ' ') }}{{ line.content }}
</div>
<div v-if="file.patch_truncated" class="approval-note">内容过长,已截断展示</div>
</div>
</template>
</div>
</template>
<div v-else class="empty">请选择一个版本点查看详情</div>
</section>
</div>
<footer class="versioning-footer">
<div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致,无法执行回溯。
</div>
<span class="restore-mode-label">回溯模式:覆盖当前对话</span>
<button
type="button"
class="confirm-btn"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || (needWorkspaceMatch && workspaceMatched === false)"
@click="$emit('confirm')"
>
{{ restoring ? '回溯中...' : '确认回溯' }}
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
const props = defineProps<{
hostMode: boolean;
enabled: boolean;
trackingMode: string;
loading: boolean;
items: any[];
selectedSeq: number | null;
detail: any;
detailLoading: boolean;
restoring: boolean;
restoreMode: string;
workspaceMatched?: boolean;
iconStyle?: (key: string) => Record<string, string>;
}>();
const emit = defineEmits([
'close',
'refresh',
'toggle-enabled',
'update:tracking-mode',
'select',
'confirm',
'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));
});
const expandedFileKey = ref<string | null>(null);
const fileKey = (file: any, idx: number) => `${file?.status || ''}:${file?.old_path || ''}:${file?.path || ''}:${idx}`;
const toggleFileExpand = (file: any, idx: number) => {
const key = fileKey(file, idx);
expandedFileKey.value = expandedFileKey.value === key ? null : key;
};
const isFileExpanded = (file: any, idx: number) => expandedFileKey.value === fileKey(file, idx);
watch(
() => props.detail?.seq,
() => {
expandedFileKey.value = null;
}
);
const formatTime = (value: string) => {
if (!value) return '-';
try {
return new Date(value).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return value;
}
};
</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(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);
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);
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);
}
.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);
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);
}
.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);
}
.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);
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);
}
.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: var(--state-success);
}
.minus {
color: var(--state-danger);
}
.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);
}
.file-row.expanded {
background: var(--theme-tab-active);
}
.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);
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: var(--on-accent);
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;
}
/* ===== diff 展示:与文件编辑差异保持一致 ===== */
.tool-result-diff {
margin-top: 10px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
}
.tool-result-diff.scroll-area {
max-height: calc(8 * 1.5em + 16px);
border: 1px solid var(--claude-border);
border-radius: 8px;
padding: 8px;
background: var(--theme-surface-soft);
}
.diff-line {
padding: 2px 4px;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* diff 增删色:通用语义色,保留固定值 */
.diff-remove {
background: var(--diff-del-bg);
color: var(--state-danger);
}
.diff-add {
background: var(--diff-add-bg);
color: var(--state-success);
}
.diff-context {
background: transparent;
color: var(--claude-text-secondary);
}
.approval-note {
padding: 6px 8px;
font-size: 11px;
color: var(--claude-text-secondary);
}
@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>