收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -1,4 +1,4 @@
import { Check, X } from 'lucide-react';
import { Check } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
@@ -7,9 +7,7 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import {
isWechatMiniProgramWebViewRuntime,
} from '../../services/authService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
@@ -17,11 +15,21 @@ import {
persistLegalConsent,
readStoredLegalConsent,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
const LOGIN_TAB_ITEMS: Array<{ id: LoginTab; label: string }> = [
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
];
type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
@@ -199,14 +207,12 @@ export function LoginScreen({
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<button
type="button"
<PlatformModalCloseButton
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
label="关闭登录弹窗"
variant="platformIcon"
className="p-2"
/>
</div>
{isResetPanelOpen ? (
@@ -233,147 +239,146 @@ export function LoginScreen({
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
<PlatformSegmentedTabs
items={
passwordLoginEnabled
? LOGIN_TAB_ITEMS
: LOGIN_TAB_ITEMS.slice(0, 1)
}
activeId={activeLoginTab}
onChange={setActiveLoginTab}
columns={passwordLoginEnabled ? 'two' : 'one'}
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
<div className="flex flex-col gap-2">
<PlatformActionButton
type="submit"
disabled={
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
size="lg"
>
{loggingIn ? '登录中' : '登录'}
</PlatformActionButton>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-4 py-4 text-sm text-[var(--platform-text-base)]"
>
</PlatformSubpanel>
) : null}
</div>
)}
</div>
@@ -439,13 +444,7 @@ function LegalConsentRow({
);
}
function LegalLink({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
function LegalLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
@@ -457,35 +456,6 @@ function LegalLink({
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,
@@ -569,10 +539,11 @@ function PhoneCodeForm({
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
@@ -580,7 +551,7 @@ function PhoneCodeForm({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
@@ -594,13 +565,9 @@ function PhoneCodeForm({
{error ? <ErrorBanner message={error} /> : null}
{legalConsentNode}
<button
type="submit"
disabled={submitBlocked}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
<PlatformActionButton type="submit" disabled={submitBlocked} size="lg">
{loggingIn ? '处理中' : submitLabel}
</button>
</PlatformActionButton>
</form>
);
}
@@ -663,10 +630,11 @@ function PasswordResetPanel({
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
@@ -674,7 +642,7 @@ function PasswordResetPanel({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -692,22 +660,18 @@ function PasswordResetPanel({
{error ? <ErrorBanner message={error} /> : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="platform-button platform-button--secondary h-12 px-4 text-base"
onClick={onBack}
>
<PlatformActionButton tone="secondary" size="lg" onClick={onBack}>
</button>
<button
</PlatformActionButton>
<PlatformActionButton
type="submit"
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
size="lg"
>
{loggingIn ? '处理中' : '重置密码'}
</button>
</PlatformActionButton>
</div>
</form>
);
@@ -723,29 +687,29 @@ function WechatButton({
onClick: () => Promise<void>;
}) {
return (
<button
type="button"
<PlatformActionButton
disabled={loading || disabled}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
tone="secondary"
size="lg"
onClick={() => void onClick()}
>
{loading ? '跳转中' : '微信登录'}
</button>
</PlatformActionButton>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{message}
</div>
</PlatformStatusMessage>
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--success text-sm">
<PlatformStatusMessage tone="success" surface="profile">
{message}
</div>
</PlatformStatusMessage>
);
}