完善前端UI
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, CreditCard, X } from "lucide-react";
|
||||
import { CreditCard, X } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "../api";
|
||||
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
|
||||
@@ -63,11 +63,11 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
|
||||
];
|
||||
const MIN_RECHARGE_AMOUNT = 50;
|
||||
|
||||
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
|
||||
{ k: "视频片段(Seedance)", color: "var(--klein)", bucket: "video" },
|
||||
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
|
||||
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
|
||||
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
|
||||
const STAGES: Array<{ k: string; short: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
|
||||
{ k: "视频片段(Seedance)", short: "视频生成", color: "var(--klein)", bucket: "video" },
|
||||
{ k: "故事板(image-2)", short: "故事板", color: "var(--accent-forest)", bucket: "storyboard" },
|
||||
{ k: "基础资产", short: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
|
||||
{ k: "脚本 LLM", short: "脚本生成", color: "var(--black-alpha-32)", bucket: "script" }
|
||||
];
|
||||
|
||||
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
|
||||
@@ -116,7 +116,7 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPage({ billing, projects, team, navigate, onRecharge, onNotify }: {
|
||||
export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
billing: BillingSummary | null;
|
||||
projects: Project[];
|
||||
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
|
||||
@@ -244,6 +244,11 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
|
||||
const total14 = Number(activeTrend?.total_14d ?? used);
|
||||
const avgValue = daily.length ? total14 / daily.length : 0;
|
||||
const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used;
|
||||
const topStage = STAGES.reduce((best, s) => {
|
||||
const amt = Number(trend?.by_stage[s.bucket] || 0);
|
||||
return amt > best.amt ? { label: s.short, amt } : best;
|
||||
}, { label: "暂无消耗", amt: 0 });
|
||||
const topPct = stageTotal > 0 ? Math.round((topStage.amt / stageTotal) * 100) : 0;
|
||||
const projectSpend = (id: string) => Number(trend?.by_project[id] || 0);
|
||||
|
||||
// ── 项目筛选(状态;真实数据无 owner/per-range,故只做状态过滤)──
|
||||
@@ -282,101 +287,87 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
|
||||
|
||||
return (
|
||||
<section className="account-page">
|
||||
<div className="page-head">
|
||||
<button className="ac-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
|
||||
<ArrowLeft size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
<header className="ac-head">
|
||||
<div>
|
||||
<h1>账户</h1>
|
||||
<div className="sub">{money(balance)} 余额 · {money(used)} 本月已用 · 充值与账单流水</div>
|
||||
<h1>账单库</h1>
|
||||
<p>余额、充值与流水</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="top-grid">
|
||||
<div className="balance-banner">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
<div className="balance-hero">
|
||||
<div className="lbl">团队余额</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="meta">充值累加 · 不重置</div>
|
||||
<div className="ac-top">
|
||||
<div className="ac-hero">
|
||||
<div>
|
||||
<span>团队余额</span>
|
||||
<strong>{money(balance)}</strong>
|
||||
<em>充值累加 · 不重置</em>
|
||||
</div>
|
||||
<div className="balance-sub">
|
||||
<div className="col">
|
||||
<div className="lbl">本月限额</div>
|
||||
<div className="v">{money(limit)}</div>
|
||||
<div className="meta">按自然月重置</div>
|
||||
<div className="ac-hero-stats">
|
||||
<div>
|
||||
<span>本月限额</span>
|
||||
<strong>{money(limit)}</strong>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="lbl">当月已用</div>
|
||||
<div className="v">{money(used)}</div>
|
||||
<div className="meta">占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
|
||||
<div>
|
||||
<span>当月已用</span>
|
||||
<strong>{money(used)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="balance-foot">
|
||||
<div className="balance-meter" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}><span style={{ width: `${pct}%` }} /></div>
|
||||
<div className="balance-foot-meta">
|
||||
<div>
|
||||
<div className="ac-progress" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}>
|
||||
<span style={{ width: `${Math.max(pct, pct > 0 ? 2 : 0)}%` }} />
|
||||
</div>
|
||||
<div className="ac-progress-meta">
|
||||
<span>团队月剩余 {money(left)}</span>
|
||||
<span>使用率 {pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pane topup-pane">
|
||||
<div className="topup-head">
|
||||
<div>
|
||||
<h3>快速充值</h3>
|
||||
<div className="desc">充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
</div>
|
||||
<div className="topup-selected">已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
|
||||
</div>
|
||||
<div className="recharge-row">
|
||||
<div className="ac-recharge">
|
||||
<h2>快速充值</h2>
|
||||
<p>充值后立刻到账,可开发票 · 仅超管可操作 · 已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</p>
|
||||
<div className="ac-recharge-grid">
|
||||
{RECHARGE.map((item) => (
|
||||
<div
|
||||
<button
|
||||
key={item.amt}
|
||||
className={`recharge-card ${recharge === item.amt && !customAmt ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
className={`ac-option${recharge === item.amt && !customAmt ? " active" : ""}`}
|
||||
aria-pressed={recharge === item.amt && !customAmt}
|
||||
onClick={() => { setRecharge(item.amt); setCustomAmt(""); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setRecharge(item.amt); setCustomAmt(""); } }}
|
||||
>
|
||||
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
|
||||
<div className="amt">¥{item.amt}</div>
|
||||
<div className={`gift ${item.bonus ? "bonus" : ""}`}>{item.gift}</div>
|
||||
</div>
|
||||
<strong>¥{item.amt}</strong>
|
||||
<span className={item.bonus ? "" : "plain"}>{item.gift}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pay-row">
|
||||
<div className="pay-title">自定义金额</div>
|
||||
<input
|
||||
className={`input${!customAmountIsValid ? " is-invalid" : ""}`}
|
||||
placeholder="最低 ¥50,可输入任意金额"
|
||||
type="number"
|
||||
min={MIN_RECHARGE_AMOUNT}
|
||||
step="1"
|
||||
inputMode="numeric"
|
||||
value={customAmt}
|
||||
aria-invalid={!customAmountIsValid}
|
||||
aria-describedby="custom-recharge-error"
|
||||
onChange={(event) => setCustomIntegerAmount(event.target.value)}
|
||||
/>
|
||||
{!customAmountIsValid && <div className="field-hint is-error" id="custom-recharge-error" role="alert">充值金额不能低于 {MIN_RECHARGE_AMOUNT} 元</div>}
|
||||
<div className="pay-btn-row">
|
||||
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={() => openTopup("wechat")}>
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||
微信支付
|
||||
</button>
|
||||
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={() => openTopup("alipay")}>
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||
支付宝
|
||||
</button>
|
||||
</div>
|
||||
<div className="ac-pay-title">自定义金额</div>
|
||||
<input
|
||||
className={`input${!customAmountIsValid ? " is-invalid" : ""}`}
|
||||
placeholder="最低 ¥50,可输入任意金额"
|
||||
type="number"
|
||||
min={MIN_RECHARGE_AMOUNT}
|
||||
step="1"
|
||||
inputMode="numeric"
|
||||
value={customAmt}
|
||||
aria-invalid={!customAmountIsValid}
|
||||
aria-describedby="custom-recharge-error"
|
||||
onChange={(event) => setCustomIntegerAmount(event.target.value)}
|
||||
/>
|
||||
{!customAmountIsValid && <div className="field-hint is-error" id="custom-recharge-error" role="alert">充值金额不能低于 {MIN_RECHARGE_AMOUNT} 元</div>}
|
||||
<div className="ac-pay-row">
|
||||
<button className="ac-pay" type="button" aria-label="微信支付" onClick={() => openTopup("wechat")}>
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||
微信支付
|
||||
</button>
|
||||
<button className="ac-pay" type="button" aria-label="支付宝" onClick={() => openTopup("alipay")}>
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||
支付宝
|
||||
</button>
|
||||
</div>
|
||||
<div className="tip" style={{ marginTop: 14 }}>
|
||||
<strong>计费说明</strong>
|
||||
1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字;
|
||||
视频按时长与分辨率计量、按实际用量结算(如 15 秒 720P 竖屏约 224 积分),多退少不补超;
|
||||
拼接导出免费。生成失败、超时均不扣费,自动退回预留积分。
|
||||
<div className="ac-tip">
|
||||
<strong>计费说明 </strong>
|
||||
1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字;
|
||||
视频按时长与分辨率计量。生成失败、超时均不扣费。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,24 +390,34 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
|
||||
<button key={r} className={`ac-seg${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ac-summary">
|
||||
<div>
|
||||
<span>本月已用</span>
|
||||
<strong>{money(used)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>本月剩余</span>
|
||||
<strong>{money(left)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>主要消耗</span>
|
||||
<strong>{topStage.amt > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trend-chart">
|
||||
<div className="bars">
|
||||
{daily.map((d) => {
|
||||
const amt = Number(d.amount);
|
||||
const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0;
|
||||
const isPeak = peak > 0 && amt === peak;
|
||||
return (
|
||||
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
|
||||
<span style={{ height: `${h}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="x-axis">
|
||||
{daily.map((d, i) => (
|
||||
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
|
||||
))}
|
||||
</div>
|
||||
{daily.length === 0 ? (
|
||||
<div className="trend-chart-empty">暂无消费记录</div>
|
||||
) : daily.map((d) => {
|
||||
const amt = Number(d.amount);
|
||||
const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8;
|
||||
return (
|
||||
<div className="ac-bar-group" key={d.date} title={`${d.label} · ${money(amt)}`}>
|
||||
<strong className="ac-bar-value">{amt > 0 ? Math.round(amt) : ""}</strong>
|
||||
<div className="ac-bar" style={{ height: `${h}%` }} />
|
||||
<span>{d.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="trend-foot">
|
||||
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
|
||||
|
||||
Reference in New Issue
Block a user