e2646346a6
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。
阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
红线词扫描(铁律13) CI 红线,零命中。
阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。
验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
5.6 KiB
TypeScript
108 lines
5.6 KiB
TypeScript
'use client';
|
|
// Overview.tsx — 概览(套餐横幅 + 用量指标 + 近 7 日柱状图 + 快速操作)。承袭 UCOverview。
|
|
import React from 'react';
|
|
import { Icon } from './icons';
|
|
import { card } from './shared';
|
|
import type { TFn, Lang } from '../lib/i18n';
|
|
import type { Me } from '../lib/api/types';
|
|
|
|
export default function Overview({
|
|
t,
|
|
lang,
|
|
me,
|
|
mobile,
|
|
goSub,
|
|
goRedeem,
|
|
}: {
|
|
t: TFn;
|
|
lang: Lang;
|
|
me: Me;
|
|
mobile: boolean;
|
|
goSub: () => void;
|
|
goRedeem: () => void;
|
|
}) {
|
|
const free = me.plan === 'free';
|
|
const vals = me.weeklyGB;
|
|
const max = Math.max(...vals, 0.1);
|
|
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
|
|
|
const stats: [string, string, string, string][] = [
|
|
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
|
|
['arrow-down', t('dataToday'), me.dataTodayGB.toFixed(1), 'GB'],
|
|
['smartphone', t('devices'), `${me.devicesUsed} / ${me.devicesMax}`, ''],
|
|
];
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
<div>
|
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('greeting')}</div>
|
|
<div style={{ fontSize: 13.5, color: 'var(--fg3)', marginTop: 3, fontFamily: 'var(--font-mono)' }}>{me.email}</div>
|
|
</div>
|
|
|
|
{/* plan banner */}
|
|
<div style={{ ...card, background: 'linear-gradient(150deg,var(--clay-600),var(--clay-800))', border: 'none', color: '#fff', padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<div style={{ width: 46, height: 46, borderRadius: '50%', background: 'rgba(255,255,255,.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Icon name="crown" size={23} color="#fff" />
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 12, opacity: 0.8 }}>{t('curPlan')}</div>
|
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 19, fontWeight: 700, marginTop: 2 }}>{free ? t('freePlan') : t('proMember')}</div>
|
|
<div style={{ fontSize: 11.5, opacity: 0.8, marginTop: 3, fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>
|
|
{free ? t('quotaFree') : `${t('expires')} ${me.expiresAt ?? '—'}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={goRedeem} style={{ border: 'none', background: '#fff', color: 'var(--clay-700)', fontWeight: 700, fontSize: 13.5, padding: '11px 20px', borderRadius: 'var(--radius-full)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
|
<Icon name="ticket" size={15} color="var(--clay-700)" />
|
|
{t('renew')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* stats */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 14 }}>
|
|
{stats.map(([ic, l, v, u]) => (
|
|
<div key={l} style={{ ...card, padding: '16px 18px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--fg3)', fontSize: 12, fontWeight: 600 }}>
|
|
<Icon name={ic} size={15} color="var(--accent)" />
|
|
{l}
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 23, fontWeight: 500, color: 'var(--fg1)', marginTop: 7 }}>
|
|
{v}
|
|
{u && <span style={{ fontSize: 12, color: 'var(--fg3)' }}> {u}</span>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* usage chart + quick actions */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
|
|
<div style={{ ...card, padding: '18px 22px' }}>
|
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
|
|
{vals.map((v, i) => (
|
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
|
|
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
|
|
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ ...card, padding: '16px 18px' }}>
|
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 12 }}>{t('quickSub')}</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{([['link', t('qaSub'), goSub], ['ticket', t('qaRedeem'), goRedeem], ['download', t('qaApp'), null]] as [string, string, (() => void) | null][]).map(([ic, l, fn]) => (
|
|
<button key={l} onClick={fn || undefined} style={{ display: 'flex', alignItems: 'center', gap: 11, width: '100%', textAlign: 'left', cursor: 'pointer', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '11px 13px' }}>
|
|
<Icon name={ic} size={16} color="var(--accent)" />
|
|
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)' }}>{l}</span>
|
|
<Icon name="chevron-right" size={15} color="var(--fg3)" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|