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();
});
@@ -57,6 +57,7 @@
- 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。
- 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。
- 修改 SpacetimeDB schema 时遵守字段追加/default 约束,同步 migration、表目录、生成绑定,并运行 schema 检查;删除、改名、重排或改类型前先确认迁移计划。
- `shared-contracts` 响应 DTO 只增不删:新增字段一律可选(`Option`,或带安全 `#[serde(default ...)]`),身份 / 状态字段保持必填;删除、改名、改类型或改必填属性前先确认版本化计划,不保留仅服务旧客户端的 legacy shim。详见后端架构文档「shared-contracts DTO 变更规则」。
- 日志不递归输出完整配置、应用状态或 provider client;新增字段默认不进入安全摘要。所有 provider 配置类型(`LlmConfig`、`OssConfig`、`WechatConfig`、`WechatPayConfig`、`MattingConfig`、`VolcengineSpeechConfig`、`Hyper3dSettings`、`VectorEngineImageSettings`、`VectorEngineAudioSettings`)都必须手写脱敏 `Debug`:只输出枚举、数值、布尔、有界标识与 `<redacted>` 占位,凭据、私钥、路径与 URL 一律不递归格式化,并各带一条哨兵值回归用例(`cargo test -p platform-* debug`)。新增同类配置必须照此办理,不允许直接 `derive(Debug)`。
- HTTP 横切能力集中在 Axum/Tower 中间件:正常与降级路由复用追踪层;指标与 trace 使用 `MatchedPath` 模板及固定兜底,不把请求 ID、实际资源 ID 或 query 放入指标标签。在途请求通过 RAII guard 覆盖 Future 取消与 panic unwind;请求执行和响应体存活分别计量,不能把 handler 耗时当作 SSE 全生命周期。
- 业务依赖在组合根显式装配,Axum `FromRef` 只抽取可浅拷贝的窄能力。项目元数据与 External API 鉴权不持有完整 `AppState`,测试经相同接口注入替代依赖。集中鉴权仍保留方法级 fallback、公开入口、MCP 和 body limit 顺序;Provider span 跳过完整参数,不隐藏计费、重试、幂等或事务规则。
@@ -151,6 +151,14 @@ npm run check:server-rs-ddd
本地联调和人工排障不要使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`;发布目标显式传 `--server` / `--server-url`。
## shared-contracts DTO 变更规则
1. 面向前端 / AGC 壳的**响应** DTO 只增不删:新增字段一律可选——`Option<T>`,或非 `Option` 时带 `#[serde(default ...)]` 且默认值安全(空集合 / `0` / `false` / 最小合法值)。这样「客户端先于服务端发布」的滚动升级不会因为旧服务端缺字段而整包解析失败;这是兼容未来版本的手段,不是给当前旧客户端保留 legacy shim。
2. 身份与状态类字段(例如 `ProfileMembershipResponse.plan` / `status`、`enabled`)保持必填,不给默认值:fail-open 会把付费会员误显示成非会员或反过来,静默错误比整包失败更难排查。
3. 未知 token 值继续走 `deserialize_forward_compat_*` 兜底;它与「缺字段默认值」是两条独立路径,都要有契约测试(旧服务端缺字段、新服务端多发未知取值各一条)。
4. 删除字段、改名、改类型或改必填属性属于破坏性变更,必须先确认版本化 / 迁移计划并更新权威文档;除非明确要求新旧客户端混跑,否则不为旧客户端保留 legacy shim。
5. 响应 DTO 的 serde 属性不影响 ts-rs 绑定;字段集合或类型变化必须重新生成绑定并运行 `npm run check:generated-bindings`。
## 账户充值数据契约
1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。
@@ -13,14 +13,13 @@ import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png';
import { formatMudPointCount } from '../../utils/format';
import {
formatMudPointResetAt,
type ProfileMudPointBalanceView,
resolveProfileMudPointBalanceView,
shouldShowProfileDailyFreePool,
} from '../../utils/mudPoints';
export type PlatformMudPointWalletEntryProps = {
balance: number | null;
breakdown?: ProfileMudPointBalance | ProfileMudPointBalanceView | null;
breakdown?: ProfileMudPointBalance | null;
isLoading?: boolean;
error?: string | null;
variant?: 'desktop' | 'mobile' | 'editor';
@@ -530,6 +530,50 @@ describe('PlatformProfileRechargeModal', () => {
).toHaveProperty('disabled', true);
});
test('泥点订单提交中时会员结算入口禁用', async () => {
const user = userEvent.setup();
const renderModal = (submittingProductId: string | null) => (
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
membershipPlans: [STARTER_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={submittingProductId}
nativePayment={null}
membershipQuote={null}
isLoadingMembershipQuote={false}
membershipQuoteError={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onRequestMembershipQuote={vi.fn()}
onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>
);
const { rerender } = render(renderModal(null));
await user.click(
screen.getByRole('button', { name: 'Starter 月付 开通会员' }),
);
// 泥点订单提交中:会员结算 CTA 同样禁用,避免宿主守卫静默丢弃提交后直接关弹层。
rerender(renderModal('points_60'));
expect(
within(screen.getByRole('dialog', { name: '确认订单' })).getByRole(
'button',
{ name: '支付 ¥39' },
),
).toHaveProperty('disabled', true);
});
test('shows the empty state when the recharge tab has no point product', async () => {
const user = userEvent.setup();
@@ -1074,7 +1074,8 @@ function PlatformRechargeCheckoutDialog({
}
const canSubmit =
Boolean(plan) &&
!submitting &&
// 与充点分支一致:任一商品在途都禁用提交,避免静默无操作后直接关弹层。
!submittingProductId &&
(!isUpgrade || Boolean(quoteForTarget)) &&
amountCents > 0;
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'vitest';
import { formatAdminDate, formatAdminDateTime } from './AdminFormat';
import {
formatAdminCurrencyCents,
formatAdminDate,
formatAdminDateTime,
} from './AdminFormat';
describe('formatAdminDateTime', () => {
test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => {
@@ -44,3 +48,11 @@ describe('formatAdminDate', () => {
expect(formatAdminDate('1791008678.817257Z')).not.toBe('-');
});
});
describe('formatAdminCurrencyCents', () => {
test('分转成带两位小数的元', () => {
expect(formatAdminCurrencyCents(0)).toBe('¥0.00');
expect(formatAdminCurrencyCents(600)).toBe('¥6.00');
expect(formatAdminCurrencyCents(1234)).toBe('¥12.34');
});
});
@@ -54,6 +54,11 @@ export function formatAdminDateTime(
}
}
/** 后台统一的金额展示:分 → `¥12.34`;避免各后台页面各自实现导致格式漂移。 */
export function formatAdminCurrencyCents(cents: number): string {
return `¥${(cents / 100).toFixed(2)}`;
}
/** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */
export function formatAdminDate(
value: string | number | Date | null | undefined,
@@ -19,7 +19,11 @@ export type {
AdminFormRowProps,
} from './AdminField';
export { AdminField, AdminFieldGroup, AdminFormRow } from './AdminField';
export { formatAdminDate, formatAdminDateTime } from './AdminFormat';
export {
formatAdminCurrencyCents,
formatAdminDate,
formatAdminDateTime,
} from './AdminFormat';
export type {
AdminInfoItemProps,
AdminInfoListEntry,
@@ -15,6 +15,6 @@ plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPr
*/
modelAccess: ProfileMembershipModelAccessToken,
/**
* 并发上限;`128` 表示不设上限。
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
*/
concurrentJobLimit: number, enabled: boolean, };
@@ -12,7 +12,7 @@ plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents:
*/
modelAccess: ProfileMembershipModelAccessToken,
/**
* 并发上限;`128` 表示不设上限。
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
*/
concurrentJobLimit: number,
/**
@@ -5,7 +5,7 @@ import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToke
export type ProfileMembershipResponse = {
/**
* 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。
* 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。
*/
status: ProfileMembershipStatusToken,
/**
+1
View File
@@ -1,4 +1,5 @@
export * from './components';
export * from './components/PlatformProfileRechargeModal/membershipProduct';
export * from './contracts/auth';
export type * from './contracts/clientDownload';
export * from './contracts/common';
+8 -2
View File
@@ -454,10 +454,14 @@ const userDetail = {
createdAt: iso(60 * 24 * 120),
lastLoginAt: iso(30),
wallet: {
balance: 1860,
permanentPoints: 1200,
userId: 'u_1001',
totalBalance: 1860,
spendableBalance: 1660,
dailyFreePoints: 0,
monthlyPoints: 0,
permanentPoints: 1200,
heldPoints: 200,
refundDebtPoints: 0,
walletFrozen: false,
manualFrozen: false,
refundDebtFrozen: false,
@@ -771,6 +775,8 @@ function route(pathname, method, url, requestBody = '') {
periodPoints,
modelAccess,
concurrentJobLimit,
// 与后端目录单一真相源 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN` 对齐:运营推荐 Plus。
recommended: planId === 'plus',
unlimitedConcurrency:
concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD,
enabled,

Some files were not shown because too many files have changed in this diff Show More