diff --git a/.project-docs/30-worklog/tasks/20260922-permanent-points-client-7f4c9a2e.md b/.project-docs/30-worklog/tasks/20260922-permanent-points-client-7f4c9a2e.md new file mode 100644 index 00000000..340dd5a8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260922-permanent-points-client-7f4c9a2e.md @@ -0,0 +1,61 @@ +# Task: Adapt MakeLore to permanent token points + +## Identity + +- Task ID: 20260922-permanent-points-client-7f4c9a2e +- Mode: Feature +- Branch: codex/20260922-permanent-points-client-7f4c9a2e-permanent-points-client +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260922-permanent-points-client-7f4c9a2e +- Base commit: 2f82b9f79cf72873d75c17a48853292323aa5df5 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Complete MakeLore desktop adaptation to Works Square permanent Token Point accounts: Main/Renderer contracts, personal balance/recharge/history/orders, removal of subscription/reset-card surfaces, exhaustion wording, mock fixtures and focused unit/Electron acceptance. + +## Intent And Constraints + +- Concurrent Task Gate Passed: official check/start/status agree with this task ID, feature mode, branch, absolute worktree and main base above. User approved missing-directory registry recovery; official release-missing removed the 31 listed stale packaging records plus the subsequently missing package-155 record. No worktree, branch, commit or existing file was deleted. +- Planning Gate Passed: read entry/own record, memory index, positioning placeholder, integrated state, decisions, architecture/module/data-flow, domain rules/glossary, success criteria, evidence/reflection/commitment/stale indexes, auth ADR, README and AGENTS. Product positioning is established by current README/source: Code, Canvas, Robot and cloud Agents, Main-owned auth/network and light desktop UI. +- Read all 121 peer task records' Scope, Intent And Constraints and Promotion Candidates; 17 undefined scopes remain unknown and read-only. Old reset-card/menu tasks are superseded by the explicit user-approved permanent-points policy. No unresolved competing dependency affects this isolated implementation. +- Confirmed server source 4ef177b8 is integrated into works-square-server master: genuine signup gets 100 once, recharge CNY 1 = 50 points, no expiry, old rights canceled without gift/conversion, parent personal recharge, explicit shared payer and private balances/history. Consume the actual new DTOs rather than infer eligibility from first login. +- Preserve Main-owned credentials, fixed upstream routes, account isolation, existing module restrictions and cloud creator-pays/Plugin personal-payer boundaries. Do not restore Learning, Works gallery or publishing pages. Do not change provider pricing, app identity, external service configuration or unrelated Code/Pi architecture. +- The repository's no-worklog guidance is subordinate to the user's explicit requirement to apply maintain-project-docs. Keep this mandatory task record; feature mode writes no canonical project memory. README will describe product behavior. +- No subagents, remote push, packaging/install, production deployment or real payment. Primary checkout and existing task worktrees remain untouched. + +## Plan + +1. Replace old Main billing projection and Renderer DTOs with bounded permanent-wallet/recharge contracts; preserve auth/privacy and idempotent payment recovery. +2. Replace membership/reset-card UI with personal balance, recharge, paginated history/orders and frozen-order payment status; adapt exhaustion navigation and account changes. +3. Update focused unit/Host and Electron fixtures/specs; run typecheck, relevant tests, lint and full Vite build, then meaningful integration acceptance. +4. Record outcomes and release limits, run documentation gates and commit the completed feature. + +## Outcome + +- Replaced the desktop membership/week-cycle/reset-card account surfaces and DTOs with permanent personal balance, held points, recharge products, frozen orders, QR checkout, server-confirmed payment status and paginated own-wallet ledger/orders. Updated README; removed the subscription upgrade helper and obsolete reset-card tests. +- Main owns credentials and the fixed billing routes, projects only supported fields, uses the existing Renderer capability for recharge, preserves the request identity on an unknown payment outcome and discards results after an account switch. Renderer never supplies a payer, price or access token for billing. Existing pending/manual-review orders block a new checkout; reopening uses the original order. +- Genuine signup gifts and legacy-account treatment remain server-owned. The client does not invent a gift, expiry, payment result, balance or fallback payer. Recharge always targets the authenticated parent's personal wallet. Youth see their own exact balance; another payer's wallet exposes availability only. +- Real server inspection uncovered that a family payer's own wallet may have family_shared=true (legacy source=self or V1 source=shared_group). Projection now uses the API's shared_available field to distinguish another payer's coarse balance from the authenticated payer's own precise balance. AI programming funding wording is explicit; cloud creator-pays and Plugin personal-payer semantics are unchanged. +- Account reopening/focus refreshes the balance; an account-keyed wallet discards late prior-account responses. Pending checkout hides other products and redundant banners so the payment QR fits the desktop dialog. Payment-source management opens the fixed web account page. + +## Verification + +- Final focused run: 8 files / 103 tests passed, including Main credential/capability/privacy projection, permanent account contracts, real-server serialized fixtures, idempotent retry, frozen prices, pending/review recovery, youth/shared/own-family balances, menu reopening, account switching, pagination and retained Works routes. +- pnpm 10.33.4 run typecheck passed. Scoped ESLint across all changed TypeScript/TSX files passed. Final pnpm run build:vite passed for Renderer/Main/Preload/utility. +- Final Windows Electron E2E passed: zero balance, recharge, product repricing, unpaid-order recovery after Renderer reload, one POST, server-confirmed credit, updated balance and ledger. Fixture waits for the authenticated profile before operating the wallet. Pending/credited screenshots were visually inspected; no clipped QR or overlapping controls. This test uses a fake Host API/payment response, not a live payment. +- Cross-repository acceptance used the actual works-square-server permanent-points FastAPI router with an isolated in-memory SQLite database and a fake payment provider. Verified old account zero, genuine youth signup 100, same-key replay one order, repricing preserved the original 10 yuan / 500 points, duplicate success callback one credit, and exact own-family balance. Captured synthetic API responses in tests/fixtures/permanent-points-server.json; the desktop contract tests accept those actual serialized responses. +- Full pnpm test first phase: 245 files passed, 2 failed; 2159 tests passed, 2 failed, 2 skipped. Failures were an untouched Pi opener test expecting makelore-runtime-v6.mjs while the base runtime uses v7, and a Pi Bash timing assertion (2557 ms versus 2000 ms). Isolated rerun of these files plus the separately scheduled coding-chat pressure test: 9 passed / 1 failed; the timing case and pressure test passed, and the unchanged v6 assertion remained. No Pi/runtime/test expectation was edited. +- Extended Main TypeScript check reports 67 diagnostics. A read-only TypeScript Compiler API comparison overlaying the recorded base's works.ts and excluding the newly added route from the base reproduced the same 67 diagnostics, with zero added or removed. The normal Renderer typecheck and production build pass; this task does not repair the pre-existing full-Main baseline. +- Reviewed source and test diffs against the recorded base. Primary checkout, foreign task documents, dependencies and server source remain untouched. Build outputs, screenshots, logs and local runtime data are excluded from the commit. + +## Follow-ups + +- Integrate this feature into MakeLore main when requested; no merge, push, packaging, installation or deployment occurred. +- Before release, deploy the matching Works Square permanent-points API and 0094 migration, and verify real registration/payment callbacks and the configured desktop payment payload. JSAPI-only providers use the web account page to continue the original order. PostgreSQL concurrency/upgrade and non-Windows packaged acceptance remain separate release checks. +- Track the existing Pi v6/v7 test mismatch and the 67-diagnostic Main TypeScript baseline independently. The single full-suite timing failure passed in isolation; it is not evidence of a wallet regression. + +## Promotion Candidates + +- Target: .project-docs/30-worklog/current-state.md and .project-docs/40-domain/business-rules.md. Proposal: replace membership/week allowance/reset-card desktop claims with the user-confirmed permanent-points policy and own-versus-other-payer visibility. Evidence: updated README/UI/Main contracts, 103 focused tests, real-server contract acceptance and Electron E2E. Future impact: desktop release and account support must use the matching permanent-points API. Semantic conflict: supersedes older membership/reset-card task descriptions; the user explicitly authorized this product change. Additional human confirmation is not required for that accepted policy; canonical promotion still requires Integration Gate. +- Target: .project-docs/20-architecture/data-flow.md. Proposal: record Main-owned billing authentication/projection and stable explicit recharge request identity, server order recovery, and shared_available as the distinction between own precision and another payer's coarse availability. Evidence: works-billing route tests and captured real API family-payer response. Future impact: do not derive privacy or recharge rights solely from family_shared or old membership-management flags. No competing product decision; no additional human confirmation required. diff --git a/README.md b/README.md index 967c8546..4412dbd6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 - AI 编程默认入口:进入 Code 后保持在 `/chat`。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 -- 账号词元点数:左下角账号菜单只通过 Main-owned `/api/works/billing/points` 读取 Works Square V2 余额。可管理会员的账号看到套餐、本周剩余/额度、总可用点数与刷新时间;家庭成员和青少年自有权益只收到“可用/已用尽”等粗粒度状态,不向 Renderer 暴露套餐周期或精确余额。 账号菜单的重置卡列表隐藏已使用的卡;使用成功后立即移除该卡并刷新卡包和余额,没有剩余卡片时显示“暂无未使用的重置卡”。 +- 账号词元点数:左下角账户菜单显示本人永久余额和预占,提供充值、充值订单与分页收支记录。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长充值始终进入自己的钱包,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理。需配套部署 Works Square 永久点数 API 与 0094 迁移;本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 diff --git a/electron/api/routes/works-billing.ts b/electron/api/routes/works-billing.ts new file mode 100644 index 00000000..b3cd7f2e --- /dev/null +++ b/electron/api/routes/works-billing.ts @@ -0,0 +1,203 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { WorksBillingSummary, WorksPointHistory, WorksRechargeOrder, WorksRechargeProduct, WorksTokenPointBalance } from '../../../shared/works-billing'; +import { getValidWorksSquareAccessToken, getWorksSquareAccountBinding, isCurrentWorksSquareAccountBinding } from '../../services/works-square-session'; +import { proxyAwareFetch } from '../../utils/proxy-fetch'; +import { parseJsonBody, sendJson } from '../route-utils'; +import { hasRendererCapability } from '../renderer-capability'; +import { WORKS_SQUARE_CONFIG } from '../works-config'; + +const pointsPattern = /^(?:0|[1-9]\d*)\.\d{2}$/u; +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid billing response'); + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid billing text'); + return value; +} +function bool(value: unknown): boolean { + if (typeof value !== 'boolean') throw new Error('Invalid billing flag'); + return value; +} +function point(value: unknown, signed = false): string { + const result = text(value); + if (!pointsPattern.test(signed ? result.replace(/^-/, '') : result)) throw new Error('Invalid points'); + return result; +} +function date(value: unknown): string { + const result = text(value); + if (!Number.isFinite(Date.parse(result))) throw new Error('Invalid billing date'); + return result; +} +function items(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error('Invalid billing page'); + return value; +} +function amount(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) throw new Error('Invalid amount'); + return value; +} +function currency(value: unknown): 'CNY' { + if (value !== 'CNY') throw new Error('Invalid currency'); + return value; +} + +export function projectBillingBalance(input: unknown): WorksTokenPointBalance { + const value = record(input); + const source = value.entitlement_source; + if (source !== 'self' && source !== 'family_owner' && source !== 'shared_group') throw new Error('Invalid funding source'); + const shared = bool(value.family_shared); + // A family payer can use their own shared wallet. The API exposes coarse + // availability only when the selected wallet belongs to someone else. + const coarse = value.shared_available !== null; + if (coarse) bool(value.shared_available); + if (value.expires_at !== null) throw new Error('Invalid permanent wallet'); + return { + total: coarse ? null : point(value.total), + used: coarse ? null : point(value.used), + reserved: coarse ? null : point(value.reserved), + total_remaining: coarse ? null : point(value.total_remaining), + entitlement_source: source, + family_shared: shared, + can_recharge: coarse ? false : bool(value.can_recharge), + shared_available: coarse ? bool(value.shared_available) : null, + expires_at: null, + }; +} + +function projectProduct(input: unknown): WorksRechargeProduct { + const value = record(input); + const cents = amount(value.amount_cents); + const points = point(value.point_amount); + if (BigInt(points.replace('.', '')) !== BigInt(cents) * 50n) throw new Error('Invalid recharge ratio'); + return { id: text(value.id), name: text(value.name), amount_cents: cents, point_amount: points, currency: currency(value.currency) }; +} + +export function projectBillingSummary(input: unknown): WorksBillingSummary { + const value = record(input); + const own = projectBillingBalance(value.own_points); + if (own.family_shared || value.points_per_yuan !== 50 || value.signup_bonus !== '100.00') throw new Error('Invalid wallet contract'); + const canRecharge = bool(value.can_recharge); + if (own.can_recharge !== canRecharge) throw new Error('Invalid recharge permission'); + return { + own_points: own, + points: value.points === null ? null : projectBillingBalance(value.points), + // Do not pass arbitrary upstream exception messages to Renderer. + funding_error: value.funding_error ? 'AI 编程付款来源不可用,请在网页账户页选择或检查付款来源。' : null, + recharge_products: items(value.recharge_products).map(projectProduct), + points_per_yuan: 50, signup_bonus: '100.00', + can_recharge: canRecharge, payment_configured: bool(value.payment_configured), + }; +} + +function paymentQr(input: unknown): string | null { + if (!input || typeof input !== 'object') return null; + const payload = record(input); + for (const key of ['codeUrl', 'code_url', 'qrCode', 'qr_code', 'qrCodeUrl', 'qr_code_url', 'paymentQrCodeUrl', 'payment_qr_code_url', 'nativeUrl', 'native_url', 'payUrl', 'pay_url', 'mwebUrl', 'mweb_url', 'checkout_url']) { + const value = payload[key]; + if (typeof value !== 'string') continue; + try { + const url = new URL(value); + if ((url.protocol === 'weixin:' || url.protocol === 'https:') && !url.username && !url.password) return value; + } catch { /* Not a supported desktop payment URL. */ } + } + return null; +} + +export function projectRechargeOrder(input: unknown, payment?: unknown): WorksRechargeOrder { + const value = record(input); + const status = value.status; + if (status !== 'pending' && status !== 'succeeded' && status !== 'failed' && status !== 'canceled' && status !== 'manual_review') throw new Error('Invalid order state'); + const review = bool(value.manual_review_required); + return { + id: text(value.id), product_id: text(value.product_id), product_name: text(value.product_name), + status: review ? 'manual_review' : status, + amount_cents: amount(value.amount_cents), point_amount: point(value.point_amount), currency: currency(value.currency), + created_at: date(value.created_at), paid_at: value.paid_at === null ? null : date(value.paid_at), + manual_review_required: review, + qr_payload: status === 'pending' && !review ? paymentQr(payment ?? value.provider_payload) : null, + }; +} + +export function projectPointHistory(input: unknown): WorksPointHistory { + const value = record(input); + return { has_more: bool(value.has_more), items: items(value.items).map((item) => { + const row = record(item); + if (row.kind !== 'credit' && row.kind !== 'usage') throw new Error('Invalid transaction kind'); + return { id: text(row.id), kind: row.kind, description: text(row.description), status: text(row.status), points: point(row.points, true), reserved_points: point(row.reserved_points), created_at: date(row.created_at) }; + }) }; +} + +export async function handleWorksBillingRoutes(req: IncomingMessage, res: ServerResponse, url: URL): Promise { + const prefix = '/api/works/billing'; + if (!url.pathname.startsWith(`${prefix}/`)) return false; + const path = url.pathname.slice(prefix.length); + const creating = path === '/recharge/orders' && req.method === 'POST'; + const orderMatch = /^\/recharge\/orders\/([^/]+)$/.exec(path); + const reading = req.method === 'GET' && (['/me', '/points', '/points/transactions', '/recharge/orders'].includes(path) || orderMatch); + if (!creating && !reading) { + sendJson(res, 410, { success: false, code: 'membership_retired', error: '旧账户接口已停用,请使用词元点数充值。' }); + return true; + } + if (creating && !hasRendererCapability(req)) { + sendJson(res, 403, { success: false, error: '请从应用内发起充值。', commandOutcome: 'definitive_failure' }); + return true; + } + const binding = getWorksSquareAccountBinding(); + let dispatched = false; + try { + const accessToken = await getValidWorksSquareAccessToken(); + if (!binding || !accessToken || !isCurrentWorksSquareAccountBinding(binding)) { + sendJson(res, 401, { success: false, error: '请重新登录后查看账户。', commandOutcome: 'definitive_failure' }); + return true; + } + const target = new URL(`/api/billing${path}`, WORKS_SQUARE_CONFIG.apiBaseUrl); + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + let body: string | undefined; + if (creating) { + const input = record(await parseJsonBody(req)); + const productId = text(input.productId); + const requestId = text(input.requestId); + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(requestId) || !isCurrentWorksSquareAccountBinding(binding)) throw new Error('Invalid recharge request'); + headers['Content-Type'] = 'application/json'; + headers['Idempotency-Key'] = `makelore:${requestId}`; + body = JSON.stringify({ product_id: productId }); + } else if (path === '/points/transactions' || path === '/recharge/orders') { + const offset = Number(url.searchParams.get('offset') ?? 0); + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page'); + target.searchParams.set('limit', '20'); + target.searchParams.set('offset', String(offset)); + } + dispatched = true; + const response = await proxyAwareFetch(target.toString(), { method: creating ? 'POST' : 'GET', headers, body }); + const payload: unknown = await response.json(); + if (!isCurrentWorksSquareAccountBinding(binding)) { + sendJson(res, 409, { success: false, error: '账户已切换,请重新打开词元点数。', commandOutcome: 'unknown' }); + return true; + } + if (!response.ok) { + const detail = record(payload).detail; + const rejection = detail && typeof detail === 'object' && record(detail).outcome === 'definitive_rejection'; + const messages: Record = { 401: '登录已失效,请重新登录。', 403: '当前账户不能充值,请联系家长。', 404: '充值档位或订单已不可用,请刷新。', 409: '请先完成或核查已有充值订单;计费服务也可能正在切换。', 410: '账户服务版本不匹配,请更新客户端和服务端。' }; + sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, { + success: false, error: rejection ? '支付服务拒绝了本次请求,请稍后重试。' : messages[response.status] ?? '支付结果尚未确认,请刷新订单或联系运营核查,勿重复付款。', + commandOutcome: rejection || [401, 403, 404, 422].includes(response.status) ? 'definitive_failure' : 'unknown', + }); + return true; + } + let data: unknown; + if (path === '/me') data = projectBillingSummary(payload); + else if (path === '/points') data = projectBillingBalance(payload); + else if (path === '/points/transactions') data = projectPointHistory(payload); + else if (creating) data = projectRechargeOrder(record(payload).order, record(payload).provider_payload); + else if (orderMatch) data = projectRechargeOrder(payload); + else data = { items: items(record(payload).items).map((item) => projectRechargeOrder(item)) }; + sendJson(res, 200, { success: true, data }); + } catch { + sendJson(res, creating && !dispatched ? 400 : 502, { + success: false, error: creating && dispatched ? '充值结果尚未确认,请刷新订单,重试时沿用本次请求。' : '词元点数服务暂时不可用,请刷新后重试。', + commandOutcome: dispatched ? 'unknown' : 'definitive_failure', + }); + } + return true; +} diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index a2e39a06..13e3f609 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -1,3 +1,4 @@ +import { handleWorksBillingRoutes } from './works-billing'; import type { IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'node:crypto'; import { app } from 'electron'; @@ -60,35 +61,6 @@ 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; -const RESET_CARD_STATUSES = new Set(['available', 'expired', 'redeemed']); -const RESET_CARD_ERROR_MESSAGES: Record = { - reset_card_expired: '这张重置卡已过期。', - token_point_wallet_owner_required: '当前额度由家庭管理员管理,无法使用这张重置卡。', - billing_temporarily_paused: '计费服务正在切换,请稍后再试。', - token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。', -}; - function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Missing ${field}`); @@ -201,31 +173,6 @@ async function sendUpstreamError( }); } -async function sendResetCardUpstreamError( - res: ServerResponse, - response: Response, -): Promise { - const payload = await readResponsePayload(response); - const upstreamCode = getPayloadErrorCode(payload); - const code = upstreamCode && RESET_CARD_ERROR_MESSAGES[upstreamCode] - ? upstreamCode - : undefined; - const status = response.status >= 400 && response.status < 500 ? response.status : 502; - const error = code - ? RESET_CARD_ERROR_MESSAGES[code] - : response.status === 404 - ? '重置卡不存在或不可用。' - : response.status === 409 - ? '当前有待处理的计费操作,暂时不能使用重置卡。' - : '重置卡服务暂时不可用,请稍后重试。'; - sendJson(res, status, { - success: false, - status: response.status, - ...(code ? { code } : {}), - error, - }); -} - function sendPublishSourceFailure( res: ServerResponse, status: number, @@ -324,112 +271,6 @@ function readNullableStringField( return typeof value === 'string' ? value : undefined; } -function readNullablePointField( - source: Record, - field: string, -): string | null | undefined { - const value = source[field]; - if (value === undefined || value === null) return null; - if (typeof value !== 'string') return undefined; - const normalized = value.trim(); - return TOKEN_POINT_VALUE_PATTERN.test(normalized) ? normalized : undefined; -} - -function projectSafeTokenPointBalance(value: unknown): Record | null { - if (!isRecord(value)) return null; - - const entitlementSource = readOptionalString(value.entitlement_source); - const upgradeAction = readOptionalString(value.upgrade_action); - if ( - !entitlementSource - || !TOKEN_POINT_ENTITLEMENT_SOURCES.has(entitlementSource) - || typeof value.family_shared !== 'boolean' - || typeof value.can_manage_membership !== 'boolean' - || !upgradeAction - || !TOKEN_POINT_UPGRADE_ACTIONS.has(upgradeAction) - ) return null; - - if ( - value.shared_available !== undefined - && value.shared_available !== null - && typeof value.shared_available !== 'boolean' - ) return null; - - const canManageMembership = value.can_manage_membership; - if (!canManageMembership && typeof value.shared_available !== 'boolean') return null; - - const projected: Record = {}; - for (const field of TOKEN_POINT_METADATA_FIELDS) { - const fieldValue = canManageMembership ? readNullableStringField(value, field) : null; - if (fieldValue === undefined) return null; - projected[field] = fieldValue; - } - for (const field of TOKEN_POINT_FIELDS) { - const fieldValue = canManageMembership ? readNullablePointField(value, field) : null; - if (fieldValue === undefined) return null; - projected[field] = fieldValue; - } - - return { - ...projected, - entitlement_source: entitlementSource, - family_shared: value.family_shared, - can_manage_membership: canManageMembership, - upgrade_action: upgradeAction, - shared_available: canManageMembership ? null : value.shared_available, - }; -} - -function readResetCardTimestamp(value: unknown): string | null { - if (typeof value !== 'string' || !value.trim()) return null; - const normalized = value.trim(); - return Number.isNaN(Date.parse(normalized)) ? null : normalized; -} - -function projectSafeResetCard(value: unknown): Record | null { - if (!isRecord(value)) return null; - const id = readOptionalString(value.id); - const status = readOptionalString(value.status); - const grantedAt = readResetCardTimestamp(value.granted_at); - const expiresAt = readResetCardTimestamp(value.expires_at); - const redeemedAt = value.redeemed_at === null - ? null - : readResetCardTimestamp(value.redeemed_at); - const redeemedCycleId = readNullableStringField(value, 'redeemed_cycle_id'); - if ( - !id - || !status - || !RESET_CARD_STATUSES.has(status) - || !grantedAt - || !expiresAt - || redeemedAt === null && value.redeemed_at !== null - || redeemedCycleId === undefined - ) return null; - - if (status === 'redeemed') { - if (!redeemedAt || !redeemedCycleId) return null; - } else if (redeemedAt !== null || redeemedCycleId !== null) { - return null; - } - - return { - id, - status: status === 'available' && Date.parse(expiresAt) <= Date.now() ? 'expired' : status, - granted_at: grantedAt, - expires_at: expiresAt, - redeemed_at: redeemedAt, - redeemed_cycle_id: redeemedCycleId, - }; -} - -function projectSafeResetCardPage(value: unknown): Record[] | null { - if (!isRecord(value) || !Array.isArray(value.items)) return null; - const cards = value.items.map(projectSafeResetCard); - return cards.some((card) => card === null) - ? null - : cards as Record[]; -} - function projectSafeProject( value: unknown, options: { requireStatus?: boolean } = {}, @@ -777,85 +618,6 @@ async function handleListMyProjects( sendJson(res, response.status, { success: true, page }); } -async function handleGetBillingTokenPoints( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); - const response = await proxyAwareFetch(createWorksUrl('/api/billing/points').toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - await sendUpstreamError(res, response, `Works Square token points failed (${response.status})`); - return; - } - - 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 handleListBillingResetCards( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); - const response = await proxyAwareFetch(createWorksUrl('/api/billing/reset-cards').toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - await sendResetCardUpstreamError(res, response); - return; - } - - const cards = projectSafeResetCardPage(await readResponsePayload(response)); - if (!cards) { - sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card list' }); - return; - } - sendJson(res, response.status, { success: true, cards }); -} - -async function handleRedeemBillingResetCard( - req: IncomingMessage, - res: ServerResponse, - cardId: string, -): Promise { - const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); - const response = await proxyAwareFetch( - createWorksUrl(`/api/billing/reset-cards/${encodeURIComponent(readRequiredString(cardId, 'card_id'))}/redeem`).toString(), - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - await sendResetCardUpstreamError(res, response); - return; - } - - const card = projectSafeResetCard(await readResponsePayload(response)); - if (!card) { - sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card result' }); - return; - } - sendJson(res, response.status, { success: true, card }); -} - async function handleAgentProfile( req: IncomingMessage, res: ServerResponse, @@ -1620,6 +1382,7 @@ export async function handleWorksRoutes( } try { + if (await handleWorksBillingRoutes(req, res, url)) return true; if (url.pathname === '/api/works/user/avatar' && (req.method === 'POST' || req.method === 'DELETE')) { await handleAgentAvatar(req, res); return true; @@ -1635,22 +1398,6 @@ export async function handleWorksRoutes( return true; } - if (url.pathname === '/api/works/billing/points' && req.method === 'GET') { - await handleGetBillingTokenPoints(req, res); - return true; - } - - if (url.pathname === '/api/works/billing/reset-cards' && req.method === 'GET') { - await handleListBillingResetCards(req, res); - return true; - } - - const resetCardRedeemMatch = url.pathname.match(/^\/api\/works\/billing\/reset-cards\/([^/]+)\/redeem$/); - if (resetCardRedeemMatch && req.method === 'POST') { - await handleRedeemBillingResetCard(req, res, decodeURIComponent(resetCardRedeemMatch[1])); - return true; - } - if (url.pathname === '/api/works/ai-gateway/images/generations' && req.method === 'POST') { await handleSubmitImageGeneration(req, res); return true; diff --git a/shared/works-billing.ts b/shared/works-billing.ts new file mode 100644 index 00000000..fd405894 --- /dev/null +++ b/shared/works-billing.ts @@ -0,0 +1,56 @@ +export type WorksTokenPointBalance = { + total: string | null; + used: string | null; + reserved: string | null; + total_remaining: string | null; + entitlement_source: 'self' | 'family_owner' | 'shared_group'; + family_shared: boolean; + can_recharge: boolean; + shared_available: boolean | null; + expires_at: null; +}; + +export type WorksRechargeProduct = { + id: string; + name: string; + amount_cents: number; + point_amount: string; + currency: 'CNY'; +}; + +export type WorksBillingSummary = { + own_points: WorksTokenPointBalance; + points: WorksTokenPointBalance | null; + funding_error: string | null; + recharge_products: WorksRechargeProduct[]; + points_per_yuan: 50; + signup_bonus: string; + can_recharge: boolean; + payment_configured: boolean; +}; + +export type WorksRechargeOrder = { + id: string; + product_id: string; + product_name: string; + status: 'pending' | 'succeeded' | 'failed' | 'canceled' | 'manual_review'; + amount_cents: number; + point_amount: string; + currency: 'CNY'; + created_at: string; + paid_at: string | null; + manual_review_required: boolean; + qr_payload: string | null; +}; + +export type WorksPointTransaction = { + id: string; + kind: 'credit' | 'usage'; + description: string; + status: string; + points: string; + reserved_points: string; + created_at: string; +}; + +export type WorksPointHistory = { items: WorksPointTransaction[]; has_more: boolean }; diff --git a/src/components/account/PointWallet.tsx b/src/components/account/PointWallet.tsx new file mode 100644 index 00000000..215e7a79 --- /dev/null +++ b/src/components/account/PointWallet.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; +import { Coins, RefreshCw, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { AppError } from '@/lib/error-model'; +import { formatWorksTokenPointValue, isWorksTokenPointBalanceExhausted } from '@/lib/works-square-token-points'; +import { + createWorksRechargeOrder, fetchWorksBillingSummary, fetchWorksPointHistory, openWorksBillingAccount, + fetchWorksRechargeOrder, fetchWorksRechargeOrders, + type WorksBillingSummary, type WorksPointHistory, type WorksRechargeOrder, +} from '@/lib/works-billing'; + +const labels: Record = { + pending: '待支付', succeeded: '已到账', failed: '支付失败', canceled: '已关闭', manual_review: '待核查', + credited: '已入账', reserved: '已预占', dispatched: '处理中', pending_review: '待核对', + settled: '已结算', released: '已释放', refunded: '已退款', expired: '已释放', +}; +const points = (value: string | null) => formatWorksTokenPointValue(value) ?? '—'; +const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`; +const time = (value: string) => new Date(value).toLocaleString('zh-CN'); +const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review'; + +export function PointWallet({ active = true }: { active?: boolean }) { + const [summary, setSummary] = useState(null); + const [summaryError, setSummaryError] = useState(false); + const [open, setOpen] = useState(false); + const [tab, setTab] = useState<'recharge' | 'orders' | 'history'>('recharge'); + const [offset, setOffset] = useState(0); + const [orders, setOrders] = useState([]); + const [pending, setPending] = useState(null); + const [history, setHistory] = useState({ items: [], has_more: false }); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [checkout, setCheckout] = useState(null); + const [qr, setQr] = useState(''); + const [unknownProduct, setUnknownProduct] = useState(null); + const intent = useRef<{ productId: string; requestId: string } | null>(null); + const alive = useRef(true); + const summaryRequest = useRef(0); + const pageRequest = useRef(0); + const purchaseBusy = useRef(false); + + useEffect(() => { + alive.current = true; + return () => { alive.current = false; summaryRequest.current += 1; pageRequest.current += 1; }; + }, []); + + const refreshSummary = useCallback(async () => { + const request = ++summaryRequest.current; + try { + const next = await fetchWorksBillingSummary(); + if (!alive.current || summaryRequest.current !== request) return; + setSummary(next); setSummaryError(false); + } catch { + if (!alive.current || summaryRequest.current !== request) return; + setSummary(null); setSummaryError(true); + } + }, []); + + useEffect(() => { + if (active) void refreshSummary(); + const focus = () => { void refreshSummary(); }; + const visibility = () => { if (document.visibilityState === 'visible') focus(); }; + window.addEventListener('focus', focus); + document.addEventListener('visibilitychange', visibility); + return () => { + window.removeEventListener('focus', focus); + document.removeEventListener('visibilitychange', visibility); + }; + }, [active, refreshSummary]); + + const refreshPage = useCallback(async () => { + const request = ++pageRequest.current; + setLoading(true); setLoaded(false); + try { + const latest = await fetchWorksRechargeOrders(); + const page = tab === 'orders' && offset > 0 ? await fetchWorksRechargeOrders(offset) : latest; + const ledger = tab === 'history' ? await fetchWorksPointHistory(offset) : null; + if (!alive.current || pageRequest.current !== request) return; + setOrders(page.items); setPending(latest.items.find(unresolved) ?? null); + if (ledger) setHistory(ledger); + setLoaded(true); + } catch { + if (alive.current && pageRequest.current === request) setError('记录加载失败,请刷新后再充值。'); + } finally { + if (alive.current && pageRequest.current === request) setLoading(false); + } + }, [offset, tab]); + + useEffect(() => { + if (open) { setError(''); void refreshSummary(); void refreshPage(); } + return () => { pageRequest.current += 1; }; + }, [open, refreshPage, refreshSummary]); + + useEffect(() => { + let active = true; + setQr(''); + if (checkout?.qr_payload) { + void QRCode.toDataURL(checkout.qr_payload, { width: 224, margin: 1 }).then((value) => { + if (active) setQr(value); + }).catch(() => { if (active) setError('二维码生成失败,请刷新此订单。'); }); + } + return () => { active = false; }; + }, [checkout?.qr_payload]); + + const checkOrder = useCallback(async (id: string) => { + const order = await fetchWorksRechargeOrder(id); + if (!alive.current) return; + setCheckout((current) => current?.id === id ? order : current); + if (!unresolved(order)) { + intent.current = null; setUnknownProduct(null); + void refreshSummary(); void refreshPage(); + } + }, [refreshPage, refreshSummary]); + + const checkoutId = checkout?.id; + const checkoutStatus = checkout?.status; + useEffect(() => { + if (!open || !checkoutId || (checkoutStatus !== 'pending' && checkoutStatus !== 'manual_review')) return; + let active = true; + let timer: ReturnType; + const poll = async () => { + if (document.visibilityState === 'visible') { + try { await checkOrder(checkoutId); } catch { /* Keep the frozen order visible; never resubmit payment. */ } + } + if (active) timer = setTimeout(() => void poll(), 4000); + }; + timer = setTimeout(() => void poll(), 4000); + return () => { active = false; clearTimeout(timer); }; + }, [open, checkoutId, checkoutStatus, checkOrder]); + + async function purchase(productId: string) { + if (purchaseBusy.current || !loaded || !summary?.can_recharge || !summary.payment_configured || pending) return; + if (intent.current && intent.current.productId !== productId) return; + purchaseBusy.current = true; setBusy(true); setError(''); + intent.current ??= { productId, requestId: crypto.randomUUID() }; + try { + const order = await createWorksRechargeOrder(productId, intent.current.requestId); + if (!alive.current) return; + setCheckout(order); setPending(unresolved(order) ? order : null); + intent.current = null; setUnknownProduct(null); + void refreshSummary(); void refreshPage(); + } catch (cause) { + if (!alive.current) return; + const definitive = cause instanceof AppError && cause.details?.commandOutcome === 'definitive_failure'; + if (definitive) { intent.current = null; setUnknownProduct(null); } + else setUnknownProduct(productId); + setError(definitive ? '本次充值未提交成功,请刷新后重试。' : '付款结果尚未确认。请先检查订单;重试将沿用同一次请求,请勿重复付款。'); + void refreshPage(); + } finally { + purchaseBusy.current = false; + if (alive.current) setBusy(false); + } + } + + const openWebAccount = () => { void openWorksBillingAccount().catch(() => setError('网页账户页暂时无法打开。')); }; + const own = summary?.own_points; + const selected = summary?.points; + const usingShared = typeof selected?.shared_available === 'boolean'; + return <> +
+
我的词元点数{own ? `${points(own.total_remaining)} 点` : summaryError ? '暂时不可用' : '加载中…'}
+ {own &&

永久有效 · 预占 {points(own.reserved)} 点

} + {usingShared &&

AI 编程使用共享点数:{selected?.shared_available ? '可用' : '已用尽,请联系付款人'}

} + {summary?.funding_error &&

{summary.funding_error}

} + {isWorksTokenPointBalanceExhausted(own) && !usingShared &&

{summary?.can_recharge ? '点数已用尽,充值后可继续使用。' : '点数已用尽,请联系家长。'}

} + {summaryError && } +
+ + + + +
+ 词元点数 + +
+ 注册赠送与充值点数永久有效。新账号注册赠送 100 点,1 元充值 50 点。 +
+
+
+

