|
|
@@ -1,329 +0,0 @@
|
|
|
-<template>
|
|
|
- <a-drawer
|
|
|
- :open="open"
|
|
|
- title="结论快照"
|
|
|
- wrap-class-name="codex-snapshot"
|
|
|
- :width="760"
|
|
|
- @close="emit('update:open', false)"
|
|
|
- >
|
|
|
- <div class="codex-snapshot__bar">
|
|
|
- <a-input v-model:value="title" size="small" placeholder="快照标题(留空则用会话名 + 时间)" />
|
|
|
- <a-button size="small" type="primary" :disabled="!hasMessages" @click="onSave">保存当前会话</a-button>
|
|
|
- </div>
|
|
|
- <p class="codex-snapshot__hint">
|
|
|
- 只存文本与表格,不存图表图片(图片会让单条快照上兆,localStorage 撑不住);单条上限 200KB、最多 20 条。
|
|
|
- </p>
|
|
|
-
|
|
|
- <div v-if="!snapshots.length" class="codex-placeholder">
|
|
|
- 还没有快照。分析出结论后存一份,隔几天再跑一次就能并排看差异。
|
|
|
- </div>
|
|
|
-
|
|
|
- <template v-else>
|
|
|
- <div class="codex-snapshot__list">
|
|
|
- <article v-for="snap in snapshots" :key="snap.id" class="codex-snapshot__item">
|
|
|
- <a-checkbox :checked="selected.includes(snap.id)" @change="toggleSelect(snap.id)" />
|
|
|
- <div class="codex-snapshot__meta">
|
|
|
- <div class="codex-snapshot__title" :title="snap.title">{{ snap.title }}</div>
|
|
|
- <div class="codex-snapshot__sub">
|
|
|
- {{ formatCodexDateTime(snap.createdAt) }} · {{ sizeLabel(snap) }}<template v-if="snap.truncated">
|
|
|
- · 已截断</template
|
|
|
- >
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="codex-snapshot__actions">
|
|
|
- <button class="codex-btn" type="button" @click="onExport(snap)">导出 MD</button>
|
|
|
- <button class="codex-btn" type="button" @click="onRemove(snap.id)">删除</button>
|
|
|
- </div>
|
|
|
- </article>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div v-if="diffResult" class="codex-snapshot__diff">
|
|
|
- <div class="codex-snapshot__diff-head">
|
|
|
- <Icon icon="mdi:compare-horizontal" :size="14" />
|
|
|
- <span class="codex-snapshot__diff-title">
|
|
|
- {{ nameOf(selected[0]) }} → {{ nameOf(selected[1]) }}
|
|
|
- </span>
|
|
|
- <span class="codex-snapshot__spacer"></span>
|
|
|
- <button class="codex-btn" type="button" :class="{ 'is-on': collapseSame }" @click="collapseSame = !collapseSame">
|
|
|
- 折叠相同行
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <div class="codex-snapshot__diff-body">
|
|
|
- <div
|
|
|
- v-for="(line, index) in diffLines"
|
|
|
- :key="index"
|
|
|
- class="codex-snapshot__diff-line"
|
|
|
- :class="`is-${line.type}`"
|
|
|
- >
|
|
|
- <span class="codex-snapshot__sign">{{ SIGN[line.type] }}</span>
|
|
|
- <span class="codex-snapshot__text">{{ line.text || ' ' }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <p v-if="diffResult.truncated" class="codex-snapshot__hint">内容过长,仅比较了前 1200 行。</p>
|
|
|
- </div>
|
|
|
- <p v-else-if="selected.length" class="codex-snapshot__hint">再勾一个快照即可对比(先勾的作为基准)。</p>
|
|
|
- </template>
|
|
|
- </a-drawer>
|
|
|
-</template>
|
|
|
-
|
|
|
-<script lang="ts" setup>
|
|
|
- /**
|
|
|
- * 结论快照抽屉:存快照 / 删快照 / 导出 / 并排 diff。
|
|
|
- *
|
|
|
- * 对比用**统一视图**(+/- 标记 + 行文本)而不是左右并排:
|
|
|
- * 并排需要两列行号对齐,长报告里一列换行就会错位,统一视图不存在这个问题。
|
|
|
- */
|
|
|
- import { computed, ref, watch } from 'vue';
|
|
|
- import { Button, Checkbox, Drawer, Input } from 'ant-design-vue';
|
|
|
- import Icon from '@/core/components/Icon/src/Icon.vue';
|
|
|
- import { useMessage } from '@/core/hooks/web/useMessage';
|
|
|
- import { useCodexChatStore } from '../store/codexChatStore';
|
|
|
- import type { CodexDiffLine, CodexSnapshot } from '../types/codexTypes';
|
|
|
- import { formatCodexDateTime, formatCodexSize } from '../utils/codexFormat';
|
|
|
- import { exportCodexTextMarkdown } from '../utils/codexReportExport';
|
|
|
- import { codexConversationToMarkdown } from '../utils/codexReportTemplate';
|
|
|
- import { collapseCodexDiff, diffCodexLines } from '../utils/codexTextDiff';
|
|
|
- import {
|
|
|
- readCodexSnapshots,
|
|
|
- removeCodexSnapshot,
|
|
|
- saveCodexSnapshot,
|
|
|
- } from '../utils/codexSnapshot';
|
|
|
-
|
|
|
- const props = defineProps<{ open: boolean }>();
|
|
|
- const emit = defineEmits<{ (e: 'update:open', value: boolean): void }>();
|
|
|
-
|
|
|
- const ADrawer = Drawer;
|
|
|
- const AInput = Input;
|
|
|
- const AButton = Button;
|
|
|
- const ACheckbox = Checkbox;
|
|
|
-
|
|
|
- const SIGN: Record<CodexDiffLine['type'], string> = { same: ' ', add: '+', del: '−' };
|
|
|
-
|
|
|
- const store = useCodexChatStore();
|
|
|
- const { showMessage } = useMessage();
|
|
|
-
|
|
|
- const snapshots = ref<CodexSnapshot[]>(readCodexSnapshots());
|
|
|
- /** 按勾选顺序保存 id:先勾的是基准 */
|
|
|
- const selected = ref<string[]>([]);
|
|
|
- const title = ref('');
|
|
|
- const collapseSame = ref(true);
|
|
|
-
|
|
|
- const hasMessages = computed(() => store.hasMessages);
|
|
|
-
|
|
|
- const diffResult = computed(() => {
|
|
|
- if (selected.value.length !== 2) return null;
|
|
|
- const before = snapshots.value.find((item) => item.id === selected.value[0]);
|
|
|
- const after = snapshots.value.find((item) => item.id === selected.value[1]);
|
|
|
- if (!before || !after) return null;
|
|
|
- return diffCodexLines(before.content, after.content);
|
|
|
- });
|
|
|
-
|
|
|
- const diffLines = computed<CodexDiffLine[]>(() => {
|
|
|
- const result = diffResult.value;
|
|
|
- if (!result) return [];
|
|
|
- return collapseSame.value ? collapseCodexDiff(result.lines) : result.lines;
|
|
|
- });
|
|
|
-
|
|
|
- function refresh() {
|
|
|
- snapshots.value = readCodexSnapshots();
|
|
|
- }
|
|
|
-
|
|
|
- // 每次打开都重读,避免外部改动导致列表过期
|
|
|
- watch(
|
|
|
- () => props.open,
|
|
|
- (open) => {
|
|
|
- if (!open) return;
|
|
|
- refresh();
|
|
|
- selected.value = [];
|
|
|
- title.value = '';
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- function sizeLabel(snapshot: CodexSnapshot): string {
|
|
|
- return formatCodexSize(new Blob([snapshot.content]).size);
|
|
|
- }
|
|
|
-
|
|
|
- function nameOf(id: string): string {
|
|
|
- return snapshots.value.find((item) => item.id === id)?.title ?? '(已删除)';
|
|
|
- }
|
|
|
-
|
|
|
- function toggleSelect(id: string) {
|
|
|
- const at = selected.value.indexOf(id);
|
|
|
- if (at >= 0) {
|
|
|
- selected.value = selected.value.filter((item) => item !== id);
|
|
|
- return;
|
|
|
- }
|
|
|
- // 最多两个:再勾就顶掉最早的,避免出现「勾了三个不知道比哪两个」
|
|
|
- selected.value = [...selected.value, id].slice(-2);
|
|
|
- }
|
|
|
-
|
|
|
- function onSave() {
|
|
|
- const messages = store.messages;
|
|
|
- if (!messages.length) {
|
|
|
- showMessage('当前会话还没有内容', 'warning');
|
|
|
- return;
|
|
|
- }
|
|
|
- const session = store.activeSession;
|
|
|
- const content = codexConversationToMarkdown(messages, {
|
|
|
- title: session?.title || 'Codex 会话',
|
|
|
- cwd: store.activeCwd,
|
|
|
- model: session?.model ?? null,
|
|
|
- exportedAt: new Date().toLocaleString(),
|
|
|
- });
|
|
|
- const name = title.value.trim() || `${session?.title || '会话'} ${formatCodexDateTime(new Date())}`;
|
|
|
- snapshots.value = saveCodexSnapshot({ title: name, content, cwd: store.activeCwd, model: session?.model ?? null });
|
|
|
- title.value = '';
|
|
|
- showMessage('已保存快照', 'success');
|
|
|
- }
|
|
|
-
|
|
|
- function onRemove(id: string) {
|
|
|
- snapshots.value = removeCodexSnapshot(id);
|
|
|
- selected.value = selected.value.filter((item) => item !== id);
|
|
|
- showMessage('已删除快照', 'success');
|
|
|
- }
|
|
|
-
|
|
|
- function onExport(snapshot: CodexSnapshot) {
|
|
|
- const name = exportCodexTextMarkdown(snapshot.title, snapshot.content);
|
|
|
- showMessage(`已导出 ${name}`, 'success');
|
|
|
- }
|
|
|
-</script>
|
|
|
-
|
|
|
-<style lang="less">
|
|
|
- .codex-snapshot {
|
|
|
- &__bar {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: var(--codex-sp-2);
|
|
|
- margin-bottom: var(--codex-sp-2);
|
|
|
- }
|
|
|
-
|
|
|
- &__hint {
|
|
|
- margin: 0 0 var(--codex-sp-3);
|
|
|
- font-size: 11.5px;
|
|
|
- line-height: 1.7;
|
|
|
- color: var(--codex-text-3);
|
|
|
- }
|
|
|
-
|
|
|
- &__spacer {
|
|
|
- flex: 1 1 auto;
|
|
|
- }
|
|
|
-
|
|
|
- &__list {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 2px;
|
|
|
- margin: 0 0 var(--codex-sp-3);
|
|
|
- padding: 0;
|
|
|
- list-style: none;
|
|
|
- }
|
|
|
-
|
|
|
- &__item {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: var(--codex-sp-2);
|
|
|
- padding: var(--codex-sp-2);
|
|
|
- border: 1px solid var(--codex-border);
|
|
|
- border-radius: var(--codex-radius-sm);
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: var(--codex-bg-hover);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &__meta {
|
|
|
- flex: 1 1 auto;
|
|
|
- min-width: 0;
|
|
|
- }
|
|
|
-
|
|
|
- &__title {
|
|
|
- overflow: hidden;
|
|
|
- font-size: 13px;
|
|
|
- font-weight: 600;
|
|
|
- color: var(--codex-text);
|
|
|
- white-space: nowrap;
|
|
|
- text-overflow: ellipsis;
|
|
|
- }
|
|
|
-
|
|
|
- &__sub {
|
|
|
- margin-top: 1px;
|
|
|
- font-size: 11px;
|
|
|
- color: var(--codex-text-3);
|
|
|
- }
|
|
|
-
|
|
|
- &__actions {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 2px;
|
|
|
- flex: 0 0 auto;
|
|
|
- }
|
|
|
-
|
|
|
- &__diff {
|
|
|
- border: 1px solid var(--codex-border);
|
|
|
- border-radius: var(--codex-radius-sm);
|
|
|
- overflow: hidden;
|
|
|
- }
|
|
|
-
|
|
|
- &__diff-head {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 6px;
|
|
|
- padding: 6px 8px;
|
|
|
- font-size: 12px;
|
|
|
- color: var(--codex-text-2);
|
|
|
- background: var(--codex-bg-subtle);
|
|
|
- border-bottom: 1px solid var(--codex-border);
|
|
|
- }
|
|
|
-
|
|
|
- &__diff-title {
|
|
|
- min-width: 0;
|
|
|
- overflow: hidden;
|
|
|
- font-weight: 600;
|
|
|
- color: var(--codex-text);
|
|
|
- white-space: nowrap;
|
|
|
- text-overflow: ellipsis;
|
|
|
- }
|
|
|
-
|
|
|
- &__diff-body {
|
|
|
- max-height: 52vh;
|
|
|
- overflow: auto;
|
|
|
- font-family: 'SFMono-Regular', Consolas, monospace;
|
|
|
- font-size: 11.5px;
|
|
|
- line-height: 1.65;
|
|
|
- background: var(--codex-bg);
|
|
|
- }
|
|
|
-
|
|
|
- &__diff-line {
|
|
|
- display: flex;
|
|
|
- gap: var(--codex-sp-2);
|
|
|
- padding: 0 8px;
|
|
|
- white-space: pre-wrap;
|
|
|
- word-break: break-word;
|
|
|
-
|
|
|
- &.is-add {
|
|
|
- color: var(--codex-success);
|
|
|
- background: fade(@success-color, 10%);
|
|
|
- }
|
|
|
-
|
|
|
- &.is-del {
|
|
|
- color: var(--codex-error);
|
|
|
- background: fade(@error-color, 10%);
|
|
|
- }
|
|
|
-
|
|
|
- &.is-same {
|
|
|
- color: var(--codex-text-3);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &__sign {
|
|
|
- flex: 0 0 auto;
|
|
|
- width: 10px;
|
|
|
- text-align: center;
|
|
|
- user-select: none;
|
|
|
- }
|
|
|
-
|
|
|
- &__text {
|
|
|
- flex: 1 1 auto;
|
|
|
- min-width: 0;
|
|
|
- }
|
|
|
- }
|
|
|
-</style>
|