feat: replace desktop membership with permanent point wallet

This commit is contained in:
2026-09-22 11:43:04 +08:00
parent 2f82b9f79c
commit a1cce428af
24 changed files with 1343 additions and 1707 deletions

View File

@@ -0,0 +1,223 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Coins, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AppError } from '@/lib/error-model';
import { formatWorksTokenPointValue, isWorksTokenPointBalanceExhausted } from '@/lib/works-square-token-points';
import {
createWorksRechargeOrder, fetchWorksBillingSummary, fetchWorksPointHistory, openWorksBillingAccount,
fetchWorksRechargeOrder, fetchWorksRechargeOrders,
type WorksBillingSummary, type WorksPointHistory, type WorksRechargeOrder,
} from '@/lib/works-billing';
const labels: Record<string, string> = {
pending: '待支付', succeeded: '已到账', failed: '支付失败', canceled: '已关闭', manual_review: '待核查',
credited: '已入账', reserved: '已预占', dispatched: '处理中', pending_review: '待核对',
settled: '已结算', released: '已释放', refunded: '已退款', expired: '已释放',
};
const points = (value: string | null) => formatWorksTokenPointValue(value) ?? '—';
const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
const time = (value: string) => new Date(value).toLocaleString('zh-CN');
const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review';
export function PointWallet({ active = true }: { active?: boolean }) {
const [summary, setSummary] = useState<WorksBillingSummary | null>(null);
const [summaryError, setSummaryError] = useState(false);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<'recharge' | 'orders' | 'history'>('recharge');
const [offset, setOffset] = useState(0);
const [orders, setOrders] = useState<WorksRechargeOrder[]>([]);
const [pending, setPending] = useState<WorksRechargeOrder | null>(null);
const [history, setHistory] = useState<WorksPointHistory>({ items: [], has_more: false });
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [checkout, setCheckout] = useState<WorksRechargeOrder | null>(null);
const [qr, setQr] = useState('');
const [unknownProduct, setUnknownProduct] = useState<string | null>(null);
const intent = useRef<{ productId: string; requestId: string } | null>(null);
const alive = useRef(true);
const summaryRequest = useRef(0);
const pageRequest = useRef(0);
const purchaseBusy = useRef(false);
useEffect(() => {
alive.current = true;
return () => { alive.current = false; summaryRequest.current += 1; pageRequest.current += 1; };
}, []);
const refreshSummary = useCallback(async () => {
const request = ++summaryRequest.current;
try {
const next = await fetchWorksBillingSummary();
if (!alive.current || summaryRequest.current !== request) return;
setSummary(next); setSummaryError(false);
} catch {
if (!alive.current || summaryRequest.current !== request) return;
setSummary(null); setSummaryError(true);
}
}, []);
useEffect(() => {
if (active) void refreshSummary();
const focus = () => { void refreshSummary(); };
const visibility = () => { if (document.visibilityState === 'visible') focus(); };
window.addEventListener('focus', focus);
document.addEventListener('visibilitychange', visibility);
return () => {
window.removeEventListener('focus', focus);
document.removeEventListener('visibilitychange', visibility);
};
}, [active, refreshSummary]);
const refreshPage = useCallback(async () => {
const request = ++pageRequest.current;
setLoading(true); setLoaded(false);
try {
const latest = await fetchWorksRechargeOrders();
const page = tab === 'orders' && offset > 0 ? await fetchWorksRechargeOrders(offset) : latest;
const ledger = tab === 'history' ? await fetchWorksPointHistory(offset) : null;
if (!alive.current || pageRequest.current !== request) return;
setOrders(page.items); setPending(latest.items.find(unresolved) ?? null);
if (ledger) setHistory(ledger);
setLoaded(true);
} catch {
if (alive.current && pageRequest.current === request) setError('记录加载失败,请刷新后再充值。');
} finally {
if (alive.current && pageRequest.current === request) setLoading(false);
}
}, [offset, tab]);
useEffect(() => {
if (open) { setError(''); void refreshSummary(); void refreshPage(); }
return () => { pageRequest.current += 1; };
}, [open, refreshPage, refreshSummary]);
useEffect(() => {
let active = true;
setQr('');
if (checkout?.qr_payload) {
void QRCode.toDataURL(checkout.qr_payload, { width: 224, margin: 1 }).then((value) => {
if (active) setQr(value);
}).catch(() => { if (active) setError('二维码生成失败,请刷新此订单。'); });
}
return () => { active = false; };
}, [checkout?.qr_payload]);
const checkOrder = useCallback(async (id: string) => {
const order = await fetchWorksRechargeOrder(id);
if (!alive.current) return;
setCheckout((current) => current?.id === id ? order : current);
if (!unresolved(order)) {
intent.current = null; setUnknownProduct(null);
void refreshSummary(); void refreshPage();
}
}, [refreshPage, refreshSummary]);
const checkoutId = checkout?.id;
const checkoutStatus = checkout?.status;
useEffect(() => {
if (!open || !checkoutId || (checkoutStatus !== 'pending' && checkoutStatus !== 'manual_review')) return;
let active = true;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
if (document.visibilityState === 'visible') {
try { await checkOrder(checkoutId); } catch { /* Keep the frozen order visible; never resubmit payment. */ }
}
if (active) timer = setTimeout(() => void poll(), 4000);
};
timer = setTimeout(() => void poll(), 4000);
return () => { active = false; clearTimeout(timer); };
}, [open, checkoutId, checkoutStatus, checkOrder]);
async function purchase(productId: string) {
if (purchaseBusy.current || !loaded || !summary?.can_recharge || !summary.payment_configured || pending) return;
if (intent.current && intent.current.productId !== productId) return;
purchaseBusy.current = true; setBusy(true); setError('');
intent.current ??= { productId, requestId: crypto.randomUUID() };
try {
const order = await createWorksRechargeOrder(productId, intent.current.requestId);
if (!alive.current) return;
setCheckout(order); setPending(unresolved(order) ? order : null);
intent.current = null; setUnknownProduct(null);
void refreshSummary(); void refreshPage();
} catch (cause) {
if (!alive.current) return;
const definitive = cause instanceof AppError && cause.details?.commandOutcome === 'definitive_failure';
if (definitive) { intent.current = null; setUnknownProduct(null); }
else setUnknownProduct(productId);
setError(definitive ? '本次充值未提交成功,请刷新后重试。' : '付款结果尚未确认。请先检查订单;重试将沿用同一次请求,请勿重复付款。');
void refreshPage();
} finally {
purchaseBusy.current = false;
if (alive.current) setBusy(false);
}
}
const openWebAccount = () => { void openWorksBillingAccount().catch(() => setError('网页账户页暂时无法打开。')); };
const own = summary?.own_points;
const selected = summary?.points;
const usingShared = typeof selected?.shared_available === 'boolean';
return <>
<div className="rounded-lg bg-surface-subtle/70 px-2 py-2 text-xs" data-testid="sidebar-point-balance">
<div className="flex items-center justify-between gap-2"><span></span><strong data-testid="sidebar-total-token-points">{own ? `${points(own.total_remaining)}` : summaryError ? '暂时不可用' : '加载中…'}</strong></div>
{own && <p className="mt-1 text-[11px] text-muted-foreground"> · {points(own.reserved)} </p>}
{usingShared && <p className="mt-1 text-[11px] text-muted-foreground">AI 使{selected?.shared_available ? '可用' : '已用尽,请联系付款人'}</p>}
{summary?.funding_error && <p className="mt-1 text-[11px] text-muted-foreground">{summary.funding_error}</p>}
{isWorksTokenPointBalanceExhausted(own) && !usingShared && <p className="mt-1 text-[11px] text-muted-foreground">{summary?.can_recharge ? '点数已用尽,充值后可继续使用。' : '点数已用尽,请联系家长。'}</p>}
{summaryError && <button className="mt-1 underline" onClick={() => void refreshSummary()}></button>}
</div>
<button type="button" role="menuitem" data-testid="sidebar-point-wallet-open" className="flex min-h-10 w-full items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle" onClick={() => { setOpen(true); setTab('recharge'); setOffset(0); }}><Coins className="h-4 w-4" /></button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" data-testid="point-wallet-dialog">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle></DialogTitle>
<Button variant="ghost" size="icon" aria-label="关闭词元点数" onClick={() => setOpen(false)}><X className="h-4 w-4" /></Button>
</div>
<DialogDescription> 100 1 50 </DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle p-4">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{own ? points(own.total_remaining) : '—'} <span className="text-sm"></span>
</p>
<p className="mt-1 text-xs text-muted-foreground">
{points(own?.reserved ?? null)} ·
<button className="ml-2 underline" onClick={openWebAccount}></button>
</p>
</div>
<Button variant="outline" size="sm" disabled={loading || busy} onClick={() => { setError(''); void refreshSummary(); void refreshPage(); }}><RefreshCw className="mr-1 h-3.5 w-3.5" /></Button>
</div>
{summary?.funding_error && <p className="text-sm text-muted-foreground">{summary.funding_error}</p>}
{usingShared && <p className="text-sm text-muted-foreground">AI 使{selected?.shared_available ? '可用' : '已用尽'}</p>}
<div className="flex gap-2" role="tablist" aria-label="词元点数记录">{(['recharge', 'orders', 'history'] as const).map((value) => <Button key={value} role="tab" aria-selected={tab === value} variant={tab === value ? 'default' : 'outline'} onClick={() => { setTab(value); setOffset(0); }}>{value === 'recharge' ? '充值' : value === 'orders' ? '充值订单' : '收支记录'}</Button>)}</div>
{summaryError && <p role="alert" className="text-sm text-destructive"></p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading && <p role="status" className="text-sm text-muted-foreground"></p>}
{tab === 'recharge' && <div className="grid gap-3">
{summary && !summary.can_recharge && <p className="text-sm text-muted-foreground"></p>}
{summary?.can_recharge && !summary.payment_configured && <p className="text-sm text-muted-foreground"></p>}
{pending && checkout?.id !== pending.id && <div className="rounded-lg border p-3 text-sm"><p>{labels[pending.status]}{money(pending.amount_cents)} / {points(pending.point_amount)} </p><Button variant="outline" className="mt-2" onClick={() => setCheckout(pending)}></Button></div>}
{(!checkout || !unresolved(checkout)) && <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">{summary?.recharge_products.map((product) => <Button key={product.id} className="h-auto flex-col gap-1 py-4" variant="outline" disabled={!loaded || busy || Boolean(pending) || !summary.can_recharge || !summary.payment_configured || Boolean(unknownProduct)} onClick={() => void purchase(product.id)}><span className="text-lg font-semibold">{points(product.point_amount)} </span><span>{money(product.amount_cents)}</span></Button>)}</div>}
{unknownProduct && !pending && <Button disabled={!loaded || busy} variant="outline" onClick={() => void purchase(unknownProduct)}></Button>}
{checkout && <section className="rounded-xl border p-4 text-center" data-testid="point-recharge-checkout">
<p className="font-semibold">{labels[checkout.status]} · {money(checkout.amount_cents)} · {points(checkout.point_amount)} </p>
<p className="mt-1 break-all text-xs text-muted-foreground"> {checkout.id}</p>
{qr && checkout.status === 'pending' && <img src={qr} alt="充值支付二维码" className="mx-auto my-3 h-56 w-56" />}
{checkout.status === 'pending' && !checkout.qr_payload && <p className="my-3 text-sm text-muted-foreground"><button className="ml-1 underline" onClick={openWebAccount}></button></p>}
{checkout.status === 'pending' && <p className="mt-2 text-xs text-muted-foreground"></p>}
{checkout.status === 'manual_review' && <p className="mt-2 text-sm text-muted-foreground"></p>}
<Button variant="outline" size="sm" className="mt-3" onClick={() => void checkOrder(checkout.id).catch(() => setError('订单状态暂时无法获取,请稍后刷新。'))}></Button>
</section>}
</div>}
{tab === 'orders' && !loading && <div className="grid gap-2">{orders.length === 0 && <p className="text-sm text-muted-foreground"></p>}{orders.map((order) => <article key={order.id} className="rounded-lg border p-3"><div className="flex justify-between gap-2 text-sm"><strong>{points(order.point_amount)} · {money(order.amount_cents)}</strong><span>{labels[order.status]}</span></div><p className="mt-1 text-xs text-muted-foreground">{time(order.created_at)}</p>{unresolved(order) && <Button variant="outline" size="sm" className="mt-2" onClick={() => { setCheckout(order); setTab('recharge'); setOffset(0); }}></Button>}</article>)}</div>}
{tab === 'history' && !loading && <div className="grid gap-2">{history.items.length === 0 && <p className="text-sm text-muted-foreground"></p>}{history.items.map((item) => <article key={`${item.kind}:${item.id}`} className="rounded-lg border p-3"><div className="flex justify-between gap-3 text-sm"><span>{item.description}</span><strong>{item.kind === 'credit' ? '+' : item.points.startsWith('-') ? '' : ''}{points(item.points.replace(/^-/, ''))} </strong></div><p className="mt-1 text-xs text-muted-foreground">{time(item.created_at)} · {labels[item.status] ?? '待核对'}{item.reserved_points !== '0.00' ? ` · 预占 ${points(item.reserved_points)}` : ''}</p></article>)}</div>}
{tab !== 'recharge' && <div className="flex items-center justify-between"><Button variant="outline" size="sm" disabled={loading || offset === 0} onClick={() => setOffset((value) => Math.max(0, value - 20))}></Button><span className="text-xs text-muted-foreground"> {offset / 20 + 1} </span><Button variant="outline" size="sm" disabled={!loaded || loading || (tab === 'history' ? !history.has_more : orders.length < 20)} onClick={() => setOffset((value) => value + 20)}></Button></div>}
</DialogContent>
</Dialog>
</>;
}

