fix: restore frontend source code (src/web/) that was accidentally deleted
Frontend source is needed for development/building the SPA. Only Node.js runtime server code should be removed.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type DownstreamCreateForm = {
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
maxCost: string;
|
||||
maxRequests: string;
|
||||
expiresAt: string;
|
||||
selectedModels: string[];
|
||||
selectedGroupRouteIds: number[];
|
||||
};
|
||||
|
||||
type DownstreamApiKeyModalProps = {
|
||||
presence: ModalPresence;
|
||||
editingDownstreamId: number | null;
|
||||
downstreamCreate: DownstreamCreateForm;
|
||||
downstreamSaving: boolean;
|
||||
inputStyle: React.CSSProperties;
|
||||
onChange: (updater: (prev: DownstreamCreateForm) => DownstreamCreateForm) => void;
|
||||
onOpenSelector: () => Promise<void> | void;
|
||||
onClose: () => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export default function DownstreamApiKeyModal({
|
||||
presence,
|
||||
editingDownstreamId,
|
||||
downstreamCreate,
|
||||
downstreamSaving,
|
||||
inputStyle,
|
||||
onChange,
|
||||
onOpenSelector,
|
||||
onClose,
|
||||
onSave,
|
||||
}: DownstreamApiKeyModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 860 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||
<input
|
||||
value={downstreamCreate.name}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Name (e.g. cc-project)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
value={downstreamCreate.key}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value.trim() }))}
|
||||
placeholder="sk-xxxx"
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<input
|
||||
value={downstreamCreate.maxCost}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))}
|
||||
placeholder="最大费用(可选)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.000001}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
value={downstreamCreate.maxRequests}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))}
|
||||
placeholder="最大请求数(可选)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
value={downstreamCreate.expiresAt}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))}
|
||||
type="datetime-local"
|
||||
placeholder="过期时间(可选)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
value={downstreamCreate.description}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="备注(可选)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => void onOpenSelector()}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
勾选模型和群组
|
||||
</button>
|
||||
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) ? (
|
||||
<button
|
||||
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
|
||||
className="btn btn-link btn-link-warning"
|
||||
>
|
||||
清空选择
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button onClick={() => void onSave()} disabled={downstreamSaving} className="btn btn-primary">
|
||||
{downstreamSaving
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
|
||||
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type FactoryResetModalProps = {
|
||||
presence: ModalPresence;
|
||||
factoryResetting: boolean;
|
||||
factoryResetSecondsLeft: number;
|
||||
adminToken: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export default function FactoryResetModal({
|
||||
presence,
|
||||
factoryResetting,
|
||||
factoryResetSecondsLeft,
|
||||
adminToken,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: FactoryResetModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const confirmLabel = factoryResetting
|
||||
? '重新初始化中...'
|
||||
: (factoryResetSecondsLeft > 0
|
||||
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
|
||||
: '确认重新初始化系统');
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据。</div>
|
||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} disabled={factoryResetting} className="btn btn-ghost">取消</button>
|
||||
<button onClick={onConfirm} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
|
||||
{factoryResetting
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
|
||||
: confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type ModelAvailabilityProbeConfirmModalProps = {
|
||||
presence: ModalPresence;
|
||||
confirmText: string;
|
||||
confirmationInput: string;
|
||||
saving: boolean;
|
||||
onConfirmationInputChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export default function ModelAvailabilityProbeConfirmModal({
|
||||
presence,
|
||||
confirmText,
|
||||
confirmationInput,
|
||||
saving,
|
||||
onConfirmationInputChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ModelAvailabilityProbeConfirmModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canConfirm = confirmationInput.trim() === confirmText;
|
||||
const titleId = 'model-availability-probe-confirm-modal-title';
|
||||
const descriptionId = 'model-availability-probe-confirm-modal-description';
|
||||
const handleRequestClose = () => {
|
||||
if (saving) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
aria-busy={saving}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
|
||||
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
请手动输入以下整句后再开启:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
<textarea
|
||||
value={confirmationInput}
|
||||
onChange={(e) => onConfirmationInputChange(e.target.value)}
|
||||
placeholder="请输入上方确认语句"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
|
||||
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
|
||||
{saving
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
|
||||
: '确认开启批量测活'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand } from '../../components/BrandIcon.js';
|
||||
import {
|
||||
resolveRouteBrand,
|
||||
resolveRouteIcon,
|
||||
resolveRouteTitle,
|
||||
} from '../token-routes/utils.js';
|
||||
|
||||
type RouteSelectorItem = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
displayName?: string | null;
|
||||
displayIcon?: string | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type DownstreamRouteSelection = {
|
||||
selectedModels: string[];
|
||||
selectedGroupRouteIds: number[];
|
||||
};
|
||||
|
||||
type RouteSelectorModalProps = {
|
||||
presence: ModalPresence;
|
||||
loading: boolean;
|
||||
exactModelOptions: string[];
|
||||
filteredExactModelOptions: string[];
|
||||
groupRouteOptions: RouteSelectorItem[];
|
||||
filteredGroupRouteOptions: RouteSelectorItem[];
|
||||
selectorModelSearch: string;
|
||||
selectorGroupSearch: string;
|
||||
onSelectorModelSearchChange: (value: string) => void;
|
||||
onSelectorGroupSearchChange: (value: string) => void;
|
||||
selection: DownstreamRouteSelection;
|
||||
onToggleModelSelection: (modelName: string) => void;
|
||||
onToggleGroupRouteSelection: (routeId: number) => void;
|
||||
onClose: () => void;
|
||||
inputStyle: React.CSSProperties;
|
||||
};
|
||||
|
||||
export default function RouteSelectorModal({
|
||||
presence,
|
||||
loading,
|
||||
exactModelOptions,
|
||||
filteredExactModelOptions,
|
||||
groupRouteOptions,
|
||||
filteredGroupRouteOptions,
|
||||
selectorModelSearch,
|
||||
selectorGroupSearch,
|
||||
onSelectorModelSearchChange,
|
||||
onSelectorGroupSearchChange,
|
||||
selection,
|
||||
onToggleModelSelection,
|
||||
onToggleGroupRouteSelection,
|
||||
onClose,
|
||||
inputStyle,
|
||||
}: RouteSelectorModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 860 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">勾选模型和群组</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
<span className="spinner spinner-sm" />
|
||||
加载路由中...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||
精确模型 ({selectorModelSearch.trim()
|
||||
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
|
||||
: exactModelOptions.length})
|
||||
</div>
|
||||
<input
|
||||
value={selectorModelSearch}
|
||||
onChange={(e) => onSelectorModelSearchChange(e.target.value)}
|
||||
placeholder="搜索精确模型(支持模糊匹配)"
|
||||
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{exactModelOptions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
|
||||
) : filteredExactModelOptions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
|
||||
) : filteredExactModelOptions.map((modelName) => {
|
||||
const checked = selection.selectedModels.includes(modelName);
|
||||
const brand = getBrand(modelName);
|
||||
return (
|
||||
<label
|
||||
key={modelName}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
background: checked
|
||||
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleModelSelection(modelName)}
|
||||
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<code
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
background: 'var(--color-bg)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-text-primary)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{brand ? (
|
||||
<InlineBrandIcon model={modelName} size={18} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 6,
|
||||
background: 'var(--color-bg-card)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{modelName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{modelName}
|
||||
</span>
|
||||
</code>
|
||||
{brand ? (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
|
||||
{brand.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||
群组 ({selectorGroupSearch.trim()
|
||||
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
|
||||
: groupRouteOptions.length})
|
||||
</div>
|
||||
<input
|
||||
value={selectorGroupSearch}
|
||||
onChange={(e) => onSelectorGroupSearchChange(e.target.value)}
|
||||
placeholder="Search groups (name / pattern)"
|
||||
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{groupRouteOptions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
|
||||
) : filteredGroupRouteOptions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||
) : filteredGroupRouteOptions.map((route) => {
|
||||
const checked = selection.selectedGroupRouteIds.includes(route.id);
|
||||
const routeTitle = resolveRouteTitle(route);
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const routeBrand = resolveRouteBrand(route);
|
||||
return (
|
||||
<label
|
||||
key={route.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
background: checked
|
||||
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||
checked={checked}
|
||||
onChange={() => onToggleGroupRouteSelection(route.id)}
|
||||
/>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<code
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
background: 'var(--color-bg)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-text-primary)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 6,
|
||||
background: 'var(--color-bg-card)',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{routeIcon.kind === 'brand' ? (
|
||||
<BrandGlyph
|
||||
icon={routeIcon.value}
|
||||
alt={routeTitle}
|
||||
size={18}
|
||||
fallbackText={routeTitle}
|
||||
/>
|
||||
) : routeIcon.kind === 'text' ? (
|
||||
routeIcon.value
|
||||
) : routeIcon.kind === 'auto' && routeBrand ? (
|
||||
<BrandGlyph brand={routeBrand} alt={routeTitle} size={18} fallbackText={routeTitle} />
|
||||
) : routeIcon.kind === 'auto' ? (
|
||||
<InlineBrandIcon model={route.modelPattern} size={18} />
|
||||
) : routeIcon.kind === 'none' ? null : null}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{routeTitle}
|
||||
</span>
|
||||
{!route.enabled ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-danger-bg)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
) : null}
|
||||
</code>
|
||||
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
|
||||
{route.modelPattern}
|
||||
</code>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export type UpdateCenterHistoryEntry = {
|
||||
revision?: string;
|
||||
updatedAt?: string | null;
|
||||
status?: string | null;
|
||||
description?: string | null;
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
};
|
||||
|
||||
type UpdateCenterHistoryEntryCardProps = {
|
||||
entry: UpdateCenterHistoryEntry;
|
||||
currentRevision: string;
|
||||
helperHealthy: boolean;
|
||||
deploying: boolean;
|
||||
compact?: boolean;
|
||||
formatTaskTime: (value?: string | null) => string;
|
||||
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
|
||||
onRollback: (revision: string) => void;
|
||||
};
|
||||
|
||||
export default function UpdateCenterHistoryEntryCard({
|
||||
entry,
|
||||
currentRevision,
|
||||
helperHealthy,
|
||||
deploying,
|
||||
compact = false,
|
||||
formatTaskTime,
|
||||
formatImageTarget,
|
||||
onRollback,
|
||||
}: UpdateCenterHistoryEntryCardProps) {
|
||||
const revision = String(entry?.revision || '').trim();
|
||||
const isCurrentRevision = revision && revision === currentRevision;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: compact ? 10 : 12,
|
||||
display: 'grid',
|
||||
gap: compact ? 5 : 6,
|
||||
background: isCurrentRevision
|
||||
? 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
revision {revision || '-'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{entry?.status ? <span className="badge badge-muted">{entry.status}</span> : null}
|
||||
{isCurrentRevision ? <span className="badge badge-info">当前运行</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', fontFamily: 'var(--font-mono)' }}>
|
||||
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
|
||||
</div>
|
||||
{entry?.description ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
{entry.description}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
更新时间:{formatTaskTime(entry?.updatedAt)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentRevision) return;
|
||||
onRollback(revision);
|
||||
}}
|
||||
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
回退到 revision {revision}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import CenteredModal from '../../components/CenteredModal.js';
|
||||
import UpdateCenterHistoryEntryCard, { type UpdateCenterHistoryEntry } from './UpdateCenterHistoryEntryCard.js';
|
||||
|
||||
type UpdateCenterHistoryModalProps = {
|
||||
open: boolean;
|
||||
helperHealthy: boolean;
|
||||
deploying: boolean;
|
||||
currentRevision: string;
|
||||
history: UpdateCenterHistoryEntry[];
|
||||
formatTaskTime: (value?: string | null) => string;
|
||||
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
|
||||
onClose: () => void;
|
||||
onRollback: (revision: string) => void;
|
||||
};
|
||||
|
||||
export default function UpdateCenterHistoryModal({
|
||||
open,
|
||||
helperHealthy,
|
||||
deploying,
|
||||
currentRevision,
|
||||
history,
|
||||
formatTaskTime,
|
||||
formatImageTarget,
|
||||
onClose,
|
||||
onRollback,
|
||||
}: UpdateCenterHistoryModalProps) {
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="全部 revision"
|
||||
maxWidth={880}
|
||||
closeOnBackdrop
|
||||
closeOnEscape
|
||||
footer={(
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
这里保留 helper 读到的全部 Helm revision。默认列表只显示最近几条,弹窗里再展开全部回退记录,避免设置页被历史卡片拉得过长。
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10, maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{history.map((entry) => {
|
||||
const revision = String(entry?.revision || '').trim();
|
||||
return (
|
||||
<UpdateCenterHistoryEntryCard
|
||||
key={revision || 'unknown-revision-modal'}
|
||||
entry={entry}
|
||||
currentRevision={currentRevision}
|
||||
helperHealthy={helperHealthy}
|
||||
deploying={deploying}
|
||||
formatTaskTime={formatTaskTime}
|
||||
formatImageTarget={formatImageTarget}
|
||||
onRollback={onRollback}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../../components/Toast.js';
|
||||
|
||||
import UpdateCenterSection from './UpdateCenterSection.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
saveUpdateCenterConfig: vi.fn(),
|
||||
checkUpdateCenter: vi.fn(),
|
||||
deployUpdateCenter: vi.fn(),
|
||||
rollbackUpdateCenter: vi.fn(),
|
||||
streamUpdateCenterTaskLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('UpdateCenterSection', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = {
|
||||
body: {
|
||||
nodeType: 1,
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
tagName: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
displayVersion: 'dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb',
|
||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'feature-without-raw-tag',
|
||||
digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
displayVersion: 'feature-without-raw-tag @ sha256:cccccccccccc',
|
||||
publishedAt: '2026-03-30T09:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '17',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
history: [
|
||||
{
|
||||
revision: '17',
|
||||
updatedAt: '2026-03-29T12:00:00Z',
|
||||
status: 'deployed',
|
||||
description: 'Upgrade complete',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
{
|
||||
revision: '16',
|
||||
updatedAt: '2026-03-28T12:00:00Z',
|
||||
status: 'superseded',
|
||||
description: 'Rollback to stable digest',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'main',
|
||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
{
|
||||
revision: '15',
|
||||
updatedAt: '2026-03-27T12:00:00Z',
|
||||
status: 'superseded',
|
||||
description: 'Earlier stable release',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.2.2',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:30:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedDisplayVersion: '1.3.0',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.saveUpdateCenterConfig.mockResolvedValue({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '18',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
history: [],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:35:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedDisplayVersion: '1.3.0',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
id: 'task-1',
|
||||
},
|
||||
});
|
||||
apiMock.rollbackUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
id: 'task-2',
|
||||
},
|
||||
});
|
||||
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
|
||||
handlers.onLog?.({ message: 'Running helm upgrade' });
|
||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
||||
handlers.onDone?.({ status: 'succeeded' });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const helperInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
|
||||
});
|
||||
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
|
||||
|
||||
await act(async () => {
|
||||
checkboxInputs[1].props.onChange({ target: { checked: false } });
|
||||
checkboxInputs[2].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const defaultSourceTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
defaultSourceTrigger.props.onClick();
|
||||
});
|
||||
|
||||
const dockerHubOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('Docker Hub Tags')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
dockerHubOption.props.onClick();
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存更新中心配置')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
githubReleasesEnabled: false,
|
||||
dockerHubTagsEnabled: false,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
}));
|
||||
|
||||
const deployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await deployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
targetDigest: null,
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('发现新版本');
|
||||
expect(text).toContain('后台检查');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables deploy actions when the helper is unhealthy', async () => {
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
helper: {
|
||||
ok: false,
|
||||
healthy: false,
|
||||
error: 'helper unavailable',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const githubDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
const dockerDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
||||
));
|
||||
|
||||
expect(githubDeployButton.props.disabled).toBe(true);
|
||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
githubDeployButton.props.onClick?.();
|
||||
dockerDeployButton.props.onClick?.();
|
||||
});
|
||||
|
||||
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('deploys manual Docker Hub tags so dev and branch images are reachable from the UI', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const manualTagInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === 'dev / dev-20260417-f67ade2 / sha-f67ade2'
|
||||
));
|
||||
const manualDigestInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '可选 digest:sha256:...'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
manualTagInput.props.onChange({ target: { value: 'dev-20260417-f67ade2' } });
|
||||
manualDigestInput.props.onChange({
|
||||
target: {
|
||||
value: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const customDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署自定义 Docker 标签')
|
||||
));
|
||||
|
||||
expect(customDeployButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await customDeployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-f67ade2',
|
||||
targetDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('deploys auto-discovered recent non-stable Docker Hub tags without manual input', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
|
||||
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
|
||||
expect(collectText(root.root)).toContain('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb');
|
||||
|
||||
const deployRecentButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 dev-20260417-f67ade2')
|
||||
));
|
||||
|
||||
expect(deployRecentButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await deployRecentButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-f67ade2',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables only the recent Docker candidate that already matches the running helper image', async () => {
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-next',
|
||||
tagName: 'dev-20260417-next',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
|
||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '17',
|
||||
imageTag: 'dev',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
history: [],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:30:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'docker-hub-tag',
|
||||
lastResolvedDisplayVersion: 'dev',
|
||||
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
lastNotifiedCandidateKey: null,
|
||||
lastNotifiedAt: null,
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const runningCandidateButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '部署 dev'
|
||||
));
|
||||
const nextCandidateButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '部署 dev-20260417-next'
|
||||
));
|
||||
|
||||
expect(runningCandidateButton.props.disabled).toBe(true);
|
||||
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
|
||||
expect(nextCandidateButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await nextCandidateButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-next',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const compactText = collectText(root.root);
|
||||
expect(compactText).toContain('展开全部 3 条');
|
||||
expect(compactText).not.toContain('Earlier stable release');
|
||||
expect(compactText).not.toContain('回退到 revision 15');
|
||||
|
||||
const openHistoryButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('展开全部 3 条')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await openHistoryButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const modal = root.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modal-content')
|
||||
&& collectText(node).includes('全部 revision')
|
||||
));
|
||||
|
||||
expect(collectText(modal)).toContain('Earlier stable release');
|
||||
expect(collectText(modal)).toContain('回退到 revision 15');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks Docker Hub deploys when the current helper image already matches the target digest', async () => {
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '17',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dockerDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
||||
));
|
||||
|
||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
||||
expect(collectText(root.root)).toContain('当前已运行该镜像');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses timezone-less SQL runtime timestamps as UTC before formatting', async () => {
|
||||
const parseSpy = vi.spyOn(Date, 'parse');
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const parseInputs = parseSpy.mock.calls.map(([value]) => String(value));
|
||||
expect(parseInputs).toContain('2026-03-30T20:30:00Z');
|
||||
expect(parseInputs).not.toContain('2026-03-30 20:30:00');
|
||||
} finally {
|
||||
parseSpy.mockRestore();
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rollbackButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('回退到 revision 16')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await rollbackButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.rollbackUpdateCenter).toHaveBeenCalledWith({
|
||||
targetRevision: '16',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
|
||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('最近状态:succeeded');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
export const PAYLOAD_RULE_PROTOCOL_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: '全部平台' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'sub2api', label: 'Sub2API' },
|
||||
{ value: 'new-api', label: 'New API' },
|
||||
{ value: 'one-api', label: 'One API' },
|
||||
{ value: 'cliproxyapi', label: 'CLIProxyAPI' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'gemini-cli', label: 'Gemini CLI' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'anyrouter', label: 'AnyRouter' },
|
||||
{ value: 'done-hub', label: 'Done Hub' },
|
||||
{ value: 'one-hub', label: 'One Hub' },
|
||||
{ value: 'veloera', label: 'Veloera' },
|
||||
];
|
||||
@@ -0,0 +1,273 @@
|
||||
export type PayloadRuleAction = 'default' | 'default-raw' | 'override' | 'override-raw' | 'filter';
|
||||
export type VisualPayloadRuleValueMode = 'text' | 'json';
|
||||
|
||||
export type VisualPayloadRule = {
|
||||
id: string;
|
||||
action: PayloadRuleAction;
|
||||
modelPattern: string;
|
||||
protocol: string;
|
||||
path: string;
|
||||
value: string;
|
||||
valueMode: VisualPayloadRuleValueMode;
|
||||
};
|
||||
|
||||
let payloadRuleIdCounter = 0;
|
||||
|
||||
function nextPayloadRuleId(): string {
|
||||
payloadRuleIdCounter += 1;
|
||||
return `payload-rule-${payloadRuleIdCounter}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function formatJsonValue(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formatRawValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : formatJsonValue(value);
|
||||
}
|
||||
|
||||
export function createVisualPayloadRule(input: Partial<VisualPayloadRule> = {}): VisualPayloadRule {
|
||||
return {
|
||||
id: input.id || nextPayloadRuleId(),
|
||||
action: input.action || 'default',
|
||||
modelPattern: input.modelPattern || '',
|
||||
protocol: input.protocol || '',
|
||||
path: input.path || '',
|
||||
value: input.value || '',
|
||||
valueMode: input.valueMode || 'text',
|
||||
};
|
||||
}
|
||||
|
||||
export function isVisualPayloadRuleBlank(rule: VisualPayloadRule): boolean {
|
||||
return !rule.modelPattern.trim()
|
||||
&& !rule.protocol.trim()
|
||||
&& !rule.path.trim()
|
||||
&& !rule.value.trim();
|
||||
}
|
||||
|
||||
export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule[] {
|
||||
return [
|
||||
createVisualPayloadRule({
|
||||
action: 'default',
|
||||
modelPattern: 'gpt-*',
|
||||
protocol: 'codex',
|
||||
path: 'reasoning.effort',
|
||||
value: 'high',
|
||||
valueMode: 'text',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
const rules: VisualPayloadRule[] = [];
|
||||
const appendValueRules = (action: Exclude<PayloadRuleAction, 'filter'>, input: unknown) => {
|
||||
if (!Array.isArray(input)) return;
|
||||
for (const entry of input) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const models = Array.isArray(entry.models) ? entry.models : [];
|
||||
const params = isRecord(entry.params) ? entry.params : null;
|
||||
if (!params) continue;
|
||||
for (const model of models) {
|
||||
if (!isRecord(model)) continue;
|
||||
const modelPattern = asTrimmedString(model.name);
|
||||
if (!modelPattern) continue;
|
||||
const protocol = asTrimmedString(model.protocol);
|
||||
for (const [path, rawValue] of Object.entries(params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
if (action === 'default-raw' || action === 'override-raw') {
|
||||
rules.push(createVisualPayloadRule({
|
||||
action,
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: formatRawValue(rawValue),
|
||||
valueMode: 'json',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
rules.push(createVisualPayloadRule({
|
||||
action,
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: typeof rawValue === 'string' ? rawValue : formatJsonValue(rawValue),
|
||||
valueMode: typeof rawValue === 'string' ? 'text' : 'json',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const appendFilterRules = (input: unknown) => {
|
||||
if (!Array.isArray(input)) return;
|
||||
for (const entry of input) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const models = Array.isArray(entry.models) ? entry.models : [];
|
||||
const params = Array.isArray(entry.params) ? entry.params : [];
|
||||
for (const model of models) {
|
||||
if (!isRecord(model)) continue;
|
||||
const modelPattern = asTrimmedString(model.name);
|
||||
if (!modelPattern) continue;
|
||||
const protocol = asTrimmedString(model.protocol);
|
||||
for (const path of params) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
rules.push(createVisualPayloadRule({
|
||||
action: 'filter',
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: '',
|
||||
valueMode: 'text',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
appendValueRules('default', value.default);
|
||||
appendValueRules('default-raw', value.defaultRaw ?? value['default-raw']);
|
||||
appendValueRules('override', value.override);
|
||||
appendValueRules('override-raw', value.overrideRaw ?? value['override-raw']);
|
||||
appendFilterRules(value.filter);
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
type PayloadRulesSerializeSuccess = {
|
||||
success: true;
|
||||
value: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PayloadRulesSerializeFailure = {
|
||||
success: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type VisualPayloadRulesSerializeResult = PayloadRulesSerializeSuccess | PayloadRulesSerializeFailure;
|
||||
|
||||
type AggregatedValueRule = {
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AggregatedFilterRule = {
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: string[];
|
||||
};
|
||||
|
||||
function buildModelRule(rule: VisualPayloadRule): { name: string; protocol?: string } {
|
||||
const protocol = rule.protocol.trim();
|
||||
return protocol ? { name: rule.modelPattern.trim(), protocol } : { name: rule.modelPattern.trim() };
|
||||
}
|
||||
|
||||
function normalizeValueForRule(rule: VisualPayloadRule): { success: true; value: unknown } | PayloadRulesSerializeFailure {
|
||||
if (rule.action === 'filter') {
|
||||
return { success: true, value: undefined };
|
||||
}
|
||||
|
||||
if (rule.action === 'default-raw' || rule.action === 'override-raw') {
|
||||
const trimmed = rule.value.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, message: '原始 JSON 值不能为空' };
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
} catch (error: any) {
|
||||
return { success: false, message: `原始 JSON 无效:${error?.message || '解析失败'}` };
|
||||
}
|
||||
return { success: true, value: trimmed };
|
||||
}
|
||||
|
||||
if (rule.valueMode === 'json') {
|
||||
const trimmed = rule.value.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, message: 'JSON 值不能为空' };
|
||||
}
|
||||
try {
|
||||
return { success: true, value: JSON.parse(trimmed) };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: `JSON 值无效:${error?.message || '解析失败'}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule.value) {
|
||||
return { success: false, message: '文本值不能为空' };
|
||||
}
|
||||
return { success: true, value: rule.value };
|
||||
}
|
||||
|
||||
export function visualRulesToPayloadRules(rules: VisualPayloadRule[]): VisualPayloadRulesSerializeResult {
|
||||
const sections = {
|
||||
default: new Map<string, AggregatedValueRule>(),
|
||||
'default-raw': new Map<string, AggregatedValueRule>(),
|
||||
override: new Map<string, AggregatedValueRule>(),
|
||||
'override-raw': new Map<string, AggregatedValueRule>(),
|
||||
filter: new Map<string, AggregatedFilterRule>(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < rules.length; index += 1) {
|
||||
const rule = rules[index];
|
||||
if (isVisualPayloadRuleBlank(rule)) continue;
|
||||
|
||||
const modelPattern = rule.modelPattern.trim();
|
||||
if (!modelPattern) {
|
||||
return { success: false, message: `第 ${index + 1} 条规则缺少模型匹配` };
|
||||
}
|
||||
const path = rule.path.trim();
|
||||
if (!path) {
|
||||
return { success: false, message: `第 ${index + 1} 条规则缺少字段路径` };
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeValueForRule(rule);
|
||||
if (!normalizedValue.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: `第 ${index + 1} 条规则保存失败:${normalizedValue.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const groupKey = `${modelPattern}\u0000${rule.protocol.trim()}`;
|
||||
if (rule.action === 'filter') {
|
||||
const existing = sections.filter.get(groupKey) || {
|
||||
models: [buildModelRule(rule)],
|
||||
params: [],
|
||||
};
|
||||
if (!existing.params.includes(path)) {
|
||||
existing.params.push(path);
|
||||
}
|
||||
sections.filter.set(groupKey, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetSection = sections[rule.action];
|
||||
const existing = targetSection.get(groupKey) || {
|
||||
models: [buildModelRule(rule)],
|
||||
params: {},
|
||||
};
|
||||
existing.params[path] = normalizedValue.value;
|
||||
targetSection.set(groupKey, existing);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
default: Array.from(sections.default.values()),
|
||||
'default-raw': Array.from(sections['default-raw'].values()),
|
||||
override: Array.from(sections.override.values()),
|
||||
'override-raw': Array.from(sections['override-raw'].values()),
|
||||
filter: Array.from(sections.filter.values()),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user