Skip to content
30 changes: 29 additions & 1 deletion frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -88,6 +90,7 @@ function renderHeader(
apis = createApis(),
previousEntry?: string,
quota = createQuotaMock(),
bill = createBillApis({ baseUrl: '/api', fetchFn: createBillBackend().fetchFn }),
) {
return {
apis,
Expand All @@ -103,7 +106,7 @@ function renderHeader(
path="*"
element={
<>
<AppHeader quotaApis={quota} />
<AppHeader quotaApis={quota} billApis={bill} />
<LocationProbe />
</>
}
Expand All @@ -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))
}
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ interface ProductNavigationItem {

export interface AppHeaderProps {
quotaApis?: QuotaApis
billApis?: BillApis
variant?: 'product' | 'marketing'
}

Expand Down Expand Up @@ -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()
Expand All @@ -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<string | null>(null)
Expand Down Expand Up @@ -460,17 +467,21 @@ export function AppHeader({
>
<div className="flex min-h-14 items-center justify-between gap-3 border-b border-app-ink/10 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-app-ink-soft">Free</p>
<p className="truncate text-[13px] font-semibold text-app-ink-soft">
{subscription.data
? subscriptionName(subscription.data.activeSubscription?.tier)
: '—'}
</p>
<p className="mt-0.5 truncate text-[11px] text-app-muted">
当前订阅 · 标准创作
{subscription.error ? '订阅信息暂不可用' : '当前订阅 · 标准创作'}
</p>
</div>
<Link
to="/account/subscription"
onClick={accountMenu.close}
className="shrink-0 rounded-md bg-app-ink px-3 py-1.5 text-[12px] font-medium text-app-canvas transition-opacity hover:opacity-80"
>
升级
{subscription.data?.activeSubscription ? '管理订阅' : '升级'}
</Link>
</div>
<div
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/assets/payments/NOTICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Payment brand artwork

Alipay: ant-design/ant-design-icons, `alipay-circle.svg`.
Source: https://github.com/ant-design/ant-design-icons/blob/master/packages/icons-svg/svg/filled/alipay-circle.svg

MIT LICENSE

Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


WeChat Pay: the unchanged mark from the official RGB artwork in wepayui/wepayui-design.
Source: https://github.com/wepayui/wepayui-design/tree/master/微信支付LOGO/线上RGB模式
Copyright Tencent. Brand rights remain with Tencent; use follows the official brand guidance.
4 changes: 4 additions & 0 deletions frontend/src/assets/payments/alipay.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/assets/payments/wechat-pay.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions frontend/src/entities/bill/api.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading
Loading