chore(design): stats-overhaul 设计 WIP — CONTRACT + ui_kits 统计页规格

stats-overhaul 的设计稿(CONTRACT.md + mobile/tablet/desktop ui_kits)。

注:这批 ui_kits 描述的是统计页「上聚合·下分设备」原方案;统计页后续已
重设计为「月/周/日 周期卡 + 设备下拉 + 两周折线」(见 client/lib/screens/
stats_page.dart)。设计稿与新页布局有出入,待更新到新布局(单列 TODO 跟踪)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-28 18:13:49 +08:00
parent bf84492eef
commit b98ad9dce4
6 changed files with 85 additions and 3 deletions
+4 -3
View File
@@ -58,10 +58,11 @@
- 搜索框:bg-subtle、radius md、padding `11 14`、maxWidth 380、marginBottom 14。
- 网格:**2 列 `1fr 1fr`gap 11**;每格 padding `13 15`、minHeight 56、active=accent-subtle+accent-borderCC + 名(14.5/600) + 副 + ping(mono 12) + Signal + active check。
### 统计视图 TabStats
### 统计视图 TabStats(上聚合 · 下分设备)
- padding `6 28 24`
- 3 指标卡一行,gap 14marginBottom 20;各 padding `18 20`label(12.5/fg3/600) + 值(mono 26/fg1)。
- 周柱卡 padding `20 22`标题(13.5/600) + 7 柱(gap 22, 容器高150, 柱 maxWidth 40, accent opacity .85, radius `6 6 0 0`) + 值(mono 10.5) + 星期(11.5)
- **上聚合**3 指标卡一行,gap 14marginBottom 20;各 padding `18 20`label(12.5/fg3/600) + 值(mono 26/fg1)。周柱卡 padding `20 22`:标题(13.5/600) + 7 柱(gap 22, 容器高150, 柱 maxWidth 40, accent opacity .85, radius `6 6 0 0`) + 值(mono 10.5) + 星期(11.5)。
- **下分设备**「设备明细 / By device」(统一组件 `DeviceStatRow`,四端复用):周柱卡下 marginTop 20标题(13/600/fg1);设备卡 surface+border+radius-lg+shadow-sm,行内分隔线 border。每行 padding `14 16/15 18`:左平台图标盒 38×38(accent-subtle, radius md, Lucide smartphone/laptop/monitor-smartphone, clay 2.2px) + 中(名 14.515/600 省略号 + 占比迷你条 height 4 radius 999, track=border, fill=accent .85, 宽=本设备流量/最忙设备) + 右(流量 mono 14.515 + " GB" caption;时长 caption "X h")。流量降序;空数据显示占位「暂无设备用量 / No device usage yet」
- 数据源:`GET /v1/usage/devices?days=N`(账户内每设备窗口聚合,busiest-first)→ `deviceUsageProvider`
### 账户视图 TabAccount
- padding `6 28 24`maxWidth 640(生产页功能更丰富,沿用同设计语言:套餐横幅 + 分区卡 + 行 + 段控/开关)。
+25
View File
@@ -135,6 +135,11 @@ function DServers({ t, lang, current, onPick }) {
function DStats({ t, lang }) {
const vals=[2.1,3.4,1.8,4.6,5.2,6.1,3.0]; const labels=t('days'); const max=6.1;
const devs=[
{ name:'iPhone 15', icon:'smartphone', gb:'11.3', h:'5.2', frac:1.0 },
{ name:'MacBook Pro', icon:'laptop', gb:'6.0', h:'3.1', frac:0.53 },
{ name:'Pixel 8', icon:'smartphone', gb:'1.6', h:'1.1', frac:0.14 },
];
return (
<div style={{ padding:'8px 32px 24px' }}>
<div style={{ display:'flex', gap:14, marginBottom:22 }}>
@@ -157,6 +162,26 @@ function DStats({ t, lang }) {
))}
</div>
</div>
<div style={{ fontSize:13, fontWeight:600, color:'var(--fg1)', margin:'22px 0 12px 2px' }}>{t('byDevice')}</div>
<div style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
{devs.map((d,i)=>(
<div key={d.name} style={{ display:'flex', alignItems:'center', gap:12, padding:'14px 16px', borderBottom:i<devs.length-1?'1px solid var(--border)':'none' }}>
<div style={{ width:38, height:38, borderRadius:'var(--radius-md)', background:'var(--accent-subtle)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
<DIcon name={d.icon} size={19} stroke={2.2} color="var(--accent)" />
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:14.5, fontWeight:600, color:'var(--fg1)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{d.name}</div>
<div style={{ marginTop:8, height:4, borderRadius:999, background:'var(--border)', overflow:'hidden' }}>
<div style={{ width:`${d.frac*100}%`, height:'100%', background:'var(--accent)', opacity:0.85, borderRadius:999 }} />
</div>
</div>
<div style={{ textAlign:'right', flexShrink:0 }}>
<div style={{ fontFamily:'var(--font-mono)', fontSize:14.5, fontWeight:500, color:'var(--fg1)' }}>{d.gb}<span style={{ fontSize:11, color:'var(--fg3)' }}> GB</span></div>
<div style={{ fontSize:11, color:'var(--fg3)', marginTop:2 }}>{d.h} h</div>
</div>
</div>
))}
</div>
</div>
);
}
+1
View File
@@ -70,6 +70,7 @@ const DSTRINGS = {
search:{zh:'搜索国家 / 城市',en:'Search country / city'},
trafficMonth:{zh:'本月流量',en:'Traffic'}, avgPing:{zh:'平均延迟',en:'Avg ping'}, durMonth:{zh:'本月时长',en:'Time'},
weekTraffic:{zh:'本周流量 (GB)',en:'This week (GB)'},
byDevice:{zh:'设备明细',en:'By device'}, noDeviceUsage:{zh:'暂无设备用量',en:'No device usage yet'},
autostart:{zh:'开机自启动',en:'Launch at login'}, autostartSub:{zh:'登录系统时自动启动穿山甲',en:'Start Pangolin when you log in'},
smartRoute:{zh:'智能分流',en:'Smart routing'}, smartRouteSub:{zh:'海外应用加速,本地直连',en:'Accelerate overseas apps, keep local direct'},
killSwitch:{zh:'Kill Switch',en:'Kill Switch'}, killSwitchSub:{zh:'断线时阻断网络,防止 IP 泄露',en:'Block all traffic if connection drops'},
+2
View File
@@ -144,6 +144,8 @@ const STRINGS = {
durMonth: { zh: '本月时长', en: 'Time' },
weekTraffic: { zh: '本周流量 (GB)', en: 'This week (GB)' },
days7: { zh: ['一','二','三','四','五','六','日'], en: ['M','T','W','T','F','S','S'] },
byDevice: { zh: '设备明细', en: 'By device' },
noDeviceUsage:{ zh: '暂无设备用量', en: 'No device usage yet' },
proMember: { zh: 'PRO 会员', en: 'PRO member' },
expires: { zh: '有效期至', en: 'Expires' },
usage: { zh: '本月用量', en: 'Used this month' },
+26
View File
@@ -298,6 +298,12 @@ function SettingsScreen({ t, lang, setLang, dark, setDark, free, setFree, onRede
function StatsScreen({ t, lang }) {
const vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0]; const max = 6.1; const labels = t('days7');
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)' };
// 分设备用量(下分设备):名称 + 占比迷你条 + 流量/时长,流量降序。
const devs = [
{ name: 'iPhone 15', icon: 'smartphone', gb: '11.3', h: '5.2', frac: 1.0 },
{ name: 'MacBook Pro', icon: 'laptop', gb: '6.0', h: '3.1', frac: 0.53 },
{ name: 'Pixel 8', icon: 'smartphone', gb: '1.6', h: '1.1', frac: 0.14 },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<TopBar t={t} lang={lang} />
@@ -323,6 +329,26 @@ function StatsScreen({ t, lang }) {
))}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg1)', margin: '20px 0 12px 2px' }}>{t('byDevice')}</div>
<div style={{ ...card, overflow: 'hidden' }}>
{devs.map((d, i) => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', borderBottom: i < devs.length - 1 ? '1px solid var(--border)' : 'none' }}>
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={d.icon} size={19} color="var(--accent)" stroke={2.2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div>
<div style={{ marginTop: 8, height: 4, borderRadius: 999, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ width: `${d.frac * 100}%`, height: '100%', background: 'var(--accent)', opacity: 0.85, borderRadius: 999 }} />
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14.5, fontWeight: 500, color: 'var(--fg1)' }}>{d.gb}<span style={{ fontSize: 11, color: 'var(--fg3)' }}> GB</span></div>
<div style={{ fontSize: 11, color: 'var(--fg3)', marginTop: 2 }}>{d.h} h</div>
</div>
</div>
))}
</div>
</div>
</div>
);
+27
View File
@@ -23,6 +23,8 @@ const TSTR = {
statTime: { zh: '本月时长', en: 'Time this month' },
weekTraffic:{ zh: '本周流量 (GB)', en: 'This week (GB)' },
days: { zh: ['周一','周二','周三','周四','周五','周六','周日'], en: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] },
byDevice: { zh: '设备明细', en: 'By device' },
noDeviceUsage:{ zh: '暂无设备用量', en: 'No device usage yet' },
acctEmail: { zh: '邮箱', en: 'Email' },
language: { zh: '语言', en: 'Language' },
darkMode: { zh: '深色外观', en: 'Dark appearance' },
@@ -220,6 +222,11 @@ function TabStats({ tt, lang }) {
const days = TSTR.days[lang];
const max = Math.max(...vals);
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)' };
const devs = [
{ name: 'iPhone 15', icon: 'smartphone', gb: '11.3', h: '5.2', frac: 1.0 },
{ name: 'MacBook Pro', icon: 'laptop', gb: '6.0', h: '3.1', frac: 0.53 },
{ name: 'Pixel 8', icon: 'smartphone', gb: '1.6', h: '1.1', frac: 0.14 },
];
return (
<div style={{ padding: '6px 28px 24px', overflow: 'auto', height: '100%', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', gap: 14, marginBottom: 20 }}>
@@ -242,6 +249,26 @@ function TabStats({ tt, lang }) {
))}
</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', margin: '20px 0 12px 2px' }}>{tt('byDevice')}</div>
<div style={{ ...card, overflow: 'hidden' }}>
{devs.map((d, i) => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '15px 18px', borderBottom: i < devs.length - 1 ? '1px solid var(--border)' : 'none' }}>
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={d.icon} size={19} color="var(--accent)" stroke={2.2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div>
<div style={{ marginTop: 8, height: 4, borderRadius: 999, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ width: `${d.frac * 100}%`, height: '100%', background: 'var(--accent)', opacity: 0.85, borderRadius: 999 }} />
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 500, color: 'var(--fg1)' }}>{d.gb}<span style={{ fontSize: 11.5, color: 'var(--fg3)' }}> GB</span></div>
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 2 }}>{d.h} h</div>
</div>
</div>
))}
</div>
</div>
);
}