Merge branch 'feat/pricing-plan-fix' into feat/pricing-plan

This commit is contained in:
2026-10-06 10:12:41 +08:00
34 changed files with 492 additions and 127 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
import type {
ProfileMembershipChangeKindToken,
ProfileMembershipCycleKind,
ProfileMembershipModelAccess,
ProfileMembershipPlan,
@@ -953,8 +954,8 @@ export interface AdminRechargeRefundHoldPayload {
}
export interface AdminMembershipOrderChangePayload {
/** `purchase`(新购)或 `upgrade`(补差升级)。 */
changeKind: 'purchase' | 'upgrade';
/** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */
changeKind: ProfileMembershipChangeKindToken;
planBefore?: ProfileMembershipPlan | null;
planAfter?: ProfileMembershipPlan | null;
cycleKindBefore?: ProfileMembershipCycleKind | null;
@@ -9,6 +9,7 @@ import {
AdminStatusPill,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
@@ -382,12 +383,14 @@ export function AdminUserDetailDialog({
<td>{order.productTitle || order.productId}</td>
<td>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付'}
</td>
<td>{order.pointsDelta} 泥点</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
{formatAdminCurrencyCents(
order.cumulativeSuccessRefundCents,
)}
<small>欠账 {order.unrecoveredPoints} 泥点</small>
</td>
<td>{formatOrderStatus(order.status)}</td>
@@ -442,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
value: (
<>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
? formatAdminCurrencyCents(detail.cumulativeRechargedCents)
: '未知'}
</>
),
@@ -545,7 +548,10 @@ function MembershipSection({
['状态', formatMembershipStatus(membership.status) || '-'],
[
'账期',
`${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`,
// 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。
membership.status === 'active'
? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`
: '-',
],
['本账期发放', `${membership.cycleGrantedPoints} 泥点`],
['本账期剩余', `${membership.cycleRemainingPoints} 泥点`],
@@ -577,10 +583,6 @@ function MembershipSection({
);
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
function formatMicros(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '-';
@@ -8,6 +8,7 @@ import {
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
@@ -92,11 +93,12 @@ export function AdminMembershipPlanPage({
setListErrorMessage('');
try {
const response = await listProfileMembershipPlans(token);
setEntries(response.entries);
// 与保存后的排序保持一致:列表按升级基准 `rank` 展示。
const sortedEntries = sortPlanEntries(response.entries);
setEntries(sortedEntries);
setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold);
const firstEntry =
response.entries.find((entry) => entry.plan === plan) ??
response.entries[0];
sortedEntries.find((entry) => entry.plan === plan) ?? sortedEntries[0];
if (firstEntry) {
fillForm(firstEntry);
}
@@ -409,8 +411,8 @@ export function AdminMembershipPlanPage({
</small>
</td>
<td>
{formatPrice(entry.monthPriceCents)} /{' '}
{formatPrice(entry.yearPriceCents)}
{formatAdminCurrencyCents(entry.monthPriceCents)} /{' '}
{formatAdminCurrencyCents(entry.yearPriceCents)}
</td>
<td>{entry.periodPoints}</td>
<td>{formatPlanLimits(entry)}</td>
@@ -459,7 +461,3 @@ function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) {
: `${entry.concurrentJobLimit} 个`;
return `${modelLabel} · 并发 ${concurrency}`;
}
function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`;
}
@@ -13,6 +13,7 @@ import {
AdminPanelHeading,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import {
AlertTriangle,
@@ -312,7 +313,7 @@ export function AdminRechargeOrderPage({
setRefundRequestId(requestId);
const confirmed = await confirmWrite({
action: '发起微信退款并追回泥点',
target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`,
target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`,
});
if (!confirmed || submitLockRef.current) {
return;
@@ -721,15 +722,19 @@ export function AdminRechargeOrderPage({
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.paidAmountCents)}
value={formatAdminCurrencyCents(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
value={formatMoney(refundOrder.cumulativeSuccessRefundCents)}
value={formatAdminCurrencyCents(
refundOrder.cumulativeSuccessRefundCents,
)}
/>
<Metric
label="剩余可退"
value={formatMoney(refundOrder.remainingRefundableCents)}
value={formatAdminCurrencyCents(
refundOrder.remainingRefundableCents,
)}
/>
<Metric
label="永久泥点"
@@ -859,7 +864,13 @@ export function AdminRechargeOrderPage({
},
{
label: <>预检后剩余可退</>,
value: <>{formatMoney(preview.remainingRefundableCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
preview.remainingRefundableCents,
)}
</>
),
},
]}
/>
@@ -984,16 +995,32 @@ export function AdminRechargeOrderPage({
},
{
label: <>本地订单总额</>,
value: <>{formatMoney(manualReviewTarget.order.amountCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.order.amountCents,
)}
</>
),
},
{
label: <>退款订单总额</>,
value: <>{formatMoney(manualReviewTarget.refund.totalCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.totalCents,
)}
</>
),
},
{
label: <>本次退款金额</>,
value: (
<>{formatMoney(manualReviewTarget.refund.refundCents)}</>
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.refundCents,
)}
</>
),
},
]}
@@ -1107,7 +1134,7 @@ function RechargeOrderRow({
<td>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small>订单 {formatMoney(order.amountCents)}</small>
<small>订单 {formatAdminCurrencyCents(order.amountCents)}</small>
)}
</td>
<td>
@@ -1117,7 +1144,9 @@ function RechargeOrderRow({
) : null}
</td>
<td>
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
<span>
累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)}
</span>
<small>
应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints}
</small>
@@ -1313,14 +1342,10 @@ function formatMembershipChangeSummary(
return `${action} ${planPath}${cycle}`;
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付';
}
@@ -8,6 +8,7 @@ import {
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminCurrencyCents,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
@@ -333,7 +334,7 @@ export function AdminRechargeProductPage({
</AdminButton>
<small>{entry.productId}</small>
</td>
<td>{formatPrice(entry.priceCents)}</td>
<td>{formatAdminCurrencyCents(entry.priceCents)}</td>
<td>{formatProductContent(entry)}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
@@ -384,10 +385,6 @@ function formatProductContent(
return `${entry.pointsAmount} 泥点`;
}
function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
@@ -290,6 +290,32 @@ mod tests {
);
}
#[test]
fn legacy_raw_catalog_payload_defaults_optional_fields() {
// 旧 dev 代理 / 本地 fixture 直接返回裸目录(没有 `ok` 信封),且缺少
// `agentMode` / `protocol` / `unavailableModels`:必须仍能解析,并按 Codex +
// OpenAI Responses + 空不可用桶兼容。
let body = serde_json::json!({
"defaultModelId": "quality",
"models": [{ "id": "quality", "displayName": "高质量" }],
"revision": 9
})
.to_string();
let catalog = parse_catalog_payload(body.as_bytes()).expect("raw catalog should parse");
assert_eq!(catalog.default_model_id, "quality");
assert_eq!(catalog.revision, 9);
assert!(catalog.unavailable_models.is_empty());
assert_eq!(
catalog.models[0].agent_mode,
shared_contracts::llm::AgcAgentMode::Codex
);
assert_eq!(
catalog.models[0].protocol,
shared_contracts::llm::AgcModelProtocol::OpenAiResponses
);
}
#[tokio::test]
async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
@@ -1,7 +1,7 @@
import { resolveMembershipProductId } from '@genarrative/shared';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { type PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { resolveMembershipProductId } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct';
import type {
ProfileMembershipCycleKind,
ProfileMembershipPlan,
@@ -19,6 +19,7 @@ import {
getClientProfileWalletLedger,
redeemClientProfileRewardCode,
} from '../../services/accountHost';
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
import { useWalletStore } from '../../stores/useWalletStore';
export function useAccountWallet(currentUserId: string) {
@@ -363,6 +364,11 @@ export function useAccountWallet(currentUserId: string) {
applyRechargeContent(walletSnapshot, response.center);
if (response.order.status === 'paid') {
setNativeRechargePayment(null);
// 会员到账会改变账号可用的模型档位,但目录 revision 不变;显式通知
// ConversationModelSelect 重置已应用 revision 并重新读取,否则锁定列表保持陈旧。
if (response.order.kind === 'membership') {
notifyLlmConfigChanged();
}
void onWalletBalanceMayHaveChanged();
return;
}
@@ -84,7 +84,7 @@ afterEach(() => {
});
describe('ConversationModelSelect 锁定模型', () => {
it('不可用模型渲染为不可点、可聚焦的原因项', async () => {
it('不可用模型渲染为不可点、可悬停查看原因且不进入 Tab 序列', async () => {
mockConfig('basic-model', true);
render(<ConversationModelSelect disabled={false} />);
// 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。
@@ -96,8 +96,12 @@ describe('ConversationModelSelect 锁定模型', () => {
const planLocked = screen.getByText('专业模型').closest('[role="option"]');
expect(planLocked?.tagName).toBe('DIV');
expect(planLocked?.getAttribute('aria-disabled')).toBe('true');
expect(planLocked?.getAttribute('tabindex')).toBe('0');
// 非焦点项:不进入 Tab 序列,避免键盘在不可选项上停留。
expect(planLocked?.getAttribute('tabindex')).toBeNull();
expect(planLocked?.getAttribute('data-reason')).toBe('plan_required');
expect(planLocked?.getAttribute('aria-label')).toBe(
'专业模型:订阅计划不支持',
);
expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持');
const disabledLocked = screen
@@ -475,7 +475,8 @@ export function ConversationModelSelect({
) : null}
</button>
))}
{/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */}
{/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露,
且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */}
{unavailableModels.map((model) => {
const reasonLabel = unavailableModelReasonLabel(model.reason);
return (
@@ -485,7 +486,6 @@ export function ConversationModelSelect({
aria-selected={false}
aria-disabled="true"
aria-label={`${model.displayName}:${reasonLabel}`}
tabIndex={0}
className="conversation-model-menu-option conversation-model-menu-option-locked"
data-reason={model.reason}
data-tooltip={reasonLabel}
+3 -5
View File
@@ -11365,12 +11365,11 @@ button.design-workspace-tree__entry:hover,
position: relative;
cursor: not-allowed;
}
.conversation-model-menu-option-locked:hover,
.conversation-model-menu-option-locked:focus-visible {
.conversation-model-menu-option-locked:hover {
background: var(--platform-button-ghost-fill, #f3f4f6);
outline: none;
}
/* 锁定项的原因提示:仅 hover / 键盘 focus 出现(AGC 桌面端,无移动端适配)。 */
/* 锁定项的原因提示:仅 hover 出现;锁定项不进入 Tab 序列,不再有键盘 focus 态。 */
.conversation-model-menu-option-locked[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
@@ -11389,8 +11388,7 @@ button.design-workspace-tree__entry:hover,
white-space: normal;
pointer-events: none;
}
.conversation-model-menu-option-locked[data-tooltip]:hover::after,
.conversation-model-menu-option-locked[data-tooltip]:focus-visible::after {
.conversation-model-menu-option-locked[data-tooltip]:hover::after {
display: block;
}
.conversation-model-menu-loading {
@@ -13,23 +13,19 @@ beforeEach(() => {
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
});
test('模型目录缺少 agentMode 时默认使用 codex,同时保留官方 cc 路由', async () => {
invoke.mockResolvedValue({
test('模型目录直接透传 Rust typed command 的解析结果', async () => {
// agentMode / protocol 缺省已下沉到 Rust 的 load_game_creator_llm_models,
// 渲染层不再做二次归一化,这里断言原样透传即可。
const catalog = {
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'claude', displayName: 'Claude', agentMode: 'cc' },
],
revision: 1,
});
};
invoke.mockResolvedValue(catalog);
await expect(loadClientLlmModels()).resolves.toEqual({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量', agentMode: 'codex' },
{ id: 'claude', displayName: 'Claude', agentMode: 'cc' },
],
revision: 1,
});
await expect(loadClientLlmModels()).resolves.toEqual(catalog);
expect(invoke).toHaveBeenCalledWith('load_game_creator_llm_models');
});
@@ -755,3 +755,27 @@ test('keeps the applied catalog when the revision is unchanged', async () => {
expect(screen.queryByRole('option', { name: '视觉' })).toBeNull();
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
});
test('applies a changed account catalog after an explicit config-changed notification', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// 会员升降级只改变账号可用档位,目录 revision 不变;显式 notify 必须重置已应用 revision
// 并强制重新应用,否则锁定列表会保持陈旧(上一条用例锁住「仅靠 focus 不会更新」)。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量', agentMode: 'codex' },
{ id: 'vision', displayName: '视觉', agentMode: 'codex' },
],
revision: 1,
});
act(() => notifyLlmConfigChanged());
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await waitFor(() =>
expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull(),
);
expect(screen.queryByRole('option', { name: '快速' })).toBeNull();
});