Desktop D2 UI: design system, portfolio, release timeline, catalog parity, listings, health, reviews, TestFlight, checks, command palette, toasts, first-run guide, en + tr with i18n check, 7:1 theme check, dev fixture mode and screenshot harness

This commit is contained in:
CRTkafa 2026-10-11 03:34:47 +08:00
parent 95b7cf7ced
commit 2ef0769730
44 changed files with 5475 additions and 742 deletions

View file

@ -0,0 +1,116 @@
// 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 Project, type ReleaseTrain } from '../../api';
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 { Icon, StoreGlyph } from '../../ui/icons';
import { Bar, Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
function stageCaption(l: Lane, i: number): string {
if (i !== l.at || !l.release) return '';
const r = l.release;
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
if (i === 2) return t(`phase.${r.phase}` as Key);
return '';
}
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
const r = lane.release;
return (
<div className="tl-lane">
<div className="tl-who">
<StoreGlyph store={lane.store} />
<div>
<div className="w1">{storeName(lane.store)}</div>
<div className="w2">{r ? `${version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
</div>
</div>
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
const last = s === 'rollout';
return (
<div key={s} className={`tl-step ${cls}${i === 0 ? ' first' : ''}`} title={i === lane.at ? r?.state : undefined}>
<span className="tl-node">{i < lane.at && <Icon name="check" />}</span>
{last && i === lane.at && lane.share !== null ? (
<div className="tl-roll"><Bar value={lane.share} tone={lane.tone === 'bad' ? 'bad' : lane.share >= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} />
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span></div>
) : <span className="tl-cap">{stageCaption(lane, i)}</span>}
</div>
);
})}
</div>
);
}
function Train({ title, train, tf, tone }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok' }) {
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>}
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
<div className="tl" style={{ paddingTop: 12 }}>
<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>
</Card>
);
}
export function ReleasesTab({ project }: { project: Project }) {
const d = useData();
const l = d.liveOf(project);
const tf = useRead<TestFlightStatus>(project.id, 'apple.testflight_status', { network: true, title: 'TestFlight' });
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
if (!l) {
return (
<Card pad={false}>
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
{!project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
</>}>{t('releases.notReadText')}</Empty>
</Card>
);
}
const history = versionHistory(l.release.all ?? []);
const errs = Object.entries(l.storeErrors);
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 && !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}>
{history.length ? (
<div className="table-wrap">
<table className="table">
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
<tbody>
{history.map(h => (
<tr key={h.version}>
<td className="strong num">{h.version}</td>
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.ios?.build ?? '–'}</td>
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.android?.build ?? '–'}</td>
<td className="muted">{h.android?.track ?? '–'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
</Card>
<Notice tone="info" icon="shield" action={<div className="actions">
{l.app?.platforms.ios && <button className="btn btn-sm" onClick={() => open(ascUrl(l.app?.platforms.ios?.ascAppId))}><Icon name="external" />App Store Connect</button>}
{l.app?.platforms.android && <button className="btn btn-sm" onClick={() => open(PLAY_CONSOLE)}><Icon name="external" />Play Console</button>}
</div>}>{t('releases.consoleNote')}</Notice>
</>
);
}