front/src/components/PublishDiff/index.vue
zzh 33f696bc42
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m24s
feat: 抽取 PublishDiff 公共组件,完善发布/订单/渠道与多模块体验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 23:34:54 +08:00

530 lines
13 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 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>