Files
shop-user-trade/docs/superpowers/specs/pricing-config-preview.html
T

469 lines
28 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-chat{background:#409eff}.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)}
.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}
.money-note{font-size:12px;color:var(--text3);margin-bottom:8px}
</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">PriceConfig 配置
<span class="st"><select id="m-example" onchange="loadExample()">
<option value="">选择示例填充</option>
<option value="audio">音频模型示例</option>
<option value="video">视频模型示例</option>
<option value="reason">推理模型示例</option>
</select></span>
</div>
<div class="c-body">
<div class="f-row">
<div class="f"><label>计价基准 unit</label><select id="m-unit" onchange="renderModel()"><option value="per_1M" selected>per_1M(百万token</option><option value="per_1K">per_1K(千token</option><option value="per_1">per_1(单条)</option></select></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="f-row"><div class="f"><label>模型级折扣 rate(如 0.4=4折,空=无)</label><input class="num" id="m-discount" type="number" step="0.01" oninput="renderModel()"></div></div>
<div class="money-note">价格单位:元 / 基准单位(per_1M 即每百万 token)。不同模型类型配置内容不同——音频/视频/推理见示例。</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, mediaType}</code>。mediaType = audio / no_video / has_video 由请求体参考媒体字段推导。</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);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:'gpt-4o', modelType:'chat', typeLabel:'对话', conf:true},
{type:'model', id:'103', name:'语音合成·统一', modelType:'audio', typeLabel:'音频', conf:false},
{type:'model', id:'104', name:'视频理解·统一', modelType:'video', typeLabel:'视频', conf:false},
{type:'business', id:'ai_customer_service', name:'AI客服', typeLabel:'业务', conf:false},
];
/* ================= 模型示例(对应 design §4.2 ================= */
const EXAMPLES = {
audio:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[{name:'音频-统一价格(万字符5元)', input:500, output:500, cacheHit:0, inputAudio:500, cacheHitAudio:0, cacheStorageHour:0, match:''}]},
video:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[
{name:'输入不含视频', input:23, output:23, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"no_video"}'},
{name:'输入包含视频', input:14, output:14, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"has_video"}'},
]},
reason:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[
{name:'输入[0,32000]', input:0.6, output:3.6, cacheHit:0.12, inputAudio:9, cacheHitAudio:1.8, cacheStorageHour:0.017, match:'{"inputLengthMax":32000}'},
{name:'输入(32000,128000]', input:0.9, output:5.4, cacheHit:0.18, inputAudio:13.5, cacheHitAudio:2.7, cacheStorageHour:0.017, match:'{"inputLengthMin":32001,"inputLengthMax":128000}'},
{name:'输入(128000,256000]', input:1.8, output:10.8, cacheHit:0.36, inputAudio:27, cacheHitAudio:5.4, cacheStorageHour:0.017, match:'{"inputLengthMin":128001,"inputLengthMax":256000}'},
]},
};
/* ================= 状态 ================= */
let current = 'workflow';
let modelRules = [];
/* ================= 渲染左侧枚举 ================= */
function renderSubjects(){
const wf = SUBJECTS.find(s=>s.type==='workflow');
document.getElementById('subj-workflow').innerHTML = subjectHTML(wf);
const models = SUBJECTS.filter(s=>s.type==='model');
document.getElementById('subj-models').innerHTML = models.map(subjectHTML).join('');
const biz = SUBJECTS.filter(s=>s.type==='business');
document.getElementById('subj-business').innerHTML = biz.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();
const panels = ['ed-workflow','ed-model','ed-business','ed-api'];
panels.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';
loadWorkflowState();
} 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}`;
const ex = EXAMPLES[s.modelType];
if(ex && !document.getElementById('m-example').value){ loadState(ex); }
else if(!modelRules.length){ modelRules=[emptyRule()]; renderModel(); }
} 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 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 selectSubjectRaw(){}
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 wf = SUBJECTS.find(s=>s.type==='workflow');
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, 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 loadWorkflowState(){ 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);
}
/* ================= 模型编辑 ================= */
function emptyRule(){ return {name:'',input:0,output:0,cacheHit:0,inputAudio:0,cacheHitAudio:0,cacheStorageHour:0,match:''}; }
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
function loadExample(){
const v=document.getElementById('m-example').value;
if(!v) return;
loadState(JSON.parse(JSON.stringify(EXAMPLES[v])));
}
function loadState(ex){
document.getElementById('m-unit').value=ex.unit||'per_1M';
document.getElementById('m-currency').value=ex.currency||'CNY';
document.getElementById('m-enabled').checked = ex.enabled!==0;
document.getElementById('m-minbalance').value = ex.minBalance||0;
document.getElementById('m-discount').value = ex.discount ? ex.discount.rate : '';
modelRules = ex.rules && ex.rules.length ? JSON.parse(JSON.stringify(ex.rules)) : [emptyRule()];
renderModel();
}
function renderModel(){
const box=document.getElementById('m-rules');
box.innerHTML = modelRules.map((r,i)=>`<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="规则名(如:输入[0,32000]" oninput="modelRules[${i}].name=this.value;renderModel()">
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
</div>
<div class="rc-grid">
${num('input','输入单价 input',i,'元/基准')}
${num('output','输出单价 output',i,'元/基准')}
${num('cacheHit','缓存命中 cacheHit',i,'元/基准')}
${num('inputAudio','音频输入 inputAudio',i,'元/基准')}
${num('cacheHitAudio','音频缓存 cacheHitAudio',i,'元/基准')}
${num('cacheStorageHour','缓存存储 cacheStorageHour',i,'元/小时')}
</div>
<div class="f" style="margin-top:8px"><label>命中条件 matchJSON,空=无条件命中)</label>
<input value="${esc(r.match)}" placeholder='{"inputLengthMax":32000} 或 {"mediaType":"has_video"}' oninput="modelRules[${i}].match=this.value;renderModel()"></div>
</div>`).join('');
const rules = modelRules.filter(r=>r.name||r.input||r.output||r.cacheHit||r.inputAudio||r.cacheHitAudio||r.cacheStorageHour||r.match)
.map(r=>({name:r.name, input:+r.input, output:+r.output, cacheHit:+r.cacheHit,
inputAudio:+r.inputAudio, cacheHitAudio:+r.cacheHitAudio, cacheStorageHour:+r.cacheStorageHour,
...(r.match?{match:JSON.parse(r.match||'{}')}:{})}));
const unit = document.getElementById('m-unit').value;
const dis = document.getElementById('m-discount').value;
const cfg = { unit, rules, currency:document.getElementById('m-currency').value,
...(dis!==''?{discount:{rate:+dis}}:{discount:null}) };
document.getElementById('m-json').value = JSON.stringify({subjectType:'model', subjectId:current.split(':')[1], rules:cfg, minBalance:+document.getElementById('m-minbalance').value, enabled:document.getElementById('m-enabled').checked?1:0},null,2);
}
function num(key,label,i,unit){
return `<div class="f"><label>${label}</label><input type="number" step="0.01" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
}
function esc(s){ return (s||'').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
/* ================= 保存(mock ================= */
function saveMock(){
const t=document.getElementById('toast');
t.textContent='已保存(模拟)· rules JSON 已在上方预览';
t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800);
}
/* ================= 初始化 ================= */
renderSubjects();
selectSubject('workflow','workflow');
document.getElementById('w-item-on').checked=true;
document.getElementById('w-sec-on').checked=true;
document.getElementById('w-tok-on').checked=true;
renderWorkflow();
</script>
</body>
</html>