Files
shop-user-trade/docs/superpowers/specs/pricing-config-preview.html
T
19904408334andClaude 5d2b01e6f3 docs(pricing): 模型规则重设计(视频/推理/音频/图片自适应 schema + 预览)
- §4.2 model 规则改为模型类型自适应:unit 7 种(per_1K/per_1M/per_1/per_second/per_minute/per_hour/per_char)
  + tiered 阶梯(分档不累加)+ match 维度(mediaType/thinking/outputAudio/outputResolution/inputLength)+ price 收敛 input/output/cacheHit 与 unitPrice
- §5 计算器:token 计算器 + 单位计算器;ChargeUsage 扩展模型维度
- §7 枚举透传 modelType(100推理/200图片/300音频/600视频)
- preview HTML 重写模型编辑器为四类自适应 + 示例填充

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 17:06:45 +08:00

561 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>计价配置管理 · 原型预览</title>
<style>
:root{
--primary:#409eff; --primary-light:#ecf5ff; --danger:#f56c6c; --success:#67c23a;
--text:#303133; --text2:#606266; --text3:#909399; --border:#dcdfe6; --bg:#f5f7fa; --card:#fff;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;background:var(--bg);color:var(--text);font-size:14px}
.topbar{background:#fff;border-bottom:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;justify-content:space-between}
.topbar h1{font-size:18px;font-weight:600}
.topbar .tag{font-size:12px;color:var(--text3);margin-left:12px}
.topbar .badge-proto{background:var(--primary-light);color:var(--primary);border-radius:4px;padding:3px 8px;font-size:12px}
.layout{display:flex;gap:16px;padding:16px 24px;align-items:flex-start}
.panel{background:var(--card);border:1px solid var(--border);border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.04)}
/* 左侧枚举列表 */
.sidebar{width:300px;flex-shrink:0;overflow:hidden}
.sidebar .p-head{padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;display:flex;justify-content:space-between;align-items:center}
.sidebar .p-head .hint{font-size:12px;color:var(--text3);font-weight:400}
.grp-title{padding:10px 16px 4px;font-size:12px;color:var(--text3)}
.subj{display:flex;align-items:center;gap:8px;padding:10px 16px;cursor:pointer;border-left:3px solid transparent;transition:.15s}
.subj:hover{background:var(--primary-light)}
.subj.active{background:var(--primary-light);border-left-color:var(--primary)}
.subj .name{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.subj .type-badge{font-size:11px;border-radius:3px;padding:1px 6px;color:#fff;flex-shrink:0}
.subj .conf-badge{font-size:11px;border-radius:3px;padding:1px 6px;flex-shrink:0}
.conf-yes{background:#f0f9eb;color:var(--success)} .conf-no{background:#fef0f0;color:var(--danger)}
.t-reason{background:#e6a23c}.t-image{background:#00bcd4}.t-audio{background:#67c23a}.t-video{background:#909399}.t-workflow{background:#8e44ad}.t-business{background:#e74c3c}
.mode-chip{font-size:11px;background:#f4f4f5;color:var(--text2);border-radius:3px;padding:1px 5px}
/* 右侧编辑区 */
.editor{flex:1;min-width:0;overflow:hidden}
.e-head{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.e-head .st{color:var(--text3);font-size:12px}
.e-head .modes{margin-top:4px}
.e-body{padding:20px;display:flex;gap:20px;flex-wrap:wrap}
.col{flex:1;min-width:380px}
.card{border:1px solid var(--border);border-radius:6px;margin-bottom:16px;overflow:hidden}
.card .c-head{background:#fafafa;padding:10px 14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;font-size:14px}
.card .c-head .st{font-size:12px;color:var(--text3);font-weight:400}
.card .c-body{padding:14px}
.f-row{display:flex;gap:12px;margin-bottom:10px;align-items:center;flex-wrap:wrap}
.f{display:flex;flex-direction:column;gap:4px}
.f label{font-size:12px;color:var(--text2)}
.f input,.f select{height:30px;border:1px solid var(--border);border-radius:4px;padding:0 8px;font-size:13px;outline:none;background:#fff}
.f input:focus,.f select:focus{border-color:var(--primary)}
.f input.wide{width:220px}
.f input.num{width:110px}
.tbl{width:100%;border-collapse:collapse}
.tbl th,.tbl td{border:1px solid var(--border);padding:5px 8px;font-size:13px;text-align:left}
.tbl th{background:#fafafa;font-weight:500;color:var(--text2)}
.tbl input{width:100%;border:none;outline:none;font-size:13px;padding:2px}
.tbl input:focus{background:var(--primary-light)}
.btn{height:30px;padding:0 14px;border-radius:4px;border:1px solid var(--border);background:#fff;color:var(--text);cursor:pointer;font-size:13px}
.btn:hover{color:var(--primary);border-color:var(--primary)}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.primary:hover{background:#66b1ff}
.btn.mini{height:24px;padding:0 8px;font-size:12px}
.btn.danger:hover{color:var(--danger);border-color:var(--danger)}
.hint{font-size:12px;color:var(--text3)}
.switch{position:relative;display:inline-block;width:40px;height:20px}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;transition:.2s;border-radius:10px}
.slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#fff;transition:.2s;border-radius:50%}
input:checked+.slider{background:var(--primary)}
input:checked+.slider:before{transform:translateX(20px)}
input:disabled+.slider{background:#e4e7ed;cursor:not-allowed}
.json{width:100%;min-height:320px;font-family:Consolas,Menlo,monospace;font-size:12.5px;border:1px solid var(--border);border-radius:6px;padding:12px;background:#1e1e1e;color:#d4d4d4;white-space:pre;overflow:auto;line-height:1.55}
.json .p-key{color:#9cdcfe}.json .p-str{color:#ce9178}.json .p-num{color:#b5cea8}.json .p-bool{color:#569cd6}.json .p-null{color:#6a9955}
.note{border:1px solid #e6a23c;background:#fdf6ec;color:#b88230;border-radius:6px;padding:10px 14px;font-size:12.5px;line-height:1.7;margin-bottom:16px}
.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--success);color:#fff;padding:10px 20px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transition:.3s;z-index:99;font-size:14px}
.toast.show{opacity:1}
.section-title{font-size:13px;color:var(--text2);margin:18px 0 10px;display:flex;align-items:center;gap:6px}
.section-title::before{content:"";width:3px;height:14px;background:var(--primary);border-radius:2px}
.api-box{margin-top:16px}
.rule-card{border:1px solid var(--border);border-radius:6px;margin-bottom:10px;padding:12px}
.rule-card .rc-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.rule-card .rc-head input{flex:1}
.rc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.rc-sec{font-size:12px;color:var(--text3);margin:10px 0 6px;display:flex;align-items:center;gap:6px}
.rc-sec::before{content:"";width:3px;height:12px;background:#e6a23c;border-radius:2px}
.rc-sec.price::before{background:var(--primary)}
.money-note{font-size:12px;color:var(--text3);margin-bottom:8px}
.field-pill{font-size:11px;color:var(--text2);background:#f0f2f5;border-radius:3px;padding:1px 5px;margin-right:4px}
</style>
</head>
<body>
<div class="topbar">
<h1>计价配置管理 <span class="tag">/pricing · 原型预览</span></h1>
<span class="badge-proto">原型预览 · 静态无后端</span>
</div>
<div class="layout">
<!-- ============ 左侧:计价对象枚举 ============ -->
<div class="panel sidebar">
<div class="p-head">计价对象 <span class="hint">GET /pricing/subjects</span></div>
<div class="grp-title">工作流</div>
<div id="subj-workflow"></div>
<div class="grp-title">模型(实时拉取 model-gateway 系统模型)</div>
<div id="subj-models"></div>
<div class="grp-title">业务模块</div>
<div id="subj-business"></div>
</div>
<!-- ============ 右侧:配置编辑 ============ -->
<div class="panel editor">
<div class="e-head">
<div>
<div style="font-size:15px;font-weight:600" id="ed-title">工作流</div>
<div class="st" id="ed-sub">subjectType=workflow · subjectId=workflow</div>
</div>
<div>
<button class="btn" onclick="loadApiExample()">枚举响应示例</button>
<button class="btn primary" onclick="saveMock()">保存配置</button>
</div>
</div>
<!-- 工作流编辑 -->
<div class="e-body" id="ed-workflow" style="display:none">
<div class="col">
<div class="note">工作流可同时配置 <b>按条 / 按秒 / 按token</b> 三套价格。创建/使用工作流时,从这些配置中<b>选择本次执行的扣费方式</b>。只配置的模式才会出现在 rules 里。</div>
<!-- per_item -->
<div class="card">
<div class="c-head">按条计费 per_item
<span class="st"><label class="switch"><input type="checkbox" id="w-item-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">档位开放列表,上不封顶。命中首个 <code>maxSec ≥ 时长</code> 的档;超出最大档按 overflowUnitPrice 线性叠加。</div>
<table class="tbl" style="margin:10px 0">
<tr><th>档位最大秒数 maxSec</th><th>价格(元)</th><th></th></tr>
<tbody id="w-item-tiers"></tbody>
</table>
<button class="btn mini" onclick="addTier()">+ 加档</button>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>超出最大档单价(元/秒)</label><input class="num" id="w-item-overflow" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_second -->
<div class="card">
<div class="c-head">按秒计费 per_second
<span class="st"><label class="switch"><input type="checkbox" id="w-sec-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">按秒向上取整(不满 1 秒按 1 秒),再乘单价。</div>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>单价(元/秒)</label><input class="num" id="w-sec-unit" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_token -->
<div class="card">
<div class="c-head">按token计费 per_token
<span class="st"><label class="switch"><input type="checkbox" id="w-tok-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="note" style="margin-bottom:0">价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0 计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="w-minbalance" type="number" step="0.01" value="0" oninput="renderWorkflow()"></div>
<div class="f"><label>币种</label><select id="w-currency" onchange="renderWorkflow()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="w-enabled" checked onchange="renderWorkflow()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="wf-json" readonly spellcheck="false"></textarea>
</div>
</div>
<!-- 模型编辑 -->
<div class="e-body" id="ed-model" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">模型计费规则(按模型类型自适应)
<span class="st"><select id="m-example" onchange="loadExample()"><option value="">选择示例填充</option></select></span>
</div>
<div class="c-body">
<div class="f-row">
<div class="f"><label>计费单位 unit</label>
<select id="m-unit" onchange="onUnitChange()">
<option value="per_1M">per_1M(每百万token</option>
<option value="per_1K">per_1K(每千token</option>
<option value="per_1">per_1(每个/每张图)</option>
<option value="per_second">per_second(每秒)</option>
<option value="per_minute">per_minute(每分钟)</option>
<option value="per_hour">per_hour(每小时)</option>
<option value="per_char">per_char(每字)</option>
</select>
</div>
<div class="f"><label>阶梯分档 tiered</label>
<label class="switch"><input type="checkbox" id="m-tiered" disabled onchange="renderModel()"><span class="slider"></span></label>
</div>
<div class="f"><label>币种</label><select id="m-currency" onchange="renderModel()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="m-enabled" checked onchange="renderModel()"><span class="slider"></span></label></div>
</div>
<div class="hint" id="m-tiered-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K/per_1M)可用。</div>
<div class="money-note" id="m-price-note" style="margin-top:8px"></div>
<div id="m-rules"></div>
<button class="btn mini" onclick="addModelRule()">+ 加规则</button>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="m-minbalance" type="number" step="0.01" value="0" oninput="renderModel()"></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="m-json" readonly spellcheck="false"></textarea>
<div class="note" style="margin-top:12px">结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。</div>
</div>
</div>
<!-- 业务模块编辑 -->
<div class="e-body" id="ed-business" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">周期订阅 per_period
<span class="st">通用周期建模,后续可扩 month/quarter</span>
</div>
<div class="c-body">
<div class="hint" style="margin-bottom:10px">按周期一次性收取,与用量无关。本期支持按年(period=year)。</div>
<div class="f-row">
<div class="f"><label>周期 period</label>
<select id="b-period" onchange="renderBusiness()"><option value="year" selected>year(按年)</option></select>
</div>
<div class="f"><label>每周期价格(元)</label>
<input class="num" id="b-price" type="number" step="0.01" value="1999" oninput="renderBusiness()">
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="b-minbalance" type="number" step="0.01" value="0" oninput="renderBusiness()"></div>
<div class="f"><label>币种</label><select id="b-currency" onchange="renderBusiness()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="b-enabled" checked onchange="renderBusiness()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="b-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">订阅购买/续费流程接入另期;本期提供价格标准配置。</div>
</div>
</div>
<!-- 枚举响应示例 -->
<div class="e-body" id="ed-api" style="display:none">
<div class="col" style="flex:1;min-width:0">
<div class="section-title">GET /pricing/subjects · 枚举响应</div>
<textarea class="json" id="api-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">模型部分实时调 model-gateway <code>/listModelManage</code>system_model=true);modelType 透传 model-gateway 枚举编码(100推理/200图片/300音频/600视频);model-gateway 不可用时接口报错。</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/* ================= 主体数据(模拟枚举) ================= */
const SUBJECTS = [
{type:'workflow', id:'workflow', name:'工作流', modes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
{type:'model', id:'101', name:'deepseek-v3(推理)', modelType:'reason', typeLabel:'推理', conf:true},
{type:'model', id:'102', name:'文生视频·配音', modelType:'video', typeLabel:'视频', conf:false},
{type:'model', id:'103', name:'语音合成', modelType:'audio', typeLabel:'音频', conf:false},
{type:'model', id:'104', name:'文生图', modelType:'image', typeLabel:'图片', conf:false},
{type:'business', id:'ai_customer_service', name:'AI客服', typeLabel:'业务', conf:false},
];
/* ================= 四类模型示例(对应新设计 §4.2 ================= */
const EXAMPLES = {
video:[
{label:'视频·按秒(分辨率×有声/无声×输入媒体)', unit:'per_second', tiered:false, rules:[
{name:'无声-720p-输入不含视频', mediaType:'text', outputAudio:false, outputResolution:'720p', unitPrice:0.8},
{name:'有声-1080p-输入含视频', mediaType:'video', outputAudio:true, outputResolution:'1080p', unitPrice:1.5},
]},
{label:'视频·按token(百万)', unit:'per_1M', tiered:false, rules:[
{name:'文本输入-无声-1080p', mediaType:'text', outputAudio:false, outputResolution:'1080p', input:0.6, output:3.6, cacheHit:0.12},
{name:'视频输入-有声-1080p', mediaType:'video', outputAudio:true, outputResolution:'1080p', input:9, output:27, cacheHit:1.8},
]},
],
reason:[
{label:'推理·阶梯(思考×输入token档×输入媒体)', unit:'per_1M', tiered:true, rules:[
{name:'思考-输入≤32000-文本', mediaType:'text', thinking:true, inLenMax:32000, input:0.6, output:3.6, cacheHit:0.12},
{name:'思考-输入32001~128000-文本', mediaType:'text', thinking:true, inLenMin:32001, inLenMax:128000, input:0.9, output:5.4, cacheHit:0.18},
{name:'思考-输入含音频', mediaType:'audio', thinking:true, input:9, output:3.6, cacheHit:1.8},
{name:'非思考-统一价', thinking:false, input:0.3, output:1.2, cacheHit:0.06},
]},
],
audio:[
{label:'音频·按字数', unit:'per_char', tiered:false, rules:[
{name:'语音-统一价(万字符5元)', unitPrice:0.0005},
]},
{label:'音频·按分钟', unit:'per_minute', tiered:false, rules:[
{name:'语音-统一价', unitPrice:2},
]},
{label:'音频·按token(百万)', unit:'per_1M', tiered:false, rules:[
{name:'统一价', input:500, output:500, cacheHit:0},
]},
],
image:[
{label:'图片·按张数×分辨率', unit:'per_1', tiered:false, rules:[
{name:'512x512', outputResolution:'512x512', unitPrice:0.2},
{name:'1024x1024', outputResolution:'1024x1024', unitPrice:0.5},
]},
],
};
/* ================= 状态 ================= */
let current = 'workflow';
let currentModelType = '';
let modelRules = [];
/* ================= 渲染左侧枚举 ================= */
function renderSubjects(){
document.getElementById('subj-workflow').innerHTML = subjectHTML(SUBJECTS.find(s=>s.type==='workflow'));
document.getElementById('subj-models').innerHTML = SUBJECTS.filter(s=>s.type==='model').map(subjectHTML).join('');
document.getElementById('subj-business').innerHTML = SUBJECTS.filter(s=>s.type==='business').map(subjectHTML).join('');
}
function subjectHTML(s){
const conf = s.type==='workflow' ? s.hasConfig : s.conf;
const typeLabel = s.type==='workflow' ? '工作流' : (s.type==='business' ? '业务' : s.typeLabel);
const tclass = s.type==='workflow' ? 't-workflow' : (s.type==='business' ? 't-business' : 't-'+s.modelType);
let extra='';
if(s.type==='workflow'){ extra = s.modes.map(m=>`<span class="mode-chip">${m}</span>`).join(' '); }
if(s.type==='business'){ extra = `<span class="mode-chip">per_period</span>`; }
return `<div class="subj ${current===s.type+':'+s.id?'active':''}" onclick="selectSubject('${s.type}','${s.id}')">
<span class="type-badge ${tclass}">${typeLabel}</span>
<span class="name">${s.name}</span>
${extra}
<span class="conf-badge ${conf?'conf-yes':'conf-no'}">${conf?'已配置':'未配置'}</span>
</div>`;
}
/* ================= 切换主体 ================= */
function selectSubject(type, id){
current = type+':'+id;
renderSubjects();
['ed-workflow','ed-model','ed-business','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
if(type==='workflow'){
document.getElementById('ed-workflow').style.display='flex';
document.getElementById('ed-title').textContent='工作流';
document.getElementById('ed-sub').textContent='subjectType=workflow · subjectId=workflow';
renderWorkflowTiers(); renderWorkflow();
} else if(type==='model'){
document.getElementById('ed-model').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='model'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=model · subjectId=${s.id}${s.typeLabel}modelType=${s.modelType}`;
selectModel(s);
} else if(type==='business'){
document.getElementById('ed-business').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='business'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=business · subjectId=${s.id}`;
renderBusiness();
}
}
/* ================= 模型编辑器 ================= */
function selectModel(s){
currentModelType = s.modelType;
const sel = document.getElementById('m-example');
sel.innerHTML = `<option value="">选择示例填充</option>` +
EXAMPLES[s.modelType].map((e,i)=>`<option value="${i}">${e.label}</option>`).join('');
// 切模型自动加载该类型第一个示例
sel.value = '0';
loadExample();
}
function emptyRule(){
return {name:'', mediaType:'', thinking:'', outputAudio:'', outputResolution:'', inLenMin:'', inLenMax:'', input:0, output:0, cacheHit:0, unitPrice:0};
}
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
function loadExample(){
const i = document.getElementById('m-example').value;
if(i===''){ modelRules=[emptyRule()]; renderModel(); return; }
const ex = EXAMPLES[currentModelType][+i];
document.getElementById('m-unit').value = ex.unit;
document.getElementById('m-tiered').checked = !!ex.tiered;
document.getElementById('m-currency').value = ex.currency||'CNY';
document.getElementById('m-minbalance').value = ex.minBalance||0;
modelRules = ex.rules.map(r=>({name:r.name, mediaType:r.mediaType||'', thinking:r.thinking==null?'':String(r.thinking),
outputAudio:r.outputAudio==null?'':String(r.outputAudio), outputResolution:r.outputResolution||'',
inLenMin:r.inLenMin==null?'':String(r.inLenMin), inLenMax:r.inLenMax==null?'':String(r.inLenMax),
input:r.input==null?0:r.input, output:r.output==null?0:r.output, cacheHit:r.cacheHit==null?0:r.cacheHit,
unitPrice:r.unitPrice==null?0:r.unitPrice}));
onUnitChange(); renderModel();
}
function onUnitChange(){
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
document.getElementById('m-tiered').disabled = !token;
if(!token){ document.getElementById('m-tiered').checked = false; }
document.getElementById('m-price-note').innerHTML = token
? 'token 单位价格按 <b>元/基准</b>per_1M=每百万token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
: '非 token 单位价格按 <b>元/单位</b> 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。';
renderModel();
}
function fsel(key, i, opts, labels){
const o = opts.map((v,j)=>`<option value="${v}" ${modelRules[i][key]===v?'selected':''}>${labels[j]}</option>`).join('');
return `<div class="f"><label>${key}</label><select onchange="modelRules[${i}].${key}=this.value;renderModel()">${o}</select></div>`;
}
function ftext(key, i, ph){
return `<div class="f"><label>${key}</label><input value="${esc(modelRules[i][key])}" placeholder="${ph}" oninput="modelRules[${i}].${key}=this.value;renderModel()"></div>`;
}
function fnum(key, i, label, step){
return `<div class="f"><label>${label}</label><input class="num" type="number" step="${step||'0.01'}" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
}
function renderModel(){
const box = document.getElementById('m-rules');
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
box.innerHTML = modelRules.map((r,i)=>{
const mt = currentModelType;
let match = '';
match += `<div class="rc-sec">命中条件 match</div><div class="rc-grid">`;
match += fsel('mediaType', i, ['','text','audio','video','image'], ['不限制','文本 text','音频 audio','视频 video','图片 image']);
if(mt==='reason'){
match += fsel('thinking', i, ['','true','false'], ['不限制','思考模式','非思考模式']);
match += fnum('inLenMin', i, '输入token下界', 1);
match += fnum('inLenMax', i, '输入token上界', 1);
}
if(mt==='video'){
match += fsel('outputAudio', i, ['','true','false'], ['不限制','有声','无声']);
match += ftext('outputResolution', i, '480p/720p/1080p/2k/4k');
}
if(mt==='image'){
match += ftext('outputResolution', i, '512x512/1024x1024/2048x2048');
}
match += `</div>`;
const price = token
? fnum('input', i, '输入单价 input') + fnum('output', i, '输出单价 output') + fnum('cacheHit', i, '缓存命中 cacheHit')
: fnum('unitPrice', i, '每单位单价 unitPrice');
return `<div class="rule-card">
<div class="rc-head">
<span style="color:var(--text3);font-size:12px">规则 #${i+1}</span>
<input value="${esc(r.name)}" placeholder="规则名(如:有声-1080p-输入含视频)" oninput="modelRules[${i}].name=this.value;renderModel()">
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
</div>
${match}
<div class="rc-sec price">计价项 price</div>
<div class="rc-grid">${price}</div>
</div>`;
}).join('');
const cfg = buildModelCfg();
document.getElementById('m-json').value = JSON.stringify(cfg,null,2);
}
function buildModelCfg(){
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
const tiered = document.getElementById('m-tiered').checked;
const rules = modelRules.filter(r=>r.name||Object.values(r).slice(1).some(v=>v!==''&&v!==0))
.map(r=>{
const match = {};
if(r.mediaType) match.mediaType = r.mediaType;
if(r.thinking!=='') match.thinking = r.thinking==='true';
if(r.outputAudio!=='') match.outputAudio = r.outputAudio==='true';
if(r.outputResolution) match.outputResolution = r.outputResolution;
if(r.inLenMin!=='') match.inputLengthMin = +r.inLenMin;
if(r.inLenMax!=='') match.inputLengthMax = +r.inLenMax;
const price = token ? {input:+r.input, output:+r.output, cacheHit:+r.cacheHit} : {unitPrice:+r.unitPrice};
return {name:r.name, ...(Object.keys(match).length?{match}:{}), price};
});
const modelCfg = { unit, tiered, rules,
currency:document.getElementById('m-currency').value,
discount:null };
return { subjectType:'model', subjectId:current.split(':')[1], rules:modelCfg,
minBalance:+document.getElementById('m-minbalance').value,
enabled:document.getElementById('m-enabled').checked?1:0 };
}
/* ================= 业务模块 ================= */
function renderBusiness(){
const cfg = { subjectType:'business', subjectId:current.split(':')[1],
rules:{ period:document.getElementById('b-period').value, price:+document.getElementById('b-price').value },
minBalance:+document.getElementById('b-minbalance').value,
currency:document.getElementById('b-currency').value,
enabled:document.getElementById('b-enabled').checked?1:0 };
document.getElementById('b-json').value = JSON.stringify(cfg,null,2);
}
/* ================= 枚举响应示例 ================= */
function loadApiExample(){
['ed-workflow','ed-model','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
document.getElementById('ed-api').style.display='flex';
document.getElementById('ed-title').textContent='枚举响应示例';
document.getElementById('ed-sub').textContent='GET /pricing/subjects';
const mt = {'reason':100,'image':200,'audio':300,'video':600};
const api = {
subjects:[
{subjectType:'workflow', subjectId:'workflow', name:'工作流', chargeModes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
...SUBJECTS.filter(s=>s.type==='model').map(s=>({subjectType:'model', subjectId:s.id, name:s.name, modelType:s.modelType, modelTypeCode:mt[s.modelType], hasConfig:s.conf, enabled:1})),
...SUBJECTS.filter(s=>s.type==='business').map(s=>({subjectType:'business', subjectId:s.id, name:s.name, chargeModes:['per_period'], hasConfig:s.conf, enabled:1}))
]
};
document.getElementById('api-json').value = JSON.stringify(api,null,2);
}
/* ================= 工作流编辑 ================= */
let tiers = [{maxSec:15,price:29},{maxSec:30,price:49},{maxSec:60,price:89}];
function renderWorkflowTiers(){
const tb=document.getElementById('w-item-tiers');
tb.innerHTML = tiers.map((t,i)=>`<tr>
<td><input type="number" value="${t.maxSec}" oninput="tiers[${i}].maxSec=+this.value;renderWorkflow()"></td>
<td><input type="number" step="0.01" value="${t.price}" oninput="tiers[${i}].price=+this.value;renderWorkflow()"></td>
<td><button class="btn mini danger" onclick="tiers.splice(${i},1);renderWorkflowTiers();renderWorkflow()">删</button></td></tr>`).join('');
}
function addTier(){ tiers.push({maxSec:60,price:89}); renderWorkflowTiers(); renderWorkflow(); }
function renderWorkflow(){
const rules = {};
if(document.getElementById('w-item-on').checked){
rules.per_item = {tiers:tiers.filter(t=>t&&t.maxSec>0), overflowUnitPrice:+document.getElementById('w-item-overflow').value};
}
if(document.getElementById('w-sec-on').checked){
rules.per_second = {unitPrice:+document.getElementById('w-sec-unit').value};
}
if(document.getElementById('w-tok-on').checked){
rules.per_token = {};
}
const cfg = { subjectType:'workflow', subjectId:'workflow', rules,
minBalance:+document.getElementById('w-minbalance').value,
currency:document.getElementById('w-currency').value,
enabled:document.getElementById('w-enabled').checked?1:0 };
document.getElementById('wf-json').value = JSON.stringify(cfg,null,2);
}
/* ================= 保存(mock ================= */
function saveMock(){
const t=document.getElementById('toast');
t.textContent='已保存(模拟)· rules JSON 已在上方预览';
t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800);
}
/* ================= 工具 ================= */
function esc(s){ return (s||'').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
/* ================= 初始化 ================= */
renderSubjects();
selectSubject('model','102'); // 默认打开视频模型,展示多维匹配
</script>
</body>
</html>