feat: 前后端联调 — 任务创建 + 账户信息 + 预估花费 (0.0.18)
- task: device_id 改为必填参数,API 层 Omit 封装 + getDeviceId() 自动注入 - pricing.ts: Python 后端 calculate_cost 完整移植,六种定价模式 + 6 个 Bug 修复 (浮点冗余/维度匹配不一致/null 守卫/matrix 数据提取/unit_scale 等) - ModelInputForm: 三层兜底预估消费展示 + React Hooks 顺序修复 - AccountInfo: VIP 等级列表匹配,套餐详情展示(说明/周期/席位/到期) - 模型列表加密缓存 (safe-storage, stale-while-revalidate) - VIP API 模块 (激活码/等级/订单/模拟支付) - device_id 统一使用 UUID v4 持久化 (与 login/register 对齐)
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
clearStoredRefreshToken,
|
||||
initRefreshTokenStore,
|
||||
} from '@/services/auth-token';
|
||||
import {initModelCache} from '@/services/cache/model-cache';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
@@ -114,8 +115,8 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
|
||||
// 从加密存储恢复 token 到内存缓存
|
||||
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
|
||||
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
||||
if (aborted) return;
|
||||
|
||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
// 新增页面只需在这里加一行,无需创建独立文件
|
||||
// ============================================================
|
||||
|
||||
import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Drawer, Modal } from 'antd';
|
||||
import { EVENTS, off, on } from '@/utils/event-bus';
|
||||
import { FloatingPanel } from '@/components/ui/FloatingPanel';
|
||||
import { WechatWorkPage } from '@/pages/headers/WechatWorkPage';
|
||||
import { AccountInfo } from '@/pages/headers/AccountInfo.tsx';
|
||||
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {Drawer, Modal} from 'antd';
|
||||
import {EVENTS, off, on} from '@/utils/event-bus';
|
||||
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
|
||||
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -52,10 +52,10 @@ interface PageConfig {
|
||||
|
||||
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
|
||||
|
||||
function PlaceholderPage({ label }: { label: string }) {
|
||||
function PlaceholderPage({label}: { label: string }) {
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>{label} — 页面开发中</p>
|
||||
<div style={{padding: 8}}>
|
||||
<p style={{color: 'inherit', opacity: 0.6, margin: 0}}>{label} — 页面开发中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,20 +70,20 @@ function createPlaceholder(label: string): ComponentType {
|
||||
// ---------- target → 页面组件映射 ----------
|
||||
|
||||
const PAGE_MAP: Record<string, PageConfig> = {
|
||||
'/wechat-work': { component: WechatWorkPage, label: '企业微信' },
|
||||
'/compliance-assets': { label: '合规素材库' },
|
||||
'/wechat-work': {component: WechatWorkPage, label: '企业微信'},
|
||||
'/compliance-assets': {label: '合规素材库'},
|
||||
'/account': {
|
||||
component: AccountInfo,
|
||||
label: '账户',
|
||||
width: () => Math.min(window.innerWidth * 0.6, 720),
|
||||
height: () => Math.min(window.innerHeight * 0.6, 680),
|
||||
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||
},
|
||||
'/vip': { label: '开会员/激活' },
|
||||
'/recharge': { label: '充值积分' },
|
||||
'/billing': { label: '消费记录' },
|
||||
'/orders': { label: '订单明细' },
|
||||
'/projects': { label: '项目管理' },
|
||||
'/quota': { label: '查配额' },
|
||||
'/vip': {label: '开会员/激活'},
|
||||
'/recharge': {label: '充值积分'},
|
||||
'/billing': {label: '消费记录'},
|
||||
'/orders': {label: '订单明细'},
|
||||
'/projects': {label: '项目管理'},
|
||||
'/quota': {label: '查配额'},
|
||||
};
|
||||
|
||||
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
||||
@@ -118,7 +118,7 @@ export function PageDispatcher() {
|
||||
// 监听 OPEN_PAGE 事件
|
||||
useEffect(() => {
|
||||
const handler = (payload: unknown) => {
|
||||
const { target, modalType, label } = payload as PageEventPayload;
|
||||
const {target, modalType, label} = payload as PageEventPayload;
|
||||
|
||||
if (!target || !PAGE_MAP[target]) {
|
||||
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
||||
@@ -129,7 +129,7 @@ export function PageDispatcher() {
|
||||
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
||||
setActivePanel((prev) => {
|
||||
if (prev) return prev;
|
||||
return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
|
||||
return {id: `pnl-${nextIdRef.current++}`, target, modalType, label};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -152,8 +152,8 @@ export function PageDispatcher() {
|
||||
const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined;
|
||||
|
||||
// 容器默认尺寸(动态计算,视口缩放自动适配)
|
||||
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
|
||||
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
|
||||
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
|
||||
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -166,7 +166,7 @@ export function PageDispatcher() {
|
||||
onCancel={closePanel}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: true }}
|
||||
mask={{closable: true}}
|
||||
width={panelWidth ?? defaultModalWidth}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
// - 用户可读错误信息(errorMessage)
|
||||
// ============================================================
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useAppContext} from '@/components/AppProvider';
|
||||
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
|
||||
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||
|
||||
// ============================================================
|
||||
// Banner
|
||||
@@ -38,37 +39,63 @@ export function useBannerList() {
|
||||
// 模型
|
||||
// ============================================================
|
||||
|
||||
/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组)
|
||||
/** 模块级:单次会话中是否已完成首次 API 拉取 */
|
||||
let modelFirstFetchDone = false;
|
||||
|
||||
/** 模型列表数据 Hook(缓存优先 + 后台刷新)
|
||||
*
|
||||
* 策略:
|
||||
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading)
|
||||
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
|
||||
* 3. 拉取成功后将最新数据加密写入 localStorage
|
||||
*
|
||||
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||||
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||||
* category_name 字段手动分组。 */
|
||||
export function useModelList() {
|
||||
const {isLoggedIn} = useAppContext();
|
||||
|
||||
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
|
||||
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
|
||||
getCachedModels(),
|
||||
);
|
||||
|
||||
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||
() => ModelAPI.fetchModels({page: 1, page_size: 200}),
|
||||
async () => {
|
||||
const models = await ModelAPI.fetchModels({page: 1, page_size: 200});
|
||||
// 异步写入加密缓存(best-effort,不影响数据流)
|
||||
setCachedModels(models).catch(() => {});
|
||||
modelFirstFetchDone = true;
|
||||
setInstantData(models);
|
||||
return models;
|
||||
},
|
||||
[],
|
||||
{enabled: isLoggedIn, label: 'model-list'},
|
||||
);
|
||||
|
||||
// 合并策略:API 数据 > 缓存即时数据
|
||||
const mergedData = result.data ?? instantData;
|
||||
|
||||
// loading 仅在无任何数据时展示(缓存命中时不闪烁 loading)
|
||||
const loading = result.loading && mergedData === null;
|
||||
|
||||
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||||
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||||
const groups = new Map<string, ModelsListResponseBody[]>();
|
||||
result.data?.forEach((model: ModelsListResponseBody) => {
|
||||
mergedData?.forEach((model: ModelsListResponseBody) => {
|
||||
const cat: ModelCategoryStringEnum = model.model_type;
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat)!.push(model);
|
||||
});
|
||||
// 每组内按优先级降序排列
|
||||
// groups.forEach((models) => {
|
||||
// models.sort((a, b) => b.priority - a.priority);
|
||||
// });
|
||||
return groups;
|
||||
}, [result.data]);
|
||||
}, [mergedData]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
/** 合并后的模型数据(缓存优先) */
|
||||
data: mergedData,
|
||||
/** 仅在无缓存且正在加载时为 true */
|
||||
loading,
|
||||
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||||
groupedModels,
|
||||
};
|
||||
@@ -121,12 +148,12 @@ export function useTaskDetail(taskId: string | null | undefined) {
|
||||
// 提交任务
|
||||
// ============================================================
|
||||
|
||||
/** 提交任务 Mutation Hook */
|
||||
/** 提交任务 Mutation Hook(device_id 由 API 层自动注入,调用方无需传入) */
|
||||
export function useSubmitTask(options?: {
|
||||
onSuccess?: (data: TaskInfoItemResponseBody) => void;
|
||||
onError?: (err: Error) => void;
|
||||
}) {
|
||||
return useAsyncMutation<TaskInfoItemResponseBody, [CreatTaskRequestBody]>(
|
||||
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
|
||||
(body) => TaskAPI.submitTask(body),
|
||||
{
|
||||
label: 'submit-task',
|
||||
|
||||
@@ -1,210 +1,235 @@
|
||||
// ============================================================
|
||||
// AccountInfo — 账户信息展示
|
||||
// 数据来源:AuthAPI.getUserInfo() → GET /api/v1/auth/me
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {useEffect, useState, useCallback} from 'react';
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Button,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Result,
|
||||
Space,
|
||||
Tag,
|
||||
Divider,
|
||||
theme as antTheme,
|
||||
Card,
|
||||
Descriptions,
|
||||
Button,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Result,
|
||||
Space,
|
||||
Tag,
|
||||
Divider,
|
||||
theme as antTheme,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
CrownOutlined,
|
||||
BarChartOutlined,
|
||||
RightOutlined,
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
CrownOutlined,
|
||||
BarChartOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
|
||||
import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth';
|
||||
import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
/** 将分转为元,保留两位小数 */
|
||||
function centToYuan(cent: number): string {
|
||||
return (cent / 100).toFixed(2);
|
||||
return (cent / 100).toFixed(2);
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
function formatDate(date: Date | string | undefined): string {
|
||||
if (!date) return '—';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
if (!date) return '—';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'});
|
||||
}
|
||||
|
||||
export function AccountInfo() {
|
||||
const { token } = antTheme.useToken();
|
||||
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const {token} = antTheme.useToken();
|
||||
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
|
||||
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await AuthAPI.getUserInfo();
|
||||
setUserInfo(info);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '加载账户信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info: UserInfoBody = await AuthAPI.getUserInfo();
|
||||
const vips: VipLevelsItem[] = await VipAPI.getVipLevels();
|
||||
setVipsInfo(vips);
|
||||
setUserInfo(info);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '加载账户信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// ======== 加载态 ========
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 错误态 ========
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle={error || '未能获取账户信息'}
|
||||
extra={
|
||||
<Button type="primary" onClick={load}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 数据展示 ========
|
||||
const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 640 }}>
|
||||
{/* ======== 标题:账户 ======== */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space align="center" size={12}>
|
||||
<UserOutlined style={{ fontSize: 22, color: token.colorPrimary }} />
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{userInfo.display_name || userInfo.username}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
ID: {userInfo.id}
|
||||
</Text>
|
||||
</div>
|
||||
{userInfo.role !== 'user' && (
|
||||
<Tag color="gold" style={{ lineHeight: '18px', fontSize: 11 }}>
|
||||
{userInfo.role === 'admin' ? '管理员' : '超级管理员'}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
||||
充值
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ======== 积分栏 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: { padding: '16px 20px' },
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
||||
账户总积分
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: '0 0 12px 0' }}>
|
||||
{centToYuan(totalBalance)}
|
||||
<Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}>积分</Text>
|
||||
</Title>
|
||||
<Space size={32}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>主积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>赠送积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
||||
</div>
|
||||
{userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>折扣率</Text>
|
||||
<br />
|
||||
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* ======== 套餐信息 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: { padding: '16px 20px' },
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Space size={8}>
|
||||
<CrownOutlined style={{ color: token.colorWarning }} />
|
||||
<Text strong>当前套餐</Text>
|
||||
</Space>
|
||||
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
||||
续费 / 升级
|
||||
</Button>
|
||||
// ======== 加载态 ========
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{padding: 24}}>
|
||||
<Skeleton active paragraph={{rows: 6}} />
|
||||
</div>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
<Descriptions.Item label="套餐名称">
|
||||
<Text strong>{userInfo.subscription_plan || '免费版'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="到期时间">
|
||||
<Text type={new Date(userInfo.subscription_expires_at) < new Date() ? 'danger' : undefined}>
|
||||
{formatDate(userInfo.subscription_expires_at)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="席位上限">
|
||||
{userInfo.seat_limit || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ======== 今日统计 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: { padding: '16px 20px' },
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Space size={8} style={{ marginBottom: 12 }}>
|
||||
<BarChartOutlined style={{ color: token.colorSuccess }} />
|
||||
<Text strong>今日使用</Text>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13 }}>
|
||||
统计数据将在次日更新
|
||||
</Text>
|
||||
</Card>
|
||||
// ======== 错误态 ========
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle={error || '未能获取账户信息'}
|
||||
extra={
|
||||
<Button type="primary" onClick={load}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ======== 查看更多权益 ======== */}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{ color: token.colorPrimary }}>
|
||||
查看更多权益
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
// ======== 数据展示 ========
|
||||
const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent;
|
||||
|
||||
// 匹配当前 VIP 等级(vip_level_id → VipLevelsItem)
|
||||
const matchedVip: VipLevelsItem | null =
|
||||
userInfo.vip_level_id != null && vipsInfo
|
||||
? (vipsInfo.find((v) => v.id === userInfo.vip_level_id) ?? null)
|
||||
: null;
|
||||
|
||||
const isExpired = new Date(userInfo.software_expires_at) < new Date();
|
||||
return (
|
||||
<div style={{padding: 24, maxWidth: 640}}>
|
||||
{/* ======== 标题:账户 ======== */}
|
||||
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16}}>
|
||||
<Space align="center" size={12}>
|
||||
<UserOutlined style={{fontSize: 22, color: token.colorPrimary}} />
|
||||
<div>
|
||||
<Title level={4} style={{margin: 0}}>
|
||||
{userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id}
|
||||
{userInfo.account_type && (
|
||||
<Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}>
|
||||
{UserAccountTypeLabelMap[userInfo.account_type]}
|
||||
</Tag>
|
||||
)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{fontSize: 12}}>
|
||||
ID: {userInfo.id}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
||||
充值
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ======== 积分栏 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: {padding: '16px 20px'},
|
||||
}}
|
||||
style={{marginBottom: 16}}
|
||||
>
|
||||
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
|
||||
账户总积分
|
||||
</Text>
|
||||
<Title level={3} style={{margin: '0 0 12px 0'}}>
|
||||
{centToYuan(totalBalance)}
|
||||
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>积分</Text>
|
||||
</Title>
|
||||
<Space size={32}>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 12}}>主积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 12}}>赠送积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
||||
</div>
|
||||
{userInfo.discount_rate && userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 12}}>折扣率</Text>
|
||||
<br />
|
||||
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* ======== 套餐信息 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: {padding: '16px 20px'},
|
||||
}}
|
||||
style={{marginBottom: 16}}
|
||||
>
|
||||
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12}}>
|
||||
<Space size={8}>
|
||||
<CrownOutlined style={{color: token.colorWarning}} />
|
||||
<Text strong>当前套餐</Text>
|
||||
</Space>
|
||||
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
||||
续费 / 升级
|
||||
</Button>
|
||||
</div>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
<Descriptions.Item label="套餐名称">
|
||||
<Space size={8}>
|
||||
<Text strong>
|
||||
{matchedVip?.name || userInfo.subscription_plan || '免费版'}
|
||||
</Text>
|
||||
{matchedVip?.level != null && (
|
||||
<Tag color="gold" style={{fontSize: 11, lineHeight: '18px'}}>
|
||||
Lv.{matchedVip.level}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{matchedVip?.description && (
|
||||
<Descriptions.Item label="套餐说明">
|
||||
<Text type="secondary" style={{fontSize: 12}}>
|
||||
{matchedVip.description}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="套餐周期">
|
||||
{matchedVip?.duration_days ? `${matchedVip.duration_days} 天` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="到期时间">
|
||||
<Text type={isExpired ? 'danger' : undefined}>
|
||||
{formatDate(userInfo.software_expires_at)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="席位上限">
|
||||
{matchedVip?.seat_limit ?? userInfo.seat_limit ?? '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* ======== 今日统计 ======== */}
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
body: {padding: '16px 20px'},
|
||||
}}
|
||||
style={{marginBottom: 16}}
|
||||
>
|
||||
<Space size={8} style={{marginBottom: 12}}>
|
||||
<BarChartOutlined style={{color: token.colorSuccess}} />
|
||||
<Text strong>今日使用</Text>
|
||||
</Space>
|
||||
<Text type="secondary" style={{display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13}}>
|
||||
统计数据将在次日更新
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* ======== 查看更多权益 ======== */}
|
||||
<Divider style={{margin: '8px 0'}} />
|
||||
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{color: token.colorPrimary}}>
|
||||
查看更多权益
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import { OutputTypeMap } from '@/services/modules/task';
|
||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
|
||||
import {
|
||||
calculateCost,
|
||||
formatYuan,
|
||||
type ModelDefinition,
|
||||
type ModelPricingConfig,
|
||||
} from '@/utils/pricing';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -108,6 +114,19 @@ const EMPTY_ARRAY: never[] = [];
|
||||
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
||||
const EMPTY_OBJ: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* 从自由格式 JSON 中安全读取数字(兼容 int / float / string)
|
||||
*/
|
||||
function safeNum(v: unknown): number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = Number(v.trim());
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 主组件 ----------
|
||||
|
||||
export function ModelInputForm() {
|
||||
@@ -117,6 +136,9 @@ export function ModelInputForm() {
|
||||
const [form] = Form.useForm();
|
||||
const [taskTag, setTaskTag] = useState('');
|
||||
|
||||
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
|
||||
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 提交突变 Hook
|
||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||
@@ -151,6 +173,10 @@ export function ModelInputForm() {
|
||||
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||
form.setFieldsValue(initialValues);
|
||||
setTaskTag('');
|
||||
// 同步价格计算:切换模型时立即用默认值更新预估
|
||||
setCurrentValues(initialValues);
|
||||
} else if (!selectedModel) {
|
||||
setCurrentValues({});
|
||||
}
|
||||
}, [selectedModel?.id, initialValues, selectedModel, form]);
|
||||
|
||||
@@ -159,6 +185,7 @@ export function ModelInputForm() {
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initialValues);
|
||||
setTaskTag('');
|
||||
setCurrentValues(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
// ======== 提交 ========
|
||||
@@ -212,6 +239,57 @@ export function ModelInputForm() {
|
||||
}
|
||||
};
|
||||
|
||||
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
||||
|
||||
const estimatedCostDisplay: string = useMemo(() => {
|
||||
const rawCfg = selectedModel?.pricing_config;
|
||||
if (!rawCfg) return '消费';
|
||||
|
||||
const cfg = rawCfg as unknown as ModelPricingConfig;
|
||||
|
||||
// ═══ 第一层:完整动态计价(覆盖全部 6 种模式) ═══
|
||||
const modelDef: ModelDefinition = {
|
||||
id: selectedModel.id,
|
||||
name: selectedModel.name,
|
||||
pricing_config: cfg,
|
||||
};
|
||||
|
||||
const cost = calculateCost(modelDef, currentValues);
|
||||
if (cost !== null && cost !== undefined) {
|
||||
if (cost === 0) return '消费 ¥0(免费)';
|
||||
return `消费 ¥${formatYuan(cost)}`;
|
||||
}
|
||||
|
||||
// ═══ 第二层:兜底提取基础价格(calculateCost 失败时) ═══
|
||||
const rawObj = cfg.raw as Record<string, unknown> | undefined;
|
||||
|
||||
const fallbackPrice =
|
||||
safeNum(cfg.price) ??
|
||||
safeNum(cfg.price_cent != null ? cfg.price_cent / 100 : null) ??
|
||||
safeNum(rawObj?.price) ??
|
||||
safeNum(rawObj?.base_price);
|
||||
|
||||
const unitName =
|
||||
(rawObj?.unit_name as string) ||
|
||||
(rawObj?.unit as string) ||
|
||||
undefined;
|
||||
|
||||
if (fallbackPrice !== null) {
|
||||
const formatted = formatYuan(fallbackPrice);
|
||||
if (unitName) return `消费 ¥${formatted}/${unitName}`;
|
||||
if (fallbackPrice > 0) return `消费约 ¥${formatted}`;
|
||||
return `消费 ¥${formatted}`;
|
||||
}
|
||||
|
||||
if (cfg.price_per_k != null) return `消费 ¥${formatYuan(cfg.price_per_k)}/千字符`;
|
||||
if (cfg.price_per_k_cent != null) return `消费 ¥${formatYuan(cfg.price_per_k_cent / 100)}/千字符`;
|
||||
|
||||
const modeLabel = cfg.mode || cfg.price_type || cfg.billing_mode;
|
||||
if (modeLabel) return `消费(${modeLabel})`;
|
||||
|
||||
return '消费';
|
||||
}, [selectedModel, currentValues]);
|
||||
|
||||
// ======== 未选择模型 ========
|
||||
|
||||
if (!selectedModel) {
|
||||
@@ -233,7 +311,6 @@ export function ModelInputForm() {
|
||||
|
||||
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
|
||||
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
|
||||
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
|
||||
|
||||
const outputType = metaConfig.output_type as OutputTypeString | undefined;
|
||||
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
|
||||
@@ -241,11 +318,6 @@ export function ModelInputForm() {
|
||||
const group = uiConfig.group as string | undefined;
|
||||
const helpUrl = metaConfig.help_url as string | undefined;
|
||||
|
||||
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
|
||||
const price = pricingConfig.price as number | undefined;
|
||||
const priceType = pricingConfig.price_type as string | undefined;
|
||||
|
||||
// ======== 正常渲染 ========
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -322,6 +394,7 @@ export function ModelInputForm() {
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={(_, allValues) => setCurrentValues(allValues)}
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
@@ -401,9 +474,7 @@ export function ModelInputForm() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{priceType === 'SIMPLE' && price !== undefined
|
||||
? `消费 ¥${price}/${priceUnit || '次'}`
|
||||
: '消费'}
|
||||
{estimatedCostDisplay}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
51
src/services/cache/model-cache.ts
vendored
Normal file
51
src/services/cache/model-cache.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================
|
||||
// model-cache — 模型列表加密本地缓存
|
||||
//
|
||||
// 策略:
|
||||
// - 应用启动时 initModelCache() 从加密 localStorage 恢复缓存
|
||||
// - getCachedModels() 同步读取(内存缓存,高频安全)
|
||||
// - setCachedModels() 异步加密写入(API 返回后更新)
|
||||
// - 降级:非 Electron 环境回退到 localStorage 明文
|
||||
// ============================================================
|
||||
|
||||
import {initSafeStore, getSafeStore, setSafeStore} from '@/utils/safe-storage';
|
||||
import type {ModelsListResponseBody} from '@/services/modules/models';
|
||||
|
||||
/** 缓存键名(与 safe-storage 加密存储一致) */
|
||||
const MODEL_CACHE_KEY = 'ele-heixiu-model-cache';
|
||||
|
||||
/**
|
||||
* 启动时初始化模型缓存:从 localStorage 解密到内存缓存。
|
||||
* 应在 AppProvider 挂载时调用(在 useModelList 首次使用之前)。
|
||||
*/
|
||||
export async function initModelCache(): Promise<void> {
|
||||
await initSafeStore(MODEL_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步读取缓存的模型列表(内存缓存,不触发 IPC)。
|
||||
* 返回 null 表示无缓存(需从 API 拉取)。
|
||||
*/
|
||||
export function getCachedModels(): ModelsListResponseBody[] | null {
|
||||
const raw = getSafeStore(MODEL_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed as ModelsListResponseBody[];
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加密写入模型列表到 localStorage + 更新内存缓存。
|
||||
* API 返回后调用,best-effort(失败不抛异常,下次启动仍可用旧缓存)。
|
||||
*/
|
||||
export async function setCachedModels(models: ModelsListResponseBody[]): Promise<void> {
|
||||
try {
|
||||
await setSafeStore(MODEL_CACHE_KEY, JSON.stringify(models));
|
||||
} catch {
|
||||
// 写入失败静默,内存缓存已在 setSafeStore 内部更新
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,34 @@ const AuthUrlObj = {
|
||||
// 认证相关 DTO 传输模型
|
||||
// ============================================================
|
||||
|
||||
export const UserRoleTypeEnum = {
|
||||
User: "user",
|
||||
Admin: "admin",
|
||||
SuperAdmin: "superadmin",
|
||||
} as const;
|
||||
|
||||
export type UserRoleTypeValue = typeof UserRoleTypeEnum[keyof typeof UserRoleTypeEnum];
|
||||
|
||||
export const UserRoleTypeLabelMap: Record<UserRoleTypeValue, string> = {
|
||||
[UserRoleTypeEnum.User]: "用户账户",
|
||||
[UserRoleTypeEnum.Admin]: "管理账户",
|
||||
[UserRoleTypeEnum.SuperAdmin]: "超管账户"
|
||||
}
|
||||
|
||||
export const UserAccountTypeEnum = {
|
||||
Individual: 'individual',
|
||||
Company: 'company',
|
||||
Employee: 'employee',
|
||||
} as const;
|
||||
|
||||
export type UserAccountTypeValue = typeof UserAccountTypeEnum[keyof typeof UserAccountTypeEnum];
|
||||
|
||||
export const UserAccountTypeLabelMap: Record<UserAccountTypeValue, string> = {
|
||||
[UserAccountTypeEnum.Individual]: "个人用户",
|
||||
[UserAccountTypeEnum.Company]: "企业用户",
|
||||
[UserAccountTypeEnum.Employee]: "员工账号"
|
||||
};
|
||||
|
||||
/** 短信验证码请求体 */
|
||||
export interface SendSMSRequestBody {
|
||||
phone: string;
|
||||
@@ -53,10 +81,10 @@ export interface RegisterResponseBody {
|
||||
refresh_expires_in: 0;
|
||||
user_id: string;
|
||||
username: string;
|
||||
role: "user" | "admin" | "superadmin";
|
||||
role: UserRoleTypeValue;
|
||||
email: string;
|
||||
balance_cent: 0;
|
||||
account_type: "individual" | "company" | "employee";
|
||||
account_type: UserAccountTypeValue;
|
||||
display_name: string;
|
||||
company_id: string;
|
||||
real_name: string;
|
||||
@@ -81,10 +109,10 @@ export interface LoginResponseBody {
|
||||
refresh_expires_in: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
role: "user" | "admin" | "superadmin";
|
||||
role: UserRoleTypeValue;
|
||||
email: string;
|
||||
balance_cent: number;
|
||||
account_type: "individual" | "company" | "employee";
|
||||
account_type: UserAccountTypeValue;
|
||||
display_name: string;
|
||||
company_id: string;
|
||||
real_name: string;
|
||||
@@ -108,27 +136,27 @@ export interface RefreshTokenResponseBody {
|
||||
export interface UserInfoBody {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
email: string | null;
|
||||
balance_cent: number;
|
||||
gift_balance_cent: number;
|
||||
role: "user" | "admin" | "superadmin";
|
||||
role: UserRoleTypeValue;
|
||||
status: string;
|
||||
account_type: "individual" | "company" | "employee";
|
||||
display_name: string;
|
||||
real_name: string;
|
||||
account_type: UserAccountTypeValue;
|
||||
display_name: string | null;
|
||||
real_name: string | null;
|
||||
phone: string;
|
||||
company_id: string;
|
||||
discount_rate: number;
|
||||
company_id: string | null;
|
||||
discount_rate: number | null;
|
||||
subscription_plan: string;
|
||||
subscription_expires_at: Date;
|
||||
seat_limit: number;
|
||||
seat_limit: number | null;
|
||||
last_login_at: Date;
|
||||
created_at: Date;
|
||||
model_package_id: number;
|
||||
vip_level_id: number;
|
||||
software_expires_at: Date;
|
||||
referral_code: string;
|
||||
admin_permissions: string[];
|
||||
referral_code: string | null;
|
||||
admin_permissions: string[] | null;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequestBody {
|
||||
|
||||
@@ -115,7 +115,7 @@ export interface ModelsListResponseBody {
|
||||
status: string;
|
||||
priority: number;
|
||||
response_mode: string;
|
||||
pricing_config: Record<string, unknown>;
|
||||
pricing_config: Record<string, unknown> | null;
|
||||
param_schema: ParamSchemaItem[];
|
||||
quota_config: Record<string, unknown> | null;
|
||||
ui_config: Record<string, unknown>;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// ============================================================
|
||||
|
||||
import {get, post} from '../request';
|
||||
import {getDeviceId} from '../../utils/device';
|
||||
|
||||
// ============================================================
|
||||
// 基础枚举 / 字面量类型
|
||||
@@ -57,7 +58,7 @@ export interface TaskListRequestParams {
|
||||
export interface CreatTaskRequestBody {
|
||||
model_id: string;
|
||||
params: Record<string, string>;
|
||||
device_id?: string;
|
||||
device_id: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -210,9 +211,12 @@ export class TaskAPI {
|
||||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 提交新任务 */
|
||||
static submitTask(body: CreatTaskRequestBody): Promise<TaskInfoItemResponseBody> {
|
||||
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, body);
|
||||
/** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
|
||||
static submitTask(body: Omit<CreatTaskRequestBody, 'device_id'>): Promise<TaskInfoItemResponseBody> {
|
||||
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, {
|
||||
...body,
|
||||
device_id: getDeviceId(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取任务详情 */
|
||||
|
||||
154
src/services/modules/vip-api.ts
Normal file
154
src/services/modules/vip-api.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {post, get} from '../request';
|
||||
import {type UserInfoBody} from "@/services/modules/auth";
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
// ============================================================
|
||||
// VipApiUrl — VIP 模块路由常量
|
||||
// ============================================================
|
||||
|
||||
const VipApiUrl = {
|
||||
ActivateVipCode: "/api/v1/vip/activate",
|
||||
GetVipLevelsList: "/api/v1/vip/levels",
|
||||
VipOrders: "/api/v1/vip/orders",
|
||||
MockPay: "/api/v1/vip/mock-pay",
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// 激活码相关
|
||||
// ============================================================
|
||||
|
||||
export interface ActivateVipCodeRequestParams {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type ActivateVipCodeResponseBody = UserInfoBody;
|
||||
|
||||
// ============================================================
|
||||
// 账户类型常量
|
||||
// ============================================================
|
||||
|
||||
export const TargetAccountType = {
|
||||
Individual: "individual",
|
||||
Company: "company"
|
||||
} as const;
|
||||
|
||||
export type TargetAccountTypeValue = typeof TargetAccountType[keyof typeof TargetAccountType];
|
||||
export const TargetAccountTypeLabelMap: Record<TargetAccountTypeValue, string> = {
|
||||
[TargetAccountType.Individual]: "个人套餐",
|
||||
[TargetAccountType.Company]: "企业套餐"
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// VIP 等级
|
||||
// ============================================================
|
||||
|
||||
export interface VipLevelsItem {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
price_cent: number;
|
||||
duration_days: number;
|
||||
gift_balance_cent: number;
|
||||
model_package_id: number;
|
||||
description: Nullable<string>;
|
||||
image: Nullable<string>;
|
||||
is_active: number;
|
||||
sort_order: number;
|
||||
target_account_type: TargetAccountTypeValue;
|
||||
seat_limit: Nullable<number>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VIP 订单
|
||||
// ============================================================
|
||||
|
||||
/** VIP 订单状态常量 */
|
||||
export const VipOrderStatus = {
|
||||
Pending: "pending",
|
||||
Paid: "paid",
|
||||
Expired: "expired",
|
||||
Cancelled: "cancelled",
|
||||
} as const;
|
||||
|
||||
export type VipOrderStatusValue = typeof VipOrderStatus[keyof typeof VipOrderStatus];
|
||||
|
||||
export const VipOrderStatusLabelMap: Record<VipOrderStatusValue, string> = {
|
||||
[VipOrderStatus.Pending]: "待支付",
|
||||
[VipOrderStatus.Paid]: "已支付",
|
||||
[VipOrderStatus.Expired]: "已过期",
|
||||
[VipOrderStatus.Cancelled]: "已取消",
|
||||
};
|
||||
|
||||
/** POST /api/v1/vip/orders — 创建购买订单 */
|
||||
export interface CreateVipOrderRequest {
|
||||
/** 购买的 VIP 等级 ID */
|
||||
vip_level_id: number;
|
||||
/** 支付渠道:alipay | mock,默认 alipay */
|
||||
payment_channel?: string;
|
||||
}
|
||||
|
||||
/** 订单列表查询参数 */
|
||||
export interface VipOrderListParams {
|
||||
/** 按状态筛选,可选 */
|
||||
status?: VipOrderStatusValue;
|
||||
/** 页码,默认 1 */
|
||||
page?: number;
|
||||
/** 每页条数,默认 20 */
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/** VIP 订单读取 Schema */
|
||||
export interface VipOrderRead {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vip_level_id: number;
|
||||
amount_cent: number;
|
||||
status: VipOrderStatusValue;
|
||||
out_trade_no: string;
|
||||
/** 支付宝/微信交易号 */
|
||||
transaction_id: Nullable<string>;
|
||||
/** 支付链接(扫码支付用) */
|
||||
pay_url: Nullable<string>;
|
||||
/** 订单过期时间 */
|
||||
expires_at: Nullable<Date>;
|
||||
/** 支付完成时间 */
|
||||
paid_at: Nullable<Date>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VipAPI — VIP 模块 API 静态方法类
|
||||
// 调用方式:VipAPI.activateVipCode() / VipAPI.getVipLevels() 等
|
||||
// ============================================================
|
||||
|
||||
// TODO(human): 请确认 VipOrderStatus 的状态值和中文标签是否正确,根据实际后端逻辑补充/修改
|
||||
|
||||
export class VipAPI {
|
||||
/** 使用激活码激活 VIP 权益 */
|
||||
static async activateVipCode(data: ActivateVipCodeRequestParams): Promise<ActivateVipCodeResponseBody> {
|
||||
return await post<ActivateVipCodeResponseBody>(VipApiUrl.ActivateVipCode, data);
|
||||
}
|
||||
|
||||
/** 获取 VIP 等级列表(已登录时按账户类型过滤) */
|
||||
static async getVipLevels(): Promise<VipLevelsItem[]> {
|
||||
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList);
|
||||
}
|
||||
|
||||
/** 创建 VIP 套餐购买订单 */
|
||||
static async createVipOrder(data: CreateVipOrderRequest): Promise<VipOrderRead> {
|
||||
return await post<VipOrderRead>(VipApiUrl.VipOrders, data);
|
||||
}
|
||||
|
||||
/** 查询当前用户的 VIP 订单列表 */
|
||||
static async getVipOrders(params?: VipOrderListParams): Promise<VipOrderRead[]> {
|
||||
return await get<VipOrderRead[]>(VipApiUrl.VipOrders, params as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 模拟支付成功(仅开发测试用) */
|
||||
static async mockPay(orderId: string): Promise<VipOrderRead> {
|
||||
return await get<VipOrderRead>(`${VipApiUrl.MockPay}/${orderId}`);
|
||||
}
|
||||
}
|
||||
488
src/utils/pricing.ts
Normal file
488
src/utils/pricing.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
|
||||
export interface ModelPricingFloorTableConfig {
|
||||
field: string | null;
|
||||
mapping: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ModelPricingAddonConfig {
|
||||
field_key: string | null;
|
||||
operator: string;
|
||||
condition_value: unknown;
|
||||
condition_values: unknown[];
|
||||
price: number | null;
|
||||
unit_field: string | null;
|
||||
}
|
||||
|
||||
export interface ModelPricingMatrixRowConfig {
|
||||
price: number | null;
|
||||
price_cent: number | null;
|
||||
rate: number | null;
|
||||
price_per_unit: number | null;
|
||||
unit_price: number | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ModelPricingConfig {
|
||||
mode: string | null;
|
||||
price_type: string | null;
|
||||
billing_mode: string | null;
|
||||
price: number | null;
|
||||
price_per_k: number | null;
|
||||
price_cent: number | null;
|
||||
price_per_k_cent: number | null;
|
||||
field: string | null;
|
||||
unit_field: string | null;
|
||||
unit_scale: number | null;
|
||||
round_digits: number | null;
|
||||
dimensions: string[];
|
||||
rate_dimensions: string[];
|
||||
matrix: ModelPricingMatrixRowConfig[];
|
||||
conditional_addons: ModelPricingAddonConfig[];
|
||||
floor_table: ModelPricingFloorTableConfig | null;
|
||||
submit_warning_threshold: number | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
pricing_config: ModelPricingConfig | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** 对应 Python: _as_float(value) -> Optional[float] */
|
||||
function asFloat(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "number" && !Number.isNaN(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 对应 Python: _to_int(value, *, default=0) -> int */
|
||||
function toInt(value: unknown, defaultValue: number = 0): number {
|
||||
if (value === null || value === undefined) return defaultValue;
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
if (typeof value === "number") return Math.trunc(value);
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
if (!text) return defaultValue;
|
||||
const parsed = parseInt(text, 10);
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/** 对应 Python: _cent_to_yuan(value) -> float */
|
||||
function centToYuan(value: unknown): number {
|
||||
return toInt(value, 0) / 100.0;
|
||||
}
|
||||
|
||||
/** 对应 Python: _lookup_mapping_value(mapping, key, default="") -> object */
|
||||
function lookupMappingValue(
|
||||
mapping: Record<string, unknown> | null | undefined,
|
||||
key: unknown,
|
||||
defaultValue: unknown = "",
|
||||
): unknown {
|
||||
const keyStr = String(key);
|
||||
if (mapping == null) return defaultValue;
|
||||
if (keyStr in mapping) return mapping[keyStr];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/** 对应 Python: _match_dimensions(entry, dimensions, inputs) -> bool */
|
||||
function matchDimensions(
|
||||
entry: Record<string, unknown> | null | undefined,
|
||||
dimensions: string[],
|
||||
inputs: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (entry == null) return false;
|
||||
for (const dim of dimensions) {
|
||||
const entryValue = lookupMappingValue(entry, dim, null);
|
||||
if (entryValue === null || entryValue === undefined) continue;
|
||||
const inputVal = String(lookupMappingValue(inputs, dim, "")).trim().toLowerCase();
|
||||
const entryVal = String(entryValue).trim().toLowerCase();
|
||||
if (inputVal !== entryVal) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 matrix 行提取维度数据。
|
||||
*
|
||||
* API 返回的 matrix 行中,维度字段(如 resolution、quality)在顶层,
|
||||
* 而非嵌套在 raw 子对象内。本函数合并 raw(如果存在)和行自身字段,
|
||||
* 兼容两种数据格式,确保 matchDimensions 能正确匹配。
|
||||
*/
|
||||
function matrixRowData(row: ModelPricingMatrixRowConfig): Record<string, unknown> {
|
||||
return { ...(row.raw ?? {}), ...row as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 金额格式化函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。
|
||||
* 对应 workbench.py / billing_dialog.py: _format_yuan(value: float) -> str
|
||||
*/
|
||||
export function formatYuan(value: number): string {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
// .toFixed(2) → 去除尾部零 → 去除尾部小数点
|
||||
return rounded.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分(cent)转换为元并格式化。
|
||||
* 对应 workbench.py / billing_dialog.py / account_dialog.py: _format_yuan_from_cent(cent: int) -> str
|
||||
*/
|
||||
export function formatYuanFromCent(cent: number): string {
|
||||
return formatYuan(cent / 100.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(分→元),带符号和¥前缀。
|
||||
* 对应 task_table.py: _format_money_cent(value: object) -> str
|
||||
*/
|
||||
export function formatMoneyCent(value: unknown): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "boolean") return String(value);
|
||||
|
||||
let cent: number;
|
||||
if (typeof value === "number") {
|
||||
cent = Math.trunc(value);
|
||||
} else if (typeof value === "string") {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) return value;
|
||||
cent = parsed;
|
||||
} else {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const sign = cent < 0 ? "-" : "";
|
||||
const centAbs = Math.abs(cent);
|
||||
const yuan = centAbs / 100.0;
|
||||
const text = yuan.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${sign}¥${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分→元,带符号和¥前缀。
|
||||
* 对应 admin_console_dialog.py: _format_cent(cent: int) -> str
|
||||
*/
|
||||
export function formatCent(cent: number): string {
|
||||
const sign = cent < 0 ? "-" : "";
|
||||
const valueAbs = Math.abs(Math.trunc(cent));
|
||||
const yuan = valueAbs / 100.0;
|
||||
const text = yuan.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${sign}¥${text}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 服务端计价逻辑
|
||||
// ============================================================
|
||||
|
||||
/** 对应 Python: _calculate_server_simple(pricing, inputs) -> float */
|
||||
function calculateServerSimple(
|
||||
pricing: ModelPricingConfig,
|
||||
inputs: Record<string, unknown>,
|
||||
): number {
|
||||
const priceCent = pricing.price_cent ?? 0;
|
||||
const unitField = (pricing.unit_field ?? "").trim();
|
||||
if (!unitField) {
|
||||
return priceCent / 100.0;
|
||||
}
|
||||
const amount = asFloat(inputs[unitField]);
|
||||
const amountValue = amount === null ? 1.0 : amount;
|
||||
// (priceCent * amountValue) / 100 * 100 在 IEEE 754 下可能产生
|
||||
// 浮点累积误差(例如 0.03*100→2.999...),直接 round 分子避免
|
||||
return Math.round(priceCent * amountValue) / 100;
|
||||
}
|
||||
|
||||
/** 对应 Python: _calculate_server_multi_dimension(pricing, inputs) -> Optional[float] */
|
||||
function calculateServerMultiDimension(
|
||||
pricing: ModelPricingConfig,
|
||||
inputs: Record<string, unknown>,
|
||||
): number | null {
|
||||
const { dimensions, matrix } = pricing;
|
||||
if (!matrix || matrix.length === 0) return null;
|
||||
|
||||
for (const row of matrix) {
|
||||
if (matchDimensions(matrixRowData(row), dimensions, inputs)) {
|
||||
return centToYuan(row.price_cent);
|
||||
}
|
||||
}
|
||||
|
||||
const firstRow = matrix[0];
|
||||
if (firstRow.price_cent !== null && firstRow.price_cent !== undefined) {
|
||||
return centToYuan(firstRow.price_cent);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 对应 Python: _calculate_server_billing_adapter(pricing, inputs) -> Optional[float] */
|
||||
function calculateServerBillingAdapter(
|
||||
pricing: ModelPricingConfig,
|
||||
inputs: Record<string, unknown>,
|
||||
): number | null {
|
||||
const billingMode = (pricing.billing_mode ?? "rate").trim().toLowerCase();
|
||||
let baseCostCent = 0;
|
||||
|
||||
if (billingMode === "per_k_chars") {
|
||||
const field = (pricing.field ?? "prompt").trim() || "prompt";
|
||||
const text = String(inputs[field] ?? "");
|
||||
const pricePerKCent = pricing.price_per_k_cent ?? 0;
|
||||
baseCostCent = Math.floor((text.length + 999) / 1000) * pricePerKCent;
|
||||
} else {
|
||||
const matrix = pricing.matrix;
|
||||
if (!matrix || matrix.length === 0) return null;
|
||||
|
||||
const dimensions = pricing.dimensions;
|
||||
const unitField = (pricing.unit_field ?? "duration").trim() || "duration";
|
||||
const unitsRaw = asFloat(inputs[unitField]);
|
||||
let units = unitsRaw === null ? 1.0 : unitsRaw;
|
||||
|
||||
// 底表修正
|
||||
const floorTable = pricing.floor_table;
|
||||
if (floorTable) {
|
||||
const floorField = (floorTable.field ?? "").trim();
|
||||
if (floorField === unitField) {
|
||||
const floorValue = asFloat(floorTable.mapping[String(Math.trunc(units))]);
|
||||
if (floorValue !== null && units < floorValue) {
|
||||
units = floorValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rate: number | null = null;
|
||||
for (const row of matrix) {
|
||||
if (matchDimensions(matrixRowData(row), dimensions, inputs)) {
|
||||
rate = row.rate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rate === null) {
|
||||
rate = matrix[0].rate;
|
||||
}
|
||||
if (rate === null || rate === undefined) return null;
|
||||
|
||||
baseCostCent = Math.round(rate * units * 100);
|
||||
}
|
||||
|
||||
// 条件附加费
|
||||
for (const addon of pricing.conditional_addons ?? []) {
|
||||
const fieldKey = (addon.field_key ?? "").trim();
|
||||
const operator = addon.operator;
|
||||
let matched = false;
|
||||
|
||||
if (operator === "PRESENT") {
|
||||
matched = Boolean(inputs[fieldKey]);
|
||||
} else if (operator === "EQ") {
|
||||
matched = String(inputs[fieldKey] ?? "") === String(addon.condition_value ?? "");
|
||||
} else if (operator === "IN") {
|
||||
const conditionValues = (addon.condition_values ?? []).map((v) => String(v));
|
||||
matched = conditionValues.includes(String(inputs[fieldKey] ?? ""));
|
||||
}
|
||||
|
||||
if (!matched) continue;
|
||||
|
||||
const addonPrice = addon.price;
|
||||
if (addonPrice === null || addonPrice === undefined) continue;
|
||||
|
||||
const addonUnitField = String(
|
||||
addon.unit_field ?? pricing.unit_field ?? "duration",
|
||||
).trim() || "duration";
|
||||
const addonUnitsRaw = asFloat(inputs[addonUnitField]);
|
||||
const addonUnits = addonUnitsRaw === null ? 1.0 : addonUnitsRaw;
|
||||
|
||||
baseCostCent += Math.round(addonPrice * addonUnits * 100);
|
||||
}
|
||||
|
||||
return baseCostCent / 100.0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主计价入口
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 根据模型定义和输入参数,动态计算消耗金额(单位:元)。
|
||||
* 对应 Python: calculate_cost(model: ModelDefinition, inputs: Mapping) -> Optional[float]
|
||||
*/
|
||||
export function calculateCost(
|
||||
model: ModelDefinition,
|
||||
inputs: Record<string, unknown>,
|
||||
): number | null {
|
||||
const pricing = model.pricing_config;
|
||||
if (!pricing) return null;
|
||||
|
||||
// ── 服务端定价 (price_type 存在且 mode 不存在) ──
|
||||
if (pricing.price_type && !pricing.mode) {
|
||||
const priceType = (pricing.price_type ?? "SIMPLE").trim().toUpperCase();
|
||||
switch (priceType) {
|
||||
case "SIMPLE":
|
||||
return calculateServerSimple(pricing, inputs);
|
||||
case "MULTI_DIMENSION":
|
||||
return calculateServerMultiDimension(pricing, inputs);
|
||||
case "BILLING_ADAPTER":
|
||||
return calculateServerBillingAdapter(pricing, inputs);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 客户端定价 ──
|
||||
const mode = pricing.mode;
|
||||
|
||||
if (mode === "fixed") {
|
||||
if (pricing.price !== null && pricing.price !== undefined) {
|
||||
return pricing.price;
|
||||
}
|
||||
return Number(pricing.raw?.price ?? 0);
|
||||
}
|
||||
|
||||
if (mode === "per_k_chars") {
|
||||
const fieldId = String(pricing.field ?? "text");
|
||||
const rawText = inputs[fieldId] ?? "";
|
||||
const text = rawText !== null && rawText !== undefined ? String(rawText) : "";
|
||||
const pricePerK =
|
||||
pricing.price_per_k ?? Number(pricing.raw?.price_per_k ?? 0);
|
||||
return (text.length / 1000) * pricePerK;
|
||||
}
|
||||
|
||||
if (mode === "rate_matrix") {
|
||||
const unitField = String(pricing.unit_field ?? "duration");
|
||||
|
||||
const unitRaw = inputs[unitField];
|
||||
const unitValue = asFloat(unitRaw);
|
||||
if (unitValue === null) return null;
|
||||
|
||||
const { rate_dimensions, matrix } = pricing;
|
||||
if (!rate_dimensions || rate_dimensions.length === 0 || !matrix || matrix.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of matrix) {
|
||||
if (matchDimensions(matrixRowData(entry), rate_dimensions, inputs)) {
|
||||
let rate = entry.rate;
|
||||
if (rate === null || rate === undefined) rate = entry.price_per_unit;
|
||||
if (rate === null || rate === undefined) rate = entry.unit_price;
|
||||
if (rate === null || rate === undefined) rate = 0.0;
|
||||
|
||||
const rawRound = pricing.round_digits ?? pricing.raw?.round ?? 2;
|
||||
const roundDigits =
|
||||
typeof rawRound === "number" || (typeof rawRound === "string" && String(rawRound).trim())
|
||||
? Number(rawRound)
|
||||
: 2;
|
||||
return (
|
||||
Math.round(unitValue * rate * Math.pow(10, roundDigits)) /
|
||||
Math.pow(10, roundDigits)
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === "matrix") {
|
||||
const { dimensions, matrix } = pricing;
|
||||
// 与 MULTI_DIMENSION 对齐:dimensions 为空时 matchDimensions 自动全匹配
|
||||
if (!matrix || matrix.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of matrix) {
|
||||
if (matchDimensions(matrixRowData(entry), dimensions, inputs)) {
|
||||
// price_cent 优先:Python 端矩阵定价统一走 cent 分度值,
|
||||
// entry.price 可能为衍生/展示值,price_cent 才是计价基准
|
||||
if (entry.price_cent !== null && entry.price_cent !== undefined) {
|
||||
return entry.price_cent / 100;
|
||||
}
|
||||
if (entry.price !== null && entry.price !== undefined) {
|
||||
return entry.price;
|
||||
}
|
||||
return Number(entry.raw?.price ?? 0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 定价依赖字段收集
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 收集定价计算所依赖的输入字段名集合。
|
||||
* 对应 Python: collect_pricing_dependent_fields(model: ModelDefinition) -> set[str]
|
||||
*/
|
||||
export function collectPricingDependentFields(model: ModelDefinition): Set<string> {
|
||||
const pricing = model.pricing_config;
|
||||
if (!pricing) return new Set();
|
||||
|
||||
const fields = new Set<string>();
|
||||
const priceType = (pricing.price_type ?? "").trim().toUpperCase();
|
||||
const mode = (pricing.mode ?? "").trim().toLowerCase();
|
||||
const billingMode = (pricing.billing_mode ?? "").trim().toLowerCase();
|
||||
|
||||
if (priceType === "SIMPLE") {
|
||||
const unitField = (pricing.unit_field ?? "").trim();
|
||||
if (unitField) fields.add(unitField);
|
||||
} else if (priceType === "MULTI_DIMENSION") {
|
||||
for (const dim of pricing.dimensions) {
|
||||
const dimText = String(dim).trim();
|
||||
if (dimText) fields.add(dimText);
|
||||
}
|
||||
} else if (priceType === "BILLING_ADAPTER") {
|
||||
if (billingMode === "per_k_chars") {
|
||||
const field = String(pricing.field ?? "prompt").trim() || "prompt";
|
||||
fields.add(field);
|
||||
} else {
|
||||
const unitField = String(pricing.unit_field ?? "duration").trim() || "duration";
|
||||
fields.add(unitField);
|
||||
|
||||
const floorTable = pricing.floor_table;
|
||||
if (floorTable) {
|
||||
const floorField = String(floorTable.field ?? "").trim();
|
||||
if (floorField) fields.add(floorField);
|
||||
}
|
||||
for (const dim of pricing.dimensions) {
|
||||
const dimText = String(dim).trim();
|
||||
if (dimText) fields.add(dimText);
|
||||
}
|
||||
}
|
||||
for (const addon of pricing.conditional_addons ?? []) {
|
||||
const fieldKey = String(addon.field_key ?? "").trim();
|
||||
if (fieldKey) fields.add(fieldKey);
|
||||
const addonUnitField = String(
|
||||
addon.unit_field ?? pricing.unit_field ?? "duration",
|
||||
).trim();
|
||||
if (addonUnitField) fields.add(addonUnitField);
|
||||
}
|
||||
} else if (mode === "per_k_chars") {
|
||||
const field = String(pricing.field ?? "text").trim() || "text";
|
||||
fields.add(field);
|
||||
} else if (mode === "rate_matrix") {
|
||||
const unitField = String(pricing.unit_field ?? "duration").trim() || "duration";
|
||||
fields.add(unitField);
|
||||
for (const dim of pricing.rate_dimensions) {
|
||||
const dimText = String(dim).trim();
|
||||
if (dimText) fields.add(dimText);
|
||||
}
|
||||
} else if (mode === "matrix") {
|
||||
for (const dim of pricing.dimensions) {
|
||||
const dimText = String(dim).trim();
|
||||
if (dimText) fields.add(dimText);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
Reference in New Issue
Block a user