## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
|
||
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;
|
||
}
|