report: storebound report writes one shareable, self-contained readiness page (.storebound/reports/readiness-<date>.html; --md, --json, --no-open, --offline, --out) from scan, check and validate: a verdict per store (Ready to submit / N blockers / Not checked with the reason), findings per store by severity with guideline, evidence, fix and console button, the owner's compliance questions as a checklist, secrets masked; storebound.dev look, light / dark, print; desktop Checks > Report (same code path, opens through project_reveal) and its Markdown export on the same renderer; read-only MCP tool report

This commit is contained in:
CRTkafa 2026-10-18 01:42:03 +08:00
parent 6d95ea00f5
commit af349b3db1
18 changed files with 792 additions and 12 deletions

View file

@ -7,7 +7,9 @@ import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, Validat
import { useData, useRead, useWrite } from '../../store';
import { WriteButton } from '../../ui/WriteButton';
import { AiFix, aiFinding } from '../../ui/AiFix';
import { api } from '../../api';
import { api, errorText, newJob } from '../../api';
import type { ToolResult } from '../../api';
import type { ReportData } from '../../../../../cli/lib/report';
import { useEffect, useState } from 'react';
import { Icon } from '../../ui/icons';
import { OpenLink } from '../../ui/OpenLink';
@ -15,6 +17,8 @@ import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/
import { exportName, validateMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
type ReportResult = ReportData & { file: string; path: string };
const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'alert' | 'check']> = { blocker: ['bad', 'alertCircle'], warn: ['warn', 'alert'], ok: ['ok', 'check'] };
/** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store
@ -93,18 +97,55 @@ function ScanCard({ project }: { project: Project }) {
);
}
/** The shareable readiness report (`storebound report`, the same code path as the CLI): one HTML page in
* .storebound/reports/, opened in the default browser through project_reveal (the host only opens that page). */
function ReportBar({ project }: { project: Project }) {
const d = useData();
const [busy, setBusy] = useState(false);
const [last, setLast] = useState<ReportResult | null>(null);
const run = async () => {
setBusy(true);
try {
const r = await api.call<ToolResult & { data?: ReportResult }>('report', project.id, {}, newJob('report'));
if (!r.ok || !r.data?.path) throw new Error(r.output || t('checks.reportFailed'));
setLast(r.data);
await api.reveal(project.id, r.data.path, true);
} catch (e) { d.toast({ kind: 'bad', title: t('checks.reportFailed'), detail: errorText(e) }); }
finally { setBusy(false); }
};
return (
<Card title={t('checks.report')} icon="external" sub={last ? reportSummary(last) : t('checks.reportSub')}
actions={<button className="btn btn-sm btn-primary" onClick={run} disabled={busy}><Icon name={busy ? 'refresh' : 'external'} className={busy ? 'spin' : undefined} />{t('checks.reportOpen')}</button>}>
{null}
</Card>
);
}
/** One line, both stores: "App Store: 2 blockers · Google Play: not checked". */
export function reportSummary(r: ReportResult): string {
const one = (s: 'apple' | 'play') => {
const x = r.summary[s], read = r.read[s];
const v = x.blockers ? t('checks.reportBlockers', { count: x.blockers }) : !r.stores.includes(s) ? t('checks.reportNotSetUp') : !read.checked ? t('checks.reportNotChecked') : t('checks.reportReady');
return `${s === 'apple' ? 'App Store' : 'Google Play'}: ${v}`;
};
return `${one('apple')} · ${one('play')}`;
}
export function ChecksTab({ project }: { project: Project }) {
const d = useData();
const e = d.statusOf(project);
const v = useRead<ValidateResult>(project.id, 'validate', { network: true, title: t('checks.validate') });
const w = useWrite(project.id);
const name = e?.summary?.name ?? project.name;
useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'), build: () => validateMd(name, v.data!, v.at) }]);
const stores = (['apple', 'play'] as const).filter(x => !!e?.summary?.[x]);
useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'),
build: () => validateMd(name, v.data!, v.at, { check: e?.check ?? null, version: d.info?.version, ...(stores.length ? { stores } : {}) }) }]);
const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])];
const warnings = e?.check?.warnings ?? [];
const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level));
return (
<div className="grid g2" style={{ alignItems: 'start' }}>
<div style={{ gridColumn: '1 / -1' }}><ReportBar project={project} /></div>
<div style={{ gridColumn: '1 / -1' }}><ScanCard project={project} /></div>
<Card title={t('checks.offline')} icon="shield" sub={t('checks.offlineSub')} pad={false}
actions={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>}>