diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 14b9728..ba84782 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,21 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Token Point V2 account-usage source `6348e402a4e8dde712e5cba4620bb883ffe24e0b` + from task `20260906-trace-makelore-usage-3d7a5c1e` is integrated onto local + `main` by task `20260906-integrate-token-points-v2-7b4e1c92`. The account menu + now uses the Main-owned `/api/works/billing/points` route and the Works Square + `/api/billing/points` authority instead of the retired rolling-window usage + contract. Managing accounts see the authoritative plan, weekly remaining/allowance, + total remaining points, and refresh time; loading and retrieval failures remain + explicit. Every non-managing account receives only coarse `shared_available` + state, including both family-shared members and youth self entitlements, and never + receives plan, cycle, or exact balance fields through the Main projection. Source + verification passed 72 focused tests, typecheck, scoped lint, all Vite targets, + diff/source checks, and two matching Works Square V2 contract tests. The deployed + service/database state and signed-in packaged-client smoke remain release evidence + gaps; no server deployment, database migration, package, publication, or remote + push is claimed. - Automatic Game Resource delivery source `6113a2453299141eab8420a56e93675712dd607b` from task `20260906-game-resource-auto-delivery-7a4e2c91` is integrated onto local `main` diff --git a/.project-docs/30-worklog/tasks/20260906-integrate-token-points-v2-7b4e1c92.md b/.project-docs/30-worklog/tasks/20260906-integrate-token-points-v2-7b4e1c92.md new file mode 100644 index 0000000..b8ce528 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-integrate-token-points-v2-7b4e1c92.md @@ -0,0 +1,75 @@ +# Task: Integrate Token Point V2 account usage + +## Identity + +- Task ID: 20260906-integrate-token-points-v2-7b4e1c92 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 9fed0cc7c45df6b4ef01a52abc5045de478faa9c +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Integrate completed MakeLore Token Point V2 account-usage source commits + `8e5abd8` and `6348e40` from task + `20260906-trace-makelore-usage-3d7a5c1e` onto local `main@9fed0cc`. +- Reconcile the source task's accepted promotion candidate into canonical + `current-state.md` without changing its source task record. +- Preserve the already integrated Agent Browser repair and three explicitly adopted + untracked task records in the primary worktree. + +## Intent And Constraints + +- Preserve ADR-004: Renderer uses fixed Host API routes while Electron Main owns + Works Square credentials and response projection. +- Preserve Works Square as the sole Token Point balance authority; do not restore + the retired five-hour or rolling-seven-day contract and do not derive ledger state + in Renderer. +- Preserve the non-manager privacy boundary using `can_manage_membership`, not + `family_shared`, because youth self entitlements are also non-managing. +- Apply the two source commits as patches on the latest main rather than advancing to + the old source branch head, which would reverse the newer Agent Browser integration. +- Do not modify or stage the three adopted untracked task records, deploy services, + run migrations, package the client, publish, or push remotely. + +## Outcome + +- Re-anchored the integration at `main@9fed0cc` after the blocking Main-worktree + owner was explicitly released, then applied source commits `8e5abd8` and + `6348e40` with `--no-commit` and no conflicts. +- Verified the staged source task record is byte-for-byte identical to source head + `6348e40` and registered it as an unchanged adopted foreign document. +- Integrated the V2 points route, strict Main projection, account-menu presentation, + privacy handling, and focused tests without changing any Agent Browser file. +- Promoted the durable V2 account-usage contract and remaining live-release gaps to + canonical `current-state.md`. +- The three pre-existing untracked task records remain unstaged and unchanged. + +## Verification + +- Source verification is recorded in task + `20260906-trace-makelore-usage-3d7a5c1e`: 72 focused tests, typecheck, scoped lint, + all Vite targets, diff/source checks, and two matching server contract tests passed. +- Passed the same four focused Vitest files on `main@9fed0cc` plus the staged V2 + integration (72 tests). +- Passed `pnpm run typecheck` and scoped ESLint over all changed TypeScript/TSX files. +- Passed `pnpm run build:vite` for Renderer, Electron Main, preload, and utility + bundles. Only the repository's existing Browserslist, dynamic-import, and chunk-size + advisories were reported. +- Verified the staged source task record matches source head `6348e40` and the staged + path set contains no Agent Browser or README file. +- Passed task-aware project-document drift checks with the source task record and the + three pre-existing untracked task records unchanged. + +## Follow-ups + +- Verify that the deployed Works Square API and database are on the V2 release and + migration head before releasing the paired client. +- Run a signed-in packaged-client smoke against the deployed V2 service. + +## Promotion Candidates + +- None. This Integration Gate applies the accepted source promotion directly to + `.project-docs/30-worklog/current-state.md`. diff --git a/.project-docs/30-worklog/tasks/20260906-trace-makelore-usage-3d7a5c1e.md b/.project-docs/30-worklog/tasks/20260906-trace-makelore-usage-3d7a5c1e.md new file mode 100644 index 0000000..f6055e4 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-trace-makelore-usage-3d7a5c1e.md @@ -0,0 +1,101 @@ +# Task: Trace current MakeLore membership and usage display values + +## Identity + +- Task ID: 20260906-trace-makelore-usage-3d7a5c1e +- Mode: Feature +- Branch: codex/20260906-trace-makelore-usage-3d7a5c1e-trace-makelore-usage +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260906-trace-makelore-usage-3d7a5c1e +- Base commit: 5c61110f465cc4172f712ad435c06041c0aed1ec +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Trace how the account menu previously obtained membership level, five-hour usage, + and seven-day usage, and verify the matching Works Square contract. +- Migrate the Renderer and Electron Main transport from the retired rolling-window + DTO to `GET /api/billing/points` and the Token Point V2 balance DTO. +- Replace the obsolete percentage rows with current-week and total Token Point + balances, while preserving explicit loading, error, exhaustion, and family-shared + states. + +## Intent And Constraints + +- Keep Works credentials and billing authority in Electron Main/Works Square; the + Renderer only formats a safe balance projection and never derives ledger values. +- Preserve the V2 privacy boundary: any non-manager receives only the coarse + `shared_available` result, not the plan, cycle, or exact balances. This includes + both family-shared members and youth users with a self entitlement. +- Keep the existing Renderer -> Host API -> Electron Main -> Works Square ownership + chain and avoid unrelated account, runtime, or browser changes. +- Do not modify or deploy Works Square server code from this client feature task. + +## Outcome + +- Replaced the client call with `fetchWorksTokenPointBalance`, using the local route + `/api/works/billing/points`; Electron Main now forwards authenticated requests to + Works Square `GET /api/billing/points`. +- Added a strict Main-process V2 projection. Invalid point strings or malformed + entitlement metadata produce a safe 502 response, and every non-managing account + has plan, cycle, and exact point fields cleared even if upstream includes them. +- The account menu now gets the membership label from V2 `plan_name`/`plan_code`, + removes the retired five-hour and rolling-week percentage rows, and displays + `weekly_remaining / weekly_allowance`, `total_remaining`, and `next_refresh_at`. +- Loading and retrieval failures are distinct UI states. A failure no longer appears + as `不限` or as a fabricated zero balance, and the user can retry explicitly. +- Shared members see `共享会员` plus only `共享额度` availability. When exhausted they + are directed to the family administrator and are not shown a self-service upgrade + action. +- Youth users with a self entitlement also follow the coarse-availability contract: + they see `青少年账户`, no exact balances, and a parent-contact message instead of + a self-service upgrade action. +- Removed the unused V1 percentage formatter/exhaustion helper and stale-event retry + module. Initial login, focus, visibility, and account-menu refreshes remain intact. +- No Works Square server source, deployment, packaged client, or live account state + was changed. + +## Verification + +- Passed focused client, Main route, formatter, privacy, error-state, and Sidebar UI + tests: + `pnpm exec vitest run tests/unit/works-square.test.ts tests/unit/works-routes.test.ts tests/unit/works-square-token-points.test.ts tests/unit/sidebar-token-points.test.tsx --maxWorkers=2` + (4 files, 72 tests). +- Passed `pnpm run typecheck`. +- Passed scoped ESLint over every changed TypeScript/TSX source and test file. +- Passed `pnpm run build:vite` for Renderer, Electron Main, preload, and utility + bundles. Vite reported only the repository's existing dynamic-import/chunk-size + advisory warnings. +- Passed `git diff --check` and a source/test scan found no remaining account-menu V1 + symbol, retired local route, or rolling-percentage reference. +- The matching server contract and youth-account tests passed during the trace: + `tests/test_token_points.py::test_v2_billing_contract_exposes_points_and_retires_legacy_endpoints` + and + `tests/test_token_points.py::test_youth_v2_billing_is_read_only_and_rejects_reset_card_checkout` + (2 passed). + +## Follow-ups + +- Integrate the task branch into the intended MakeLore release branch. +- Verify that the deployed Works Square API image and database are on the V2 release + and migration head; source inspection cannot establish live deployment state. +- A signed-in packaged-client smoke against the deployed V2 service remains useful + release evidence; it was not run because this task does not authorize deployment, + installation, or live-account mutation. + +## Promotion Candidates + +- Target canonical document: `.project-docs/30-worklog/current-state.md`. +- Proposal: record that the account menu consumes the V2 Token Point balance, shows + weekly remaining/allowance and total available points, has explicit loading/error + states, and exposes only coarse availability to every non-managing account. +- Evidence: focused 72-test pass, typecheck, scoped lint, production Vite build, and + the two server V2 contract tests listed above. +- Future impact: account-menu changes must not restore the retired rolling-window + endpoint or derive exact balances in Renderer; non-manager privacy remains a + Main/Works contract boundary, regardless of whether the entitlement is shared. +- Semantic conflicts: none known. This replaces a client path already retired by the + authoritative V2 server contract. +- Human confirmation required: no additional product choice is required; the user + explicitly requested the V2 migration. Canonical promotion still belongs to an + Integration Gate task. diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index b2dc78f..bc7267c 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -60,6 +60,27 @@ const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp' const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024; const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const SAFE_PROJECT_STATUSES = new Set(['draft', 'published']); +const TOKEN_POINT_FIELDS = [ + 'weekly_allowance', + 'weekly_used', + 'weekly_reserved', + 'weekly_remaining', + 'permanent_total', + 'permanent_used', + 'permanent_reserved', + 'permanent_remaining', + 'total_remaining', +] as const; +const TOKEN_POINT_METADATA_FIELDS = [ + 'plan_code', + 'plan_name', + 'cycle_start', + 'cycle_end', + 'next_refresh_at', +] as const; +const TOKEN_POINT_ENTITLEMENT_SOURCES = new Set(['self', 'family_owner', 'shared_group']); +const TOKEN_POINT_UPGRADE_ACTIONS = new Set(['self_service', 'contact_family_owner']); +const TOKEN_POINT_VALUE_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { @@ -267,6 +288,62 @@ function readNullableStringField( return typeof value === 'string' ? value : undefined; } +function readNullablePointField( + source: Record, + field: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined || value === null) return null; + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return TOKEN_POINT_VALUE_PATTERN.test(normalized) ? normalized : undefined; +} + +function projectSafeTokenPointBalance(value: unknown): Record | null { + if (!isRecord(value)) return null; + + const entitlementSource = readOptionalString(value.entitlement_source); + const upgradeAction = readOptionalString(value.upgrade_action); + if ( + !entitlementSource + || !TOKEN_POINT_ENTITLEMENT_SOURCES.has(entitlementSource) + || typeof value.family_shared !== 'boolean' + || typeof value.can_manage_membership !== 'boolean' + || !upgradeAction + || !TOKEN_POINT_UPGRADE_ACTIONS.has(upgradeAction) + ) return null; + + if ( + value.shared_available !== undefined + && value.shared_available !== null + && typeof value.shared_available !== 'boolean' + ) return null; + + const canManageMembership = value.can_manage_membership; + if (!canManageMembership && typeof value.shared_available !== 'boolean') return null; + + const projected: Record = {}; + for (const field of TOKEN_POINT_METADATA_FIELDS) { + const fieldValue = canManageMembership ? readNullableStringField(value, field) : null; + if (fieldValue === undefined) return null; + projected[field] = fieldValue; + } + for (const field of TOKEN_POINT_FIELDS) { + const fieldValue = canManageMembership ? readNullablePointField(value, field) : null; + if (fieldValue === undefined) return null; + projected[field] = fieldValue; + } + + return { + ...projected, + entitlement_source: entitlementSource, + family_shared: value.family_shared, + can_manage_membership: canManageMembership, + upgrade_action: upgradeAction, + shared_available: canManageMembership ? null : value.shared_available, + }; +} + function projectSafeProject( value: unknown, options: { requireStatus?: boolean } = {}, @@ -614,12 +691,12 @@ async function handleListMyProjects( sendJson(res, response.status, { success: true, page }); } -async function handleGetBillingTokenUsage( +async function handleGetBillingTokenPoints( req: IncomingMessage, res: ServerResponse, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); - const response = await proxyAwareFetch(createWorksUrl('/api/billing/token-usage').toString(), { + const response = await proxyAwareFetch(createWorksUrl('/api/billing/points').toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, @@ -627,11 +704,16 @@ async function handleGetBillingTokenUsage( }); if (!response.ok) { - await sendUpstreamError(res, response, `Works Square token usage failed (${response.status})`); + await sendUpstreamError(res, response, `Works Square token points failed (${response.status})`); return; } - sendJson(res, response.status, { success: true, usage: await readResponsePayload(response) }); + const points = projectSafeTokenPointBalance(await readResponsePayload(response)); + if (!points) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid token point balance' }); + return; + } + sendJson(res, response.status, { success: true, points }); } async function handleAgentProfile( @@ -1413,8 +1495,8 @@ export async function handleWorksRoutes( return true; } - if (url.pathname === '/api/works/billing/token-usage' && req.method === 'GET') { - await handleGetBillingTokenUsage(req, res); + if (url.pathname === '/api/works/billing/points' && req.method === 'GET') { + await handleGetBillingTokenPoints(req, res); return true; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6637de7..b720316 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -31,9 +31,11 @@ import { import { getAiModuleForPath } from '@/lib/ai-modules'; import { invokeIpc } from '@/lib/api-client'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; -import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events'; -import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square'; -import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage'; +import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square'; +import { + formatWorksTokenPointValue, + isWorksTokenPointBalanceExhausted, +} from '@/lib/works-square-token-points'; import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; @@ -61,10 +63,9 @@ type OpenDialogResult = { filePaths: string[]; }; -type TokenUsageState = { +type TokenPointState = { status: 'idle' | 'loading' | 'loaded' | 'error'; - usage: WorksTokenUsage | null; - error?: string; + balance: WorksTokenPointBalance | null; }; type ProjectEntryError = { @@ -81,12 +82,6 @@ function getFolderName(pathValue: string): string { return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed; } -function formatRemainingPercent(value: number | null | undefined): string { - if (typeof value !== 'number' || !Number.isFinite(value)) return '不限'; - const clamped = Math.min(100, Math.max(0, value)); - return `${Math.round(clamped)}%`; -} - function padTimePart(value: number): string { return String(value).padStart(2, '0'); } @@ -103,32 +98,59 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null { return formatted ? `刷新时间 ${formatted}` : null; } -function isTokenUsageExhausted(state: TokenUsageState): boolean { - if (state.status !== 'loaded' || !state.usage) return false; - return isWorksTokenUsageExhausted(state.usage); +function isTokenPointBalanceExhausted(state: TokenPointState): boolean { + if (state.status !== 'loaded' || !state.balance) return false; + return isWorksTokenPointBalanceExhausted(state.balance); } -function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string { +function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string { if (!user) return '未登录'; - const planName = state.usage?.plan_name?.trim(); + 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.usage?.plan_code?.trim(); + const planCode = state.balance?.plan_code?.trim(); if (planCode) return planCode; return '会员'; } -function isFreeSubscriptionPlan(state: TokenUsageState): boolean { - const planCode = state.usage?.plan_code?.trim().toLowerCase(); - const planName = state.usage?.plan_name?.trim().toLowerCase(); - return planCode === 'free' || planName === 'free'; +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: TokenUsageState): string { +function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string { const levelLabel = getSubscriptionPlanLabel(user, state); - if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel; + 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; sidebarPeekOpen?: boolean; @@ -162,7 +184,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [usageDrawerOpen, setUsageDrawerOpen] = useState(false); - const [tokenUsageState, setTokenUsageState] = useState({ status: 'idle', usage: null }); + const [tokenPointState, setTokenPointState] = useState({ status: 'idle', balance: null }); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); @@ -179,7 +201,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [robotSelectedAgentId, setRobotSelectedAgentId] = useState(() => readAiHardwareAgentId()); const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); - const tokenUsageRequestIdRef = useRef(0); + const tokenPointRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); @@ -196,11 +218,14 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const accountInitial = getAccountInitial(accountName); const accountAvatarUrl = userProfile?.avatarUrl ?? null; const settingsLabel = t('sidebar.settings', '设置'); - const memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState); - const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState); - const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState); - const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at); - const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at); + 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 activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { @@ -311,52 +336,48 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi } }, [accountMenuOpen]); - const refreshTokenUsage = useCallback(() => { + const refreshTokenPoints = useCallback(() => { if (!authUser) { - tokenUsageRequestIdRef.current += 1; - setTokenUsageState({ status: 'idle', usage: null }); + tokenPointRequestIdRef.current += 1; + setTokenPointState({ status: 'idle', balance: null }); return; } - const requestId = tokenUsageRequestIdRef.current + 1; - tokenUsageRequestIdRef.current = requestId; - setTokenUsageState((current) => ({ status: 'loading', usage: current.usage })); + 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 fetchWorksTokenUsage(accessToken); + return fetchWorksTokenPointBalance(accessToken); }) - .then((usage) => { - if (tokenUsageRequestIdRef.current === requestId) { - setTokenUsageState({ status: 'loaded', usage }); + .then((balance) => { + if (tokenPointRequestIdRef.current === requestId) { + setTokenPointState({ status: 'loaded', balance }); } }) - .catch((error) => { - if (tokenUsageRequestIdRef.current === requestId) { - setTokenUsageState({ - status: 'error', - usage: null, - error: error instanceof Error ? error.message : '暂时无法获取套餐额度', - }); + .catch(() => { + if (tokenPointRequestIdRef.current === requestId) { + setTokenPointState({ status: 'error', balance: null }); } }); }, [authUser, getValidAccessToken]); useEffect(() => { - refreshTokenUsage(); - }, [refreshTokenUsage]); + refreshTokenPoints(); + }, [refreshTokenPoints]); useEffect(() => { if (!authUser) return undefined; const handleFocus = () => { - refreshTokenUsage(); + refreshTokenPoints(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { - refreshTokenUsage(); + refreshTokenPoints(); } }; @@ -366,33 +387,13 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [authUser, refreshTokenUsage]); + }, [authUser, refreshTokenPoints]); useEffect(() => { if (authUser && accountMenuOpen) { - refreshTokenUsage(); + refreshTokenPoints(); } - }, [accountMenuOpen, authUser, refreshTokenUsage]); - - useEffect(() => { - if (!authUser) return undefined; - const retryTimers = new Set(); - const handleTokenUsageStale = () => { - refreshTokenUsage(); - const retryTimer = window.setTimeout(() => { - retryTimers.delete(retryTimer); - refreshTokenUsage(); - }, 1500); - retryTimers.add(retryTimer); - }; - window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); - return () => { - window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); - for (const retryTimer of retryTimers) { - window.clearTimeout(retryTimer); - } - }; - }, [authUser, refreshTokenUsage]); + }, [accountMenuOpen, authUser, refreshTokenPoints]); const openCreateProject = () => { @@ -1171,43 +1172,80 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi 订阅等级 {memberLabel} -
- 5 小时 - - {formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)} - -
-
- 1 周 - - {formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)} - -
- {fiveHourRefreshLabel ? ( -
- {fiveHourRefreshLabel} -
- ) : null} - {weeklyRefreshLabel ? ( -
- {weeklyRefreshLabel} -
- ) : null} - {tokenUsageExhausted ? ( -
-

当前额度已用尽,升级订阅后可继续使用。

+ {tokenPointState.status === 'error' ? ( +
+

词元点数暂时无法获取。

) : null} + {(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? ( +
+ {authUser ? '正在加载词元点数…' : '登录后查看词元点数'} +
+ ) : null} + {tokenPointBalance && tokenPointsCoarse ? ( + <> +
+ {tokenPointBalance.family_shared ? '共享额度' : '词元点数'} + {getCoarsePointLabel(tokenPointBalance)} +
+
+ {tokenPointBalance.family_shared + ? '具体词元点数由家庭管理员管理' + : '具体词元点数由家长管理'} +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse ? ( + <> +
+ 本周剩余 + + {getWeeklyPointLabel(tokenPointBalance)} + +
+
+ 总可用 + + {getTotalPointLabel(tokenPointBalance)} + +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? ( +
+ {nextRefreshLabel} +
+ ) : null} + {tokenPointBalanceExhausted ? ( +
+

+ {tokenPointsCoarse + ? tokenPointBalance?.family_shared + ? '家庭共享额度已用尽,请联系家庭管理员。' + : '词元点数已用尽,请联系家长。' + : '当前词元点数已用尽,升级订阅后可继续使用。'} +

+ {!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? ( + + ) : null} +
+ ) : null}