desktop: one read registry for every read button: single-flight per canonical key, broad reads cover narrow keys, overlapping buttons all busy and disabled (useReadState), 20 s 'read just now' memo, quota pause on the buttons, every read through the scheduler (Check again, Import apps, account Test, Read the stores, Dependencies), writes invalidate only the reads they change; tests/read-registry.test.ts; fixtures ?blocked and window.__fakeCalls

This commit is contained in:
CRTkafa 2026-10-23 11:09:51 +08:00
parent 7345de2f7c
commit c03938f9dc
14 changed files with 861 additions and 164 deletions

View file

@ -10,7 +10,9 @@ import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type St
import { type Key, t } from '../i18n';
import { ago } from '../lib/format';
import { withIgnored } from '../lib/identity';
import { type AccountHealth, useData } from '../store';
import { type AccountHealth, useData, useReadState } from '../store';
import { accountCheckKey, importKey } from '../lib/read-registry';
import { ImportAppsButton, TestAccountButton } from '../ui/ReadButtons';
import { Icon, StoreGlyph } from '../ui/icons';
import { Chip, Notice, StudioMark } from '../ui/kit';
import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds';
@ -58,11 +60,14 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
};
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); };
const check = h?.check;
// Test and Import of this account: busy from the read registry (Test all / Import apps make them busy too)
const ck = useReadState(accountCheckKey(a.id));
const im = useReadState(importKey(a.id));
const cm = useContextMenu();
const more = useMenuButton();
const menu = (): MenuInput => [
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
{ label: t('acct.test'), icon: 'shield', disabled: ck.busy ? t('cm.busy') : false, onSelect: () => { d.checkAccount(a.id).catch(() => {}); } },
{ label: t('acct.import'), icon: 'download', disabled: im.busy ? t('cm.busy') : false, onSelect: () => { d.importApps(a.id).catch(() => {}); } },
{ label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
@ -90,8 +95,8 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
</div>
</div>
<div className="actions">
<button className="btn btn-sm" onClick={() => run(`ck-${a.id}`, () => d.checkAccount(a.id))} disabled={!!busy}><Icon name="shield" className={busy === `ck-${a.id}` ? 'spin' : undefined} />{t('acct.test')}</button>
<button className="btn btn-sm" onClick={() => run(`im-${a.id}`, () => d.importApps(a.id))} disabled={!!busy || d.importBusy}><Icon name="download" />{t('acct.import')}</button>
<TestAccountButton account={a.id} label={t('acct.test')} />
<ImportAppsButton account={a.id} label={t('acct.import')} />
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setEdit(x => !x)} aria-expanded={edit} aria-label={t('acct.editAria', { label: a.label })} title={t('acct.edit')}><Icon name="edit" /></button>
<button className="btn btn-sm btn-ghost btn-icon" {...more(menu)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" /></button>
</div>
@ -196,7 +201,6 @@ export function Accounts({ focus }: { focus?: string }) {
const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name }));
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label }));
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label }));
const testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); };
const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio));
const studioSelect = (id: string, value: string, set: (v: string) => void) => (
<div className="field"><label htmlFor={id}>{t('studio.studio')}</label>
@ -209,8 +213,8 @@ export function Accounts({ focus }: { focus?: string }) {
return (
<div className="page scr-accounts">
<PageHeader icon="key" eyebrow={t('v2cfg.workspace')} title={t('nav.accounts')} subtitle={t('accounts.leadStudios')} actions={<>
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
<TestAccountButton className="btn" label={t('acct.testAll')} />
<ImportAppsButton className="btn btn-primary" />
</>} />
{d.accounts.length > 0 && <div className="ds-grid acct-kpis">
<StatTile icon="building" label={t('v2cfg.studios')} value={d.studios.length} />