- §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>
561 lines
32 KiB
HTML
561 lines
32 KiB
HTML
<!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,'"').replace(/</g,'<'); }
|
||
|
||
/* ================= 初始化 ================= */
|
||
renderSubjects();
|
||
selectSubject('model','102'); // 默认打开视频模型,展示多维匹配
|
||
</script>
|
||
</body>
|
||
</html> |