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:
John
2026-06-15 22:09:38 -07:00
parent 3cd322ccfe
commit 6ee6fd64dd
94 changed files with 7015 additions and 2136 deletions
+175 -4
View File
@@ -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"