我的可用点数

+

+ {own ? points(own.total_remaining) : '—'} +

+

+ 预占 {points(own?.reserved ?? null)} 点 · 充值到账本人钱包 + +

+
+ +
+ {summary?.funding_error &&

{summary.funding_error}

} + {usingShared &&

AI 编程使用共享点数({selected?.shared_available ? '可用' : '已用尽'})。下方余额、充值与记录均属于你自己的钱包;充值不会自动切换付款来源。

} +
{(['recharge', 'orders', 'history'] as const).map((value) => )}
+ {summaryError &&

余额读取失败,请刷新后重试。

} + {error &&

{error}

} + {loading &&

正在读取记录…

} + {tab === 'recharge' &&
+ {summary && !summary.can_recharge &&

仅家长身份可以充值,请联系家长。你仍可查看自己的余额和收支。

} + {summary?.can_recharge && !summary.payment_configured &&

充值暂未开放,请稍后再试。

} + {pending && checkout?.id !== pending.id &&

已有{labels[pending.status]}订单:{money(pending.amount_cents)} / {points(pending.point_amount)} 点。请先完成或联系运营核查。

} + {(!checkout || !unresolved(checkout)) &&
{summary?.recharge_products.map((product) => )}
} + {unknownProduct && !pending && } + {checkout &&
+

{labels[checkout.status]} · {money(checkout.amount_cents)} · {points(checkout.point_amount)} 点

+

订单 {checkout.id}

+ {qr && checkout.status === 'pending' && 充值支付二维码} + {checkout.status === 'pending' && !checkout.qr_payload &&

当前支付方式未提供扫码信息,请在网页账户页继续此订单。

} + {checkout.status === 'pending' &&

付款后会自动核对到账状态,关闭窗口不会取消订单。

} + {checkout.status === 'manual_review' &&

付款结果需要运营核查,请勿再次付款。

} + +
} +
} + {tab === 'orders' && !loading &&
{orders.length === 0 &&

暂无充值订单。

}{orders.map((order) =>
{points(order.point_amount)} 点 · {money(order.amount_cents)}{labels[order.status]}

{time(order.created_at)}

{unresolved(order) && }
)}
} + {tab === 'history' && !loading &&
{history.items.length === 0 &&

暂无收支记录。

}{history.items.map((item) =>
{item.description}{item.kind === 'credit' ? '+' : item.points.startsWith('-') ? '−' : ''}{points(item.points.replace(/^-/, ''))} 点

{time(item.created_at)} · {labels[item.status] ?? '待核对'}{item.reserved_points !== '0.00' ? ` · 预占 ${points(item.reserved_points)} 点` : ''}

)}
} + {tab !== 'recharge' &&
第 {offset / 20 + 1} 页
} +
+
+ ; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1f9ea938..3adf94fc 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,18 +3,14 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { - BarChart3, Bot, Cpu, ChevronRight, - Crown, - ExternalLink, FolderKanban, LogOut, Plus, RadioTower, Settings as SettingsIcon, - Ticket, UserCircle2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -36,19 +32,7 @@ import { subscribeCodingProjectCreationRequest, subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; -import { AppError } from '@/lib/error-model'; -import { - fetchWorksResetCards, - fetchWorksTokenPointBalance, - redeemWorksResetCard, - type WorksResetCard, - type WorksTokenPointBalance, -} from '@/lib/works-square'; -import { - formatWorksTokenPointValue, - isWorksTokenPointBalanceExhausted, -} from '@/lib/works-square-token-points'; -import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade'; +import { PointWallet } from '@/components/account/PointWallet'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -59,7 +43,7 @@ import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; import { useSettingsStore } from '@/stores/settings'; -import { useAuthStore, type AuthUser } from '@/stores/auth'; +import { useAuthStore } from '@/stores/auth'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProviderStore } from '@/stores/providers'; import type { CodingProjectSummary } from '@/types/coding-project'; @@ -71,16 +55,6 @@ type OpenDialogResult = { filePaths: string[]; }; -type TokenPointState = { - status: 'idle' | 'loading' | 'loaded' | 'error'; - balance: WorksTokenPointBalance | null; -}; - -type ResetCardState = { - status: 'idle' | 'loading' | 'loaded' | 'error'; - cards: WorksResetCard[]; -}; - type ProjectEntryError = { project: CodingProjectSummary; message: string; @@ -94,103 +68,6 @@ function getFolderName(pathValue: string): string { return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed; } -function padTimePart(value: number): string { - return String(value).padStart(2, '0'); -} - -function formatRefreshTime(value: string | null | undefined): string | null { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`; -} - -function getRefreshTimeLabel(value: string | null | undefined): string | null { - const formatted = formatRefreshTime(value); - return formatted ? `刷新时间 ${formatted}` : null; -} - -function formatResetCardTime(value: string | null | undefined): string | null { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`; -} - -function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] { - if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired'; - return card.status; -} - -function getResetCardErrorMessage(error: unknown): string { - const details = error instanceof AppError ? error.details : undefined; - const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null; - const status = typeof details?.status === 'number' ? details.status : null; - const messages: Record = { - reset_card_expired: '这张重置卡已过期,卡包已刷新。', - token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。', - billing_temporarily_paused: '计费服务正在切换,请稍后再试。', - token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。', - }; - if (backendCode && messages[backendCode]) return messages[backendCode]; - if (status === 404) return '重置卡不存在或已不可用。'; - if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。'; - return '重置卡暂时无法使用,请稍后重试。'; -} - -function isTokenPointBalanceExhausted(state: TokenPointState): boolean { - if (state.status !== 'loaded' || !state.balance) return false; - return isWorksTokenPointBalanceExhausted(state.balance); -} - -function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string { - if (!user) return '未登录'; - if (state.balance && !state.balance.can_manage_membership) { - return state.balance.family_shared ? '共享会员' : '青少年账户'; - } - const planName = state.balance?.plan_name?.trim(); - if (planName) return planName; - const planCode = state.balance?.plan_code?.trim(); - if (planCode) return planCode; - return '会员'; -} - -function isExperienceSubscriptionPlan(state: TokenPointState): boolean { - const planCode = state.balance?.plan_code?.trim().toLowerCase(); - const planName = state.balance?.plan_name?.trim().toLowerCase(); - return planCode === 'experience' || planName === '体验'; -} - -function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string { - const levelLabel = getSubscriptionPlanLabel(user, state); - if ( - !user - || isExperienceSubscriptionPlan(state) - || levelLabel === '会员' - || levelLabel === '共享会员' - || levelLabel === '青少年账户' - ) return levelLabel; - return `${levelLabel} 会员`; -} - -function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string { - const remaining = formatWorksTokenPointValue(balance?.weekly_remaining); - const allowance = formatWorksTokenPointValue(balance?.weekly_allowance); - if (remaining && allowance) return `${remaining} / ${allowance} 点`; - return remaining ? `${remaining} 点` : '—'; -} - -function getTotalPointLabel(balance: WorksTokenPointBalance | null): string { - const remaining = formatWorksTokenPointValue(balance?.total_remaining); - return remaining ? `${remaining} 点` : '—'; -} - -function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string { - if (balance?.shared_available === true) return '可用'; - if (balance?.shared_available === false) return '已用尽'; - return balance?.family_shared ? '由家庭管理员管理' : '由家长管理'; -} - type SidebarProps = { workspaceLayout?: boolean; hideOnCompact?: boolean; @@ -220,7 +97,6 @@ export function Sidebar({ const loadProjectConfig = useProjectConfigStore((state) => state.load); const removeProjectConfig = useProjectConfigStore((state) => state.remove); const authUser = useAuthStore((state) => state.user); - const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken); const logout = useAuthStore((state) => state.logout); const { userProfile, @@ -232,11 +108,6 @@ export function Sidebar({ const [projectsOpen, setProjectsOpen] = useState(true); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); - const [usageDrawerOpen, setUsageDrawerOpen] = useState(false); - const [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false); - const [tokenPointState, setTokenPointState] = useState({ status: 'idle', balance: null }); - const [resetCardState, setResetCardState] = useState({ status: 'idle', cards: [] }); - const [redeemingResetCardId, setRedeemingResetCardId] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); @@ -250,8 +121,6 @@ export function Sidebar({ const [robotSelectedAgentId, setRobotSelectedAgentId] = useState(() => readAiHardwareAgentId()); const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); - const tokenPointRequestIdRef = useRef(0); - const resetCardRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); @@ -265,19 +134,6 @@ export function Sidebar({ const accountInitial = getAccountInitial(accountName); const accountAvatarUrl = userProfile?.avatarUrl ?? null; const settingsLabel = t('sidebar.settings', '设置'); - const memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState); - const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState); - const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState); - const tokenPointBalance = tokenPointState.balance; - const tokenPointsCoarse = Boolean( - tokenPointBalance && !tokenPointBalance.can_manage_membership, - ); - const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at); - const visibleResetCards = resetCardState.cards.filter((card) => card.status !== 'redeemed'); - const availableResetCardCount = visibleResetCards.filter( - (card) => getEffectiveResetCardStatus(card) === 'available', - ).length; - const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group'; const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { @@ -382,111 +238,6 @@ export function Sidebar({ }; }, [accountMenuOpen]); - useEffect(() => { - if (!accountMenuOpen) { - setUsageDrawerOpen(false); - setResetCardDrawerOpen(false); - } - }, [accountMenuOpen]); - - const refreshTokenPoints = useCallback(() => { - if (!authUser) { - tokenPointRequestIdRef.current += 1; - setTokenPointState({ status: 'idle', balance: null }); - return; - } - - const requestId = tokenPointRequestIdRef.current + 1; - tokenPointRequestIdRef.current = requestId; - setTokenPointState((current) => ({ status: 'loading', balance: current.balance })); - void getValidAccessToken() - .then((accessToken) => { - if (!accessToken) { - throw new Error('请先登录后再查看用量'); - } - return fetchWorksTokenPointBalance(accessToken); - }) - .then((balance) => { - if (tokenPointRequestIdRef.current === requestId) { - setTokenPointState({ status: 'loaded', balance }); - } - }) - .catch(() => { - if (tokenPointRequestIdRef.current === requestId) { - setTokenPointState({ status: 'error', balance: null }); - } - }); - }, [authUser, getValidAccessToken]); - - const refreshResetCards = useCallback(async (): Promise => { - if (!authUser) { - resetCardRequestIdRef.current += 1; - setResetCardState({ status: 'idle', cards: [] }); - return; - } - - const requestId = resetCardRequestIdRef.current + 1; - resetCardRequestIdRef.current = requestId; - setResetCardState((current) => ({ status: 'loading', cards: current.cards })); - try { - const accessToken = await getValidAccessToken(); - if (!accessToken) throw new Error('请先登录后再查看重置卡'); - const cards = await fetchWorksResetCards(accessToken); - if (resetCardRequestIdRef.current === requestId) { - setResetCardState({ status: 'loaded', cards }); - } - } catch { - if (resetCardRequestIdRef.current === requestId) { - setResetCardState((current) => ({ status: 'error', cards: current.cards })); - } - } - }, [authUser, getValidAccessToken]); - - useEffect(() => { - refreshTokenPoints(); - }, [refreshTokenPoints]); - - useEffect(() => { - resetCardRequestIdRef.current += 1; - setResetCardState({ status: 'idle', cards: [] }); - setRedeemingResetCardId(null); - }, [authUser?.userId]); - - useEffect(() => { - if (!authUser) return undefined; - - const handleFocus = () => { - refreshTokenPoints(); - if (resetCardDrawerOpen) void refreshResetCards(); - }; - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - refreshTokenPoints(); - if (resetCardDrawerOpen) void refreshResetCards(); - } - }; - - window.addEventListener('focus', handleFocus); - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - window.removeEventListener('focus', handleFocus); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]); - - useEffect(() => { - if (authUser && accountMenuOpen) { - refreshTokenPoints(); - } - }, [accountMenuOpen, authUser, refreshTokenPoints]); - - useEffect(() => { - if (authUser && resetCardDrawerOpen) { - void refreshResetCards(); - } - }, [authUser, refreshResetCards, resetCardDrawerOpen]); - - const openCreateProject = useCallback(() => { setNewProjectName(''); setNewProjectSelectedPath(''); @@ -601,61 +352,17 @@ export function Sidebar({ const navigateFromAccountMenu = (path: string) => { setAccountMenuOpen(false); - setUsageDrawerOpen(false); - setResetCardDrawerOpen(false); navigate(path); }; const openProfileDialog = () => { setAccountMenuOpen(false); - setUsageDrawerOpen(false); - setResetCardDrawerOpen(false); setProfileDialogOpen(true); void syncProfileNow().catch(() => undefined); }; - const openSubscriptionUpgrade = () => { - void openWorksSquareSubscriptionUpgrade().catch(() => undefined); - }; - - const handleRedeemResetCard = async (card: WorksResetCard) => { - if (redeemingResetCardId) return; - if (getEffectiveResetCardStatus(card) !== 'available') { - toast.error('这张重置卡已过期。'); - void refreshResetCards(); - return; - } - if (resetCardRedemptionBlocked) { - toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。'); - return; - } - - setRedeemingResetCardId(card.id); - try { - const accessToken = await getValidAccessToken(); - if (!accessToken) throw new Error('请先登录后再使用重置卡'); - const redeemedCard = await redeemWorksResetCard(accessToken, card.id); - setResetCardState((current) => ({ - status: 'loaded', - cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item), - })); - toast.success('重置卡已使用,本周额度已刷新。'); - refreshTokenPoints(); - void refreshResetCards(); - } catch (error) { - toast.error(getResetCardErrorMessage(error)); - void refreshResetCards(); - } finally { - setRedeemingResetCardId((current) => current === card.id ? null : current); - } - }; - const handleAccountLogout = async () => { setAccountMenuOpen(false); - setUsageDrawerOpen(false); - setResetCardDrawerOpen(false); - resetCardRequestIdRef.current += 1; - setResetCardState({ status: 'idle', cards: [] }); await logout(); navigate('/login'); }; @@ -1097,7 +804,7 @@ export function Sidebar({ ref={accountButtonRef} type="button" data-testid="sidebar-member-menu-trigger" - aria-label={`打开会员菜单:${accountName}`} + aria-label={`打开账户菜单:${accountName}`} aria-haspopup="menu" aria-expanded={accountMenuOpen} onClick={() => setAccountMenuOpen((open) => !open)} @@ -1148,210 +855,13 @@ export function Sidebar({ />

{accountFullName}

-

{membershipLabel}

+

词元点数账户

- - -
- 订阅等级 - {memberLabel} -
- {tokenPointState.status === 'error' ? ( -
-

词元点数暂时无法获取。

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

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

- {!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? ( - - ) : null} -
- ) : null} -
- - - {resetCardState.status === 'loading' && visibleResetCards.length === 0 ? ( -

正在加载重置卡…

- ) : null} - {resetCardState.status === 'error' ? ( -
-

重置卡暂时无法获取。

- -
- ) : null} - {resetCardState.status === 'loaded' && visibleResetCards.length === 0 ? ( -

暂无未使用的重置卡。

- ) : null} - {resetCardRedemptionBlocked && visibleResetCards.length > 0 ? ( -

- 当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。 -

- ) : null} - {visibleResetCards.length > 0 ? ( -
- {visibleResetCards.map((card) => { - const status = getEffectiveResetCardStatus(card); - const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用'; - const redeeming = redeemingResetCardId === card.id; - return ( -
-
- 额度重置卡 - - {status === 'available' ? '可使用' : '已过期'} - -
-

- 有效期至 {expiresAt} -

- {status === 'available' ? ( - - ) : null} -
- ); - })} -
- ) : null} -
+ {authUser && }