Add application event dictionary and rule management console
This commit is contained in:
@@ -43,3 +43,11 @@ npm run preview
|
||||
```sh
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5174 uv run uvicorn adcloud_server.main:app --reload
|
||||
```
|
||||
|
||||
## 事件字典管理
|
||||
|
||||
新增“事件字典”页面,连接真实后端:浏览 33 个标准事件的必选/可选字段、查看标准特征窗口与上限、加载应用事件、保存 JSON 规则草稿、示例试算、发布、停用和历史版本回滚。
|
||||
当前尚未接入客户账号登录。平台受控后端调用 POST /v1/registry/apps 创建应用后,将对应 management_key 提供给管理操作者;该页面凭据仅存当前内存,不使用平台 SERVICE_TOKEN,不写入 localStorage。
|
||||
正式管理操作使用 HTTPS;当前公网 HTTP 后端只用于合成测试数据。VITE_API_BASE_URL 设置连接后端,页面也可手动输入测试地址。CORS 需允许控制台实际来源。
|
||||
规则试算按钮会先保存当前草稿,再执行无埋点/特征写入的试算;发布按钮会保存并产生不可变规则版本。事件参数结构修改需创建新的 event_version。
|
||||
本页已接入注册表业务 API,其他收入、应用草稿与登录页面仍是原有脚手架。本仓库没有新增公网前端部署。
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
import EventRegistry from "./EventRegistry";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
const tabs = ["总览", "应用管理", "接入数据"];
|
||||
const tabs = ["总览", "应用管理", "接入数据", "事件字典"];
|
||||
const metrics = ["预估收益", "广告请求", "展示次数", "填充率"];
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || "http://localhost:8000").replace(/\/+$/, "");
|
||||
|
||||
@@ -43,7 +44,7 @@ export default function App() {
|
||||
<main>
|
||||
<header><span>开发者控制台 / {tab}</span><span className="badge">开发预览 · 登录待接入</span></header>
|
||||
<section className="intro"><p className="eyebrow">ADCLOUD WORKSPACE</p><h1>{tab}</h1><p>管理应用接入、广告位与流量收益。</p></section>
|
||||
<p className="notice">当前为前端脚手架。业务 API、账号登录和真实数据尚未接入。后端健康检查:<strong role="status">{connection}</strong></p>
|
||||
<p className="notice">事件注册表已接入;账号登录、收益和投放数据尚未接入。后端健康检查:<strong role="status">{connection}</strong></p>
|
||||
{tab === tabs[0] && <>
|
||||
<section className="metrics" aria-label="数据概览">{metrics.map((metric) => <article key={metric}><p>{metric}</p><strong>—</strong><small>等待业务数据接入</small></article>)}</section>
|
||||
<section className="panel"><h2>开始使用</h2><p>在{tabs[1]}中创建本地应用草稿,预览工作流程。</p><button className="primary" onClick={() => setTab(tabs[1])}>进入{tabs[1]}</button></section>
|
||||
@@ -53,6 +54,7 @@ export default function App() {
|
||||
<form onSubmit={saveDraft}><label htmlFor="draft-name">应用名称</label><div className="form-row"><input id="draft-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={100} required placeholder="输入名称" /><button className="primary" type="submit">创建本地草稿</button></div></form>
|
||||
{drafts.length === 0 ? <p className="empty">暂无草稿</p> : <ul className="drafts">{drafts.map((draft, index) => <li key={index}><span>{draft}</span><span className="badge">本地草稿</span></li>)}</ul>}
|
||||
</section>}
|
||||
{tab === tabs[3] && <EventRegistry defaultBaseUrl={apiBaseUrl} />}
|
||||
{tab === tabs[2] && <section className="panel empty"><h2>暂无数据</h2><p>连接业务数据接口后,将在这里展示应用请求、广告展示与收益数据。</p></section>}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Field = { type: string; enum?: string[] };
|
||||
type Standard = { title: string; category: string; source: string; required: string[]; fields: Record<string, Field>; business_keys: string[] };
|
||||
type Catalog = { events: Record<string, Standard>; features: Record<string, { title: string; window_days: number; cap: number }> };
|
||||
type Config = { standard_event: string; description: string; extra_fields?: Record<string, string>; required_extra?: string[]; rules: unknown[]; required_fields?: string[] };
|
||||
type Definition = { name: string; version: number; status: string; revision: number; draft: Config };
|
||||
const initialRules = [{ conditions: [{ field: "properties.category_id", operator: "eq", value: "digital" }], effects: [{ feature: "intent.purchase.digital", operation: "increment", value: 1 }] }];
|
||||
|
||||
export default function EventRegistry({ defaultBaseUrl }: { defaultBaseUrl: string }) {
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
|
||||
const [appId, setAppId] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [items, setItems] = useState<Definition[]>([]);
|
||||
const [name, setName] = useState("add_cart");
|
||||
const [version, setVersion] = useState(1);
|
||||
const [standardName, setStandardName] = useState("commerce.cart.add");
|
||||
const [description, setDescription] = useState("用户将数码商品加入购物车");
|
||||
const [rules, setRules] = useState(JSON.stringify(initialRules, null, 2));
|
||||
const [extensions, setExtensions] = useState('{"extra_fields": {}, "required_extra": []}');
|
||||
const [sample, setSample] = useState('{"product_id": "camera", "quantity": 1, "category_id": "digital"}');
|
||||
const [revision, setRevision] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [preview, setPreview] = useState<unknown>(null);
|
||||
|
||||
const server = baseUrl.replace(/\/+$/, "");
|
||||
const prefix = `${server}/v1/registry/apps/${encodeURIComponent(appId)}`;
|
||||
const definitionPath = `${prefix}/events/${encodeURIComponent(name)}/versions/${version}`;
|
||||
const standard = catalog?.events[standardName];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setCatalog(null);
|
||||
fetch(`${server}/v1/registry/catalog`, { signal: controller.signal })
|
||||
.then(async (response) => { if (!response.ok) throw new Error(`字典加载失败 HTTP ${response.status}`); return response.json() as Promise<Catalog>; })
|
||||
.then(setCatalog)
|
||||
.catch((error: unknown) => { if (!controller.signal.aborted) setMessage(error instanceof Error ? error.message : "字典加载失败"); });
|
||||
return () => controller.abort();
|
||||
}, [server]);
|
||||
|
||||
async function request(url: string, method = "GET", body?: unknown) {
|
||||
if (!appId || !key) throw new Error("请填写应用 ID 和应用管理凭据");
|
||||
const response = await fetch(url, { method, credentials: "include", headers: { "Content-Type": "application/json", "X-Adcloud-Management-Key": key }, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail)}`);
|
||||
return data;
|
||||
}
|
||||
async function run(operation: () => Promise<void>) {
|
||||
setBusy(true); setMessage(""); setPreview(null);
|
||||
try { await operation(); } catch (error) { setMessage(error instanceof Error ? error.message : "操作失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function refresh() { const data = await request(`${prefix}/events`); setItems(data.items); }
|
||||
async function save() {
|
||||
const extension = JSON.parse(extensions) as { extra_fields: Record<string, string>; required_extra: string[] };
|
||||
await request(definitionPath, "PUT", { standard_event: standardName, description, rules: JSON.parse(rules), extra_fields: extension.extra_fields, required_extra: extension.required_extra });
|
||||
await refresh(); setMessage("草稿已保存到服务器,尚未改变已发布规则。");
|
||||
}
|
||||
function edit(item: Definition) {
|
||||
setName(item.name); setVersion(item.version); setStandardName(item.draft.standard_event); setDescription(item.draft.description);
|
||||
setRules(JSON.stringify(item.draft.rules, null, 2)); setExtensions(JSON.stringify({ extra_fields: item.draft.extra_fields || {}, required_extra: item.draft.required_extra || [] }, null, 2));
|
||||
setRevision(item.revision || 1); setPreview(null); setMessage("");
|
||||
}
|
||||
|
||||
return <div className="registry">
|
||||
<section className="panel"><h2>应用连接</h2><p>使用平台服务端创建应用时返回的 management_key。凭据仅保存在当前页面内存,刷新后需重新输入。客户账号登录尚未接入。</p>
|
||||
<div className="registry-grid"><label>后端地址<input value={baseUrl} onChange={(e) => { setBaseUrl(e.target.value); setItems([]); }} /></label><label>应用 ID<input value={appId} onChange={(e) => { setAppId(e.target.value); setItems([]); }} /></label><label>应用管理凭据<input type="password" autoComplete="off" value={key} onChange={(e) => setKey(e.target.value)} /></label></div>
|
||||
<button disabled={busy} className="primary" onClick={() => void run(async () => { await refresh(); setMessage("已加载应用事件。"); })}>加载已注册事件</button>
|
||||
</section>
|
||||
<section className="panel"><h2>标准事件目录</h2><p>按业务选择标准事件,自定义事件名称与它建立映射。支付、注册结果和投递结果等事件仅允许服务端上报。</p>
|
||||
<div className="table-scroll"><table><thead><tr><th>分类 / 事件</th><th>必选参数</th><th>可选参数</th><th>来源 / 去重键</th></tr></thead><tbody>{catalog && Object.entries(catalog.events).map(([id, item]) => <tr key={id}><td><small>{item.category}</small><br />{item.title}<br /><code>{id}</code></td><td>{item.required.join("、")}</td><td>{Object.keys(item.fields).filter((field) => !item.required.includes(field)).join("、") || "—"}</td><td>{item.source === "server" ? "可信服务端" : "SDK / 服务端"}<br />{item.business_keys.join(" + ") || "event_id"}</td></tr>)}</tbody></table></div>
|
||||
</section>
|
||||
<section className="panel"><h2>注册事件与规则</h2>
|
||||
<div className="registry-grid"><label>自定义事件名称<input value={name} onChange={(e) => setName(e.target.value)} maxLength={128} /></label><label>事件参数版本<input type="number" min={1} max={10000} value={version} onChange={(e) => setVersion(Number(e.target.value))} /></label><label>标准事件<select value={standardName} onChange={(e) => { setStandardName(e.target.value); setRules("[]"); }}>{catalog && Object.entries(catalog.events).map(([id, item]) => <option key={id} value={id}>{item.title} · {id}</option>)}</select></label></div>
|
||||
<label>事件含义<input value={description} onChange={(e) => setDescription(e.target.value)} maxLength={1000} /></label>
|
||||
{standard && <p>必选:{standard.required.join("、")}。规则条件引用的可选字段也会成为必选参数。金额为最小货币单位整数,并需提供 currency。</p>}
|
||||
<label>扩展参数定义(JSON)<textarea rows={4} value={extensions} onChange={(e) => setExtensions(e.target.value)} spellCheck={false} /></label>
|
||||
<label>特征映射规则(JSON)<textarea rows={10} value={rules} onChange={(e) => setRules(e.target.value)} spellCheck={false} /></label>
|
||||
<p>支持 eq、in、contains、gt、gte、lt、lte;规则内条件全部满足才执行,规则之间独立匹配。仅支持正数 increment。</p>
|
||||
<div className="actions"><button disabled={busy} className="primary" onClick={() => void run(save)}>保存草稿</button><button disabled={busy} onClick={() => void run(async () => { await save(); const data = await request(`${definitionPath}/publish`, "POST", {}); await refresh(); setMessage(`已发布规则版本 ${data.rule_revision}`); })}>保存并发布</button><button disabled={busy} onClick={() => void run(async () => { await request(`${definitionPath}/disable`, "POST", {}); await refresh(); setMessage("事件已停用,新事件上报将被拒绝。"); })}>停用事件</button></div>
|
||||
<label>回滚至规则版本<input type="number" min={1} value={revision} onChange={(e) => setRevision(Number(e.target.value))} /></label><button disabled={busy} onClick={() => void run(async () => { await request(`${definitionPath}/rollback`, "POST", { revision }); await refresh(); setMessage(`已激活历史规则版本 ${revision}`); })}>回滚规则</button>
|
||||
</section>
|
||||
<section className="panel"><h2>规则试算</h2><label>示例 properties(JSON)<textarea rows={5} value={sample} onChange={(e) => setSample(e.target.value)} spellCheck={false} /></label><button disabled={busy} onClick={() => void run(async () => { await save(); setPreview(await request(`${definitionPath}/preview`, "POST", { properties: JSON.parse(sample) })); setMessage("试算完成,未写入埋点或用户特征;编辑内容已保存为草稿。"); })}>保存草稿并试算</button>{preview !== null && <pre>{JSON.stringify(preview, null, 2)}</pre>}</section>
|
||||
<p role="status" aria-live="polite">{busy ? "正在处理…" : message}</p>
|
||||
<section className="panel"><h2>标准特征</h2><ul>{catalog && Object.entries(catalog.features).map(([id, feature]) => <li key={id}>{feature.title} · <code>{id}</code> · 最近 {feature.window_days} 天 · 上限 {feature.cap}</li>)}</ul></section>
|
||||
<section className="panel"><h2>已注册事件</h2>{items.length === 0 ? <p>加载应用后在这里查看事件与版本。</p> : items.map((item) => <div className="definition-row" key={`${item.name}:${item.version}`}><span>{item.name} · 参数版本 {item.version} · {item.status} · 规则版本 {item.revision}</span><button disabled={busy} onClick={() => edit(item)}>编辑</button></div>)}</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -38,3 +38,19 @@ input { border: 1px solid #ccd5e4; border-radius: 7px; padding: 12px; flex: 1; m
|
||||
.drafts li { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; border-top: 1px solid #edf0f5; overflow-wrap: anywhere; }
|
||||
@media (max-width: 1000px) { .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } main { padding: 0 24px 32px; } }
|
||||
@media (max-width: 640px) { .layout { grid-template-columns: 1fr; } aside { padding: 20px; } nav { grid-template-columns: repeat(3, 1fr); margin-top: 20px; } nav button { text-align: center; padding: 12px 6px; } .aside-note { display: none; } header { flex-wrap: wrap; padding: 16px 0; } .form-row { flex-direction: column; } }
|
||||
|
||||
.registry { display: grid; gap: 20px; }
|
||||
.registry-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.registry label { display: grid; gap: 8px; margin: 14px 0; }
|
||||
.registry input, .registry select, .registry textarea { box-sizing: border-box; width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: #15253a; font: inherit; }
|
||||
.registry textarea, .registry pre, .registry code { font-family: ui-monospace, monospace; }
|
||||
.registry textarea { resize: vertical; }
|
||||
.table-scroll { overflow-x: auto; max-height: 520px; }
|
||||
.registry table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.registry th, .registry td { padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
|
||||
.registry th { position: sticky; top: 0; background: #f8fafc; }
|
||||
.registry pre { padding: 14px; background: #f1f5f9; overflow: auto; }
|
||||
.registry .actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.definition-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
|
||||
.registry button:disabled { opacity: .5; cursor: wait; }
|
||||
@media (max-width: 850px) { .registry-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user