Prepare the dev snapshot for main review on a clean release branch

This release branch starts from the latest origin/main and folds in the
current origin/dev snapshot so the PR to main can follow the repo's
publish flow without opening directly from dev. Conflict-heavy files
were normalized back to the dev snapshot where necessary so the release
branch reflects the code currently being carried on dev instead of a
line-merged hybrid.

Constraint: Publication flow requires a clean release branch from latest origin/main rather than a PR directly from dev
Constraint: The branch must preserve the current dev snapshot even when main and dev have drifted substantially
Rejected: Open the PR directly from dev | violates the repo publication flow
Rejected: Manually redesign every historic dev/main conflict during release prep | too broad for a publish branch
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat this branch as a publication snapshot of dev-on-top-of-main, not as a general cleanup pass over old divergence
Tested: update-center focused vitest suite on release branch; typecheck; build:web; docs:build; repo:drift-check
Not-tested: Full npm test remains red from inherited tmp/sub2api frontend suites and an existing firstByteTimeout unhandled error
This commit is contained in:
cita-777
2026-04-17 22:58:06 +08:00
141 changed files with 13603 additions and 7426 deletions
+11 -11
View File
@@ -8,7 +8,6 @@ import ModernSelect from '../components/ModernSelect.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
import {
createCodexDefaultHighReasoningVisualPreset,
createVisualPayloadRule,
@@ -210,6 +209,15 @@ const PAYLOAD_RULE_ACTION_OPTIONS: Array<{ value: PayloadRuleAction; label: stri
{ value: 'filter', label: '删除字段' },
];
const PAYLOAD_RULE_PROTOCOL_OPTIONS = [
{ value: '', label: '全部协议' },
{ value: 'codex', label: 'Codex' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'claude', label: 'Claude' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
];
const PAYLOAD_RULE_VALUE_MODE_OPTIONS: Array<{ value: VisualPayloadRuleValueMode; label: string }> = [
{ value: 'text', label: '文本' },
{ value: 'json', label: 'JSON' },
@@ -639,14 +647,6 @@ export default function Settings() {
const applyVisualPayloadRules = (
nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]),
) => {
if (
payloadAdvancedDirty
&& typeof globalThis.confirm === 'function'
&& !globalThis.confirm('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?')
) {
return;
}
setPayloadVisualRules((currentRules) => {
const nextRules = typeof nextRulesOrUpdater === 'function'
? nextRulesOrUpdater(currentRules)
@@ -1629,14 +1629,14 @@ export default function Settings() {
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>上游类型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>协议</div>
<ModernSelect
size="sm"
data-testid={`payload-rule-protocol-${index + 1}`}
value={rule.protocol}
onChange={(value) => updatePayloadVisualRule(rule.id, { protocol: String(value || '') })}
options={PAYLOAD_RULE_PROTOCOL_OPTIONS}
placeholder="全部上游类型"
placeholder="全部协议"
/>
</div>
<div>
+14 -39
View File
@@ -93,14 +93,8 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
sourceRouteIds: [],
advancedOpen: false,
};
const DESKTOP_DETAIL_ENTER_MS = 260;
const DESKTOP_DETAIL_COLLAPSE_MS = 200;
function prefersReducedMotion(): boolean {
return typeof globalThis.matchMedia === 'function'
&& globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
if (value === 'round_robin') return '已切换为轮询策略';
if (value === 'stable_first') return '已切换为稳定优先策略';
@@ -120,44 +114,30 @@ export function DesktopDetailPanelPresence({
}) {
const [shouldRender, setShouldRender] = useState(open);
const [isOpen, setIsOpen] = useState(open);
const [isEntering, setIsEntering] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const hasEverOpenedRef = useRef(open);
useEffect(() => {
const reduceMotion = prefersReducedMotion();
if (open) {
hasEverOpenedRef.current = true;
setShouldRender(true);
setIsOpen(true);
setIsClosing(false);
if (reduceMotion) {
setIsEntering(false);
return undefined;
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsOpen(true));
return () => window.cancelAnimationFrame(rafId);
}
setIsEntering(true);
const enterTimerId = globalThis.setTimeout(() => {
setIsEntering(false);
}, DESKTOP_DETAIL_ENTER_MS);
return () => globalThis.clearTimeout(enterTimerId);
setIsOpen(true);
return undefined;
}
if (!hasEverOpenedRef.current) {
setShouldRender(false);
setIsOpen(false);
setIsEntering(false);
setIsClosing(false);
return undefined;
}
setIsOpen(false);
setIsEntering(false);
if (reduceMotion) {
setShouldRender(false);
setIsClosing(false);
return undefined;
}
setIsClosing(true);
const timerId = globalThis.setTimeout(() => {
setShouldRender(false);
@@ -170,7 +150,7 @@ export function DesktopDetailPanelPresence({
if (!shouldRender) return null;
return (
<div
className={`route-detail-panel-presence ${isOpen ? 'is-open' : ''} ${isEntering ? 'is-entering' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
className={`route-detail-panel-presence ${isOpen ? 'is-open' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
style={{ gridColumn: '1 / -1' }}
>
{children(isClosing)}
@@ -1300,20 +1280,15 @@ export default function TokenRoutes() {
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) {
if (!isMobile) {
const reduceMotion = prefersReducedMotion();
if (reduceMotion) {
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
} else {
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
if (existingTimer) {
globalThis.clearTimeout(existingTimer);
}
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
delete desktopDetailCloseTimersRef.current[routeId];
}, DESKTOP_DETAIL_COLLAPSE_MS);
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
if (existingTimer) {
globalThis.clearTimeout(existingTimer);
}
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
delete desktopDetailCloseTimersRef.current[routeId];
}, DESKTOP_DETAIL_COLLAPSE_MS);
}
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
} else {
@@ -102,7 +102,6 @@ describe('Settings payload rules', () => {
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('loads saved payload rules into the editor', async () => {
@@ -367,81 +366,4 @@ describe('Settings payload rules', () => {
root?.unmount();
}
});
it('asks before discarding unsynced advanced JSON when switching back to visual edits', async () => {
const confirmMock = vi.fn().mockReturnValue(false);
vi.stubGlobal('confirm', confirmMock);
apiMock.getRuntimeSettings.mockResolvedValueOnce({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
adminIpAllowlist: [],
systemProxyUrl: '',
payloadRules: {},
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const overrideTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override'
));
const advancedDraft = `[
{
"models": [{ "name": "gpt-*", "protocol": "codex" }],
"params": {
"text.verbosity": "low"
}
}
]`;
await act(async () => {
overrideTextarea.props.onChange({ target: { value: advancedDraft } });
});
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '新增规则'
));
await act(async () => {
addButton.props.onClick();
});
expect(confirmMock).toHaveBeenCalledWith('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?');
expect(root.root.findAll((node) => (
node.type === 'input'
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
))).toHaveLength(0);
expect(String(root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override'
)).props.value)).toBe(advancedDraft);
} finally {
root?.unmount();
}
});
});
@@ -375,6 +375,62 @@ describe('UpdateCenterSection', () => {
}
});
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('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
let root!: ReactTestRenderer;
try {
+94 -17
View File
@@ -223,6 +223,10 @@ export default function UpdateCenterSection() {
const [deploying, setDeploying] = useState(false);
const [status, setStatus] = useState<UpdateCenterStatus | null>(null);
const [config, setConfig] = useState(DEFAULT_CONFIG);
const [manualDockerTarget, setManualDockerTarget] = useState({
tag: '',
digest: '',
});
const [logs, setLogs] = useState<string[]>([]);
const [taskStatus, setTaskStatus] = useState('');
const [historyModalOpen, setHistoryModalOpen] = useState(false);
@@ -426,6 +430,13 @@ export default function UpdateCenterSection() {
});
const canDeployGithub = !deploying && githubDeployState.canDeploy;
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
const canDeployManualDocker = !deploying
&& config.enabled
&& config.dockerHubTagsEnabled
&& helperHealthy
&& !!manualDockerTag;
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
const historyPreview = helperHistory.slice(0, 2);
const currentRevision = String(status?.helper?.revision || '').trim();
@@ -559,7 +570,7 @@ export default function UpdateCenterSection() {
/>
<span style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
<span style={fieldHintStyle}>从镜像标签中找出最新稳定版本,适合镜像优先的发布链路。</span>
<span style={fieldHintStyle}>自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。</span>
</span>
</label>
</div>
@@ -720,7 +731,7 @@ export default function UpdateCenterSection() {
) : null}
</div>
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>自动候选优先 latest / main / 稳定 SemVer;dev / 分支 / sha 标签可手动填写部署。</div>
<div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
</div>
@@ -730,21 +741,87 @@ export default function UpdateCenterSection() {
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
</div>
<button
type="button"
onClick={() => {
if (!helperHealthy) return;
void runDeploy('docker-hub-tag', {
tag: status?.dockerHubTag?.tagName || status?.dockerHubTag?.normalizedVersion || '',
digest: status?.dockerHubTag?.digest || null,
});
}}
disabled={!canDeployDocker}
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
style={config.defaultDeploySource === 'docker-hub-tag' ? undefined : { border: '1px solid var(--color-border)' }}
>
部署 Docker Hub 标签
</button>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button
type="button"
onClick={() => {
if (!helperHealthy) return;
void runDeploy('docker-hub-tag', {
tag: status?.dockerHubTag?.tagName || status?.dockerHubTag?.normalizedVersion || '',
digest: status?.dockerHubTag?.digest || null,
});
}}
disabled={!canDeployDocker}
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
style={config.defaultDeploySource === 'docker-hub-tag' ? undefined : { border: '1px solid var(--color-border)' }}
>
部署 Docker Hub 标签
</button>
{status?.dockerHubTag?.tagName ? (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => {
setManualDockerTarget({
tag: status?.dockerHubTag?.tagName || '',
digest: status?.dockerHubTag?.digest || '',
});
}}
>
填入当前候选
</button>
) : null}
</div>
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
手动部署 Docker Hub 标签
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: 8,
marginBottom: 8,
}}
>
<input
value={manualDockerTarget.tag}
onChange={(e) => setManualDockerTarget((prev) => ({ ...prev, tag: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
placeholder="dev / dev-20260417-f67ade2 / sha-f67ade2"
/>
<input
value={manualDockerTarget.digest}
onChange={(e) => setManualDockerTarget((prev) => ({ ...prev, digest: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
placeholder="可选 digest:sha256:..."
/>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
disabled={!canDeployManualDocker}
onClick={() => {
if (!canDeployManualDocker) return;
void runDeploy('docker-hub-tag', {
tag: manualDockerTag,
digest: manualDockerDigest || null,
});
}}
>
部署自定义 Docker 标签
</button>
<span style={fieldHintStyle}>
digest 选填;如果 chart 当前锁定了旧 digest,建议把 tag 和 digest 一起填写。
</span>
</div>
</div>
</div>
</div>