Files
memind/design-preview/balance-ring-preview.html
T
John 2e14873f2d Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:04:43 -07:00

507 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>余额展示 · 圆环灯管方案预览</title>
<style>
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color-scheme: dark;
--bg: #0f1419;
--surface: #121820;
--elevated: #1c2735;
--border: #243041;
--text: #e7ecf3;
--muted: #8fa3bf;
--faint: #5f738d;
--accent: #3d8bfd;
--remain: #3dd6c5;
--remain-glow: rgba(61, 214, 197, 0.55);
--spent: #e8a44a;
--spent-glow: rgba(232, 164, 74, 0.45);
--warn: #ddb945;
--danger: #ef7b7b;
--danger-glow: rgba(239, 123, 123, 0.5);
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); }
body { padding: 24px; line-height: 1.5; }
h1 { font-size: 20px; margin: 0 0 6px; }
.lead { color: var(--muted); font-size: 14px; margin: 0 0 24px; max-width: 720px; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.panel h2 { font-size: 15px; margin: 0 0 4px; }
.panel p.desc { font-size: 13px; color: var(--muted); margin: 0 0 16px; }
.controls {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
margin-bottom: 20px; padding: 14px 16px;
background: var(--elevated); border-radius: 10px;
}
.controls label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.controls input[type="range"] { width: 160px; }
.controls output { font-size: 13px; min-width: 72px; }
.state-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.state-btns button {
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--bg); color: var(--muted); font-size: 12px; cursor: pointer;
}
.state-btns button.active { border-color: var(--accent); color: var(--text); background: #152033; }
/* ===== 模拟 Chat Header ===== */
.mock-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--border);
background: var(--bg); border-radius: 10px 10px 0 0;
}
.mock-header-left { display: flex; align-items: center; gap: 10px; }
.avatar { width: 32px; height: 32px; border-radius: 8px; background: linear-gradient(135deg, #2458a6, #3d8bfd); }
.mock-title { font-weight: 700; font-size: 18px; }
.mock-sub { font-size: 12px; color: var(--muted); }
.mock-actions { display: flex; align-items: center; gap: 8px; }
.ghost-btn {
border: none; border-radius: 10px; padding: 10px 14px; font-size: 14px;
background: var(--elevated); color: #c9d7ea; cursor: pointer;
}
/* 当前方案 */
.balance-pill-current {
padding: 6px 10px; border-radius: 999px;
background: var(--elevated); color: #9ec5ff; font-size: 12px;
border: 1px solid transparent; cursor: pointer;
}
/* ===== 方案 A:纯圆环灯管(推荐) ===== */
.balance-ring-btn {
position: relative;
width: 40px; height: 40px;
border: none; background: transparent; cursor: pointer;
padding: 0; border-radius: 50%;
flex-shrink: 0;
}
.balance-ring-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.balance-ring-svg {
width: 40px; height: 40px;
transform: rotate(-90deg);
filter: drop-shadow(0 0 4px var(--ring-glow, var(--remain-glow)));
}
.balance-ring-track {
fill: none;
stroke: #1a2838;
stroke-width: 4;
}
.balance-ring-spent {
fill: none;
stroke: var(--spent);
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s ease;
filter: drop-shadow(0 0 3px var(--spent-glow));
}
.balance-ring-remain {
fill: none;
stroke: var(--ring-color, var(--remain));
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s ease, stroke 0.2s;
filter: drop-shadow(0 0 4px var(--ring-glow, var(--remain-glow)));
}
.balance-ring-btn.low .balance-ring-remain {
--ring-color: var(--warn);
--ring-glow: rgba(221, 185, 69, 0.5);
}
.balance-ring-btn.empty .balance-ring-remain {
--ring-color: var(--danger);
--ring-glow: var(--danger-glow);
animation: pulse-empty 1.8s ease-in-out infinite;
}
@keyframes pulse-empty {
0%, 100% { opacity: 1; filter: drop-shadow(0 0 4px var(--danger-glow)); }
50% { opacity: 0.55; filter: drop-shadow(0 0 8px var(--danger-glow)); }
}
.balance-ring-center {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 600; color: var(--muted);
pointer-events: none;
}
.balance-ring-btn.low .balance-ring-center { color: var(--warn); }
.balance-ring-btn.empty .balance-ring-center { color: var(--danger); font-size: 9px; }
/* ===== 方案 B:圆环 + 悬停/点击浮层 ===== */
.balance-popover-wrap { position: relative; }
.balance-popover {
display: none;
position: absolute; top: calc(100% + 10px); right: 0;
width: 220px; padding: 14px;
background: #171f2b; border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.45);
z-index: 10;
}
.balance-popover-wrap:hover .balance-popover,
.balance-popover-wrap.open .balance-popover { display: block; }
.balance-popover h4 { margin: 0 0 10px; font-size: 13px; font-weight: 600; }
.balance-popover-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; margin-bottom: 6px;
}
.balance-popover-row .label { color: var(--muted); display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.remain { background: var(--remain); box-shadow: 0 0 6px var(--remain-glow); }
.dot.spent { background: var(--spent); box-shadow: 0 0 6px var(--spent-glow); }
.balance-popover-bar {
height: 6px; border-radius: 999px; background: #1a2838;
overflow: hidden; display: flex; margin: 10px 0 12px;
}
.balance-popover-bar .spent-part { background: var(--spent); }
.balance-popover-bar .remain-part { background: var(--remain); }
.balance-popover-cta {
width: 100%; padding: 8px; border: none; border-radius: 8px;
background: var(--accent); color: #fff; font-size: 13px; cursor: pointer;
}
/* ===== 方案 C:胶囊微环 ===== */
.balance-capsule {
display: flex; align-items: center; gap: 8px;
padding: 4px 10px 4px 4px;
border-radius: 999px; border: 1px solid var(--border);
background: var(--elevated); cursor: pointer;
transition: border-color 0.2s;
}
.balance-capsule:hover { border-color: rgba(61, 214, 197, 0.35); }
.balance-capsule.low:hover { border-color: rgba(221, 185, 69, 0.4); }
.balance-capsule.empty:hover { border-color: rgba(239, 123, 123, 0.4); }
.balance-capsule-ring { width: 28px; height: 28px; flex-shrink: 0; }
.balance-capsule-ring svg { width: 28px; height: 28px; transform: rotate(-90deg); }
.balance-capsule-ring .balance-ring-track { stroke-width: 3; }
.balance-capsule-ring .balance-ring-spent,
.balance-capsule-ring .balance-ring-remain { stroke-width: 3; }
.balance-capsule-text { font-size: 12px; color: var(--muted); white-space: nowrap; }
.balance-capsule-text strong { color: var(--text); font-weight: 600; }
.compare-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
}
@media (max-width: 800px) { .compare-row { grid-template-columns: 1fr; } }
.legend {
display: flex; gap: 16px; flex-wrap: wrap;
font-size: 12px; color: var(--muted); margin-top: 12px;
}
.legend span { display: flex; align-items: center; gap: 6px; }
.note {
font-size: 12px; color: var(--faint);
border-left: 3px solid var(--border);
padding-left: 12px; margin-top: 16px;
}
.tag {
display: inline-block; padding: 2px 8px; border-radius: 6px;
font-size: 11px; font-weight: 600; margin-left: 8px;
background: rgba(61, 214, 197, 0.12); color: var(--remain);
}
.tag.alt { background: rgba(61, 139, 253, 0.12); color: #9ec5ff; }
.recharge-preview {
margin-top: 12px; padding: 14px; border-radius: 10px;
background: var(--elevated); border: 1px solid var(--border);
}
.recharge-preview h4 { margin: 0 0 10px; font-size: 13px; }
.recharge-ring-large {
display: flex; align-items: center; gap: 20px;
}
.recharge-ring-large svg { width: 88px; height: 88px; transform: rotate(-90deg); }
.recharge-ring-large .balance-ring-track { stroke-width: 6; }
.recharge-ring-large .balance-ring-spent,
.recharge-ring-large .balance-ring-remain { stroke-width: 6; }
.recharge-stats { font-size: 13px; }
.recharge-stats div { margin-bottom: 4px; color: var(--muted); }
.recharge-stats strong { color: var(--text); font-size: 15px; }
</style>
</head>
<body>
<h1>余额展示 · 圆环灯管方案</h1>
<p class="lead">
替代 header 里直白的「余额 ¥12.80」。用圆环灯管表达<strong>累计额度</strong><strong>已消耗</strong>比例,剩余部分用另一色发光段表示。点击/悬停可看具体金额。
</p>
<div class="controls">
<label>
累计充值(总额)
<input type="range" id="totalSlider" min="500" max="20000" step="100" value="5000" />
<output id="totalOut">¥50.00</output>
</label>
<label>
当前余额(剩余)
<input type="range" id="remainSlider" min="0" max="5000" step="10" value="1280" />
<output id="remainOut">¥12.80</output>
</label>
<div class="state-btns">
<button type="button" data-preset="healthy" class="active">健康</button>
<button type="button" data-preset="low">余额低</button>
<button type="button" data-preset="empty">已耗尽</button>
</div>
</div>
<!-- 当前 vs 推荐 -->
<div class="panel">
<h2>对比:当前 vs 方案 A(推荐)</h2>
<p class="desc">方案 A 仅显示圆环 + 剩余百分比,不直接暴露金额,适合 header 紧凑场景。</p>
<div class="compare-row">
<div>
<div class="mock-header" style="border-radius:10px">
<div class="mock-header-left">
<div class="avatar"></div>
<div>
<div class="mock-title">John</div>
<div class="mock-sub">新对话</div>
</div>
</div>
<div class="mock-actions">
<span class="balance-pill-current" id="currentPill">余额 ¥12.80</span>
<button class="ghost-btn">我的空间</button>
</div>
</div>
<p class="note">当前:金额完全外露,像账单条目。</p>
</div>
<div>
<div class="mock-header" style="border-radius:10px">
<div class="mock-header-left">
<div class="avatar"></div>
<div>
<div class="mock-title">John</div>
<div class="mock-sub">新对话</div>
</div>
</div>
<div class="mock-actions">
<div class="balance-popover-wrap" id="variantA">
<button type="button" class="balance-ring-btn" aria-label="账户额度">
<svg class="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
<circle class="balance-ring-track" cx="20" cy="20" r="16" />
<circle class="balance-ring-spent" cx="20" cy="20" r="16"
stroke-dasharray="0 100.53" stroke-dashoffset="0" data-role="spent" />
<circle class="balance-ring-remain" cx="20" cy="20" r="16"
stroke-dasharray="0 100.53" stroke-dashoffset="0" data-role="remain" />
</svg>
<span class="balance-ring-center" data-role="pct">26%</span>
</button>
<div class="balance-popover">
<h4>账户额度</h4>
<div class="balance-popover-row">
<span class="label"><span class="dot remain"></span>剩余</span>
<strong data-role="pop-remain">¥12.80</strong>
</div>
<div class="balance-popover-row">
<span class="label"><span class="dot spent"></span>已消耗</span>
<strong data-role="pop-spent">¥37.20</strong>
</div>
<div class="balance-popover-row">
<span class="label" style="color:var(--faint)">累计充值</span>
<span data-role="pop-total">¥50.00</span>
</div>
<div class="balance-popover-bar" data-role="bar"></div>
<button type="button" class="balance-popover-cta">充值</button>
</div>
</div>
<button class="ghost-btn">我的空间</button>
</div>
</div>
<div class="legend">
<span><span class="dot remain"></span>青绿 = 剩余可用</span>
<span><span class="dot spent"></span>琥珀 = 已消耗</span>
</div>
<p class="note">方案 A:圆环总长 = 累计充值;两段颜色从 12 点顺时针排列。中心显示剩余占比。</p>
</div>
</div>
</div>
<!-- 方案 B -->
<div class="panel">
<h2>方案 B:圆环 + 点击固定浮层<span class="tag alt">移动端友好</span></h2>
<p class="desc">与 A 相同圆环,但浮层通过点击打开(避免手机无 hover)。适合需要明确金额时再展开。</p>
<div class="mock-header" style="border-radius:10px; max-width:480px">
<div class="mock-header-left">
<div class="avatar"></div>
<div><div class="mock-title">John</div><div class="mock-sub">新对话</div></div>
</div>
<div class="mock-actions">
<div class="balance-popover-wrap" id="variantB">
<button type="button" class="balance-ring-btn" aria-label="账户额度" data-click-toggle>
<svg class="balance-ring-svg" viewBox="0 0 40 40"><circle class="balance-ring-track" cx="20" cy="20" r="16" /><circle class="balance-ring-spent" cx="20" cy="20" r="16" data-role="spent" /><circle class="balance-ring-remain" cx="20" cy="20" r="16" data-role="remain" /></svg>
<span class="balance-ring-center" data-role="pct">26%</span>
</button>
<div class="balance-popover">
<h4>账户额度</h4>
<div class="balance-popover-row"><span class="label"><span class="dot remain"></span>剩余</span><strong data-role="pop-remain">¥12.80</strong></div>
<div class="balance-popover-row"><span class="label"><span class="dot spent"></span>已消耗</span><strong data-role="pop-spent">¥37.20</strong></div>
<div class="balance-popover-bar" data-role="bar"></div>
<button type="button" class="balance-popover-cta">去充值</button>
</div>
</div>
</div>
</div>
<p class="note">点击圆环切换浮层;低余额时环色变黄,耗尽时红色脉冲。</p>
</div>
<!-- 方案 C -->
<div class="panel">
<h2>方案 C:胶囊微环<span class="tag">折中</span></h2>
<p class="desc">保留简短文案「可用 26%」,圆环缩小嵌在胶囊左侧,比纯数字含蓄但仍有一行提示。</p>
<div class="mock-header" style="border-radius:10px; max-width:520px">
<div class="mock-header-left">
<div class="avatar"></div>
<div><div class="mock-title">John</div><div class="mock-sub">新对话</div></div>
</div>
<div class="mock-actions">
<div class="balance-capsule" id="variantC">
<div class="balance-capsule-ring">
<svg viewBox="0 0 40 40"><circle class="balance-ring-track" cx="20" cy="20" r="16" /><circle class="balance-ring-spent" cx="20" cy="20" r="16" data-role="spent" /><circle class="balance-ring-remain" cx="20" cy="20" r="16" data-role="remain" /></svg>
</div>
<span class="balance-capsule-text">可用 <strong data-role="pct">26%</strong></span>
</div>
</div>
</div>
</div>
<!-- 充值弹窗里的放大版 -->
<div class="panel">
<h2>充值弹窗 · 大号圆环(配套)</h2>
<p class="desc">弹窗内可用 88px 大圆环 + 三项明细,与 header 小环视觉语言一致。</p>
<div class="recharge-preview">
<h4>账户充值</h4>
<div class="recharge-ring-large" id="rechargePreview">
<svg viewBox="0 0 40 40">
<circle class="balance-ring-track" cx="20" cy="20" r="16" />
<circle class="balance-ring-spent" cx="20" cy="20" r="16" data-role="spent" />
<circle class="balance-ring-remain" cx="20" cy="20" r="16" data-role="remain" />
</svg>
<div class="recharge-stats">
<div>剩余 <strong data-role="pop-remain">¥12.80</strong></div>
<div>已消耗 <strong data-role="pop-spent">¥37.20</strong></div>
<div>累计充值 <strong data-role="pop-total">¥50.00</strong></div>
</div>
</div>
</div>
</div>
<!-- 数据与实现说明 -->
<div class="panel">
<h2>数据口径 & 实现要点</h2>
<p class="desc" style="margin-bottom:8px">
<strong>圆环语义</strong>:外圈总长 = 累计充值总额;琥珀段 = 已消耗;青绿段 = 当前余额。<br>
<strong>计算公式</strong>:已消耗 = 累计充值 − 当前余额(需后端返回 <code>totalRechargeCents</code>,或从流水汇总)。<br>
<strong>状态色</strong>:剩余 &gt; ¥1 青绿;≤ ¥1 琥珀警告;≤ ¥0 红色脉冲 + 点击直达充值。<br>
<strong>无障碍</strong><code>aria-label="账户额度,剩余 26%"</code>,浮层内展示具体金额。<br>
<strong>推荐</strong>Header 用 <strong>方案 A</strong>(纯圆环 + hover 浮层);移动端同一组件点击展开(方案 B 行为)。
</p>
</div>
<script>
const CIRC = 2 * Math.PI * 16; // r=16
const fmt = (cents) => '¥' + (cents / 100).toFixed(2);
const totalSlider = document.getElementById('totalSlider');
const remainSlider = document.getElementById('remainSlider');
const totalOut = document.getElementById('totalOut');
const remainOut = document.getElementById('remainOut');
const currentPill = document.getElementById('currentPill');
const variants = [
document.getElementById('variantA'),
document.getElementById('variantB'),
document.getElementById('variantC'),
document.getElementById('rechargePreview'),
].filter(Boolean);
function updateRing(root, total, remain) {
if (!root) return;
const spent = Math.max(0, total - remain);
const pct = total > 0 ? Math.round((remain / total) * 100) : 0;
const spentLen = total > 0 ? (spent / total) * CIRC : 0;
const remainLen = total > 0 ? (remain / total) * CIRC : 0;
root.querySelectorAll('[data-role="spent"]').forEach((el) => {
el.setAttribute('stroke-dasharray', `${spentLen} ${CIRC}`);
el.setAttribute('stroke-dashoffset', '0');
});
root.querySelectorAll('[data-role="remain"]').forEach((el) => {
el.setAttribute('stroke-dasharray', `${remainLen} ${CIRC}`);
el.setAttribute('stroke-dashoffset', `${-spentLen}`);
});
root.querySelectorAll('[data-role="pct"]').forEach((el) => {
el.textContent = total <= 0 ? '—' : (remain <= 0 ? '0%' : pct + '%');
});
root.querySelectorAll('[data-role="pop-remain"]').forEach((el) => { el.textContent = fmt(remain); });
root.querySelectorAll('[data-role="pop-spent"]').forEach((el) => { el.textContent = fmt(spent); });
root.querySelectorAll('[data-role="pop-total"]').forEach((el) => { el.textContent = fmt(total); });
const bar = root.querySelector('[data-role="bar"]');
if (bar && total > 0) {
const spentPct = (spent / total) * 100;
const remainPct = (remain / total) * 100;
bar.innerHTML = `<div class="spent-part" style="width:${spentPct}%"></div><div class="remain-part" style="width:${remainPct}%"></div>`;
}
const btn = root.querySelector('.balance-ring-btn') || root.querySelector('.balance-capsule');
if (btn) {
btn.classList.remove('low', 'empty');
if (remain <= 0) btn.classList.add('empty');
else if (remain <= 100) btn.classList.add('low');
}
}
function render() {
let total = Number(totalSlider.value);
let remain = Number(remainSlider.value);
if (remain > total) {
remain = total;
remainSlider.value = String(total);
}
remainSlider.max = String(total);
totalOut.textContent = fmt(total);
remainOut.textContent = fmt(remain);
currentPill.textContent = '余额 ' + fmt(remain);
variants.forEach((v) => updateRing(v, total, remain));
}
totalSlider.addEventListener('input', render);
remainSlider.addEventListener('input', render);
document.querySelectorAll('[data-preset]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-preset]').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const p = btn.dataset.preset;
if (p === 'healthy') { totalSlider.value = '5000'; remainSlider.value = '1280'; }
if (p === 'low') { totalSlider.value = '5000'; remainSlider.value = '80'; }
if (p === 'empty') { totalSlider.value = '5000'; remainSlider.value = '0'; }
render();
});
});
document.querySelectorAll('[data-click-toggle]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrap = btn.closest('.balance-popover-wrap');
wrap.classList.toggle('open');
});
});
document.addEventListener('click', () => {
document.querySelectorAll('.balance-popover-wrap.open').forEach((w) => w.classList.remove('open'));
});
render();
</script>
</body>
</html>