Desktop D3 writes in the window: push listing text, upload graphics, screenshots and previews, render jobs, catalog sync and price export, Play rollout share / halt / resume / complete and iOS phased release controls, TestFlight What to Test, internal groups and testers, reply editor with approve and send; every one a dry run, a native dialog, then the real run

This commit is contained in:
CRTkafa 2026-10-11 04:25:53 +08:00
parent f47c00bed0
commit ba58895d3a
15 changed files with 573 additions and 69 deletions

View file

@ -1,11 +1,13 @@
// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
// rollout %), then every version read from the stores. Submit and release stay in the consoles.
import { api, type ReleaseTrain } from '../../api';
import { useState } from 'react';
import { api, type ReleaseTrain, type StoreRelease } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
import { ago, date, share } from '../../lib/format';
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
import { useData, useRead } from '../../store';
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Bar, Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
@ -48,7 +50,54 @@ function LaneRow({ lane, version }: { lane: Lane; version: string }) {
);
}
function Train({ title, train, tf, tone }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok' }) {
const SHARES = [1, 2, 5, 10, 20, 50];
/** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release
* pause / resume / everyone, or turning the phased release on for an upcoming iOS version. Owner actions: each one is
* a dry run first, then a system dialog that names it. */
function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
const w = useWrite(app.project?.id ?? null);
const pct = r.fraction !== null ? Math.round(r.fraction * 100) : null;
const [share, setShare] = useState(() => String(SHARES.find(s => pct === null || s > pct) ?? 50));
if (!app.project) return null;
const v = r.version ?? '';
if (r.store === 'android' && (r.phase === 'rollout' || r.phase === 'halted')) {
const track = r.track && ['production', 'beta', 'alpha', 'internal'].includes(r.track) ? r.track : 'production';
const next = SHARES.filter(s => pct === null || s > pct);
return (
<div className="roll-ctl">
<span className="small muted">{t('roll.playNow', { pct: pct ?? 0 })}</span>
{r.phase === 'rollout' && next.length > 0 && <>
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)} aria-label={t('roll.share')}>{next.map(s => <option key={s} value={s}>{s} %</option>)}</select>
<WriteButton run={w} icon="arrowRight" w={{ method: 'owner.play_rollout', args: { action: 'percent', percent: Number(share), track }, title: t('roll.toShare', { pct: share }), done: t('roll.done') }}>{t('roll.toShare', { pct: share })}</WriteButton>
</>}
{r.phase === 'rollout' && <WriteButton run={w} icon="pause" w={{ method: 'owner.play_rollout', args: { action: 'halt', track }, title: t('roll.halt'), done: t('roll.halted') }} hint={t('roll.haltHint')}>{t('roll.halt')}</WriteButton>}
{r.phase === 'halted' && <WriteButton run={w} icon="play" w={{ method: 'owner.play_rollout', args: { action: 'resume', track }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
<WriteButton run={w} icon="users" w={{ method: 'owner.play_rollout', args: { action: 'complete', track }, title: t('roll.complete'), done: t('roll.done') }} hint={t('roll.completeHint')}>{t('roll.complete')}</WriteButton>
</div>
);
}
if (r.store === 'ios' && r.phased && (r.phased.state === 'ACTIVE' || r.phased.state === 'PAUSED')) {
return (
<div className="roll-ctl">
<span className="small muted">{t('roll.iosNow', { day: r.phased.day ?? 0 })}</span>
{r.phased.state === 'ACTIVE' && <WriteButton run={w} icon="pause" w={{ method: 'owner.ios_phased', args: { action: 'pause', version: v }, title: t('roll.pause'), done: t('roll.paused') }} hint={t('roll.pauseHint')}>{t('roll.pause')}</WriteButton>}
{r.phased.state === 'PAUSED' && <WriteButton run={w} icon="play" w={{ method: 'owner.ios_phased', args: { action: 'resume', version: v }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
<WriteButton run={w} icon="users" w={{ method: 'owner.ios_phased', args: { action: 'complete', version: v }, title: t('roll.everyone'), done: t('roll.done') }} hint={t('roll.everyoneHint')}>{t('roll.everyone')}</WriteButton>
</div>
);
}
if (r.store === 'ios' && !r.phased && ['draft', 'review', 'approved'].includes(r.phase) && /^\d+(\.\d+){0,2}$/.test(v)) {
return (
<div className="roll-ctl">
<WriteButton run={w} icon="layers" w={{ method: 'owner.ios_phased', args: { action: 'on', version: v }, title: t('roll.phasedOn'), done: t('roll.phasedOnDone') }} hint={t('roll.phasedOnHint')}>{t('roll.phasedOn')}</WriteButton>
</div>
);
}
return null;
}
function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) {
const lanes = trainLanes(train, tf);
return (
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone}>{title}</Chip></span>}
@ -57,6 +106,11 @@ function Train({ title, train, tf, tone }: { title: string; train: ReleaseTrain;
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} />)}
</div>
{lanes.some(l => l.release) && app.project && (
<div className="card-foot roll-foot">
{lanes.map(l => l.release && <RolloutControls key={l.store} app={app} r={l.release} />)}
</div>
)}
</Card>
);
}
@ -83,8 +137,8 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
return (
<>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" />}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>