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:
+5
-31
@@ -6764,6 +6764,7 @@ textarea {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
contain: layout paint;
|
||||
will-change: transform, opacity, clip-path;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@@ -6777,42 +6778,15 @@ textarea {
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-entering .route-card-detail-panel {
|
||||
will-change: transform, opacity, clip-path;
|
||||
animation: route-detail-panel-in 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
will-change: transform, opacity, clip-path;
|
||||
animation: route-detail-panel-out 0.2s cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.route-detail-panel-presence.is-entering .route-card-detail-panel,
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-6px) scale(0.994);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-in {
|
||||
0% {
|
||||
from {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-8px) scale(0.992);
|
||||
@@ -6824,7 +6798,7 @@ textarea {
|
||||
transform: translateY(0) scale(1.006);
|
||||
}
|
||||
|
||||
100% {
|
||||
to {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
@@ -6832,13 +6806,13 @@ textarea {
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-out {
|
||||
0% {
|
||||
from {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
to {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-6px) scale(0.994);
|
||||
|
||||
+11
-11
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user