All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m24s
Co-authored-by: Cursor <cursoragent@cursor.com>
530 lines
13 KiB
Vue
530 lines
13 KiB
Vue
<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 class="diff-pre synced-preview">{{ modifiedJson }}</pre>
|
||
</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>
|
||
<div 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>
|
||
</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-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>
|