front/src/components/PublishDiff/index.vue
2026-10-07 17:34:36 +08:00

547 lines
14 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.

<script lang="ts" setup>
import { computed } from 'vue';
import SlidingTabs from '@/components/SlidingTabs/index.vue';
import {
filterDiffRows,
type SnapshotDiffRow,
} from '@/utils/publishSnapshotDiff';
export type PublishDiffStatus = 'synced' | 'changed' | 'first';
export type PublishDiffFilterMode = 'tabs' | 'checkbox' | 'none';
export type PublishDiffRawMode =
| 'none'
| 'details'
| 'always'
| 'always-before-legend';
const props = withDefaults(
defineProps<{
status: PublishDiffStatus;
rows: SnapshotDiffRow[];
diffLength: number;
statusTitle: string;
statusDesc?: string;
filterMode?: PublishDiffFilterMode;
/** checkbox 模式:只看差异 */
showOnlyDiff?: boolean;
/** tabs 模式:all | diff */
diffViewMode?: 'all' | 'diff';
showAllLabel?: string;
showOnlyDiffLabel?: string;
legendLabels: {
add: string;
remove: string;
change: string;
same: string;
};
emptyText?: string;
mergedTitle?: string;
diffCountText?: string;
headerTitle?: string;
headerCountText?: string;
rawMode?: PublishDiffRawMode;
rawSummaryText?: string;
originalTitle?: string;
modifiedTitle?: string;
originalJson?: string;
modifiedJson?: string;
rawDetailsOpen?: boolean;
sameLineMark?: 'dot' | 'space';
showSyncedPanel?: boolean;
}>(),
{
filterMode: 'tabs',
showOnlyDiff: true,
diffViewMode: 'diff',
showAllLabel: '全部',
showOnlyDiffLabel: '只看差异',
emptyText: '',
rawMode: 'none',
rawDetailsOpen: false,
sameLineMark: 'dot',
showSyncedPanel: true,
originalJson: '{}',
modifiedJson: '{}',
},
);
const emit = defineEmits<{
(e: 'update:showOnlyDiff', value: boolean): void;
(e: 'update:diffViewMode', value: 'all' | 'diff'): void;
(e: 'update:rawDetailsOpen', value: boolean): void;
}>();
const diffViewTabs = computed(() => [
{ key: 'all', label: props.showAllLabel },
{ key: 'diff', label: props.showOnlyDiffLabel },
]);
const onlyDiff = computed(() => {
if (props.filterMode === 'checkbox') return !!props.showOnlyDiff;
if (props.filterMode === 'tabs') return props.diffViewMode === 'diff';
return false;
});
const visibleRows = computed(() =>
filterDiffRows(props.rows || [], onlyDiff.value),
);
const sameMark = computed(() =>
props.sameLineMark === 'space' ? ' ' : '·',
);
const showHeader = computed(
() =>
!!props.headerTitle ||
!!props.headerCountText ||
props.filterMode !== 'none',
);
const showRawBoxes = computed(
() =>
props.rawMode === 'always' ||
props.rawMode === 'always-before-legend' ||
props.rawMode === 'details',
);
function onRawToggle(e: Event) {
const el = e.target as HTMLDetailsElement;
emit('update:rawDetailsOpen', !!el?.open);
}
</script>
<template>
<div class="publish-diff-panel">
<div
class="publish-diff-status"
:class="{
'is-synced': status === 'synced',
'is-changed': status === 'changed',
'is-first': status === 'first',
}"
>
<div class="status-title">{{ statusTitle }}</div>
<div v-if="statusDesc" class="status-desc">{{ statusDesc }}</div>
<slot name="status-extra" />
</div>
<div v-if="showHeader" class="diff-header">
<div v-if="headerTitle || headerCountText" class="diff-header-left">
<div v-if="headerTitle" class="diff-title">{{ headerTitle }}</div>
<span v-if="headerCountText" class="diff-count">{{ headerCountText }}</span>
</div>
<div class="diff-actions">
<SlidingTabs
v-if="filterMode === 'tabs' && diffLength > 0"
:model-value="diffViewMode"
size="mini"
class="diff-view-tabs"
:items="diffViewTabs"
@update:model-value="emit('update:diffViewMode', $event as 'all' | 'diff')"
/>
<a-checkbox
v-if="filterMode === 'checkbox' && diffLength > 0"
:model-value="showOnlyDiff"
size="small"
@update:model-value="emit('update:showOnlyDiff', !!$event)"
>
{{ showOnlyDiffLabel }}
</a-checkbox>
<slot name="header-actions" />
</div>
</div>
<slot name="before-body" />
<div
v-if="status === 'synced' && showSyncedPanel"
class="publish-diff-synced-panel"
>
<slot name="synced">
<pre
v-if="(modifiedJson?.length || 0) <= 120000"
class="diff-pre synced-preview"
>{{ modifiedJson }}</pre>
<p v-else class="raw-json-placeholder">
快照较大(约 {{ Math.round((modifiedJson?.length || 0) / 1024) }} KB),已省略全文预览以免卡顿。
</p>
</slot>
</div>
<template v-else-if="status !== 'synced'">
<div
v-if="rawMode === 'always-before-legend' && showRawBoxes"
class="diff-boxes"
>
<div class="diff-box">
<div class="diff-box-title">{{ originalTitle }}</div>
<pre class="diff-pre">{{ originalJson || '{}' }}</pre>
</div>
<div class="diff-box">
<div class="diff-box-title">{{ modifiedTitle }}</div>
<pre class="diff-pre">{{ modifiedJson || '{}' }}</pre>
</div>
</div>
<div class="legend">
<span class="legend-item add">{{ legendLabels.add }}</span>
<span class="legend-item remove">{{ legendLabels.remove }}</span>
<span class="legend-item change">{{ legendLabels.change }}</span>
<span class="legend-item same">{{ legendLabels.same }}</span>
</div>
<div class="merged-diff">
<div v-if="mergedTitle || diffCountText" class="diff-box-title">
{{ mergedTitle }}
<span v-if="diffCountText" class="diff-count-inline">{{ diffCountText }}</span>
</div>
<div class="merged-diff-body">
<template v-for="(row, idx) in visibleRows" :key="idx">
<template v-if="row.type === 'change'">
<div class="merged-line change">
<span class="line-mark">~</span>
<span class="line-text">{{ row.oldText }} → {{ row.newText }}</span>
</div>
</template>
<template v-else>
<div
class="merged-line"
:class="{
added: row.type === 'add',
removed: row.type === 'remove',
same: row.type === 'same',
}"
>
<span class="line-mark">
{{
row.type === 'add'
? '+'
: row.type === 'remove'
? '-'
: sameMark
}}
</span>
<span class="line-text">{{ row.text }}</span>
</div>
</template>
</template>
<div v-if="!visibleRows.length" class="diff-empty">
<slot name="empty">{{ emptyText }}</slot>
</div>
</div>
</div>
<slot name="after-merged" />
<details
v-if="rawMode === 'details'"
class="raw-json-details"
:open="rawDetailsOpen"
@toggle="onRawToggle"
>
<summary>{{ rawSummaryText }}</summary>
<!-- 大快照仅在展开后挂载,避免未展开就把 MB 级文本塞进 DOM 卡死 -->
<div v-if="rawDetailsOpen" class="diff-boxes">
<div class="diff-box">
<div class="diff-box-title">{{ originalTitle }}</div>
<pre class="diff-pre">{{ originalJson || '{}' }}</pre>
</div>
<div class="diff-box">
<div class="diff-box-title">{{ modifiedTitle }}</div>
<pre class="diff-pre">{{ modifiedJson || '{}' }}</pre>
</div>
</div>
<p v-else class="raw-json-placeholder">展开后加载原始 JSON…</p>
</details>
<div v-else-if="rawMode === 'always'" class="diff-boxes">
<div class="diff-box">
<div class="diff-box-title">{{ originalTitle }}</div>
<pre class="diff-pre">{{ originalJson || '{}' }}</pre>
</div>
<div class="diff-box">
<div class="diff-box-title">{{ modifiedTitle }}</div>
<pre class="diff-pre">{{ modifiedJson || '{}' }}</pre>
</div>
</div>
</template>
</div>
</template>
<style lang="less" scoped>
.publish-diff-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.publish-diff-status {
flex-shrink: 0;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-1);
.status-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.status-desc {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
:slotted(.status-sub) {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
&.is-synced {
border-color: #b7eb8f;
background: #f6ffed;
.status-title {
color: #389e0d;
}
}
&.is-changed {
border-color: #ffd591;
background: #fff7e6;
.status-title {
color: #d46b08;
}
}
&.is-first {
border-color: #91d5ff;
background: #e6f7ff;
.status-title {
color: #096dd9;
}
}
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.diff-header-left {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.diff-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.diff-count {
font-size: 12px;
color: var(--color-text-3);
}
.diff-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.publish-diff-synced-panel {
border: 1px solid var(--color-border-2);
border-radius: 8px;
background: var(--color-fill-1);
max-height: 320px;
overflow: auto;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.legend-item {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-1);
color: var(--color-text-2);
}
.legend-item.add {
color: #0a8f2a;
border-color: color-mix(in srgb, #00b42a 28%, transparent);
background: color-mix(in srgb, #00b42a 8%, #fff);
}
.legend-item.remove {
color: #cb2634;
border-color: color-mix(in srgb, #f53f3f 28%, transparent);
background: color-mix(in srgb, #f53f3f 8%, #fff);
}
.legend-item.change {
color: #a65d00;
border-color: color-mix(in srgb, #ff7d00 28%, transparent);
background: color-mix(in srgb, #ff7d00 8%, #fff);
}
.merged-diff {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-box-title {
font-size: 12px;
font-weight: 500;
color: var(--color-text-2);
}
.diff-count-inline {
margin-left: 6px;
font-weight: 400;
color: var(--color-text-3);
}
.merged-diff-body {
border: 1px solid var(--color-border-2);
border-radius: 6px;
background: var(--app-card-bg, #fff);
max-height: 360px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-all;
}
.merged-line {
display: flex;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.line-mark {
flex-shrink: 0;
width: 14px;
text-align: center;
font-weight: 600;
}
.merged-line.added {
background: color-mix(in srgb, #00b42a 10%, #fff);
color: #0a8f2a;
}
.merged-line.removed {
background: color-mix(in srgb, #f53f3f 10%, #fff);
color: #cb2634;
text-decoration: line-through;
}
.merged-line.change {
background: color-mix(in srgb, #ff7d00 10%, #fff);
color: #a65d00;
}
.merged-line.same {
color: var(--color-text-3);
}
.diff-empty {
padding: 24px 8px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
}
.raw-json-placeholder {
margin: 8px 0 0;
padding: 10px 12px;
border-radius: 6px;
background: var(--color-fill-1, #f7f8fa);
color: var(--color-text-3);
font-size: 12px;
}
.raw-json-details {
border: 1px solid var(--color-border-2);
border-radius: 6px;
padding: 8px 12px;
background: var(--color-fill-1);
summary {
cursor: pointer;
font-size: 12px;
color: var(--color-text-2);
user-select: none;
}
}
.diff-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 10px;
}
.raw-json-details .diff-boxes {
margin-top: 10px;
}
.diff-box {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.diff-pre {
margin: 0;
padding: 10px 12px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
min-height: 120px;
max-height: 220px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-all;
color: var(--color-text-1);
}
.synced-preview {
min-height: 0;
max-height: none;
border: 0;
background: transparent;
}
</style>