feat: integrate token point usage v2
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
src/lib/works-square-token-points.ts
Normal file
24
src/lib/works-square-token-points.ts
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
210
tests/unit/sidebar-token-points.test.tsx
Normal file
210
tests/unit/sidebar-token-points.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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: '小泥',
|
||||
|
||||
63
tests/unit/works-square-token-points.test.ts
Normal file
63
tests/unit/works-square-token-points.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user