feat: integrate token point usage v2
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-06 14:24:50 +08:00
parent 9fed0cc7c4
commit 83ad10d95e
14 changed files with 907 additions and 201 deletions

View File

@@ -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`

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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<string, unknown>,
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<string, unknown> | 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<string, unknown> = {};
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<void> {
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;
}

View File

@@ -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<TokenUsageState>({ status: 'idle', usage: null });
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ 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<string | null>(() => readAiHardwareAgentId());
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(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<number>();
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,31 +1172,67 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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>5 </span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
<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>1 </span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
<span></span>
<span data-testid="sidebar-total-token-points" className="text-foreground">
{getTotalPointLabel(tokenPointBalance)}
</span>
</div>
{fiveHourRefreshLabel ? (
<div data-testid="sidebar-five-hour-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{fiveHourRefreshLabel}
</>
) : 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}
{weeklyRefreshLabel ? (
<div data-testid="sidebar-weekly-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{weeklyRefreshLabel}
</div>
) : null}
{tokenUsageExhausted ? (
{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">使</p>
<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"
@@ -1206,6 +1243,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<span></span>
<ExternalLink className="h-3.5 w-3.5" />
</button>
) : null}
</div>
) : null}
</DisclosureContent>

View File

@@ -0,0 +1,24 @@
import type { WorksTokenPointBalance } from './works-square';
const TOKEN_POINT_VALUE_PATTERN = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/u;
export function formatWorksTokenPointValue(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const match = TOKEN_POINT_VALUE_PATTERN.exec(value.trim());
if (!match) return null;
const whole = match[1].replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
const fraction = (match[2] ?? '').replace(/0+$/u, '');
return fraction ? `${whole}.${fraction}` : whole;
}
export function isWorksTokenPointBalanceExhausted(
balance: WorksTokenPointBalance | null | undefined,
): boolean {
if (!balance) return false;
if (!balance.can_manage_membership && typeof balance.shared_available === 'boolean') {
return !balance.shared_available;
}
const remaining = formatWorksTokenPointValue(balance.total_remaining);
return remaining !== null && /^0(?:\.0+)?$/u.test(remaining);
}

View File

@@ -1,14 +0,0 @@
import type { WorksTokenUsage } from './works-square';
function isRemainingPercentExhausted(value: number | null | undefined): boolean {
return typeof value === 'number' && Number.isFinite(value) && value <= 0;
}
export function isWorksTokenUsageExhausted(
usage: WorksTokenUsage | null | undefined,
): boolean {
return Boolean(usage && (
isRemainingPercentExhausted(usage.five_hour_remaining_percent)
|| isRemainingPercentExhausted(usage.weekly_remaining_percent)
));
}

View File

@@ -1,11 +0,0 @@
export const WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT = 'works-square-token-usage:stale';
export function dispatchWorksSquareTokenUsageStale(): void {
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
return;
}
if (typeof Event !== 'function') {
return;
}
window.dispatchEvent(new Event(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT));
}

View File

@@ -187,13 +187,26 @@ export type WorksSpeechTranscriptionInput = {
model?: string;
};
export type WorksTokenUsage = {
five_hour_remaining_percent: number | null;
five_hour_refresh_at?: string | null;
weekly_remaining_percent: number | null;
weekly_refresh_at?: string | null;
plan_code?: string | null;
plan_name?: string | null;
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 PlazaCard = {
@@ -402,16 +415,16 @@ export async function fetchMyWorksProjects(
return assertSuccess(response, 'page', 'Failed to load your Works Square projects');
}
export async function fetchWorksTokenUsage(accessToken: string): Promise<WorksTokenUsage> {
const response = await hostApiFetch<WorksActionResponse<'usage', WorksTokenUsage>>(
'/api/works/billing/token-usage',
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, 'usage', 'Failed to load Works Square token usage');
return assertSuccess(response, 'points', 'Failed to load Works Square token points');
}
export async function fetchMyWorksProjectStatus(

View File

@@ -0,0 +1,210 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '@/components/layout/Sidebar';
import type { WorksTokenPointBalance } from '@/lib/works-square';
const fetchTokenPointBalanceMock = vi.hoisted(() => vi.fn());
const getValidAccessTokenMock = vi.hoisted(() => vi.fn());
const authState = vi.hoisted(() => ({
user: {
username: 'member@example.com',
userId: 'user-1',
tenantId: null,
deptId: null,
authorities: [],
},
getValidAccessToken: getValidAccessTokenMock,
logout: vi.fn(),
}));
const codingState = vi.hoisted(() => ({
projects: [],
activeProject: null,
load: vi.fn().mockResolvedValue(undefined),
setActiveProject: vi.fn(),
removeProject: vi.fn(),
createProject: vi.fn(),
}));
const projectConfigState = vi.hoisted(() => ({
configsByProjectId: {},
load: vi.fn().mockResolvedValue(undefined),
remove: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
}));
vi.mock('@/lib/works-square', () => ({
fetchWorksTokenPointBalance: (...args: unknown[]) => fetchTokenPointBalanceMock(...args),
}));
vi.mock('@/lib/subscription-upgrade', () => ({
openWorksSquareSubscriptionUpgrade: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
vi.mock('@/hooks/use-current-user-profile', () => ({
useCurrentUserProfile: () => ({
userProfile: { displayName: '测试用户', avatarUrl: null },
profileRequired: false,
profileSyncError: null,
syncProfileNow: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('@/stores/auth', () => ({
useAuthStore: (selector: (state: typeof authState) => unknown) => selector(authState),
}));
vi.mock('@/stores/settings', () => ({
useSettingsStore: (selector: (state: { sidebarCollapsed: boolean }) => unknown) => (
selector({ sidebarCollapsed: false })
),
}));
vi.mock('@/stores/coding-workspace', () => ({
useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState),
}));
vi.mock('@/stores/project-config', () => ({
useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => (
selector(projectConfigState)
),
}));
vi.mock('@/stores/providers', () => ({
useProviderStore: (selector: (state: { refreshProviderSnapshot: () => Promise<void> }) => unknown) => (
selector({ refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined) })
),
}));
vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null }));
vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null }));
vi.mock('@/components/layout/LearningSidebar', () => ({ LearningSidebar: () => null }));
vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null }));
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
function pointBalance(
overrides: Partial<WorksTokenPointBalance> = {},
): WorksTokenPointBalance {
return {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
...overrides,
};
}
async function renderUsageDrawer(): Promise<HTMLElement> {
render(
<MemoryRouter initialEntries={['/learning']}>
<Sidebar />
</MemoryRouter>,
);
await waitFor(() => expect(getValidAccessTokenMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByTestId('sidebar-account-usage-menuitem'));
return screen.getByTestId('sidebar-account-usage-drawer');
}
describe('Sidebar V2 token point balance', () => {
beforeEach(() => {
getValidAccessTokenMock.mockResolvedValue('access-token');
});
it('shows the authoritative weekly and total point balances without the retired rolling rows', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('精通')).toBeInTheDocument());
expect(drawer).toHaveTextContent('本周剩余');
expect(screen.getByTestId('sidebar-weekly-token-points')).toHaveTextContent('375 / 500 点');
expect(drawer).toHaveTextContent('总可用');
expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('425 点');
expect(drawer).not.toHaveTextContent('5 小时');
expect(drawer).not.toHaveTextContent('1 周');
});
it('shows a distinct retrieval error instead of treating missing data as unlimited or zero', async () => {
fetchTokenPointBalanceMock.mockRejectedValue(new Error('upstream unavailable'));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(screen.getByTestId('sidebar-token-points-error')).toBeInTheDocument());
expect(drawer).toHaveTextContent('词元点数暂时无法获取。');
expect(drawer).not.toHaveTextContent('不限');
expect(drawer).not.toHaveTextContent('0%');
});
it('shows only coarse availability for a family-shared member', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({
plan_code: null,
plan_name: null,
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
}));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('共享会员')).toBeInTheDocument());
expect(drawer).toHaveTextContent('共享额度');
expect(drawer).toHaveTextContent('已用尽');
expect(drawer).toHaveTextContent('请联系家庭管理员');
expect(drawer).not.toHaveTextContent('本周剩余');
expect(drawer).not.toHaveTextContent('总可用');
expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument();
});
it('shows only parent-managed availability for a youth self entitlement', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({
plan_code: null,
plan_name: null,
entitlement_source: 'self',
family_shared: false,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
}));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('青少年账户')).toBeInTheDocument());
expect(drawer).toHaveTextContent('词元点数');
expect(drawer).toHaveTextContent('已用尽');
expect(drawer).toHaveTextContent('请联系家长');
expect(drawer).not.toHaveTextContent('共享额度');
expect(drawer).not.toHaveTextContent('本周剩余');
expect(drawer).not.toHaveTextContent('总可用');
expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument();
});
});

View File

@@ -592,12 +592,31 @@ describe('works square host api routes', () => {
);
});
it('loads current token plan usage through the Works Square API', async () => {
it('loads and safely projects the V2 token point balance through the Works Square API', async () => {
const points = {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
ignored_internal_field: 'do-not-project',
};
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
five_hour_remaining_percent: 72.5,
weekly_remaining_percent: 48,
}), { status: 200 }),
new Response(JSON.stringify(points), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
@@ -605,7 +624,7 @@ describe('works square host api routes', () => {
const handled = await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/token-usage'),
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
@@ -613,13 +632,30 @@ describe('works square host api routes', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
usage: {
five_hour_remaining_percent: 72.5,
weekly_remaining_percent: 48,
points: {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
},
});
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/billing/token-usage',
'https://square.nianxx.cn/api/billing/points',
{
method: 'GET',
headers: {
@@ -629,6 +665,95 @@ describe('works square host api routes', () => {
);
});
it('rejects an invalid V2 token point balance instead of forwarding it to Renderer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
plan_code: 'mastery',
plan_name: '精通',
weekly_remaining: '-1.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
}), { status: 200 }),
));
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: 'Works Square returned an invalid token point balance',
});
});
it('removes exact plan and point values from a family-shared balance', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
plan_code: 'excellence',
plan_name: '卓越',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '1000.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '875.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '925.00',
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: true,
}), { status: 200 }),
));
const response = createResponse();
await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
points: {
plan_code: null,
plan_name: null,
cycle_start: null,
cycle_end: null,
next_refresh_at: null,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: true,
},
});
});
it('loads the current Agent Profile through the Works Square API', async () => {
const profile = {
display_name: '小泥',

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import type { WorksTokenPointBalance } from '@/lib/works-square';
import {
formatWorksTokenPointValue,
isWorksTokenPointBalanceExhausted,
} from '@/lib/works-square-token-points';
function pointBalance(
overrides: Partial<WorksTokenPointBalance> = {},
): WorksTokenPointBalance {
return {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
...overrides,
};
}
describe('Works Square V2 token points', () => {
it('formats exact server point strings without losing integer precision', () => {
expect(formatWorksTokenPointValue('1000.00')).toBe('1,000');
expect(formatWorksTokenPointValue('123456789012345678.50')).toBe('123,456,789,012,345,678.5');
expect(formatWorksTokenPointValue(null)).toBeNull();
expect(formatWorksTokenPointValue('-1.00')).toBeNull();
});
it('treats an exact zero total balance as exhausted for the owner', () => {
expect(isWorksTokenPointBalanceExhausted(pointBalance({ total_remaining: '0.00' }))).toBe(true);
expect(isWorksTokenPointBalanceExhausted(pointBalance())).toBe(false);
expect(isWorksTokenPointBalanceExhausted(pointBalance({ total_remaining: null }))).toBe(false);
});
it('uses the server-provided coarse availability for every non-manager', () => {
const nonManager = pointBalance({
plan_code: null,
plan_name: null,
can_manage_membership: false,
family_shared: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
total_remaining: null,
});
expect(isWorksTokenPointBalanceExhausted(nonManager)).toBe(true);
expect(isWorksTokenPointBalanceExhausted({ ...nonManager, shared_available: true })).toBe(false);
expect(isWorksTokenPointBalanceExhausted({ ...nonManager, family_shared: true })).toBe(true);
});
});

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
describe('isWorksTokenUsageExhausted', () => {
it('returns true when either rolling window is exhausted', () => {
expect(isWorksTokenUsageExhausted({
five_hour_remaining_percent: 50,
weekly_remaining_percent: 0,
})).toBe(true);
expect(isWorksTokenUsageExhausted({
five_hour_remaining_percent: 0,
weekly_remaining_percent: 40,
})).toBe(true);
});
it('returns false while both rolling windows have remaining allowance', () => {
expect(isWorksTokenUsageExhausted({
five_hour_remaining_percent: 50,
weekly_remaining_percent: 40,
})).toBe(false);
});
it('does not treat missing or non-finite usage as confirmed exhaustion', () => {
expect(isWorksTokenUsageExhausted(undefined)).toBe(false);
expect(isWorksTokenUsageExhausted({
five_hour_remaining_percent: null,
weekly_remaining_percent: Number.NaN,
})).toBe(false);
});
});

View File

@@ -7,7 +7,7 @@ import {
fetchMyWorksProjects,
fetchWorksAsset,
fetchWorksAssets,
fetchWorksTokenUsage,
fetchWorksTokenPointBalance,
fetchWorksProjectVersions,
fetchWorksProjects,
publishWorksProjectSource,
@@ -212,22 +212,37 @@ describe('works square client', () => {
);
});
it('loads billing token usage with the current access token', async () => {
const usage = {
five_hour_remaining_percent: 72.5,
weekly_remaining_percent: 48,
plan_code: 'pro',
plan_name: '专业版',
it('loads the V2 token point balance with the current access token', async () => {
const points = {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, usage });
hostApiFetchMock.mockResolvedValueOnce({ success: true, points });
const result = await fetchWorksTokenUsage('access-token');
const result = await fetchWorksTokenPointBalance('access-token');
expect(result).toEqual(usage);
expect(result.plan_code).toBe('pro');
expect(result.plan_name).toBe('专业版');
expect(result).toEqual(points);
expect(result.plan_code).toBe('mastery');
expect(result.plan_name).toBe('精通');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/billing/token-usage',
'/api/works/billing/points',
{
headers: {
'X-NianCode-Access-Token': 'access-token',