diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index ebcafaec..e87066d8 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' import type { AuthTokens, CreditAccount, QuotaApis, UserApis } from '@/entities' +import { createBillApis } from '@/entities' +import { createBillBackend } from '@/test/bill-backend' import { forgetActiveRun, rememberActiveRun } from '@/features/active-run' import { AuthSessionProvider } from '@/features/auth-session' import defaultAccountAvatar from '@/assets/account/avatars/windup-witch-avatar.webp' @@ -88,6 +90,7 @@ function renderHeader( apis = createApis(), previousEntry?: string, quota = createQuotaMock(), + bill = createBillApis({ baseUrl: '/api', fetchFn: createBillBackend().fetchFn }), ) { return { apis, @@ -103,7 +106,7 @@ function renderHeader( path="*" element={ <> - + } @@ -120,6 +123,31 @@ function renderAuthenticatedHeader(entry = '/') { return renderHeader(entry) } +it('账号菜单显示后端真实会员档位,不固定显示 Free', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const backend = createBillBackend() + backend.subscription.active_subscription = { + tier: 2, + status: 1, + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z', + } + renderHeader( + '/workspace', + undefined, + undefined, + undefined, + createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }), + ) + fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' })) + const menu = screen.getByTestId('account-menu') + expect(await within(menu).findByText('Pro')).toBeTruthy() + expect(within(menu).queryByText('Free')).toBeNull() + expect(within(menu).getByRole('link', { name: '管理订阅' }).getAttribute('href')).toBe( + '/account/subscription', + ) +}) + function finishBackAnimation() { act(() => vi.advanceTimersByTime(240)) } diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 26c41579..5bc09d53 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -4,10 +4,11 @@ import { Link, useLocation, useNavigate } from 'react-router' import defaultAccountAvatar from '@/assets/account/avatars/windup-witch-avatar.webp' import { quotaApis as defaultQuotaApis } from '@/entities' -import type { QuotaApis } from '@/entities' +import type { BillApis, QuotaApis } from '@/entities' import { readActiveRun, subscribeActiveRun } from '@/features/active-run' import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session' import { useQuotaBalance } from '@/features/quota' +import { subscriptionName, useSubscriptionStatus } from '@/features/billing' import { productControlClass, productMenuItemClass, @@ -28,6 +29,7 @@ interface ProductNavigationItem { export interface AppHeaderProps { quotaApis?: QuotaApis + billApis?: BillApis variant?: 'product' | 'marketing' } @@ -117,6 +119,7 @@ function WaveText({ playId, text }: { playId: number; text: string }) { */ export function AppHeader({ quotaApis = defaultQuotaApis, + billApis, variant = 'product', }: AppHeaderProps = {}) { const { pathname, search, hash } = useLocation() @@ -131,6 +134,10 @@ export function AppHeader({ accountMenu.mounted && session.state.status === 'authenticated', quotaApis, ) + const subscription = useSubscriptionStatus( + accountMenu.mounted && session.state.status === 'authenticated', + billApis, + ) const [wave, setWave] = useState({ entry: '', playId: 0 }) const activeRunUserId = session.state.status === 'authenticated' ? session.state.user.id : null const [activeRunId, setActiveRunId] = useState(null) @@ -460,9 +467,13 @@ export function AppHeader({ >
-

Free

+

+ {subscription.data + ? subscriptionName(subscription.data.activeSubscription?.tier) + : '—'} +

- 当前订阅 · 标准创作 + {subscription.error ? '订阅信息暂不可用' : '当前订阅 · 标准创作'}

- 升级 + {subscription.data?.activeSubscription ? '管理订阅' : '升级'}
+ + + diff --git a/frontend/src/assets/payments/wechat-pay.svg b/frontend/src/assets/payments/wechat-pay.svg new file mode 100644 index 00000000..a13e25ad --- /dev/null +++ b/frontend/src/assets/payments/wechat-pay.svg @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/entities/bill/api.test.ts b/frontend/src/entities/bill/api.test.ts new file mode 100644 index 00000000..ea1da9d0 --- /dev/null +++ b/frontend/src/entities/bill/api.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from 'vitest' +import { createBillBackend } from '@/test/bill-backend' +import { createBillApis } from './api' + +it('读取真实商品价格并映射订阅账期与两类余额', async () => { + const backend = createBillBackend() + backend.products[0].amount_fen = 3100 + backend.subscription.active_subscription = { + tier: 2, + status: 1, + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z', + } + backend.subscription.expiring_credits = 1280 + const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }) + expect((await apis.listProducts())[0]).toMatchObject({ + id: 11, + amountFen: 3100, + credits: 400, + type: 2, + }) + expect(await apis.getSubscription()).toEqual({ + activeSubscription: { + tier: 2, + status: 1, + periodStart: '2026-10-01T00:00:00Z', + periodEnd: '2026-11-01T00:00:00Z', + }, + nextSubscription: null, + perpetualCredits: 250, + expiringCredits: 1280, + }) +}) + +it('自定义下单使用整数分和选中渠道,并复用认证边界', async () => { + const backend = createBillBackend() + const apis = createBillApis({ + baseUrl: '/api', + fetchFn: backend.fetchFn, + getAccessToken: () => 'test-access', + }) + const order = await apis.createOrder({ amountFen: 700, channel: 'wxpay' }) + expect(order).toMatchObject({ + orderNo: 'ORD202610150001', + amountFen: 700, + credits: 88, + channel: 'wxpay', + status: 0, + payUrl: 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001', + }) + expect(backend.calls[0]).toEqual({ + path: '/bill/orders', + method: 'POST', + body: { amount_fen: 700, channel: 'wxpay' }, + authorization: 'Bearer test-access', + }) +}) + +it('套餐下单只传商品编号,金额和积分采用后端订单快照', async () => { + const backend = createBillBackend() + const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }) + const order = await apis.createOrder({ productId: 12, channel: 'alipay' }) + expect(backend.calls[0].body).toEqual({ product_id: 12, channel: 'alipay' }) + expect(order).toMatchObject({ productId: 12, amountFen: 9900, credits: 1500, name: 'Pro' }) +}) + +it('历史订单使用后端分页,关闭后再查询权威状态', async () => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder()) + const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }) + const page = await apis.listOrders({ page: 2, pageSize: 5 }) + expect(page).toEqual({ items: [], total: 1, page: 2, pageSize: 5 }) + expect(backend.calls[0].path).toBe('/bill/orders?page=2&page_size=5') + await apis.closeOrder('ORD202610150001') + expect((await apis.getOrder('ORD202610150001')).status).toBe(2) +}) diff --git a/frontend/src/entities/bill/api.ts b/frontend/src/entities/bill/api.ts new file mode 100644 index 00000000..f557c0a4 --- /dev/null +++ b/frontend/src/entities/bill/api.ts @@ -0,0 +1,162 @@ +import { createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' +import type { BillApis, BillOrder, BillProduct, BillSubscription } from './types' + +interface ProductDto { + id: number + type: number + tier: number | null + name: string + description: string | null + amount_fen: number + credits: number + original_amount_fen: number | null + badge: string | null +} + +interface SubscriptionDto { + tier: number + status: number + period_start: string + period_end: string +} + +interface SubscriptionStatusDto { + active_subscription?: SubscriptionDto | null + next_subscription?: SubscriptionDto | null + perpetual_credits: number + expiring_credits: number +} + +interface OrderDto { + order_no: string + product_id: number | null + product_type: number + product_snapshot: Record + amount_fen: number + credits: number + channel: string + status: number + pay_url: string | null + expire_at: string + paid_at: string | null + closed_at: string | null + create_at: string +} + +function toProduct(dto: ProductDto): BillProduct { + return { + id: dto.id, + type: dto.type, + tier: dto.tier, + name: dto.name, + description: dto.description, + amountFen: dto.amount_fen, + credits: dto.credits, + originalAmountFen: dto.original_amount_fen, + badge: dto.badge, + } +} + +function toSubscription(dto: SubscriptionDto | null | undefined): BillSubscription | null { + return dto + ? { + tier: dto.tier, + status: dto.status, + periodStart: dto.period_start, + periodEnd: dto.period_end, + } + : null +} + +function toOrder(dto: OrderDto): BillOrder { + return { + orderNo: dto.order_no, + productId: dto.product_id, + productType: dto.product_type, + name: typeof dto.product_snapshot.name === 'string' ? dto.product_snapshot.name : '积分充值', + amountFen: dto.amount_fen, + credits: dto.credits, + channel: dto.channel, + status: dto.status, + payUrl: dto.pay_url, + expireAt: dto.expire_at, + paidAt: dto.paid_at, + closedAt: dto.closed_at, + createdAt: dto.create_at, + } +} + +export interface CreateBillApisOptions extends ApiClientOptions { + client?: ApiClient +} + +export function createBillApis(options: CreateBillApisOptions = {}): BillApis { + const { client, ...clientOptions } = options + const protectedClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + }) + return { + async listProducts() { + const result = await protectedClient.requestList('/bill/products') + return result.items.map(toProduct) + }, + async getSubscription() { + const dto = await protectedClient.request('/bill/subscription') + return { + activeSubscription: toSubscription(dto.active_subscription), + nextSubscription: toSubscription(dto.next_subscription), + perpetualCredits: dto.perpetual_credits, + expiringCredits: dto.expiring_credits, + } + }, + async createOrder(input) { + return toOrder( + await protectedClient.request('/bill/orders', { + method: 'POST', + replayAfterAuth: false, + json: { + ...(input.productId === undefined + ? { amount_fen: input.amountFen } + : { product_id: input.productId }), + channel: input.channel, + }, + }), + ) + }, + async listOrders(query = {}) { + const result = await protectedClient.requestList('/bill/orders', { + query: { page: query.page, page_size: query.pageSize }, + }) + return { ...result, items: result.items.map(toOrder) } + }, + async getOrder(orderNo) { + return toOrder( + await protectedClient.request(`/bill/orders/${encodeURIComponent(orderNo)}`), + ) + }, + async closeOrder(orderNo) { + await protectedClient.request(`/bill/orders/${encodeURIComponent(orderNo)}/close`, { + method: 'POST', + replayAfterAuth: false, + }) + }, + } +} + +let defaultApis: BillApis | undefined +function getDefaultApis(): BillApis { + return (defaultApis ??= createBillApis()) +} + +export const billApis: BillApis = { + listProducts: () => getDefaultApis().listProducts(), + getSubscription: () => getDefaultApis().getSubscription(), + createOrder: (input) => getDefaultApis().createOrder(input), + listOrders: (query) => getDefaultApis().listOrders(query), + getOrder: (orderNo) => getDefaultApis().getOrder(orderNo), + closeOrder: (orderNo) => getDefaultApis().closeOrder(orderNo), +} diff --git a/frontend/src/entities/bill/index.ts b/frontend/src/entities/bill/index.ts new file mode 100644 index 00000000..d988a89c --- /dev/null +++ b/frontend/src/entities/bill/index.ts @@ -0,0 +1,12 @@ +export { billApis, createBillApis } from './api' +export type { CreateBillApisOptions } from './api' +export { BILL_ORDER_STATUS, BILL_PRODUCT_TYPE } from './types' +export type { + BillApis, + BillOrder, + BillProduct, + BillSubscription, + CreateBillOrder, + PaymentChannel, + SubscriptionStatus, +} from './types' diff --git a/frontend/src/entities/bill/types.ts b/frontend/src/entities/bill/types.ts new file mode 100644 index 00000000..782221e5 --- /dev/null +++ b/frontend/src/entities/bill/types.ts @@ -0,0 +1,69 @@ +import type { Paged, PageQuery } from '@/shared/pagination' + +export const BILL_PRODUCT_TYPE = { TOP_UP: 1, SUBSCRIPTION: 2 } as const +export const BILL_ORDER_STATUS = { + PENDING: 0, + PAID: 1, + CLOSED: 2, + REFUNDING: 3, + REFUNDED: 4, +} as const +export type PaymentChannel = 'alipay' | 'wxpay' + +export interface BillProduct { + id: number + type: number + tier: number | null + name: string + description: string | null + /** 人民币整数分;仅在展示时转换为元。 */ + amountFen: number + credits: number + originalAmountFen: number | null + badge: string | null +} + +export interface BillSubscription { + tier: number + status: number + periodStart: string + periodEnd: string +} + +export interface SubscriptionStatus { + activeSubscription: BillSubscription | null + nextSubscription: BillSubscription | null + perpetualCredits: number + expiringCredits: number +} + +export interface BillOrder { + orderNo: string + productId: number | null + productType: number + name: string + amountFen: number + credits: number + channel: string + status: number + payUrl: string | null + expireAt: string + paidAt: string | null + closedAt: string | null + createdAt: string +} + +/** 商品金额由服务端确定;自定义充值才传 amountFen。 */ +export type CreateBillOrder = ( + | { productId: number; amountFen?: never } + | { amountFen: number; productId?: never } +) & { channel: PaymentChannel } + +export interface BillApis { + listProducts(): Promise + getSubscription(): Promise + createOrder(input: CreateBillOrder): Promise + listOrders(query?: PageQuery): Promise> + getOrder(orderNo: string): Promise + closeOrder(orderNo: string): Promise +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index f3a83d12..8cfd728a 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,5 +1,18 @@ /** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ +/* 账单 —— 商品、支付订单与订阅账期,以后端账本为准。 */ +export { billApis, createBillApis, BILL_ORDER_STATUS, BILL_PRODUCT_TYPE } from './bill' +export type { + BillApis, + BillOrder, + BillProduct, + BillSubscription, + CreateBillOrder, + CreateBillApisOptions, + PaymentChannel, + SubscriptionStatus, +} from './bill' + /* 用户 —— 认证传输与稳定会话身份 */ export { createUserApis, userApis } from './user' export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user' diff --git a/frontend/src/features/billing/index.test.ts b/frontend/src/features/billing/index.test.ts new file mode 100644 index 00000000..02565d95 --- /dev/null +++ b/frontend/src/features/billing/index.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { createBillApis } from '@/entities' +import { createBillBackend } from '@/test/bill-backend' +import { usePaymentOrder } from './index' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +it('待支付时查询,确认支付后停止轮询且只通知一次', async () => { + vi.useFakeTimers() + const backend = createBillBackend() + backend.orders.push(backend.makeOrder()) + const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }) + const onPaid = vi.fn() + const { result, unmount } = renderHook(() => usePaymentOrder('ORD202610150001', apis, onPaid)) + await act(async () => {}) + expect(result.current.order?.status).toBe(0) + const detailRequests = () => + backend.calls.filter((call) => call.path === '/bill/orders/ORD202610150001').length + expect(detailRequests()).toBe(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(detailRequests()).toBe(2) + backend.pay('ORD202610150001') + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(result.current.order?.status).toBe(1) + expect(onPaid).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + expect(detailRequests()).toBe(3) + unmount() +}) + +it('快速切换订单后,迟到的旧订单不能覆盖当前订单或触发成功通知', async () => { + const backend = createBillBackend() + backend.orders.push( + backend.makeOrder({ order_no: 'ORDER_A', status: 1 }), + backend.makeOrder({ order_no: 'ORDER_B' }), + ) + const releases: Record void> = {} + const fetchFn: typeof fetch = async (input, init) => { + const id = String(input).split('/').at(-1)! + await new Promise((resolve) => { + releases[id] = resolve + }) + return backend.fetchFn(input, init) + } + const apis = createBillApis({ baseUrl: '/api', fetchFn }) + const onPaid = vi.fn() + const { result, rerender } = renderHook(({ id }) => usePaymentOrder(id, apis, onPaid), { + initialProps: { id: 'ORDER_A' }, + }) + await act(async () => {}) + rerender({ id: 'ORDER_B' }) + await act(async () => {}) + await act(async () => releases.ORDER_B()) + expect(result.current.order?.orderNo).toBe('ORDER_B') + await act(async () => releases.ORDER_A()) + expect(result.current.order?.orderNo).toBe('ORDER_B') + expect(onPaid).not.toHaveBeenCalled() +}) + +it('关单时后端发现已经付款,前端采用 PAID 而非自行标记 CLOSED', async () => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder()) + const fetchFn: typeof fetch = async (input, init) => { + if (String(input).endsWith('/close')) { + backend.pay('ORD202610150001') + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + } + return backend.fetchFn(input, init) + } + const apis = createBillApis({ baseUrl: '/api', fetchFn }) + const onPaid = vi.fn() + const { result } = renderHook(() => usePaymentOrder('ORD202610150001', apis, onPaid)) + await act(async () => {}) + await act(async () => result.current.close()) + expect(result.current.order?.status).toBe(1) + expect(onPaid).toHaveBeenCalledTimes(1) +}) diff --git a/frontend/src/features/billing/index.ts b/frontend/src/features/billing/index.ts new file mode 100644 index 00000000..7a44809a --- /dev/null +++ b/frontend/src/features/billing/index.ts @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { BILL_ORDER_STATUS, billApis } from '@/entities' +import type { BillApis, BillOrder, CreateBillOrder } from '@/entities' + +interface Resource { + data: T | null + status: 'loading' | 'ready' | 'error' + error: string | null + reload(): void +} + +export function billingError(error: unknown): string { + return error instanceof Error && error.message ? error.message : '加载失败,请稍后重试' +} + +/** 账单内的三个只读资源共用失效规则;切页、关闭或更换依赖后忽略旧请求。 */ +function useBillResource(read: () => Promise, enabled = true): Resource { + const [attempt, setAttempt] = useState(0) + const [result, setResult] = useState, 'reload'>>({ + data: null, + status: 'loading', + error: null, + }) + useEffect(() => { + if (!enabled) { + setResult({ data: null, status: 'loading', error: null }) + return + } + let active = true + setResult((current) => ({ ...current, status: 'loading', error: null })) + void Promise.resolve() + .then(read) + .then( + (data) => { + if (active) setResult({ data, status: 'ready', error: null }) + }, + (error: unknown) => { + if (active) + setResult((current) => ({ ...current, status: 'error', error: billingError(error) })) + }, + ) + return () => { + active = false + } + }, [attempt, enabled, read]) + const reload = useCallback(() => setAttempt((value) => value + 1), []) + return { ...result, reload } +} + +export function useBillProducts(apis: BillApis = billApis) { + return useBillResource(useCallback(() => apis.listProducts(), [apis])) +} + +export function useSubscriptionStatus(enabled = true, apis: BillApis = billApis) { + return useBillResource( + useCallback(() => apis.getSubscription(), [apis]), + enabled, + ) +} + +export function useBillOrders(page: number, apis: BillApis = billApis) { + return useBillResource(useCallback(() => apis.listOrders({ page, pageSize: 5 }), [apis, page])) +} + +export function subscriptionName(tier: number | null | undefined): string { + return tier == null + ? 'Free' + : (({ 1: 'Plus', 2: 'Pro', 3: 'Max' } as Record)[tier] ?? '会员') +} + +export function topUpQuote(amount: string): { amountFen: number; credits: number } | null { + const amountFen = Number(amount) * 100 + // 自定义充值与后端采用同一粒度:>=500 分、整元、每 8 分向上取一积分。 + if (!/^\d+$/.test(amount) || !Number.isSafeInteger(amountFen) || amountFen < 500) return null + return { amountFen, credits: Math.ceil(amountFen / 8) } +} + +/** 只有服务端仍待支付的订单可继续付款;回跳参数不参与此判断。 */ +export function paymentUrl(order: BillOrder): string | null { + if ( + order.status !== BILL_ORDER_STATUS.PENDING || + Date.parse(order.expireAt) <= Date.now() || + !Number.isFinite(Date.parse(order.expireAt)) + ) + return null + try { + const url = new URL(order.payUrl ?? '') + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.hostname === 'pay.example.com' + ) + return null + return url.href + } catch { + return null + } +} + +export function useBillCheckout(apis: BillApis) { + const submitting = useRef(false) + const mounted = useRef(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + const create = async (input: CreateBillOrder): Promise => { + if (submitting.current) return null + submitting.current = true + setBusy(true) + setError(null) + try { + const order = await apis.createOrder(input) + return mounted.current ? order : null + } catch (cause) { + if (mounted.current) setError(billingError(cause)) + return null + } finally { + submitting.current = false + if (mounted.current) setBusy(false) + } + } + return { create, busy, error } +} + +export function usePaymentOrder(orderNo: string | null, apis: BillApis, onPaid: () => void) { + const read = useCallback(() => apis.getOrder(orderNo!), [apis, orderNo]) + const result = useBillResource(read, !!orderNo) + const { reload, status } = result + // 更换订单时旧 data 会保留到新请求结束;不能将旧订单误作当前订单展示或发货。 + const order = result.data?.orderNo === orderNo ? result.data : null + const lastPaid = useRef(null) + const [closing, setClosing] = useState(false) + const [closeError, setCloseError] = useState(null) + const closingRef = useRef(false) + + useEffect(() => { + if (order?.status === BILL_ORDER_STATUS.PAID && lastPaid.current !== order.orderNo) { + lastPaid.current = order.orderNo + onPaid() + } + }, [onPaid, order]) + + useEffect(() => { + if (!orderNo || order?.status !== BILL_ORDER_STATUS.PENDING || status !== 'ready') return + // 每次查询结束才安排下一次;隐藏页面不轮询,回到页面立即核对,避免并发补偿查询。 + const timer = window.setTimeout(() => { + if (document.visibilityState !== 'hidden') reload() + }, 5000) + const onVisible = () => { + if (document.visibilityState !== 'hidden') reload() + } + document.addEventListener('visibilitychange', onVisible) + return () => { + window.clearTimeout(timer) + document.removeEventListener('visibilitychange', onVisible) + } + }, [orderNo, order?.status, status, reload]) + + const close = async () => { + if (!orderNo || closingRef.current) return + closingRef.current = true + setClosing(true) + setCloseError(null) + try { + await apis.closeOrder(orderNo) + } catch (cause) { + setCloseError(billingError(cause)) + } finally { + // 关单也可能发现已付款并补发货;无论成功或失败都重新读取,不能在本地直接置 CLOSED。 + reload() + setClosing(false) + closingRef.current = false + } + } + return { ...result, order, closing, closeError, close } +} diff --git a/frontend/src/pages/subscription/checkout-dialog.tsx b/frontend/src/pages/subscription/checkout-dialog.tsx new file mode 100644 index 00000000..cbad9296 --- /dev/null +++ b/frontend/src/pages/subscription/checkout-dialog.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState } from 'react' +import { ArrowUpRight, SpinnerGap, X } from '@phosphor-icons/react' +import { BILL_ORDER_STATUS } from '@/entities' +import type { BillApis, BillOrder, PaymentChannel } from '@/entities' +import alipayLogo from '@/assets/payments/alipay.svg' +import wechatPayLogo from '@/assets/payments/wechat-pay.svg' +import { billingError, paymentUrl, useBillCheckout, usePaymentOrder } from '@/features/billing' +import { credits, money, moment, orderStatus } from './presentation' + +export interface PurchaseSelection { + name: string + amountFen: number + credits: number + productId?: number + kind: 'topup' | 'subscription' +} + +interface CheckoutDialogProps { + selection: PurchaseSelection | null + orderNo: string | null + apis: BillApis + onClose(): void + onCreated(order: BillOrder): void + onPaid(): void + onOrderChanged(): void + navigateToPayment(url: string): void +} + +export function CheckoutDialog({ + selection, + orderNo, + apis, + onClose, + onCreated, + onPaid, + onOrderChanged, + navigateToPayment, +}: CheckoutDialogProps) { + const dialog = useRef(null) + const [channel, setChannel] = useState('alipay') + const [navigationError, setNavigationError] = useState(null) + const [priceChanged, setPriceChanged] = useState(false) + const checkout = useBillCheckout(apis) + const payment = usePaymentOrder(orderNo, apis, onPaid) + const order = payment.order + const pending = order?.status === BILL_ORDER_STATUS.PENDING + const busy = checkout.busy || payment.closing + const url = order ? paymentUrl(order) : null + const error = + checkout.error ?? navigationError ?? (pending ? payment.closeError : null) ?? payment.error + + useEffect(() => { + const element = dialog.current + element?.showModal() + return () => element?.close() + }, []) + + function navigate(url: string) { + try { + navigateToPayment(url) + } catch (cause) { + setNavigationError(billingError(cause)) + } + } + + async function submit() { + if (!selection) return + const created = await checkout.create({ + ...(selection.productId === undefined + ? { amountFen: selection.amountFen } + : { productId: selection.productId }), + channel, + }) + if (!created) return + const changed = + created.amountFen !== selection.amountFen || created.credits !== selection.credits + setPriceChanged(changed) + // 先保存订单号到当前路由,返回或刷新页面后仍可核对;价格变化时停下来展示真实订单。 + onCreated(created) + const target = paymentUrl(created) + if (target && !changed) navigate(target) + } + + const title = selection + ? selection.name + : order?.status === BILL_ORDER_STATUS.PAID + ? '支付成功' + : order?.status === BILL_ORDER_STATUS.CLOSED + ? '订单已关闭' + : pending + ? '等待支付' + : order + ? (orderStatus[order.status] ?? '订单状态待确认') + : '查询订单' + + return ( + { + event.preventDefault() + if (!busy) onClose() + }} + onClick={(event) => { + if (event.target !== event.currentTarget || busy) return + const rect = event.currentTarget.getBoundingClientRect() + if ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) + onClose() + }} + className="subscription-dialog max-h-[calc(100dvh-2rem)] overflow-y-auto" + > + +

{title}

+ {selection || order ? ( +
+ {money(selection?.amountFen ?? order!.amountFen)} + {credits(selection?.credits ?? order!.credits)} 积分 +
+ ) : ( +

正在查询订单…

+ )} + + {selection ? ( + <> +
+ 支付方式 +
+ {( + [ + { value: 'alipay', name: '支付宝', logo: alipayLogo }, + { value: 'wxpay', name: '微信支付', logo: wechatPayLogo }, + ] as const + ).map((item) => ( + + ))} +
+
+

+ {selection.kind === 'subscription' + ? '按月手动续购。续购接续当前账期,积分在对应账期开始时发放。' + : '充值积分长期有效,不改变会员资格。'} +

+ + + ) : order ? ( + <> + {pending ? ( + <> +

+ {priceChanged + ? '价格或积分已更新,请核对订单后继续支付。' + : url + ? `请在 ${moment(order.expireAt)} 前完成付款,支付结果会自动更新。` + : Date.parse(order.expireAt) <= Date.now() + ? '此订单已超时,请刷新支付结果确认状态。' + : '暂时无法打开收银台,请联系管理员。'} +

+
+ + +
+ + + ) : ( + <> +

+ {order.status === BILL_ORDER_STATUS.PAID + ? '支付已确认,可查看最新积分与订阅状态。' + : order.status === BILL_ORDER_STATUS.CLOSED + ? '此订单无法继续付款,可以重新选择套餐或充值金额。' + : '请以此处查询到的订单状态为准。'} +

+ + + )} +

订单号 {order.orderNo}

+ + ) : error ? ( + + ) : null} + {error && ( +
+ {error} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/subscription/index.test.tsx b/frontend/src/pages/subscription/index.test.tsx index 53148899..7f08a9b7 100644 --- a/frontend/src/pages/subscription/index.test.tsx +++ b/frontend/src/pages/subscription/index.test.tsx @@ -1,46 +1,169 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, expect, it } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' +import { createBillApis } from '@/entities' +import { createBillBackend } from '@/test/bill-backend' import { SubscriptionPage } from './index' +beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', '') + } + HTMLDialogElement.prototype.close = function () { + this.removeAttribute('open') + } +}) afterEach(cleanup) -it('rounds custom credits up and rejects fractional or insufficient payments', () => { +function setup(path = '/account/subscription', backend = createBillBackend()) { + const navigateToPayment = vi.fn() + const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }) render( - - + + , ) + return { backend, navigateToPayment } +} + +it('展示后端商品价格和真实订阅,不把已有会员显示成 Free', async () => { + const backend = createBillBackend() + backend.products[0].amount_fen = 3100 + backend.subscription.active_subscription = { + tier: 2, + status: 1, + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z', + } + backend.subscription.expiring_credits = 1280 + setup(undefined, backend) + expect(await screen.findByText('31')).toBeTruthy() + expect(screen.getByLabelText('当前方案').textContent).toBe('Pro') + expect(screen.getByLabelText('本期积分').textContent).toBe('1,280') + expect(screen.getByLabelText('长期积分').textContent).toBe('250') +}) + +it('自定义金额先校验,再按整数分下单;重复点击不重复创建', async () => { + const { backend, navigateToPayment } = setup('/account/subscription?tab=credits') + await screen.findByLabelText('长期积分') const input = screen.getByLabelText('自定义充值金额') - fireEvent.change(input, { target: { value: '7' } }) - expect(screen.getByText('88')).toBeTruthy() const buy = screen.getByRole('button', { name: '充值积分' }) as HTMLButtonElement - expect(buy.disabled).toBe(false) for (const value of ['4', '5.5', '', 'abc']) { fireEvent.change(input, { target: { value } }) expect(buy.disabled).toBe(true) - expect(screen.getByRole('alert')).toBeTruthy() } + fireEvent.change(input, { target: { value: '7' } }) + expect(screen.getByLabelText('本次到账积分').textContent).toContain('88') + fireEvent.click(buy) + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByRole('radio', { name: '微信支付' })) + const submit = within(dialog).getByRole('button', { name: '前往支付' }) + fireEvent.click(submit) + fireEvent.click(submit) + await waitFor(() => expect(navigateToPayment).toHaveBeenCalledTimes(1)) + expect(backend.calls.filter((call) => call.method === 'POST')).toEqual([ + expect.objectContaining({ path: '/bill/orders', body: { amount_fen: 700, channel: 'wxpay' } }), + ]) + expect(navigateToPayment).toHaveBeenCalledWith( + 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001', + ) }) -it('highlights a selected plan and explains that payment is not connected', () => { - HTMLDialogElement.prototype.showModal = function () { - this.setAttribute('open', '') - } - render( - - - , - ) - fireEvent.click(screen.getByText('Pro')) - expect(screen.getByText('Pro').closest('article')?.className).toContain('selected') +it('订阅下单传商品编号,不由页面决定最终价格', async () => { + const { backend, navigateToPayment } = setup() + fireEvent.click(await screen.findByRole('button', { name: '选择 Pro' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' })) + await waitFor(() => expect(navigateToPayment).toHaveBeenCalledTimes(1)) + expect(backend.calls.find((call) => call.method === 'POST')?.body).toEqual({ + product_id: 12, + channel: 'alipay', + }) +}) - fireEvent.click(screen.getByRole('button', { name: '已选择' })) +it('下单时价格变更必须重新确认,不能直接跳转付款', async () => { + const { backend, navigateToPayment } = setup() + fireEvent.click(await screen.findByRole('button', { name: '选择 Pro' })) + backend.products[1].amount_fen = 10500 + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' })) const dialog = screen.getByRole('dialog') - expect(dialog.textContent).toContain('¥99') - expect(dialog.textContent).toContain('1,500 积分') - expect(dialog.textContent).toContain('支付还没有接入,请联系管理员') - expect(dialog.textContent).toContain('不会扣款') - expect(screen.getByText('当前订阅方案:')).toBeTruthy() + expect(await within(dialog).findByText('¥105')).toBeTruthy() + expect(navigateToPayment).not.toHaveBeenCalled() + expect(await within(dialog).findByText('价格或积分已更新,请核对订单后继续支付。')).toBeTruthy() +}) + +it('过期订单不能继续付款,界面等待后端确认终态', async () => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder({ expire_at: '2020-01-01T00:00:00Z' })) + const { navigateToPayment } = setup('/account/subscription?order_no=ORD202610150001', backend) + const dialog = await screen.findByRole('dialog') + const button = (await within(dialog).findByRole('button', { + name: '继续支付', + })) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(within(dialog).queryByText('订单已关闭')).toBeNull() + expect(navigateToPayment).not.toHaveBeenCalled() +}) + +it('回跳参数不能证明付款成功,订单确认支付后才刷新两类余额', async () => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder()) + setup('/account/subscription?out_trade_no=ORD202610150001&trade_status=TRADE_SUCCESS', backend) + const dialog = await screen.findByRole('dialog') + await within(dialog).findByRole('button', { name: '继续支付' }) + expect(within(dialog).queryByText('支付成功')).toBeNull() + expect(screen.getByLabelText('长期积分').textContent).toBe('250') + backend.pay('ORD202610150001') + fireEvent.click(within(dialog).getByRole('button', { name: '刷新支付结果' })) + expect(await within(dialog).findByText('支付成功')).toBeTruthy() + await waitFor(() => expect(screen.getByLabelText('长期积分').textContent).toBe('338')) +}) + +it('后端没有在售套餐时展示空态,不填充虚构套餐', async () => { + const backend = createBillBackend() + backend.products.splice(0) + setup(undefined, backend) + expect(await screen.findByText('暂无在售套餐')).toBeTruthy() + expect(screen.queryByRole('button', { name: '选择 Pro' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '积分充值' })) + expect(screen.getByRole('button', { name: '充值积分' }).hasAttribute('disabled')).toBe(false) +}) + +it.each(['javascript:alert(1)', null])('无效支付地址不能跳转:%s', async (payUrl) => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder({ pay_url: payUrl })) + const { navigateToPayment } = setup('/account/subscription?order_no=ORD202610150001', backend) + const dialog = await screen.findByRole('dialog') + const pay = (await within(dialog).findByRole('button', { name: '继续支付' })) as HTMLButtonElement + expect(pay.disabled).toBe(true) + expect(navigateToPayment).not.toHaveBeenCalled() +}) + +it('关闭待支付订单后采用服务端状态,不能继续付款', async () => { + const backend = createBillBackend() + backend.orders.push(backend.makeOrder()) + setup('/account/subscription?order_no=ORD202610150001', backend) + const dialog = await screen.findByRole('dialog') + fireEvent.click(await within(dialog).findByRole('button', { name: '关闭订单' })) + expect(await within(dialog).findByText('订单已关闭')).toBeTruthy() + expect(within(dialog).queryByRole('button', { name: '继续支付' })).toBeNull() +}) + +it('下单失败可重试,不自动重复请求或伪装成功', async () => { + const backend = createBillBackend() + const fetchFn = backend.fetchFn + backend.fetchFn = async (input, init) => + init?.method === 'POST' + ? new Response(JSON.stringify({ code: 500, message: '支付网关暂不可用', data: null })) + : fetchFn(input, init) + const { navigateToPayment } = setup(undefined, backend) + fireEvent.click(await screen.findByRole('button', { name: '选择 Plus' })) + await act(async () => + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' })), + ) + expect(await screen.findByRole('alert')).toHaveProperty('textContent', '支付网关暂不可用') + expect(navigateToPayment).not.toHaveBeenCalled() + expect((screen.getByRole('button', { name: '前往支付' }) as HTMLButtonElement).disabled).toBe( + false, + ) }) diff --git a/frontend/src/pages/subscription/index.tsx b/frontend/src/pages/subscription/index.tsx index 58159717..f24ee7f4 100644 --- a/frontend/src/pages/subscription/index.tsx +++ b/frontend/src/pages/subscription/index.tsx @@ -1,51 +1,106 @@ -import { useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { useSearchParams } from 'react-router' -import { ArrowUpRight, Check, Plus, X } from '@phosphor-icons/react' +import { ArrowUpRight, Check, Plus } from '@phosphor-icons/react' +import { BILL_PRODUCT_TYPE, billApis } from '@/entities' +import type { BillApis, BillOrder } from '@/entities' +import { + subscriptionName, + topUpQuote, + useBillOrders, + useBillProducts, + useSubscriptionStatus, +} from '@/features/billing' +import { CheckoutDialog } from './checkout-dialog' +import type { PurchaseSelection } from './checkout-dialog' +import { OrderHistory } from './order-history' +import { credits as formatCredits, moment } from './presentation' import './subscription.css' -const plans = [ - { - name: 'Plus', - price: 29, - credits: 400, - saving: '9.4%', - unit: '0.0725', - }, - { - name: 'Pro', - price: 99, - credits: 1500, - saving: '17.5%', - unit: '0.0660', - }, - { - name: 'Max', - price: 199, - credits: 3200, - saving: '22.3%', - unit: '0.0622', - }, -] const amounts = [5, 10, 20, 30, 40, 100] -export function SubscriptionPage() { +interface SubscriptionPageProps { + apis?: BillApis + navigateToPayment?: (url: string) => void +} + +const navigateToCashier = (url: string) => window.location.assign(url) + +export function SubscriptionPage({ + apis = billApis, + navigateToPayment = navigateToCashier, +}: SubscriptionPageProps = {}) { const [params, setParams] = useSearchParams() const recharge = params.get('tab') === 'credits' + const orderNo = params.get('order_no') || params.get('out_trade_no') const [amount, setAmount] = useState('20') - const [selectedPlan, setSelectedPlan] = useState(null) - const [selection, setSelection] = useState<{ - name: string - price: number - credits: number - } | null>(null) - const dialog = useRef(null) + const [selectedPlan, setSelectedPlan] = useState(null) + const [selection, setSelection] = useState(null) + const [page, setPage] = useState(1) + const products = useBillProducts(apis) + const subscription = useSubscriptionStatus(true, apis) + const history = useBillOrders(page, apis) + const reloadSubscription = subscription.reload + const reloadHistory = history.reload + const onPaid = useCallback(() => { + reloadSubscription() + reloadHistory() + }, [reloadSubscription, reloadHistory]) + const plans = products.data?.filter((item) => item.type === BILL_PRODUCT_TYPE.SUBSCRIPTION) ?? [] + const current = subscription.data?.activeSubscription + const next = subscription.data?.nextSubscription + const currentPlan = subscription.data ? subscriptionName(current?.tier) : '—' + const quote = topUpQuote(amount) + const valid = quote !== null const value = Number(amount) - const valid = /^\d+$/.test(amount) && Number.isSafeInteger(value * 100) && value >= 5 - const credits = valid ? Math.ceil((value * 100) / 8) : 0 - function preview(item: { name: string; price: number; credits: number }) { - setSelection(item) - dialog.current?.showModal() + const credits = quote?.credits ?? 0 + + function changeTab(tab: 'plans' | 'credits') { + setParams((current) => { + const next = new URLSearchParams(current) + if (tab === 'credits') next.set('tab', 'credits') + else next.delete('tab') + return next + }) + } + + function showOrder(order: BillOrder) { + // 订单号写回路由后才切换弹窗内容,避免下单结束与路由更新之间卸载弹窗。 + setParams( + (current) => { + const next = new URLSearchParams(current) + next.delete('out_trade_no') + next.set('order_no', order.orderNo) + return next + }, + { replace: true }, + ) + reloadHistory() } + + function closeCheckout() { + setSelection(null) + setParams( + (current) => { + const next = new URLSearchParams(current) + for (const key of [ + 'order_no', + 'out_trade_no', + 'trade_no', + 'trade_status', + 'sign', + 'sign_type', + 'money', + 'type', + 'pid', + 'name', + ]) + next.delete(key) + return next + }, + { replace: true }, + ) + } + return (
@@ -53,10 +108,10 @@ export function SubscriptionPage() {
- -
@@ -101,7 +156,7 @@ export function SubscriptionPage() {

本次将获得

- + {valid ? credits.toLocaleString('zh-CN') : '—'} 积分 @@ -112,51 +167,88 @@ export function SubscriptionPage() {
当前方案 - Free + {currentPlan}
充值只增加积分,不改变会员资格。
+ ) : products.error ? ( +
+

+ {products.error} +

+ +
+ ) : products.data === null ? ( +

+ 正在加载套餐… +

+ ) : plans.length === 0 ? ( +

暂无在售套餐

) : (
- {plans.map((plan, index) => ( + {plans.map((plan) => (
setSelectedPlan(plan.name)} + key={plan.id} + className={`subscription-plan ${plan.badge ? 'recommended' : ''} ${selectedPlan === plan.id ? 'selected' : ''}`} + onClick={() => setSelectedPlan(plan.id)} onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { + if ( + event.target === event.currentTarget && + (event.key === 'Enter' || event.key === ' ') + ) { event.preventDefault() - setSelectedPlan(plan.name) + setSelectedPlan(plan.id) } }} role="button" tabIndex={0} - aria-pressed={selectedPlan === plan.name} + aria-pressed={selectedPlan === plan.id} >
- {index === 1 && 最受欢迎} + {plan.badge && {plan.badge}}

{plan.name}

¥ - {plan.price} + {plan.amountFen / 100} / 月
{plan.credits.toLocaleString('zh-CN')} 积分 - 省 {plan.saving} + {plan.amountFen < plan.credits * 8 && ( + 省 {((1 - plan.amountFen / (plan.credits * 8)) * 100).toFixed(1)}% + )}
-
@@ -173,7 +265,10 @@ export function SubscriptionPage() { 会员专属能力筹备中

-

约 ¥{plan.unit} / 积分

+

+ 约 ¥{plan.credits > 0 ? (plan.amountFen / 100 / plan.credits).toFixed(4) : '—'} / + 积分 +

))}
@@ -181,43 +276,71 @@ export function SubscriptionPage() {

- 当前订阅方案:Free + 当前订阅方案:{currentPlan}

+ {subscription.data && ( + <> +

+ 长期积分{' '} + + {formatCredits(subscription.data.perpetualCredits)} + {' '} + · 本期积分{' '} + + {formatCredits(subscription.data.expiringCredits)} + +

+ {current && ( +

+ 当前账期:{moment(current.periodStart)} 至 {moment(current.periodEnd)} +

+ )} + {next && ( +

+ 已续购 {subscriptionName(next.tier)}:{moment(next.periodStart)} 至{' '} + {moment(next.periodEnd)} +

+ )} + + )} + {subscription.error && ( +
+ {subscription.error} + +
+ )}

Free 包含的功能

Free 同样支持标准制作、局部重做与透明素材导出;生成任务按实际报价消耗积分。

暂未开放的会员权益

- 更好的模型、高质量处理、三渲二暂未开放。会员期限、积分有效期与续购规则将在开售前说明。 + 更好的模型、高质量处理、三渲二暂未开放。订阅按月手动续购,本期积分到期失效;充值积分长期有效。

- { - if (event.target === event.currentTarget) dialog.current?.close() - }} - aria-labelledby="purchase-title" - > - -

{selection?.name}

-
- ¥{selection?.price} - {selection?.credits.toLocaleString('zh-CN')} 积分 -
-

支付还没有接入,请联系管理员。当前不会扣款,也不会变更积分或会员资格。

- -
+
+ 订单记录 + +
+ {(selection || orderNo) && ( + + )} ) } diff --git a/frontend/src/pages/subscription/order-history.tsx b/frontend/src/pages/subscription/order-history.tsx new file mode 100644 index 00000000..a8e155cc --- /dev/null +++ b/frontend/src/pages/subscription/order-history.tsx @@ -0,0 +1,86 @@ +import type { BillOrder } from '@/entities' +import type { useBillOrders } from '@/features/billing' +import { Pagination } from '@/shared/ui' +import { credits, moment, money, orderStatus, secondaryButton } from './presentation' + +export function OrderHistory({ + resource, + onSelect, + onPageChange, +}: { + resource: ReturnType + onSelect(order: BillOrder): void + onPageChange(page: number): void +}) { + return ( +
+
+

+ 订单记录 +

+ +
+ {resource.error ? ( +
+

+ {resource.error} +

+ +
+ ) : !resource.data ? ( +

+ 正在加载订单… +

+ ) : resource.data.items.length === 0 ? ( +

还没有订单。

+ ) : ( + <> +
    + {resource.data.items.map((order) => ( +
  • + +
  • + ))} +
+ + + )} +
+ ) +} diff --git a/frontend/src/pages/subscription/presentation.ts b/frontend/src/pages/subscription/presentation.ts new file mode 100644 index 00000000..c99b79d0 --- /dev/null +++ b/frontend/src/pages/subscription/presentation.ts @@ -0,0 +1,38 @@ +export function money(amountFen: number): string { + return `¥${(amountFen / 100).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}` +} + +export function credits(value: number): string { + return value.toLocaleString('zh-CN') +} + +export function moment(value: string): string { + const date = new Date(value) + return Number.isFinite(date.getTime()) + ? new Intl.DateTimeFormat('zh-CN', { + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date) + : '时间未知' +} + +export const orderStatus: Record = { + 0: '待支付', + 1: '已支付', + 2: '已关闭', + 3: '退款中', + 4: '已退款', +} +import { productControlClass } from '@/shared/ui' + +export const primaryButton = productControlClass( + 'primary', + 'justify-center disabled:cursor-not-allowed disabled:opacity-50', +) +export const secondaryButton = productControlClass( + 'secondary', + 'justify-center disabled:cursor-not-allowed disabled:opacity-50', +) diff --git a/frontend/src/test/bill-backend.ts b/frontend/src/test/bill-backend.ts new file mode 100644 index 00000000..e84b9c44 --- /dev/null +++ b/frontend/src/test/bill-backend.ts @@ -0,0 +1,176 @@ +/** HTTP 替身复刻 openapi.json;生产入口不引用此模块。 */ +export function createBillBackend() { + const products = [ + { + id: 11, + type: 2, + tier: 1, + name: 'Plus', + description: null, + amount_fen: 2900, + credits: 400, + original_amount_fen: null, + badge: null, + sort_order: 1, + extra_meta: {}, + }, + { + id: 12, + type: 2, + tier: 2, + name: 'Pro', + description: null, + amount_fen: 9900, + credits: 1500, + original_amount_fen: null, + badge: '最受欢迎', + sort_order: 2, + extra_meta: {}, + }, + { + id: 13, + type: 2, + tier: 3, + name: 'Max', + description: null, + amount_fen: 19900, + credits: 3200, + original_amount_fen: null, + badge: null, + sort_order: 3, + extra_meta: {}, + }, + ] + const subscription = { + active_subscription: null as null | { + tier: number + status: number + period_start: string + period_end: string + }, + next_subscription: null as null | { + tier: number + status: number + period_start: string + period_end: string + }, + perpetual_credits: 250, + expiring_credits: 0, + } + const calls: { + path: string + method: string + body: Record | null + authorization: string | null + }[] = [] + const makeOrder = ( + overrides: Partial<{ + order_no: string + product_id: number | null + product_type: number + amount_fen: number + credits: number + channel: string + status: number + pay_url: string | null + expire_at: string + product_snapshot: Record + paid_at: string | null + closed_at: string | null + }> = {}, + ) => ({ + order_no: 'ORD202610150001', + product_id: null, + product_type: 1, + product_snapshot: { name: '积分充值', amount_fen: 700, credits: 88 }, + amount_fen: 700, + credits: 88, + channel: 'alipay', + provider: 1, + status: 0, + pay_url: 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001', + expire_at: new Date(Date.now() + 900_000).toISOString(), + paid_at: null as string | null, + closed_at: null as string | null, + create_at: '2026-10-15T10:00:00Z', + ...overrides, + }) + const orders: ReturnType[] = [] + const response = (data: unknown, extra = {}) => + new Response(JSON.stringify({ code: 200, message: 'success', data, ...extra }), { + headers: { 'Content-Type': 'application/json' }, + }) + const fetchFn: typeof fetch = async (input, init) => { + const url = new URL(String(input), 'https://windup.test') + const path = url.pathname.replace(/^\/api/, '') + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : null + calls.push({ + path: path + url.search, + method, + body, + authorization: new Headers(init?.headers).get('Authorization'), + }) + if (path === '/bill/products') + return response(products, { total: products.length, page: 1, page_size: 20 }) + if (path === '/bill/subscription') return response(subscription) + if (path === '/bill/orders' && method === 'POST') { + const product = products.find((item) => item.id === body?.product_id) + const amount = product?.amount_fen ?? Number(body?.amount_fen) + const order = makeOrder({ + order_no: `ORD20261015${String(orders.length + 1).padStart(4, '0')}`, + product_id: product?.id ?? null, + product_type: product?.type ?? 1, + product_snapshot: { + name: product?.name ?? '积分充值', + tier: product?.tier, + amount_fen: amount, + credits: product?.credits ?? Math.ceil(amount / 8), + }, + amount_fen: amount, + credits: product?.credits ?? Math.ceil(amount / 8), + channel: String(body?.channel), + }) + orders.unshift(order) + return response(order) + } + if (path === '/bill/orders') { + const page = Number(url.searchParams.get('page') ?? 1) + const size = Number(url.searchParams.get('page_size') ?? 5) + return response(orders.slice((page - 1) * size, page * size), { + total: orders.length, + page, + page_size: size, + }) + } + const match = path.match(/^\/bill\/orders\/([^/]+)(\/close)?$/) + if (match) { + const order = orders.find((item) => item.order_no === decodeURIComponent(match[1])) + if (!order) + return new Response(JSON.stringify({ code: 404, message: '订单不存在', data: null })) + if (match[2] && method === 'POST') { + order.status = 2 + order.closed_at = new Date().toISOString() + return response(null) + } + return response(order) + } + throw new Error(`Unexpected billing request: ${method} ${path}`) + } + function pay(orderNo: string) { + const order = orders.find((item) => item.order_no === orderNo)! + order.status = 1 + order.paid_at = new Date().toISOString() + if (order.product_type === 1) subscription.perpetual_credits += order.credits + else { + subscription.active_subscription = { + tier: Number(order.product_snapshot.tier), + status: 1, + period_start: new Date().toISOString(), + period_end: new Date(Date.now() + 30 * 86400_000).toISOString(), + } + subscription.expiring_credits += order.credits + } + } + return { products, subscription, orders, calls, fetchFn, makeOrder, pay } +}