Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,23 +1,86 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { getMyBillingLedger, getMyUsage } from '../api/client';
|
||||
import type { LedgerEntry, UsageRecord } from '../types';
|
||||
|
||||
const RING_R = 16;
|
||||
const CIRC = 2 * Math.PI * RING_R;
|
||||
const POPOVER_WIDTH = 260;
|
||||
const RECENT_USAGE_LIMIT = 5;
|
||||
const RECENT_LEDGER_LIMIT = 8;
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatTokenCount(tokens: number) {
|
||||
if (tokens >= 100_000) {
|
||||
return `${(tokens / 10_000).toFixed(1).replace(/\.0$/, '')} 万`;
|
||||
}
|
||||
if (tokens >= 10_000) {
|
||||
return `${(tokens / 10_000).toFixed(2).replace(/\.?0+$/, '')} 万`;
|
||||
}
|
||||
if (tokens >= 1_000) {
|
||||
return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
|
||||
}
|
||||
return tokens.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatTokenCompact(tokens: number) {
|
||||
if (tokens >= 1_000) {
|
||||
return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
|
||||
}
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
function formatUsageTime(ts: number) {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `今天 ${time}`;
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatLedgerLabel(entry: LedgerEntry) {
|
||||
if (entry.note === '新用户赠送') return '新用户赠送';
|
||||
if (entry.note?.startsWith('order:')) return '微信支付充值';
|
||||
if (entry.note === '用户自助充值') return '微信支付充值';
|
||||
if (entry.note === '管理员充值') return '管理员充值';
|
||||
if (entry.type === 'refund') return '退款';
|
||||
if (entry.type === 'adjust') return entry.note?.trim() || '账户调整';
|
||||
return entry.note?.trim() || '账户充值';
|
||||
}
|
||||
|
||||
type BalanceRingProps = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed?: number;
|
||||
onRecharge: (force?: boolean) => void;
|
||||
};
|
||||
|
||||
export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: BalanceRingProps) {
|
||||
export function BalanceRing({
|
||||
balanceCents,
|
||||
totalCreditCents,
|
||||
tokensUsed = 0,
|
||||
onRecharge,
|
||||
}: BalanceRingProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const [usageRecords, setUsageRecords] = useState<UsageRecord[] | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState<string | null>(null);
|
||||
const [ledgerEntries, setLedgerEntries] = useState<LedgerEntry[] | null>(null);
|
||||
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||
const [ledgerError, setLedgerError] = useState<string | null>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const popoverWidth = 220;
|
||||
|
||||
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
|
||||
const spent = Math.max(0, total - balanceCents);
|
||||
@@ -29,6 +92,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
|
||||
|
||||
const low = balanceCents > 0 && balanceCents <= 100;
|
||||
const empty = balanceCents <= 0;
|
||||
const showRecentUsage = low || empty;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -48,7 +112,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
|
||||
const anchor = wrapRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(popoverWidth, window.innerWidth - 24);
|
||||
const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24);
|
||||
let left = rect.left + rect.width / 2 - width / 2;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - width - 12));
|
||||
setPopoverStyle({
|
||||
@@ -68,6 +132,54 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setUsageLoading(true);
|
||||
setUsageError(null);
|
||||
setLedgerLoading(true);
|
||||
setLedgerError(null);
|
||||
void getMyUsage()
|
||||
.then((records) => {
|
||||
if (!cancelled) setUsageRecords(records);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setUsageError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setUsageLoading(false);
|
||||
});
|
||||
void getMyBillingLedger(RECENT_LEDGER_LIMIT)
|
||||
.then((entries) => {
|
||||
if (!cancelled) setLedgerEntries(entries);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setLedgerError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLedgerLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, tokensUsed, balanceCents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setUsageRecords(null);
|
||||
setUsageError(null);
|
||||
setUsageLoading(false);
|
||||
setLedgerEntries(null);
|
||||
setLedgerError(null);
|
||||
setLedgerLoading(false);
|
||||
}, [open]);
|
||||
|
||||
const recentUsage = (usageRecords ?? []).slice(0, RECENT_USAGE_LIMIT);
|
||||
const recentLedger = (ledgerEntries ?? []).slice(0, RECENT_LEDGER_LIMIT);
|
||||
const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
|
||||
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
|
||||
|
||||
@@ -122,13 +234,72 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
|
||||
<strong>{formatYuan(spent)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">累计额度</span>
|
||||
<span className="balance-popover-label balance-popover-label-muted">累计充值</span>
|
||||
<span>{formatYuan(total)}</span>
|
||||
</div>
|
||||
<div className="balance-popover-bar" aria-hidden="true">
|
||||
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
|
||||
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="balance-popover-section">
|
||||
<h5>AI 消耗</h5>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">累计 Token</span>
|
||||
<span>{formatTokenCount(tokensUsed)}</span>
|
||||
</div>
|
||||
<p className="balance-popover-hint">按人民币结算,每次对话按实际用量扣费</p>
|
||||
</div>
|
||||
|
||||
<div className="balance-popover-section">
|
||||
<h5>充值明细</h5>
|
||||
{ledgerLoading ? (
|
||||
<p className="balance-popover-muted">加载中…</p>
|
||||
) : ledgerError ? (
|
||||
<p className="balance-popover-muted">{ledgerError}</p>
|
||||
) : recentLedger.length === 0 ? (
|
||||
<p className="balance-popover-muted">暂无充值记录</p>
|
||||
) : (
|
||||
<ul className="balance-usage-list">
|
||||
{recentLedger.map((row) => (
|
||||
<li key={row.id} className="balance-usage-item">
|
||||
<span className="balance-usage-time">{formatUsageTime(row.createdAt)}</span>
|
||||
<span className="balance-usage-tokens">{formatLedgerLabel(row)}</span>
|
||||
<strong className="balance-usage-credit">
|
||||
+{formatYuan(Math.abs(row.amountCents))}
|
||||
</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`balance-popover-section balance-popover-usage${showRecentUsage ? ' expanded' : ''}`}
|
||||
>
|
||||
<h5>近期扣费</h5>
|
||||
{usageLoading ? (
|
||||
<p className="balance-popover-muted">加载中…</p>
|
||||
) : usageError ? (
|
||||
<p className="balance-popover-muted">{usageError}</p>
|
||||
) : recentUsage.length === 0 ? (
|
||||
<p className="balance-popover-muted">暂无扣费记录</p>
|
||||
) : (
|
||||
<ul className="balance-usage-list">
|
||||
{recentUsage.map((row) => (
|
||||
<li key={row.id} className="balance-usage-item">
|
||||
<span className="balance-usage-time">{formatUsageTime(row.createdAt)}</span>
|
||||
<span className="balance-usage-tokens">
|
||||
输入 {formatTokenCompact(row.inputTokens)} / 输出{' '}
|
||||
{formatTokenCompact(row.outputTokens)}
|
||||
</span>
|
||||
<strong className="balance-usage-cost">{formatYuan(row.costCents)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="balance-popover-cta"
|
||||
|
||||
Reference in New Issue
Block a user