Merge branch 'feat/pricing-plan-fix' into feat/pricing-plan
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user