View File

@@ -3,18 +3,14 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
BarChart3,
Bot,
Cpu,
ChevronRight,
Crown,
ExternalLink,
FolderKanban,
LogOut,
Plus,
RadioTower,
Settings as SettingsIcon,
Ticket,
UserCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -36,19 +32,7 @@ import {
subscribeCodingProjectCreationRequest,
subscribeCodingProjectOpenRequest,
} from '@/lib/coding-project-entry';
import { AppError } from '@/lib/error-model';
import {
fetchWorksResetCards,
fetchWorksTokenPointBalance,
redeemWorksResetCard,
type WorksResetCard,
type WorksTokenPointBalance,
} from '@/lib/works-square';
import {
formatWorksTokenPointValue,
isWorksTokenPointBalanceExhausted,
} from '@/lib/works-square-token-points';
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
import { PointWallet } from '@/components/account/PointWallet';
import { cn } from '@/lib/utils';
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
import type { SidebarPeekSource } from './sidebar-peek';
@@ -59,7 +43,7 @@ import { UserAvatar } from '@/components/profile/UserAvatar';
import { getAccountInitial } from '@/components/profile/user-avatar-utils';
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
import { useSettingsStore } from '@/stores/settings';
import { useAuthStore, type AuthUser } from '@/stores/auth';
import { useAuthStore } from '@/stores/auth';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProviderStore } from '@/stores/providers';
import type { CodingProjectSummary } from '@/types/coding-project';
@@ -71,16 +55,6 @@ type OpenDialogResult = {
filePaths: string[];
};
type TokenPointState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
balance: WorksTokenPointBalance | null;
};
type ResetCardState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
cards: WorksResetCard[];
};
type ProjectEntryError = {
project: CodingProjectSummary;
message: string;
@@ -94,103 +68,6 @@ function getFolderName(pathValue: string): string {
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
}
function padTimePart(value: number): string {
return String(value).padStart(2, '0');
}
function formatRefreshTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getRefreshTimeLabel(value: string | null | undefined): string | null {
const formatted = formatRefreshTime(value);
return formatted ? `刷新时间 ${formatted}` : null;
}
function formatResetCardTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] {
if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired';
return card.status;
}
function getResetCardErrorMessage(error: unknown): string {
const details = error instanceof AppError ? error.details : undefined;
const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null;
const status = typeof details?.status === 'number' ? details.status : null;
const messages: Record<string, string> = {
reset_card_expired: '这张重置卡已过期,卡包已刷新。',
token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。',
billing_temporarily_paused: '计费服务正在切换,请稍后再试。',
token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。',
};
if (backendCode && messages[backendCode]) return messages[backendCode];
if (status === 404) return '重置卡不存在或已不可用。';
if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。';
return '重置卡暂时无法使用,请稍后重试。';
}
function isTokenPointBalanceExhausted(state: TokenPointState): boolean {
if (state.status !== 'loaded' || !state.balance) return false;
return isWorksTokenPointBalanceExhausted(state.balance);
}
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string {
if (!user) return '未登录';
if (state.balance && !state.balance.can_manage_membership) {
return state.balance.family_shared ? '共享会员' : '青少年账户';
}
const planName = state.balance?.plan_name?.trim();
if (planName) return planName;
const planCode = state.balance?.plan_code?.trim();
if (planCode) return planCode;
return '会员';
}
function isExperienceSubscriptionPlan(state: TokenPointState): boolean {
const planCode = state.balance?.plan_code?.trim().toLowerCase();
const planName = state.balance?.plan_name?.trim().toLowerCase();
return planCode === 'experience' || planName === '体验';
}
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string {
const levelLabel = getSubscriptionPlanLabel(user, state);
if (
!user
|| isExperienceSubscriptionPlan(state)
|| levelLabel === '会员'
|| levelLabel === '共享会员'
|| levelLabel === '青少年账户'
) return levelLabel;
return `${levelLabel} 会员`;
}
function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string {
const remaining = formatWorksTokenPointValue(balance?.weekly_remaining);
const allowance = formatWorksTokenPointValue(balance?.weekly_allowance);
if (remaining && allowance) return `${remaining} / ${allowance}`;
return remaining ? `${remaining}` : '—';
}
function getTotalPointLabel(balance: WorksTokenPointBalance | null): string {
const remaining = formatWorksTokenPointValue(balance?.total_remaining);
return remaining ? `${remaining}` : '—';
}
function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string {
if (balance?.shared_available === true) return '可用';
if (balance?.shared_available === false) return '已用尽';
return balance?.family_shared ? '由家庭管理员管理' : '由家长管理';
}
type SidebarProps = {
workspaceLayout?: boolean;
hideOnCompact?: boolean;
@@ -220,7 +97,6 @@ export function Sidebar({
const loadProjectConfig = useProjectConfigStore((state) => state.load);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const authUser = useAuthStore((state) => state.user);
const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken);
const logout = useAuthStore((state) => state.logout);
const {
userProfile,
@@ -232,11 +108,6 @@ export function Sidebar({
const [projectsOpen, setProjectsOpen] = useState(true);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
const [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false);
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ status: 'idle', balance: null });
const [resetCardState, setResetCardState] = useState<ResetCardState>({ status: 'idle', cards: [] });
const [redeemingResetCardId, setRedeemingResetCardId] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
@@ -250,8 +121,6 @@ export function Sidebar({
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const tokenPointRequestIdRef = useRef(0);
const resetCardRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
const activeModule = getAiModuleForPath(location.pathname);
@@ -265,19 +134,6 @@ export function Sidebar({
const accountInitial = getAccountInitial(accountName);
const accountAvatarUrl = userProfile?.avatarUrl ?? null;
const settingsLabel = t('sidebar.settings', '设置');
const memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState);
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState);
const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState);
const tokenPointBalance = tokenPointState.balance;
const tokenPointsCoarse = Boolean(
tokenPointBalance && !tokenPointBalance.can_manage_membership,
);
const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at);
const visibleResetCards = resetCardState.cards.filter((card) => card.status !== 'redeemed');
const availableResetCardCount = visibleResetCards.filter(
(card) => getEffectiveResetCardStatus(card) === 'available',
).length;
const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group';
const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
@@ -382,111 +238,6 @@ export function Sidebar({
};
}, [accountMenuOpen]);
useEffect(() => {
if (!accountMenuOpen) {
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
}
}, [accountMenuOpen]);
const refreshTokenPoints = useCallback(() => {
if (!authUser) {
tokenPointRequestIdRef.current += 1;
setTokenPointState({ status: 'idle', balance: null });
return;
}
const requestId = tokenPointRequestIdRef.current + 1;
tokenPointRequestIdRef.current = requestId;
setTokenPointState((current) => ({ status: 'loading', balance: current.balance }));
void getValidAccessToken()
.then((accessToken) => {
if (!accessToken) {
throw new Error('请先登录后再查看用量');
}
return fetchWorksTokenPointBalance(accessToken);
})
.then((balance) => {
if (tokenPointRequestIdRef.current === requestId) {
setTokenPointState({ status: 'loaded', balance });
}
})
.catch(() => {
if (tokenPointRequestIdRef.current === requestId) {
setTokenPointState({ status: 'error', balance: null });
}
});
}, [authUser, getValidAccessToken]);
const refreshResetCards = useCallback(async (): Promise<void> => {
if (!authUser) {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
return;
}
const requestId = resetCardRequestIdRef.current + 1;
resetCardRequestIdRef.current = requestId;
setResetCardState((current) => ({ status: 'loading', cards: current.cards }));
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再查看重置卡');
const cards = await fetchWorksResetCards(accessToken);
if (resetCardRequestIdRef.current === requestId) {
setResetCardState({ status: 'loaded', cards });
}
} catch {
if (resetCardRequestIdRef.current === requestId) {
setResetCardState((current) => ({ status: 'error', cards: current.cards }));
}
}
}, [authUser, getValidAccessToken]);
useEffect(() => {
refreshTokenPoints();
}, [refreshTokenPoints]);
useEffect(() => {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
setRedeemingResetCardId(null);
}, [authUser?.userId]);
useEffect(() => {
if (!authUser) return undefined;
const handleFocus = () => {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]);
useEffect(() => {
if (authUser && accountMenuOpen) {
refreshTokenPoints();
}
}, [accountMenuOpen, authUser, refreshTokenPoints]);
useEffect(() => {
if (authUser && resetCardDrawerOpen) {
void refreshResetCards();
}
}, [authUser, refreshResetCards, resetCardDrawerOpen]);
const openCreateProject = useCallback(() => {
setNewProjectName('');
setNewProjectSelectedPath('');
@@ -601,61 +352,17 @@ export function Sidebar({
const navigateFromAccountMenu = (path: string) => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
navigate(path);
};
const openProfileDialog = () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
setProfileDialogOpen(true);
void syncProfileNow().catch(() => undefined);
};
const openSubscriptionUpgrade = () => {
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
};
const handleRedeemResetCard = async (card: WorksResetCard) => {
if (redeemingResetCardId) return;
if (getEffectiveResetCardStatus(card) !== 'available') {
toast.error('这张重置卡已过期。');
void refreshResetCards();
return;
}
if (resetCardRedemptionBlocked) {
toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。');
return;
}
setRedeemingResetCardId(card.id);
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再使用重置卡');
const redeemedCard = await redeemWorksResetCard(accessToken, card.id);
setResetCardState((current) => ({
status: 'loaded',
cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item),
}));
toast.success('重置卡已使用,本周额度已刷新。');
refreshTokenPoints();
void refreshResetCards();
} catch (error) {
toast.error(getResetCardErrorMessage(error));
void refreshResetCards();
} finally {
setRedeemingResetCardId((current) => current === card.id ? null : current);
}
};
const handleAccountLogout = async () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
await logout();
navigate('/login');
};
@@ -1097,7 +804,7 @@ export function Sidebar({
ref={accountButtonRef}
type="button"
data-testid="sidebar-member-menu-trigger"
aria-label={`打开会员菜单:${accountName}`}
aria-label={`打开账户菜单:${accountName}`}
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
@@ -1148,210 +855,13 @@ export function Sidebar({
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{accountFullName}</p>
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">{membershipLabel}</p>
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground"></p>
</div>
</div>
</div>
<div className="mt-1 grid gap-0.5">
<button
type="button"
role="menuitem"
data-testid="sidebar-account-usage-menuitem"
aria-expanded={usageDrawerOpen}
aria-controls="sidebar-account-usage-drawer"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
onClick={() => {
setUsageDrawerOpen((open) => !open);
setResetCardDrawerOpen(false);
}}
>
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', usageDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={usageDrawerOpen}
id="sidebar-account-usage-drawer"
data-testid="sidebar-account-usage-drawer"
className="ml-5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
innerClassName="grid gap-0.5"
>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span className="text-foreground">{memberLabel}</span>
</div>
{tokenPointState.status === 'error' ? (
<div data-testid="sidebar-token-points-error" className="rounded-md bg-background px-1.5 py-1.5">
<p className="text-[11px] leading-4 text-muted-foreground"></p>
<button
type="button"
className="mt-1 text-[11px] font-semibold text-foreground underline underline-offset-2"
onClick={refreshTokenPoints}
>
</button>
</div>
) : null}
{(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? (
<div className="px-1 py-1 text-[11px] text-muted-foreground">
{authUser ? '正在加载词元点数…' : '登录后查看词元点数'}
</div>
) : null}
{tokenPointBalance && tokenPointsCoarse ? (
<>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>{tokenPointBalance.family_shared ? '共享额度' : '词元点数'}</span>
<span className="text-foreground">{getCoarsePointLabel(tokenPointBalance)}</span>
</div>
<div className="px-1 text-[10px] leading-4 text-muted-foreground">
{tokenPointBalance.family_shared
? '具体词元点数由家庭管理员管理'
: '具体词元点数由家长管理'}
</div>
</>
) : null}
{tokenPointBalance && !tokenPointsCoarse ? (
<>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span data-testid="sidebar-weekly-token-points" className="text-foreground">
{getWeeklyPointLabel(tokenPointBalance)}
</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span data-testid="sidebar-total-token-points" className="text-foreground">
{getTotalPointLabel(tokenPointBalance)}
</span>
</div>
</>
) : null}
{tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? (
<div data-testid="sidebar-token-points-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{nextRefreshLabel}
</div>
) : null}
{tokenPointBalanceExhausted ? (
<div className="mt-1 rounded-md border border-accent/50 bg-background p-1.5 text-foreground">
<p className="text-[11px] leading-4 text-muted-foreground">
{tokenPointsCoarse
? tokenPointBalance?.family_shared
? '家庭共享额度已用尽,请联系家庭管理员。'
: '词元点数已用尽,请联系家长。'
: '当前词元点数已用尽,升级订阅后可继续使用。'}
</p>
{!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? (
<button
type="button"
data-testid="sidebar-account-upgrade-button"
className="mt-1.5 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-foreground/15 bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-colors duration-100 hover:bg-background"
onClick={openSubscriptionUpgrade}
>
<Crown className="h-3.5 w-3.5" />
<span></span>
<ExternalLink className="h-3.5 w-3.5" />
</button>
) : null}
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"
data-testid="sidebar-reset-cards-menuitem"
aria-expanded={resetCardDrawerOpen}
aria-controls="sidebar-reset-cards-drawer"
className="motion-press flex min-h-10 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-[background-color,transform] duration-100 hover:bg-surface-subtle"
onClick={() => {
setResetCardDrawerOpen((open) => !open);
setUsageDrawerOpen(false);
}}
>
<Ticket className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
{availableResetCardCount > 0 ? (
<span
data-testid="sidebar-reset-card-available-count"
className="min-w-5 rounded-full bg-accent-soft px-1.5 py-0.5 text-center text-[10px] font-semibold tabular-nums text-accent-strong"
>
{availableResetCardCount}
</span>
) : null}
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', resetCardDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={resetCardDrawerOpen}
id="sidebar-reset-cards-drawer"
data-testid="sidebar-reset-cards-drawer"
className="ml-5 rounded-lg bg-surface-subtle/70 p-1 text-xs font-medium text-muted-foreground shadow-soft"
innerClassName="grid gap-1"
>
{resetCardState.status === 'loading' && visibleResetCards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]"></p>
) : null}
{resetCardState.status === 'error' ? (
<div data-testid="sidebar-reset-cards-error" className="rounded-md bg-background px-1.5 py-1.5 shadow-soft">
<p className="text-pretty text-[11px] leading-4"></p>
<button
type="button"
className="motion-press mt-1 min-h-10 text-[11px] font-semibold text-foreground underline underline-offset-2 transition-transform duration-100"
onClick={() => void refreshResetCards()}
>
</button>
</div>
) : null}
{resetCardState.status === 'loaded' && visibleResetCards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]">使</p>
) : null}
{resetCardRedemptionBlocked && visibleResetCards.length > 0 ? (
<p className="rounded-md bg-background px-1.5 py-1.5 text-pretty text-[10px] leading-4 shadow-soft">
使使
</p>
) : null}
{visibleResetCards.length > 0 ? (
<div className="grid max-h-64 gap-1 overflow-y-auto pr-0.5">
{visibleResetCards.map((card) => {
const status = getEffectiveResetCardStatus(card);
const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用';
const redeeming = redeemingResetCardId === card.id;
return (
<article
key={card.id}
data-testid={`sidebar-reset-card-${card.id}`}
className="rounded-lg bg-background p-2 shadow-soft"
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-foreground"></span>
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
status === 'available' && 'bg-accent-soft text-accent-strong',
status === 'expired' && 'bg-destructive/10 text-destructive',
)}>
{status === 'available' ? '可使用' : '已过期'}
</span>
</div>
<p className="mt-1 text-pretty text-[10px] leading-4 tabular-nums">
{expiresAt}
</p>
{status === 'available' ? (
<button
type="button"
data-testid={`sidebar-reset-card-redeem-${card.id}`}
className="motion-press mt-1.5 min-h-10 w-full rounded-md bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-[background-color,transform] duration-100 hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
disabled={Boolean(redeemingResetCardId) || resetCardRedemptionBlocked}
onClick={() => void handleRedeemResetCard(card)}
>
{redeeming ? '使用中…' : resetCardRedemptionBlocked ? '家庭共享中不可用' : '立即使用'}
</button>
) : null}
</article>
);
})}
</div>
) : null}
</DisclosureContent>
{authUser && <PointWallet key={authUser.userId ?? authUser.username} active={accountMenuOpen} />}
<button
type="button"
role="menuitem"

View File

@@ -1,7 +0,0 @@
import { invokeIpc } from '@/lib/api-client';
export const WORKS_SQUARE_UPGRADE_URL = 'https://square.nianxx.cn/#profile';
export async function openWorksSquareSubscriptionUpgrade(): Promise<void> {
await invokeIpc('shell:openExternal', WORKS_SQUARE_UPGRADE_URL);
}

19
src/lib/works-billing.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { WorksBillingSummary, WorksPointHistory, WorksRechargeOrder, WorksTokenPointBalance } from '../../shared/works-billing';
import { hostApiFetch } from './host-api';
import { invokeIpc } from './api-client';
export type { WorksBillingSummary, WorksPointHistory, WorksRechargeOrder, WorksRechargeProduct, WorksTokenPointBalance } from '../../shared/works-billing';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const result = await hostApiFetch<{ success: boolean; data: T }>(`/api/works/billing${path}`, init);
if (!result.success) throw new Error('词元点数服务暂时不可用');
return result.data;
}
export const fetchWorksBillingSummary = () => request<WorksBillingSummary>('/me');
export const openWorksBillingAccount = () => invokeIpc('shell:openExternal', 'https://square.nianxx.cn/#profile');
export const fetchWorksTokenPointBalance = () => request<WorksTokenPointBalance>('/points');
export const fetchWorksPointHistory = (offset = 0) => request<WorksPointHistory>(`/points/transactions?offset=${offset}`);
export const fetchWorksRechargeOrders = (offset = 0) => request<{ items: WorksRechargeOrder[] }>(`/recharge/orders?offset=${offset}`);
export const fetchWorksRechargeOrder = (id: string) => request<WorksRechargeOrder>(`/recharge/orders/${encodeURIComponent(id)}`);
export const createWorksRechargeOrder = (productId: string, requestId: string) => request<WorksRechargeOrder>('/recharge/orders', {
method: 'POST', body: JSON.stringify({ productId, requestId }),
});

View File

@@ -1,4 +1,4 @@
import type { WorksTokenPointBalance } from './works-square';
import type { WorksTokenPointBalance } from './works-billing';
const TOKEN_POINT_VALUE_PATTERN = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/u;
@@ -16,7 +16,7 @@ export function isWorksTokenPointBalanceExhausted(
balance: WorksTokenPointBalance | null | undefined,
): boolean {
if (!balance) return false;
if (!balance.can_manage_membership && typeof balance.shared_available === 'boolean') {
if (balance.family_shared && typeof balance.shared_available === 'boolean') {
return !balance.shared_available;
}
const remaining = formatWorksTokenPointValue(balance.total_remaining);

View File

@@ -187,37 +187,6 @@ export type WorksSpeechTranscriptionInput = {
model?: string;
};
export type WorksTokenPointBalance = {
plan_code: string | null;
plan_name: string | null;
cycle_start: string | null;
cycle_end: string | null;
next_refresh_at: string | null;
weekly_allowance: string | null;
weekly_used: string | null;
weekly_reserved: string | null;
weekly_remaining: string | null;
permanent_total: string | null;
permanent_used: string | null;
permanent_reserved: string | null;
permanent_remaining: string | null;
total_remaining: string | null;
entitlement_source: 'self' | 'family_owner' | 'shared_group';
family_shared: boolean;
can_manage_membership: boolean;
upgrade_action: 'self_service' | 'contact_family_owner';
shared_available: boolean | null;
};
export type WorksResetCard = {
id: string;
status: 'available' | 'expired' | 'redeemed';
granted_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_cycle_id: string | null;
};
export type PlazaCard = {
id: string;
title: string;
@@ -424,46 +393,6 @@ export async function fetchMyWorksProjects(
return assertSuccess(response, 'page', 'Failed to load your Works Square projects');
}
export async function fetchWorksTokenPointBalance(accessToken: string): Promise<WorksTokenPointBalance> {
const response = await hostApiFetch<WorksActionResponse<'points', WorksTokenPointBalance>>(
'/api/works/billing/points',
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'points', 'Failed to load Works Square token points');
}
export async function fetchWorksResetCards(accessToken: string): Promise<WorksResetCard[]> {
const response = await hostApiFetch<WorksActionResponse<'cards', WorksResetCard[]>>(
'/api/works/billing/reset-cards',
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'cards', 'Failed to load Works Square reset cards');
}
export async function redeemWorksResetCard(
accessToken: string,
cardId: string,
): Promise<WorksResetCard> {
const response = await hostApiFetch<WorksActionResponse<'card', WorksResetCard>>(
`/api/works/billing/reset-cards/${encodeURIComponent(cardId)}/redeem`,
{
method: 'POST',
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'card', 'Failed to redeem Works Square reset card');
}
export async function fetchMyWorksProjectStatus(
accessToken: string,
appId: string,