mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
[优化] go 引用调整
This commit is contained in:
+84
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
export function CleanupConfigVersionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (keepCount: number) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [keepCount, setKeepCount] = useState<number>(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (keepCount < 3) {
|
||||
setError('最少保留 3 个历史快照');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onConfirm(keepCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="清理历史快照"
|
||||
description="删除旧的历史快照配置,系统将始终自动保护当前已激活的版本不被删除。"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="cleanup-form"
|
||||
disabled={isPending}
|
||||
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
|
||||
>
|
||||
{isPending ? '清理中...' : '确认清理'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="cleanup-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="keepCount"
|
||||
className="mb-1 block text-sm font-medium text-[var(--foreground-primary)]"
|
||||
>
|
||||
保留最近快照个数
|
||||
</label>
|
||||
<input
|
||||
id="keepCount"
|
||||
type="number"
|
||||
min={3}
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(parseInt(e.target.value, 10) || 3)}
|
||||
className="w-full rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm text-[var(--foreground-primary)] focus:border-[var(--brand-primary)] focus:ring-1 focus:ring-[var(--brand-primary)] focus:outline-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
默认为 10 个,最少需保留 3 个。
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const versionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'detail', version?.id ?? 0],
|
||||
queryFn: () => {
|
||||
if (!version) {
|
||||
throw new Error('missing config version');
|
||||
}
|
||||
return getConfigVersion(version.id);
|
||||
},
|
||||
enabled: Boolean(version?.id),
|
||||
});
|
||||
const versionDetail = versionDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
onClose={onClose}
|
||||
title={version ? `版本 ${version.version}` : '查看快照'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
|
||||
<LoadingState />
|
||||
) : versionDetailQuery.isError ? (
|
||||
<ErrorState
|
||||
title="配置版本详情加载失败"
|
||||
description={getErrorMessage(versionDetailQuery.error)}
|
||||
/>
|
||||
) : versionDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{versionDetail.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{versionDetail.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(versionDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{versionDetail.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
activateConfigVersion,
|
||||
getActiveConfigVersion,
|
||||
getConfigVersionDiff,
|
||||
getConfigVersionPreview,
|
||||
getConfigVersions,
|
||||
publishConfigVersion,
|
||||
cleanupConfigVersions,
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import { CleanupConfigVersionsModal } from '@/features/config-versions/components/cleanup-config-versions-modal';
|
||||
import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
SupportFile,
|
||||
} from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function truncateChecksum(checksum: string) {
|
||||
if (!checksum) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
|
||||
}
|
||||
|
||||
function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
return (
|
||||
diff.added_domains.length > 0 ||
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
}
|
||||
|
||||
function DiffList({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${items.length} 项`}
|
||||
variant={items.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无相关变更。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupportFilesList({ files }: { files: SupportFile[] }) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前发布不需要额外支持文件。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<details
|
||||
key={file.path}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{file.path}
|
||||
</summary>
|
||||
<CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
|
||||
{file.content}
|
||||
</CodeBlock>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionValue(value: string) {
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无 OpenResty 性能参数变化。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">参数</th>
|
||||
<th className="px-3 py-3 font-medium">当前激活值</th>
|
||||
<th className="px-3 py-3 font-medium">待发布值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{items.map((item) => (
|
||||
<tr key={item.key} className="align-top">
|
||||
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{item.key}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.previous_value)}</code>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.current_value)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishPreviewCard({
|
||||
preview,
|
||||
diff,
|
||||
activeVersionMeta,
|
||||
activeVersionDetail,
|
||||
isActiveVersionDetailLoading,
|
||||
activeVersionDetailError,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
activeVersionMeta: ConfigVersionSummary | null;
|
||||
activeVersionDetail: ConfigVersionDetail | null;
|
||||
isActiveVersionDetailLoading: boolean;
|
||||
activeVersionDetailError: string | null;
|
||||
isPublishing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="发布前预览"
|
||||
description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
|
||||
action={
|
||||
<StatusBadge
|
||||
label={`启用规则 ${preview.route_count} 条`}
|
||||
variant="info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
当前激活版本
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{diff.active_version || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
|
||||
新增域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
|
||||
{diff.added_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
|
||||
删除域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
|
||||
{diff.removed_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
|
||||
修改域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
|
||||
{diff.modified_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
主配置变化
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{diff.main_config_changed ? '已变化' : '无变化'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canPublish ? (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前规则与已激活版本一致,已阻止重复发布。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<DiffList title="新增域名" items={diff.added_domains} />
|
||||
<DiffList title="删除域名" items={diff.removed_domains} />
|
||||
<DiffList title="修改域名" items={diff.modified_domains} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
OpenResty 参数变化
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${diff.changed_option_keys.length} 项`}
|
||||
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
{diff.main_config_changed && activeVersionMeta ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Current Active Main Config
|
||||
</p>
|
||||
<StatusBadge label={activeVersionMeta.version} variant="info" />
|
||||
</div>
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={activeVersionDetailError}
|
||||
/>
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前激活版本详情暂不可用。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Pending Main Config
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Legacy duplicated main config preview block kept commented out while
|
||||
we replace it with an explicit active-vs-pending comparison above.
|
||||
{diff.main_config_changed && activeVersion ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
褰撳墠婵€娲讳富閰嶇疆
|
||||
</p>
|
||||
<StatusBadge label={activeVersion.version} variant="info" />
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersion.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
寰呭彂甯冧富閰嶇疆
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum锛歿preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum:{preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
支持文件
|
||||
</p>
|
||||
<SupportFilesList files={preview.support_files} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPublishing || !canPublish}
|
||||
>
|
||||
{isPublishing ? '发布中...' : '确认发布'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
取消预览
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigVersionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [publishPreview, setPublishPreview] = useState<{
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
} | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
|
||||
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: versionsQueryKey,
|
||||
queryFn: getConfigVersions,
|
||||
});
|
||||
|
||||
const versions = useMemo(
|
||||
() => versionsQuery.data ?? [],
|
||||
[versionsQuery.data],
|
||||
);
|
||||
const activeVersion = useMemo(
|
||||
() => versions.find((item) => item.is_active) ?? null,
|
||||
[versions],
|
||||
);
|
||||
const selectedVersion = useMemo(
|
||||
() => versions.find((item) => item.id === selectedVersionId) ?? null,
|
||||
[selectedVersionId, versions],
|
||||
);
|
||||
const shouldLoadActiveVersionDetail = Boolean(
|
||||
activeVersion?.id && publishPreview?.diff.main_config_changed,
|
||||
);
|
||||
const activeVersionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
|
||||
queryFn: getActiveConfigVersion,
|
||||
enabled: shouldLoadActiveVersionDetail,
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (force?: boolean) => publishConfigVersion(force),
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `发布成功,版本 ${version.version}`,
|
||||
});
|
||||
setPublishPreview(null);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: activateConfigVersion,
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `已激活版本 ${version.version}`,
|
||||
});
|
||||
setSelectedVersionId(version.id);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: cleanupConfigVersions,
|
||||
onSuccess: async (data) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `成功清理 ${data.deleted_count} 个历史快照`,
|
||||
});
|
||||
setIsCleanupModalOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenPublishPreview = async () => {
|
||||
setFeedback(null);
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
try {
|
||||
const [preview, diff] = await Promise.all([
|
||||
getConfigVersionPreview(),
|
||||
getConfigVersionDiff(),
|
||||
]);
|
||||
setPublishPreview({ preview, diff });
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (version: ConfigVersionSummary) => {
|
||||
if (version.is_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
activateMutation.mutate(version.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="配置版本"
|
||||
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
|
||||
)
|
||||
) {
|
||||
publishMutation.mutate(true);
|
||||
}
|
||||
}}
|
||||
disabled={publishMutation.isPending}
|
||||
>
|
||||
重新发布
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleOpenPublishPreview}
|
||||
disabled={isPreviewLoading || publishMutation.isPending}
|
||||
>
|
||||
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{publishPreview ? (
|
||||
<PublishPreviewCard
|
||||
preview={publishPreview.preview}
|
||||
diff={publishPreview.diff}
|
||||
activeVersionMeta={activeVersion}
|
||||
activeVersionDetail={activeVersionDetailQuery.data ?? null}
|
||||
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
|
||||
activeVersionDetailError={
|
||||
activeVersionDetailQuery.isError
|
||||
? getErrorMessage(activeVersionDetailQuery.error)
|
||||
: null
|
||||
}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={() => publishMutation.mutate(false)}
|
||||
onCancel={() => setPublishPreview(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="历史版本"
|
||||
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCleanupModalOpen(true)}
|
||||
>
|
||||
清理旧版本
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: versionsQueryKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新列表
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{versionsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : versionsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="版本列表加载失败"
|
||||
description={getErrorMessage(versionsQuery.error)}
|
||||
/>
|
||||
) : versions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无历史版本"
|
||||
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">版本号</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">创建人</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">创建时间</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{versions.map((version) => (
|
||||
<tr key={version.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{version.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={version.is_active ? '当前激活' : '历史版本'}
|
||||
variant={version.is_active ? 'success' : 'info'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{version.created_by || '系统'}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={version.checksum}
|
||||
>
|
||||
{truncateChecksum(version.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setSelectedVersionId(version.id)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
查看快照
|
||||
</SecondaryButton>
|
||||
{!version.is_active ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => handleActivate(version)}
|
||||
disabled={activateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
重新激活
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ConfigVersionSnapshotModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setSelectedVersionId(null)}
|
||||
/>
|
||||
|
||||
<CleanupConfigVersionsModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
onConfirm={(keepCount) =>
|
||||
cleanupMutation.mutate({ keep_count: keepCount })
|
||||
}
|
||||
isPending={cleanupMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user