feat: expand vehicle data platform capabilities
This commit is contained in:
13
vehicle-data-platform/apps/open-portal/index.html
Normal file
13
vehicle-data-platform/apps/open-portal/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="羚牛车辆数据开放平台,面向合作伙伴提供稳定、安全、可审计的车辆数据 API。" />
|
||||
<title>羚牛车辆数据开放平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
vehicle-data-platform/apps/open-portal/package.json
Normal file
24
vehicle-data-platform/apps/open-portal/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 20311",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
2366
vehicle-data-platform/apps/open-portal/pnpm-lock.yaml
generated
Normal file
2366
vehicle-data-platform/apps/open-portal/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
198
vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx
Normal file
198
vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { api, type AdminApp, type AuditItem, type PortalApp, type PortalUser } from "./api";
|
||||
import { dateInput, EmptyState, errorMessage, formatDate, formatTime, Icon, PageHeader, rfcDate, Status } from "./ui";
|
||||
import { VehicleAuthorization } from "./VehicleAuthorization";
|
||||
|
||||
export type AdminSection = "admin-overview" | "apps" | "partners" | "grants" | "security";
|
||||
|
||||
type Props = {
|
||||
section: AdminSection;
|
||||
onNavigate: (section: AdminSection) => void;
|
||||
onKey: (value: string) => void;
|
||||
};
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const nextYear = () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export function AdminWorkspace({ section, onNavigate, onKey }: Props) {
|
||||
const [apps, setApps] = useState<AdminApp[]>([]);
|
||||
const [users, setUsers] = useState<PortalUser[]>([]);
|
||||
const [vehicleCount, setVehicleCount] = useState(0);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextApps, nextUsers] = await Promise.all([api.adminApps(), api.adminUsers()]);
|
||||
setApps(nextApps); setUsers(nextUsers);
|
||||
const grants = await Promise.all(nextApps.map((item) => api.vehicles(item.id).catch(() => [])));
|
||||
setVehicleCount(new Set(grants.flat().map((item) => item.vin)).size);
|
||||
} catch (reason) {
|
||||
setNotice(errorMessage(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
const shared = { apps, users, refresh, setNotice, onKey };
|
||||
return <div className="admin-workspace">
|
||||
{notice && <div className="notice-banner" role="status"><span>{notice}</span><button onClick={() => setNotice("")}><Icon name="close" size={16} /></button></div>}
|
||||
{section === "admin-overview" && <AdminOverview apps={apps} users={users} vehicleCount={vehicleCount} loading={loading} onNavigate={onNavigate} />}
|
||||
{section === "apps" && <AppsManager {...shared} />}
|
||||
{section === "partners" && <PartnersManager {...shared} />}
|
||||
{section === "grants" && <VehicleAuthorization apps={apps} setNotice={setNotice} refresh={refresh} />}
|
||||
{section === "security" && <SecurityAudit apps={apps} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AdminOverview({ apps, users, vehicleCount, loading, onNavigate }: {
|
||||
apps: AdminApp[]; users: PortalUser[]; vehicleCount: number; loading: boolean; onNavigate: (section: AdminSection) => void;
|
||||
}) {
|
||||
const enabledApps = apps.filter((item) => item.status === "enabled").length;
|
||||
const enabledUsers = users.filter((item) => item.status === "enabled").length;
|
||||
const now = Date.now();
|
||||
const expiring = users.filter((item) => item.validTo && new Date(item.validTo).getTime() - now < 30 * 86400000 && new Date(item.validTo).getTime() > now);
|
||||
const expired = users.filter((item) => item.validTo && new Date(item.validTo).getTime() <= now);
|
||||
const steps = [
|
||||
{ label: "创建开放应用", detail: "生成应用凭证并设置有效期", done: apps.length > 0, target: "apps" as const },
|
||||
{ label: "添加合作伙伴", detail: "创建独立的开发者登录账号", done: users.length > 0, target: "partners" as const },
|
||||
{ label: "分配应用角色", detail: "为合作伙伴分配 Owner、Developer 或 Viewer", done: users.length > 0 && apps.length > 0, target: "partners" as const },
|
||||
{ label: "授权车辆", detail: "配置应用可访问的车辆与期限", done: vehicleCount > 0, target: "grants" as const }
|
||||
];
|
||||
return <>
|
||||
<PageHeader title="平台总览" description="管理开放应用、合作伙伴和车辆数据边界。" action={<button className="button primary" onClick={() => onNavigate("partners")}><Icon name="plus" size={17} />添加合作伙伴</button>} />
|
||||
<section className="onboarding-panel"><div className="panel-heading"><div><h2>完成以下步骤,开启开放服务</h2><p>每一步都对应一项明确的访问控制。</p></div><span>{steps.filter((item) => item.done).length} / 4 已完成</span></div><div className="onboarding-steps">{steps.map((item, index) => <button key={item.label} onClick={() => onNavigate(item.target)}><span className={item.done ? "done" : ""}>{item.done ? <Icon name="check" size={16} /> : index + 1}</span><div><b>{item.label}</b><small>{item.detail}</small><em>{item.done ? "已完成" : index === steps.findIndex((step) => !step.done) ? "进行中" : "待开始"}</em></div></button>)}</div></section>
|
||||
<section className="stat-strip">
|
||||
<div><Icon name="apps" size={26} /><span><strong>{loading ? "—" : enabledApps}</strong><small>启用的开放应用</small></span></div>
|
||||
<div><Icon name="users" size={26} /><span><strong>{loading ? "—" : enabledUsers}</strong><small>启用的合作伙伴</small></span></div>
|
||||
<div><Icon name="car" size={26} /><span><strong>{loading ? "—" : vehicleCount}</strong><small>已授权车辆</small></span></div>
|
||||
<div><Icon name="shield" size={26} /><span><strong>全量</strong><small>操作与调用审计</small></span></div>
|
||||
</section>
|
||||
<section className="action-table panel">
|
||||
<div className="panel-heading"><div><h2>待处理事项</h2><p>优先处理即将影响合作伙伴访问的问题。</p></div></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>事项</th><th>相关对象</th><th>时间</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
{expiring.map((user) => <tr key={`exp-${user.id}`}><td><span className="table-leading warning"><Icon name="clock" size={17} /></span>账号即将到期</td><td><b>{user.displayName}</b><small>{user.username}</small></td><td>{formatDate(user.validTo)}</td><td><span className="status warning"><i />即将到期</span></td><td><button className="link-button" onClick={() => onNavigate("partners")}>查看详情</button></td></tr>)}
|
||||
{expired.map((user) => <tr key={`old-${user.id}`}><td><span className="table-leading danger"><Icon name="clock" size={17} /></span>账号授权已到期</td><td><b>{user.displayName}</b><small>{user.username}</small></td><td>{formatDate(user.validTo)}</td><td><Status value="disabled" label="已过期" /></td><td><button className="link-button" onClick={() => onNavigate("partners")}>处理</button></td></tr>)}
|
||||
{!vehicleCount && apps.map((app) => <tr key={`grant-${app.id}`}><td><span className="table-leading danger"><Icon name="car" size={17} /></span>应用尚未授权车辆</td><td><b>{app.name}</b><small>{app.appKeyPrefix}••••••••</small></td><td>—</td><td><span className="status warning"><i />待处理</span></td><td><button className="link-button" onClick={() => onNavigate("grants")}>前往授权</button></td></tr>)}
|
||||
{!expiring.length && !expired.length && (vehicleCount > 0 || !apps.length) && <tr><td colSpan={5}><div className="table-empty"><Icon name="check" size={20} />当前没有需要处理的授权风险</div></td></tr>}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
type Shared = {
|
||||
apps: AdminApp[]; users: PortalUser[]; refresh: () => Promise<void>;
|
||||
setNotice: (value: string) => void; onKey: (value: string) => void;
|
||||
};
|
||||
|
||||
function AppsManager({ apps, refresh, setNotice, onKey }: Shared) {
|
||||
const [editing, setEditing] = useState<AdminApp | "new" | null>(null);
|
||||
return <>
|
||||
<PageHeader title="开放应用" description="每个应用拥有独立 AppKey、有效期和车辆访问范围。" action={<button className="button primary" onClick={() => setEditing("new")}><Icon name="plus" size={17} />创建开放应用</button>} />
|
||||
{!apps.length ? <EmptyState title="还没有开放应用" body="创建首个应用后,才能分配合作伙伴和授权车辆。" action={<button className="button primary" onClick={() => setEditing("new")}>创建开放应用</button>} /> :
|
||||
<section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索开放应用" placeholder="搜索应用名称或 AppKey 标识" /></div><span>共 {apps.length} 个应用</span></div><div className="table-scroll"><table><thead><tr><th>应用名称</th><th>AppKey 标识</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody>{apps.map((app) => <tr key={app.id}><td><b>{app.name}</b><small>应用 ID {app.id}</small></td><td><code>{app.appKeyPrefix}••••••••</code></td><td>{formatDate(app.validFrom)} — {formatDate(app.validTo)}</td><td><Status value={app.status} /></td><td><button className="link-button" onClick={() => setEditing(app)}>编辑</button><button className="link-button" onClick={async () => { if (!window.confirm(`确认轮换“${app.name}”的 AppKey?旧密钥将立即失效。`)) return; try { const result = await api.rotateKey(app.id); onKey(result.appKey); } catch (reason) { setNotice(errorMessage(reason)); } }}>轮换密钥</button></td></tr>)}</tbody></table></div></section>}
|
||||
{editing && <AppDrawer app={editing} onClose={() => setEditing(null)} onSaved={async (message, key) => { setEditing(null); if (key) onKey(key); setNotice(message); await refresh(); }} />}
|
||||
</>;
|
||||
}
|
||||
|
||||
function AppDrawer({ app, onClose, onSaved }: { app: AdminApp | "new"; onClose: () => void; onSaved: (message: string, key?: string) => Promise<void> }) {
|
||||
const existing = app === "new" ? null : app;
|
||||
const [name, setName] = useState(existing?.name || "");
|
||||
const [status, setStatus] = useState(existing?.status || "enabled");
|
||||
const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today());
|
||||
const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setError("");
|
||||
if (!name.trim()) return setError("请输入应用名称。");
|
||||
if (!validFrom || !validTo || validTo <= validFrom) return setError("截止日期必须晚于生效日期。");
|
||||
setSaving(true);
|
||||
try {
|
||||
if (existing) {
|
||||
await api.updateApp(existing.id, { name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) });
|
||||
await onSaved("应用设置已保存。");
|
||||
} else {
|
||||
const created = await api.createApp({ name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) });
|
||||
await onSaved("应用已创建。完整 AppKey 仅展示一次,请立即保存。", created.appKey);
|
||||
}
|
||||
} catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="drawer-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}><aside className="detail-drawer"><header><div><h2>{existing ? "编辑开放应用" : "创建开放应用"}</h2><p>{existing ? "更新应用状态和访问有效期。" : "创建独立凭证,稍后配置合作伙伴和车辆。"}</p></div><button aria-label="关闭" onClick={onClose}><Icon name="close" /></button></header><form onSubmit={submit}><section><h3>应用信息</h3><label>应用名称<input value={name} onChange={(event) => setName(event.target.value)} maxLength={96} placeholder="例如:华东车队数据接入" /></label><label>状态<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="enabled">启用</option><option value="disabled">停用</option></select></label></section><section><h3>应用有效期</h3><div className="field-row"><label>生效日期<input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><label>截止日期<input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div></section>{error && <div className="form-error" role="alert">{error}</div>}<footer><button type="button" className="button secondary" onClick={onClose}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : existing ? "保存更改" : "创建并生成 AppKey"}</button></footer></form></aside></div>;
|
||||
}
|
||||
|
||||
function PartnersManager({ apps, users, refresh, setNotice }: Shared) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<PortalUser | "new" | null>(null);
|
||||
const filtered = users.filter((user) => `${user.displayName} ${user.username}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return <>
|
||||
<PageHeader title="合作伙伴" description="创建开发者账号,控制账号有效期并分配应用角色。" action={<button className="button primary" onClick={() => setEditing("new")} disabled={!apps.length}><Icon name="plus" size={17} />添加合作伙伴</button>} />
|
||||
{!apps.length && <div className="context-alert"><Icon name="apps" size={18} /><span><b>请先创建开放应用</b>添加合作伙伴前,至少需要一个可以分配的开放应用。</span></div>}
|
||||
<section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="搜索合作伙伴" placeholder="搜索合作伙伴名称或登录账号" /></div><span>共 {filtered.length} 个合作伙伴</span></div><div className="table-scroll"><table><thead><tr><th>合作伙伴</th><th>登录账号</th><th>账号有效期</th><th>最近登录</th><th>状态</th><th>操作</th></tr></thead><tbody>{filtered.map((user) => <tr key={user.id} className={editing !== "new" && editing?.id === user.id ? "selected" : ""}><td><b>{user.displayName}</b><small>合作伙伴 ID {user.id}</small></td><td><code>{user.username}</code></td><td>{formatDate(user.validFrom)} — {formatDate(user.validTo)}</td><td>{formatTime(user.lastLoginAt)}</td><td><Status value={user.status} /></td><td><button className="link-button" onClick={() => setEditing(user)}>查看与编辑</button></td></tr>)}{!filtered.length && <tr><td colSpan={6}><div className="table-empty">没有匹配的合作伙伴</div></td></tr>}</tbody></table></div></section>
|
||||
{editing && <PartnerDrawer user={editing} apps={apps} onClose={() => setEditing(null)} onSaved={async (message) => { setEditing(null); setNotice(message); await refresh(); }} />}
|
||||
</>;
|
||||
}
|
||||
|
||||
type Membership = { appId: number; role: string; enabled: boolean };
|
||||
|
||||
function PartnerDrawer({ user, apps, onClose, onSaved }: { user: PortalUser | "new"; apps: AdminApp[]; onClose: () => void; onSaved: (message: string) => Promise<void> }) {
|
||||
const existing = user === "new" ? null : user;
|
||||
const [displayName, setDisplayName] = useState(existing?.displayName || "");
|
||||
const [username, setUsername] = useState(existing?.username || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [status, setStatus] = useState(existing?.status || "enabled");
|
||||
const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today());
|
||||
const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear());
|
||||
const [memberships, setMemberships] = useState<Membership[]>(apps.map((app) => ({ appId: app.id, role: "developer", enabled: false })));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing) return;
|
||||
api.userApps(existing.id).then((assigned) => setMemberships(apps.map((app) => {
|
||||
const membership = assigned.find((item) => item.appId === app.id);
|
||||
return { appId: app.id, role: membership?.role || "developer", enabled: Boolean(membership) };
|
||||
}))).catch((reason) => setError(errorMessage(reason)));
|
||||
}, [existing?.id, apps]);
|
||||
|
||||
function validate() {
|
||||
if (!/^[A-Za-z0-9._-]{3,64}$/.test(username.trim())) return "登录账号需为 3–64 位,只能包含字母、数字、点、下划线或连字符。";
|
||||
if (!displayName.trim() || [...displayName.trim()].length > 96) return "显示名称不能为空且不能超过 96 个字符。";
|
||||
if (!existing && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "初始密码必须为 12–128 位,并包含大写字母、小写字母和数字。";
|
||||
if (password && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "新密码必须为 12–128 位,并包含大写字母、小写字母和数字。";
|
||||
if (!validFrom || !validTo || validTo <= validFrom) return "截止日期必须晚于生效日期。";
|
||||
return "";
|
||||
}
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setError("");
|
||||
const validation = validate(); if (validation) return setError(validation);
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { username: username.trim(), displayName: displayName.trim(), password, status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) };
|
||||
const saved = existing ? await api.updateUser(existing.id, input) : await api.createUser(input);
|
||||
await api.replaceUserApps(saved.id, memberships.filter((item) => item.enabled).map(({ appId, role }) => ({ appId, role })));
|
||||
await onSaved(existing ? "合作伙伴账号和应用权限已保存。" : "合作伙伴账号已创建并完成应用授权。");
|
||||
} catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="drawer-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}><aside className="detail-drawer partner-drawer"><header><div><h2>{existing ? "合作伙伴详情" : "添加合作伙伴"}</h2><p>{existing ? "维护账号状态、有效期和应用角色。" : "一次完成账号创建和应用权限分配。"}</p></div><button aria-label="关闭" onClick={onClose}><Icon name="close" /></button></header><div className="drawer-steps"><span className="active"><i>1</i>基础信息</span><span className="active"><i>2</i>应用权限</span><span><i>3</i>确认保存</span></div><form onSubmit={submit}><section><h3>基础信息</h3><label>显示名称<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} maxLength={96} placeholder="合作伙伴企业或团队名称" /></label><label>登录账号<input value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3–64 位字母、数字或 . _ -" /></label><label>状态<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="enabled">启用</option><option value="disabled">停用并撤销会话</option></select></label></section><section><h3>账号有效期</h3><div className="field-row"><label>生效日期<input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><label>截止日期<input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div></section><section><div className="section-label"><h3>应用角色</h3><small>Owner 可轮换密钥;Developer 可调用;Viewer 只读。</small></div><div className="membership-list">{memberships.map((membership) => { const app = apps.find((item) => item.id === membership.appId)!; return <div key={membership.appId}><label className="check-row"><input type="checkbox" checked={membership.enabled} onChange={(event) => setMemberships((items) => items.map((item) => item.appId === membership.appId ? { ...item, enabled: event.target.checked } : item))} /><span><b>{app.name}</b><small>{app.appKeyPrefix}••••••••</small></span></label><select aria-label={`${app.name}角色`} disabled={!membership.enabled} value={membership.role} onChange={(event) => setMemberships((items) => items.map((item) => item.appId === membership.appId ? { ...item, role: event.target.value } : item))}><option value="owner">Owner</option><option value="developer">Developer</option><option value="viewer">Viewer</option></select></div>; })}</div></section><section><h3>{existing ? "安全" : "初始密码"}</h3><label>{existing ? "重置密码(不修改请留空)" : "初始密码"}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 12 位,含大小写字母和数字" /><small className="field-help">{existing ? "保存新密码会立即撤销该合作伙伴的现有会话。" : "请通过安全渠道将初始密码交给合作伙伴。"}</small></label>{existing && <div className="last-login"><span>最近登录</span><b>{formatTime(existing.lastLoginAt)}</b></div>}</section>{error && <div className="form-error" role="alert">{error}</div>}<footer><button type="button" className="button secondary" onClick={onClose}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : existing ? "保存更改" : "创建并授权"}</button></footer></form></aside></div>;
|
||||
}
|
||||
|
||||
function SecurityAudit({ apps }: { apps: AdminApp[] }) {
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [audit, setAudit] = useState<AuditItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => { if (!appId && apps[0]) setAppId(apps[0].id); }, [apps, appId]);
|
||||
useEffect(() => { if (appId) api.audit(appId).then(setAudit).catch((reason) => setError(errorMessage(reason))); }, [appId]);
|
||||
return <>
|
||||
<PageHeader title="调用与安全" description="按应用查看最近 API 调用结果和 Trace ID。" action={apps.length ? <select className="header-select" value={appId} onChange={(event) => setAppId(Number(event.target.value))}>{apps.map((app) => <option value={app.id} key={app.id}>{app.name}</option>)}</select> : undefined} />
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<section className="panel directory-panel"><div className="panel-heading"><div><h2>最近调用记录</h2><p>最多展示最近 100 条记录。</p></div><span>{audit.length} 条</span></div><div className="table-scroll"><table><thead><tr><th>时间</th><th>接口</th><th>车辆数</th><th>结果</th><th>Trace ID</th></tr></thead><tbody>{audit.map((item) => <tr key={`${item.traceId}-${item.createdAt}`}><td>{formatTime(item.createdAt)}</td><td><code>{item.endpoint}</code></td><td>{item.vehicleCount}</td><td><Status value={item.result} /></td><td><code>{item.traceId}</code></td></tr>)}{!audit.length && <tr><td colSpan={5}><div className="table-empty">暂无调用记录</div></td></tr>}</tbody></table></div></section>
|
||||
</>;
|
||||
}
|
||||
170
vehicle-data-platform/apps/open-portal/src/App.test.tsx
Normal file
170
vehicle-data-platform/apps/open-portal/src/App.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "./App";
|
||||
|
||||
const app = {
|
||||
id: 7,
|
||||
name: "测试开放应用",
|
||||
appKeyPrefix: "12345678",
|
||||
status: "enabled",
|
||||
validFrom: "2026-07-20T00:00:00+08:00",
|
||||
validTo: "2027-07-20T00:00:00+08:00"
|
||||
};
|
||||
|
||||
describe("open platform portal", () => {
|
||||
beforeEach(() => {
|
||||
window.location.hash = "/";
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) }));
|
||||
});
|
||||
|
||||
it("renders the factual public homepage and interactive API example", () => {
|
||||
render(<App />);
|
||||
expect(screen.getAllByAltText("羚牛智能").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("heading", { name: "车辆数据,按需开放。" })).toBeInTheDocument();
|
||||
expect(screen.getByText("车辆日用氢量")).toBeInTheDocument();
|
||||
expect(screen.getByText("车辆日里程")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "进入控制台" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "阅读文档" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "日里程" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("日里程响应示例")).toHaveTextContent("totalMileageKm");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "日用氢量" }));
|
||||
expect(screen.getByRole("tab", { name: "日用氢量" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("日用氢量响应示例")).toHaveTextContent("hydrogenConsumptionKg");
|
||||
});
|
||||
|
||||
it("provides complete onboarding and API reference documentation", async () => {
|
||||
window.location.hash = "/docs";
|
||||
render(<App />);
|
||||
expect(screen.getByRole("heading", { name: "五分钟完成首次调用" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "获取 AppKey" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "确认授权车辆" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("/api/v1/vehicles/mileage/query").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "车辆区间日里程" }));
|
||||
expect(screen.getByRole("heading", { name: "车辆区间日里程" })).toBeInTheDocument();
|
||||
expect(screen.getByText("/api/v1/vehicles/mileage/range/query")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("snapshotId", { exact: false }).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "错误码与重试" }));
|
||||
expect(screen.getByRole("heading", { name: "错误码与重试" })).toBeInTheDocument();
|
||||
expect(screen.getByText("INVALID_DATE_FORMAT")).toBeInTheDocument();
|
||||
expect(screen.getByText("指数退避", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives administrators a dedicated partner management flow", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "a".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" };
|
||||
} else if (url.endsWith("/portal-api/admin/apps")) {
|
||||
data = [app];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "合作伙伴" }));
|
||||
expect(await screen.findByRole("heading", { name: "合作伙伴" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "添加合作伙伴" })[0]);
|
||||
expect(await screen.findByRole("heading", { name: "添加合作伙伴" })).toBeInTheDocument();
|
||||
expect(screen.getByText("应用角色")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("显示名称"), { target: { value: "合作伙伴甲" } });
|
||||
fireEvent.change(screen.getByLabelText("登录账号"), { target: { value: "partner-a" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("至少 12 位,含大小写字母和数字"), { target: { value: "weak-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "创建并授权" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("初始密码必须为 12–128 位");
|
||||
});
|
||||
|
||||
it("gives partner developers a focused application workspace", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "b".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = {
|
||||
userId: 2,
|
||||
username: "partner-a",
|
||||
displayName: "合作伙伴甲",
|
||||
userType: "partner",
|
||||
expiresAt: "2026-07-21T00:00:00+08:00"
|
||||
};
|
||||
} else if (url.endsWith("/portal-api/apps")) {
|
||||
data = [{
|
||||
appId: 7,
|
||||
appName: "测试开放应用",
|
||||
appKeyPrefix: "12345678",
|
||||
appStatus: "enabled",
|
||||
role: "owner",
|
||||
validFrom: "2026-07-20T00:00:00+08:00",
|
||||
validTo: "2027-07-20T00:00:00+08:00"
|
||||
}];
|
||||
} else if (url.includes("/vehicles")) {
|
||||
data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00" }];
|
||||
} else if (url.includes("/audit")) {
|
||||
data = [{ traceId: "trace-1", endpoint: "mileage", result: "success", vehicleCount: 1, createdAt: "2026-07-20T12:00:00+08:00" }];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
const navigation = await screen.findByRole("navigation", { name: "开发者控制台导航" });
|
||||
expect(await screen.findByText("12345678••••••••")).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("button", { name: "应用与密钥" })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("button", { name: "授权车辆" })).toBeInTheDocument();
|
||||
fireEvent.click(within(navigation).getByRole("button", { name: "授权车辆" }));
|
||||
expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("button", { name: "合作伙伴" })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(navigation).getByRole("button", { name: "API 调试" }));
|
||||
fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "mileage/range" } });
|
||||
expect(screen.getAllByText("/api/v1/vehicles/mileage/range/query", { exact: false }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("开始日期")).toBeInTheDocument();
|
||||
fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "total-mileage" } });
|
||||
expect(screen.getAllByText("/api/v1/vehicles/total-mileage/query", { exact: false }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("采集协议")).toHaveValue("");
|
||||
fireEvent.change(screen.getByLabelText("采集协议"), { target: { value: "YUTONG_MQTT" } });
|
||||
expect(screen.getByText(/"protocol": "YUTONG_MQTT"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets administrators select, paste, and add all catalog vehicles", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "c".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" };
|
||||
} else if (url.endsWith("/portal-api/admin/apps")) {
|
||||
data = [app];
|
||||
} else if (url.endsWith("/portal-api/admin/vehicles")) {
|
||||
data = [
|
||||
{ vin: "LNB00000000000001", plate: "辽A00001", oem: "羚牛", status: "available", source: "车辆主数据" },
|
||||
{ vin: "LNB00000000000002", plate: "辽A00002", oem: "羚牛", status: "available", source: "车辆主数据" }
|
||||
];
|
||||
} else if (url.includes("/portal-api/apps/7/vehicles")) {
|
||||
data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00", validTo: "2027-07-20T00:00:00+08:00" }];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "车辆授权" }));
|
||||
expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("辽A00002")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "批量粘贴" }));
|
||||
fireEvent.change(screen.getByLabelText("批量输入 VIN 或车牌"), { target: { value: "辽A00002\nUNKNOWN" } });
|
||||
expect(await screen.findByText("识别 1 辆 · 未匹配 1 条")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用识别结果" }));
|
||||
expect(screen.getByRole("button", { name: "保存 1 辆授权" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "全部车辆" }));
|
||||
expect(screen.getByRole("heading", { name: "已选择当前全部车辆" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "保存 2 辆授权" })).toBeInTheDocument();
|
||||
expect(screen.getByText("未来新增车辆不会自动获得权限", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
34
vehicle-data-platform/apps/open-portal/src/App.tsx
Normal file
34
vehicle-data-platform/apps/open-portal/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Session, tokenStore } from "./api";
|
||||
import { LoginPage } from "./Auth";
|
||||
import { Console } from "./Console";
|
||||
import { PublicSite } from "./PublicSite";
|
||||
import { Brand, currentRoute, type Route } from "./ui";
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState<Route>(currentRoute);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [checking, setChecking] = useState(Boolean(tokenStore.get()));
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setRoute(currentRoute());
|
||||
window.addEventListener("hashchange", onHash);
|
||||
if (tokenStore.get()) {
|
||||
api.session()
|
||||
.then(setSession)
|
||||
.catch(() => tokenStore.clear())
|
||||
.finally(() => setChecking(false));
|
||||
}
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void api.catalog().catch(() => undefined); }, []);
|
||||
|
||||
if (route === "console") {
|
||||
if (checking) return <div className="loading-screen"><Brand /><span /></div>;
|
||||
if (!session) return <LoginPage onLogin={setSession} />;
|
||||
return <Console session={session} onLogout={() => setSession(null)} />;
|
||||
}
|
||||
|
||||
return <PublicSite route={route} />;
|
||||
}
|
||||
39
vehicle-data-platform/apps/open-portal/src/Auth.tsx
Normal file
39
vehicle-data-platform/apps/open-portal/src/Auth.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { api, type Session, tokenStore } from "./api";
|
||||
import { Brand, Icon, navigate } from "./ui";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (session: Session) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(""); setLoading(true);
|
||||
try {
|
||||
const response = await api.login(username.trim(), password);
|
||||
tokenStore.set(response.accessToken);
|
||||
onLogin(response.session);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <main className="login-page">
|
||||
<button className="login-back" onClick={() => navigate("home")}><Icon name="arrow" size={16} />返回开放平台</button>
|
||||
<section className="login-panel">
|
||||
<Brand />
|
||||
<div className="login-heading"><h1>登录开放平台</h1><p>使用平台管理员账号,或由管理员创建的合作伙伴账号。</p></div>
|
||||
<form onSubmit={submit}>
|
||||
<label>登录账号<input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></label>
|
||||
<label>密码<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<button className="button primary large full" disabled={loading || !username.trim() || !password}>{loading ? "正在登录…" : "登录控制台"}</button>
|
||||
</form>
|
||||
<div className="login-help"><Icon name="shield" size={17} /><p><b>两类账号,同一入口</b><span>管理员使用内部车辆平台身份;合作伙伴使用独立开放平台账号。</span></p></div>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
177
vehicle-data-platform/apps/open-portal/src/Console.tsx
Normal file
177
vehicle-data-platform/apps/open-portal/src/Console.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { AdminWorkspace, type AdminSection } from "./AdminWorkspace";
|
||||
import { api, type AuditItem, type PortalApp, type Session, tokenStore, type VehicleGrant } from "./api";
|
||||
import { Brand, EmptyState, errorMessage, formatDate, formatTime, Icon, navigate, PageHeader, Status } from "./ui";
|
||||
|
||||
type PartnerSection = "overview" | "credentials" | "vehicles" | "sandbox" | "audit" | "account";
|
||||
type Section = AdminSection | PartnerSection;
|
||||
|
||||
const adminNavigation: Array<[AdminSection | "account", string, "home" | "apps" | "users" | "car" | "shield" | "settings"]> = [
|
||||
["admin-overview", "总览", "home"],
|
||||
["apps", "开放应用", "apps"],
|
||||
["partners", "合作伙伴", "users"],
|
||||
["grants", "车辆授权", "car"],
|
||||
["security", "调用与安全", "shield"],
|
||||
["account", "账号设置", "settings"]
|
||||
];
|
||||
|
||||
const partnerNavigation: Array<[PartnerSection, string, "home" | "key" | "car" | "terminal" | "activity" | "settings"]> = [
|
||||
["overview", "总览", "home"],
|
||||
["credentials", "应用与密钥", "key"],
|
||||
["vehicles", "授权车辆", "car"],
|
||||
["sandbox", "API 调试", "terminal"],
|
||||
["audit", "调用记录", "activity"],
|
||||
["account", "账号设置", "settings"]
|
||||
];
|
||||
|
||||
export function Console({ session, onLogout }: { session: Session; onLogout: () => void }) {
|
||||
const isAdmin = session.userType === "admin";
|
||||
const [section, setSection] = useState<Section>(isAdmin ? "admin-overview" : "overview");
|
||||
const [apps, setApps] = useState<PortalApp[]>([]);
|
||||
const [selectedId, setSelectedId] = useState(0);
|
||||
const [vehicles, setVehicles] = useState<VehicleGrant[]>([]);
|
||||
const [audit, setAudit] = useState<AuditItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const selected = useMemo(() => apps.find((item) => item.appId === selectedId), [apps, selectedId]);
|
||||
const navigation = isAdmin ? adminNavigation : partnerNavigation;
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const items = await api.apps();
|
||||
setApps(items);
|
||||
if (!items.some((item) => item.appId === selectedId) && items[0]) setSelectedId(items[0].appId);
|
||||
} catch (reason) { setError(errorMessage(reason)); }
|
||||
}
|
||||
useEffect(() => { void loadApps(); }, []);
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setVehicles([]); setAudit([]); return; }
|
||||
Promise.all([api.vehicles(selectedId), api.audit(selectedId)])
|
||||
.then(([nextVehicles, nextAudit]) => { setVehicles(nextVehicles); setAudit(nextAudit); })
|
||||
.catch((reason) => setError(errorMessage(reason)));
|
||||
}, [selectedId]);
|
||||
|
||||
async function logout() {
|
||||
try { await api.logout(); } catch { /* local logout still applies */ }
|
||||
tokenStore.clear(); onLogout();
|
||||
}
|
||||
async function rotate() {
|
||||
if (!selected || !window.confirm(`确认轮换“${selected.appName}”的 AppKey?旧密钥将立即失效。`)) return;
|
||||
try { const response = await api.rotateKey(selected.appId); setNewKey(response.appKey); await loadApps(); }
|
||||
catch (reason) { setError(errorMessage(reason)); }
|
||||
}
|
||||
function choose(next: Section) { setSection(next); setMenuOpen(false); }
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className={`app-sidebar${menuOpen ? " open" : ""}`}>
|
||||
<div className="sidebar-brand"><Brand compact /><button aria-label="关闭菜单" onClick={() => setMenuOpen(false)}><Icon name="close" /></button></div>
|
||||
{!isAdmin && <div className="sidebar-app"><small>当前应用</small><select aria-label="当前应用" value={selectedId} onChange={(event) => setSelectedId(Number(event.target.value))}>{apps.map((app) => <option value={app.appId} key={app.appId}>{app.appName}</option>)}</select>{selected && <Status value={selected.appStatus} label={selected.appStatus === "enabled" ? "运行中" : "已停用"} />}</div>}
|
||||
<nav aria-label={isAdmin ? "平台管理导航" : "开发者控制台导航"}>{navigation.map(([id, label, icon]) => <button className={section === id ? "active" : ""} onClick={() => choose(id)} key={id}><Icon name={icon} size={19} />{label}</button>)}<button onClick={() => navigate("docs")}><Icon name="book" size={19} />接口文档<span>↗</span></button></nav>
|
||||
<div className="sidebar-user"><span>{session.displayName.slice(0, 1).toUpperCase()}</span><div><b>{session.displayName}</b><small>{isAdmin ? "平台管理员" : "合作伙伴开发者"}</small></div><button aria-label="退出登录" onClick={logout}><Icon name="logout" size={18} /></button></div>
|
||||
</aside>
|
||||
{menuOpen && <button className="mobile-scrim" aria-label="关闭菜单" onClick={() => setMenuOpen(false)} />}
|
||||
<main className="workspace">
|
||||
<div className="mobile-bar"><button aria-label="打开菜单" onClick={() => setMenuOpen(true)}><Icon name="menu" /></button><Brand compact /><span>{session.displayName.slice(0, 1)}</span></div>
|
||||
{error && <div className="notice-banner error" role="alert"><span>{error}</span><button onClick={() => setError("")}><Icon name="close" size={16} /></button></div>}
|
||||
{isAdmin && section !== "account" && <AdminWorkspace section={section as AdminSection} onNavigate={choose} onKey={setNewKey} />}
|
||||
{!isAdmin && <PartnerWorkspace section={section as PartnerSection} app={selected} vehicles={vehicles} audit={audit} onRotate={rotate} />}
|
||||
{section === "account" && <Account session={session} onChanged={logout} />}
|
||||
</main>
|
||||
{newKey && <KeyModal value={newKey} onClose={() => setNewKey("")} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PartnerWorkspace({ section, app, vehicles, audit, onRotate }: { section: PartnerSection; app?: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onRotate: () => void }) {
|
||||
if (!app && section !== "account") return <><PageHeader title="开发者控制台" description="管理应用凭证、车辆范围和 API 调用。" /><EmptyState title="尚未分配开放应用" body="请联系平台管理员,为此账号分配至少一个开放应用和角色。" /></>;
|
||||
if (!app) return null;
|
||||
if (section === "overview") return <DeveloperOverview app={app} vehicles={vehicles} audit={audit} onOpenSandbox={() => window.location.hash = "/console"} />;
|
||||
if (section === "credentials") return <Credentials app={app} onRotate={onRotate} />;
|
||||
if (section === "vehicles") return <PartnerVehicles vehicles={vehicles} />;
|
||||
if (section === "sandbox") return <ApiSandbox app={app} vehicles={vehicles} />;
|
||||
if (section === "audit") return <PartnerAudit audit={audit} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function DeveloperOverview({ app, vehicles, audit, onOpenSandbox }: { app: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onOpenSandbox: () => void }) {
|
||||
const success = audit.filter((item) => item.result === "success").length;
|
||||
const successRate = audit.length ? `${Math.round(success / audit.length * 1000) / 10}%` : "—";
|
||||
return <>
|
||||
<PageHeader title="开发者控制台" description={`欢迎回来。当前正在管理“${app.appName}”。`} />
|
||||
<section className="developer-facts panel"><div><small>AppKey</small><strong><code>{app.appKeyPrefix}••••••••</code></strong><span><Icon name="shield" size={15} />仅显示安全标识</span></div><div><small>授权车辆</small><strong>{vehicles.length}<em> 辆</em></strong><span>当前应用可访问</span></div><div><small>授权有效期</small><strong className="date-fact">{formatDate(app.validFrom)} <em>至</em> {formatDate(app.validTo)}</strong><span><Status value={app.appStatus} label={app.appStatus === "enabled" ? "运行中" : "已停用"} /></span></div></section>
|
||||
<section className="getting-started panel"><div className="panel-heading"><div><h2>开始接入</h2><p>沿着这条路径完成首次生产调用。</p></div></div><div className="integration-steps">{[
|
||||
["保存 AppKey", true], ["查看车辆范围", vehicles.length > 0], ["调用测试接口", audit.length > 0], ["接入生产", audit.length >= 10]
|
||||
].map(([label, done], index) => <div key={String(label)} className={done ? "done" : index === 2 ? "active" : ""}><span>{done ? <Icon name="check" size={17} /> : index + 1}</span><b>{label}</b><small>{done ? "已完成" : index === 2 ? "进行中" : "待完成"}</small></div>)}</div><a className="button primary" href="/open-api/swagger/" target="_blank" rel="noreferrer" onClick={onOpenSandbox}>打开 API 调试 <Icon name="arrow" size={16} /></a></section>
|
||||
<div className="developer-grid"><section className="panel usage-panel"><div className="panel-heading"><div><h2>近期调用</h2><p>最近 {audit.length} 条审计记录</p></div><button className="link-button">查看调用记录</button></div><div className="usage-stats"><span><small>请求数</small><strong>{audit.length}</strong></span><span><small>成功率</small><strong>{successRate}</strong></span><span><small>访问车辆</small><strong>{new Set(audit.map((item) => item.vehicleCount)).size || "—"}</strong></span></div><div className="mini-bars" aria-label="近期调用趋势">{[32, 46, 38, 62, 58, 76, 68, 86, 72, 54, 64, 42].map((height, index) => <i key={index} style={{ height: `${height}%` }} />)}</div></section><section className="panel common-apis"><div className="panel-heading"><div><h2>常用接口</h2><p>当前已开放的数据产品</p></div></div><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆日用氢量</strong><code>/api/v1/vehicles/hydrogen-consumption/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆日里程</strong><code>/api/v1/vehicles/mileage/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆区间日里程</strong><code>/api/v1/vehicles/mileage/range/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>指定时刻总里程</strong><code>/api/v1/vehicles/total-mileage/query</code></span><Icon name="arrow" size={18} /></a></section></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Credentials({ app, onRotate }: { app: PortalApp; onRotate: () => void }) {
|
||||
return <>
|
||||
<PageHeader title="应用与密钥" description="查看应用授权状态,Owner 可以轮换 AppKey。" />
|
||||
<section className="panel credential-panel"><div className="panel-heading"><div><h2>{app.appName}</h2><p>应用 ID {app.appId}</p></div><Status value={app.appStatus} /></div><dl><div><dt>AppKey 安全标识</dt><dd><code>{app.appKeyPrefix}••••••••••••••••••••••••</code></dd></div><div><dt>当前角色</dt><dd><span className="role-chip">{app.role}</span></dd></div><div><dt>应用有效期</dt><dd>{formatDate(app.validFrom)} — {formatDate(app.validTo)}</dd></div></dl><div className="security-callout"><Icon name="shield" size={22} /><div><b>完整 AppKey 不会再次展示</b><p>如怀疑密钥泄露,请立即轮换。旧密钥会即时失效,操作会写入安全审计。</p></div>{app.role === "owner" ? <button className="button danger" onClick={onRotate}>轮换 AppKey</button> : <span>仅 Owner 可轮换</span>}</div></section>
|
||||
</>;
|
||||
}
|
||||
|
||||
function PartnerVehicles({ vehicles }: { vehicles: VehicleGrant[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = vehicles.filter((item) => `${item.plate} ${item.vin}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return <><PageHeader title="授权车辆" description="这些车辆可以通过当前应用的数据接口访问。" action={<span className="header-count">{vehicles.length} 辆</span>} /><section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索授权车辆" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索车牌或 VIN" /></div></div><div className="table-scroll"><table><thead><tr><th>车牌号</th><th>VIN</th><th>授权起始</th><th>授权截止</th><th>状态</th></tr></thead><tbody>{filtered.map((item) => <tr key={item.vin}><td><b>{item.plate || "—"}</b></td><td><code>{item.vin}</code></td><td>{formatDate(item.validFrom)}</td><td>{formatDate(item.validTo)}</td><td><Status value="enabled" label="有效" /></td></tr>)}{!filtered.length && <tr><td colSpan={5}><div className="table-empty">没有匹配的授权车辆</div></td></tr>}</tbody></table></div></section></>;
|
||||
}
|
||||
|
||||
function ApiSandbox({ app, vehicles }: { app: PortalApp; vehicles: VehicleGrant[] }) {
|
||||
const [product, setProduct] = useState("hydrogen-consumption");
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [startDate, setStartDate] = useState(new Date(Date.now() - 6 * 86400000).toISOString().slice(0, 10));
|
||||
const [queryTime, setQueryTime] = useState(new Date().toISOString().slice(0, 16));
|
||||
const [protocol, setProtocol] = useState("");
|
||||
const [protocolPriority, setProtocolPriority] = useState("GB32960,MQTT,JT808");
|
||||
const plate = vehicles.find((item) => item.plate)?.plate || "授权车辆车牌";
|
||||
const vin = vehicles[0]?.vin || "授权车辆VIN";
|
||||
const isTotalMileage = product === "total-mileage";
|
||||
const isMileageRange = product === "mileage/range";
|
||||
const supportsProtocolPriority = product === "mileage" || isMileageRange;
|
||||
const priorityJSON = protocolPriority.split(",").map((item) => `"${item}"`).join(", ");
|
||||
const endpoint = `/api/v1/vehicles/${product}/query`;
|
||||
const requestBody = isTotalMileage ? `{
|
||||
"vin": "${vin}",
|
||||
"time": "${queryTime.replace("T", " ")}:00"${protocol ? `,
|
||||
"protocol": "${protocol}"` : ""}
|
||||
}` : isMileageRange ? `{
|
||||
"startDate": "${startDate}",
|
||||
"endDate": "${date}",
|
||||
"protocolPriority": [${priorityJSON}],
|
||||
"pageSize": 5000
|
||||
}` : `{
|
||||
"plateNumbers": ["${plate}"],
|
||||
"date": "${date}"${supportsProtocolPriority ? `,
|
||||
"protocolPriority": [${priorityJSON}]` : ""}
|
||||
}`;
|
||||
const curl = `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${endpoint} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '${requestBody}'`;
|
||||
return <><PageHeader title="API 调试" description="生成当前应用的请求示例,并进入 Swagger 发送真实请求。" action={<a className="button primary" href="/open-api/swagger/" target="_blank" rel="noreferrer">打开 Swagger ↗</a>} /><div className="sandbox-layout"><section className="panel sandbox-form"><div className="panel-heading"><div><h2>请求配置</h2><p>AppKey 请在 Swagger 的 Authorize 中安全输入。</p></div></div><label>数据接口<select value={product} onChange={(event) => setProduct(event.target.value)}><option value="hydrogen-consumption">车辆日用氢量</option><option value="mileage">车辆日里程</option><option value="mileage/range">车辆区间日里程</option><option value="total-mileage">指定时刻总里程</option></select></label>{isTotalMileage ? <><label>查询时间<input type="datetime-local" value={queryTime} onChange={(event) => setQueryTime(event.target.value)} /></label><label>采集协议<select value={protocol} onChange={(event) => setProtocol(event.target.value)}><option value="">自动选择(GB32960 > YUTONG_MQTT > JT808)</option><option value="GB32960">GB32960</option><option value="YUTONG_MQTT">YUTONG_MQTT</option><option value="JT808">JT808</option></select></label><label>示例 VIN<input value={vin} readOnly /></label></> : isMileageRange ? <><label>开始日期<input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></label><label>结束日期<input type="date" value={date} onChange={(event) => setDate(event.target.value)} /></label></> : <><label>查询日期<input type="date" value={date} onChange={(event) => setDate(event.target.value)} /></label><label>示例车牌<input value={plate} readOnly /></label></>}{supportsProtocolPriority && <label>协议优先级<select value={protocolPriority} onChange={(event) => setProtocolPriority(event.target.value)}><option value="GB32960,MQTT,JT808">GB32960 > MQTT > JT808</option><option value="JT808,GB32960,MQTT">JT808 > GB32960 > MQTT</option><option value="GB32960,MQTT">GB32960 > MQTT</option><option value="JT808">仅 JT808</option></select></label>}<div className="sandbox-scope"><Icon name="car" size={18} /><span>当前应用共有 <b>{vehicles.length}</b> 辆授权车辆</span></div></section><section className="panel request-preview"><div className="panel-heading"><div><h2>请求预览</h2><p><b className="method">POST</b> {endpoint}</p></div><button className="button quiet" onClick={() => navigator.clipboard?.writeText(curl)}><Icon name="copy" size={16} />复制</button></div><pre><code>{curl}</code></pre></section></div></>;
|
||||
}
|
||||
|
||||
function PartnerAudit({ audit }: { audit: AuditItem[] }) {
|
||||
return <><PageHeader title="调用记录" description="定位失败请求,并通过 Trace ID 与平台管理员协作排查。" action={<span className="header-count">最近 {audit.length} 条</span>} /><section className="panel directory-panel"><div className="table-scroll"><table><thead><tr><th>时间</th><th>接口</th><th>车辆数</th><th>结果</th><th>Trace ID</th></tr></thead><tbody>{audit.map((item) => <tr key={`${item.traceId}-${item.createdAt}`}><td>{formatTime(item.createdAt)}</td><td><code>{item.endpoint}</code></td><td>{item.vehicleCount}</td><td><Status value={item.result} /></td><td><code>{item.traceId}</code></td></tr>)}{!audit.length && <tr><td colSpan={5}><div className="table-empty">暂无调用记录。完成首次 API 调用后会显示在这里。</div></td></tr>}</tbody></table></div></section></>;
|
||||
}
|
||||
|
||||
function Account({ session, onChanged }: { session: Session; onChanged: () => void }) {
|
||||
const [currentPassword, setCurrent] = useState("");
|
||||
const [newPassword, setNext] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setMessage("");
|
||||
try { await api.changePassword(currentPassword, newPassword); tokenStore.clear(); setMessage("密码已更新,请重新登录。"); window.setTimeout(onChanged, 900); }
|
||||
catch (reason) { setMessage(errorMessage(reason)); }
|
||||
}
|
||||
return <><PageHeader title="账号设置" description="查看当前身份和登录安全设置。" /><div className="account-layout"><section className="panel identity-panel"><span>{session.displayName.slice(0, 1).toUpperCase()}</span><h2>{session.displayName}</h2><p>{session.username}</p><dl><div><dt>身份类型</dt><dd>{session.userType === "admin" ? "平台管理员" : "合作伙伴开发者"}</dd></div><div><dt>会话有效至</dt><dd>{formatTime(session.expiresAt)}</dd></div><div><dt>账号 ID</dt><dd>{session.userId}</dd></div></dl></section>{session.userType === "admin" ? <section className="panel password-panel"><Icon name="shield" size={26} /><h2>统一管理员身份</h2><p>此账号与内部车辆平台共用身份和密码。请在内部平台维护密码,两个系统保持一致。</p><a className="button primary" href="https://vehicle.d.lnoneos.com/" target="_blank" rel="noreferrer">前往内部车辆平台 ↗</a></section> : <form className="panel password-panel" onSubmit={submit}><h2>修改登录密码</h2><p>密码需 12–128 位,并同时包含大小写字母和数字。</p><label>当前密码<input type="password" value={currentPassword} onChange={(event) => setCurrent(event.target.value)} /></label><label>新密码<input type="password" value={newPassword} onChange={(event) => setNext(event.target.value)} /></label>{message && <div className="notice-banner">{message}</div>}<button className="button primary" disabled={!currentPassword || !newPassword}>更新密码</button></form>}</div></>;
|
||||
}
|
||||
|
||||
function KeyModal({ value, onClose }: { value: string; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return <div className="modal-backdrop"><section className="key-modal" role="dialog" aria-modal="true" aria-labelledby="key-title"><span><Icon name="check" size={24} /></span><h2 id="key-title">AppKey 已生成</h2><p>出于安全考虑,此密钥只展示一次。请立即复制并存放到安全的密钥管理系统。</p><code>{value}</code><button className="button primary full" onClick={async () => { await navigator.clipboard?.writeText(value); setCopied(true); }}><Icon name="copy" size={17} />{copied ? "已复制" : "复制 AppKey"}</button><button className="button quiet full" onClick={onClose}>我已安全保存</button></section></div>;
|
||||
}
|
||||
313
vehicle-data-platform/apps/open-portal/src/Documentation.tsx
Normal file
313
vehicle-data-platform/apps/open-portal/src/Documentation.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { products } from "./catalog";
|
||||
import { Icon, navigate } from "./ui";
|
||||
|
||||
type Topic =
|
||||
| "overview" | "quickstart" | "auth" | "errors" | "limits"
|
||||
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage"
|
||||
| "vehicle-auth" | "key-rotation" | "quality" | "checklist";
|
||||
|
||||
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
|
||||
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] },
|
||||
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] },
|
||||
{ title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
|
||||
];
|
||||
|
||||
const topicTitles = new Map(groups.flatMap((group) => group.items));
|
||||
|
||||
export function DocumentationPage() {
|
||||
const [topic, setTopic] = useState<Topic>("quickstart");
|
||||
const title = topicTitles.get(topic) || "";
|
||||
return <main className="developer-docs">
|
||||
<aside className="developer-docs-nav">
|
||||
<button className="docs-console-link" onClick={() => navigate("console")}>前往控制台 <Icon name="arrow" size={15} /></button>
|
||||
{groups.map((group) => <section key={group.title}><h2>{group.title}</h2>{group.items.map(([id, label]) => <button key={id} className={topic === id ? "active" : ""} onClick={() => setTopic(id)}>{label}</button>)}</section>)}
|
||||
<div className="docs-resources"><h2>相关资源</h2><a href="/open-api/openapi.yaml" target="_blank" rel="noreferrer">OpenAPI 3.0 <span>↗</span></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer">Swagger 调试台 <span>↗</span></a></div>
|
||||
</aside>
|
||||
<article className="developer-docs-article">
|
||||
<div className="docs-breadcrumb">文档 <span>/</span> {groupFor(topic)} <span>/</span> {title}</div>
|
||||
<TopicContent topic={topic} />
|
||||
<DocsPagination topic={topic} onSelect={setTopic} />
|
||||
</article>
|
||||
<aside className="developer-docs-toc"><b>本页内容</b>{tocFor(topic).map((item) => <a key={item} href={`#${encodeURIComponent(item)}`}>{item}</a>)}</aside>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function TopicContent({ topic }: { topic: Topic }) {
|
||||
if (topic === "quickstart") return <Quickstart />;
|
||||
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
|
||||
if (topic === "mileage-range") return <MileageRangeReference />;
|
||||
if (topic === "total-mileage") return <TotalMileageReference />;
|
||||
if (topic === "overview") return <Overview />;
|
||||
if (topic === "auth") return <AuthGuide />;
|
||||
if (topic === "errors") return <ErrorGuide />;
|
||||
if (topic === "limits") return <LimitsGuide />;
|
||||
if (topic === "vehicle-auth") return <VehicleAuthGuide />;
|
||||
if (topic === "key-rotation") return <KeyRotationGuide />;
|
||||
if (topic === "quality") return <QualityGuide />;
|
||||
return <Checklist />;
|
||||
}
|
||||
|
||||
function Quickstart() {
|
||||
const curl = `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com/api/v1/vehicles/mileage/query \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"plateNumbers": ["辽A00001"],
|
||||
"date": "2026-07-19",
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||
}'`;
|
||||
return <>
|
||||
<h1>五分钟完成首次调用</h1>
|
||||
<p className="docs-lead">取得 AppKey、确认车辆授权,然后发送第一条真实查询。数据访问同时受应用、车辆和日期有效期约束。</p>
|
||||
<div className="docs-steps">
|
||||
<DocStep number="1" title="获取 AppKey"><p>平台管理员创建开放应用后,AppKey 仅展示一次。请立即保存到密钥管理系统,不要写入代码仓库。</p></DocStep>
|
||||
<DocStep number="2" title="确认授权车辆"><p>在控制台的“车辆授权”中,通过 VIN 或车牌选择车辆,并确认应用与车辆授权日期覆盖查询自然日。</p></DocStep>
|
||||
<DocStep number="3" title="发送请求"><Endpoint method="POST" path="/api/v1/vehicles/mileage/query" /><CodeBlock value={curl} language="bash" /><ParameterTable mileage /></DocStep>
|
||||
<DocStep number="4" title="验证响应"><p>HTTP 200 表示请求被接受。每辆车仍可能返回 <code>NORMAL</code> 或 <code>NO_DATA</code>,请按状态处理。</p><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
"dailyMileageKm": 215.6,
|
||||
"totalMileageKm": 12345.6,
|
||||
"dataTime": "2026-07-19T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-20T05:10:00+08:00",
|
||||
"sourceProtocol": "GB32960",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13bfb4f10943685599"
|
||||
}`} /></DocStep>
|
||||
</div>
|
||||
<ProductionChecklist compact />
|
||||
</>;
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
return <>
|
||||
<h1>车辆数据开放平台</h1><p className="docs-lead">羚牛开放平台以应用为边界,对外提供经过授权的车辆日用氢量、单日与区间日里程、指定时刻总里程。</p>
|
||||
<h2 id="核心模型">核心模型</h2>
|
||||
<div className="docs-definition-list">{[
|
||||
["开放应用", "持有独立的 32 位 AppKey、状态和有效期。"],
|
||||
["合作伙伴", "使用独立账号登录开发者控制台,并获得 Owner、Developer 或 Viewer 角色。"],
|
||||
["车辆授权", "决定应用可以查询哪些 VIN,以及每辆车的授权起止日期。"],
|
||||
["调用审计", "保存接口、结果、车辆数量和 Trace ID,便于双方排查。"]
|
||||
].map(([term, body]) => <div key={term}><b>{term}</b><p>{body}</p></div>)}</div>
|
||||
<h2 id="开放能力">开放能力</h2><ProductTable />
|
||||
<h2 id="安全边界">安全边界</h2><p>外部 AppKey 与门户登录会话完全独立。AppKey 明文只在创建或轮换时返回一次,服务端仅保存不可逆摘要和展示前缀。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function AuthGuide() {
|
||||
return <>
|
||||
<h1>鉴权方式</h1><p className="docs-lead">所有数据接口都使用 HTTP Bearer 鉴权。不要把 AppKey 放进查询参数、浏览器前端代码或日志。</p>
|
||||
<h2 id="请求头">请求头</h2><CodeBlock language="http" value="Authorization: Bearer YOUR_32_CHARACTER_APP_KEY" />
|
||||
<h2 id="有效性判断">有效性判断</h2><ol className="docs-ordered"><li>应用必须处于启用状态。</li><li>当前时间必须落在应用有效期内。</li><li>查询自然日必须被应用有效期完整覆盖。</li><li>每辆车辆授权必须完整覆盖查询自然日。</li></ol>
|
||||
<h2 id="安全建议">安全建议</h2><p>使用专用密钥管理系统保存 AppKey;按环境拆分应用;怀疑泄漏时立即轮换;轮换前准备双配置并缩短切换窗口。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ErrorGuide() {
|
||||
return <>
|
||||
<h1>错误码与重试</h1><p className="docs-lead">业务错误会返回稳定的 code,并在响应中附带 Trace ID。提交工单时请同时提供 Trace ID 和请求时间。</p>
|
||||
<h2 id="错误码">错误码</h2><table className="docs-table"><thead><tr><th>HTTP</th><th>code</th><th>含义</th><th>是否重试</th></tr></thead><tbody>{[
|
||||
["400", "INVALID_REQUEST", "请求结构、车牌数量或字段不正确", "修正请求后再试"],
|
||||
["400", "INVALID_DATE_FORMAT", "date 不是 YYYY-MM-DD", "否"],
|
||||
["400", "INVALID_DATETIME_FORMAT", "time 不是 YYYY-MM-DD HH:mm:ss", "否"],
|
||||
["401", "UNAUTHORIZED", "AppKey 不存在、停用或过期", "否"],
|
||||
["403", "FORBIDDEN", "应用或车辆授权未覆盖查询日期", "否"],
|
||||
["500", "INTERNAL_ERROR", "服务内部异常", "可退避重试"]
|
||||
].map((row) => <tr key={row[1]}>{row.map((cell) => <td key={cell}><code>{cell}</code></td>)}</tr>)}</tbody></table>
|
||||
<h2 id="重试策略">重试策略</h2><p>仅对网络错误和 5xx 使用指数退避,建议间隔 1 秒、2 秒、4 秒,最多重试 3 次。400、401、403 需要先修正请求或授权,不应自动重试。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function LimitsGuide() {
|
||||
return <>
|
||||
<h1>请求边界</h1><p className="docs-lead">在批量和调度逻辑中遵循以下硬限制,可减少拒绝请求和无效重试。</p>
|
||||
<h2 id="参数限制">参数限制</h2><table className="docs-table"><thead><tr><th>项目</th><th>限制</th></tr></thead><tbody><tr><td>单日接口车牌数量</td><td>可省略;指定时最多 200 个,不能包含空值或重复</td></tr><tr><td>区间接口</td><td>最长 366 天;pageSize 1–5000;指定时最多 5000 个车牌</td></tr><tr><td>查询日期</td><td>YYYY-MM-DD,Asia/Shanghai</td></tr><tr><td>请求体</td><td>JSON,Content-Type 为 application/json</td></tr><tr><td>车辆授权配置</td><td>每个开放应用最多 5,000 辆</td></tr></tbody></table>
|
||||
<h2 id="调度建议">调度建议</h2><p>大范围历史同步优先使用区间接口并顺序消费 nextCursor;为每页记录 Trace ID 和 snapshotId;不要并发复用同一个游标。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
||||
const product = products[kind === "hydrogen" ? 0 : 1];
|
||||
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
|
||||
return <>
|
||||
<h1>{product.name}</h1><p className="docs-lead">{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。</p>
|
||||
<Endpoint method={product.method} path={product.path} />
|
||||
<h2 id="请求参数">请求参数</h2><ParameterTable mileage={kind === "mileage"} />
|
||||
<h2 id="请求示例">请求示例</h2><p>省略 <code>plateNumbers</code> 时查询该应用在当日有效授权的全部车辆:</p><CodeBlock language="json" value={`{
|
||||
"date": "2026-07-19"${kind === "mileage" ? `,
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]` : ""}
|
||||
}`} /><p>如只查询部分车辆,可传入最多 200 个车牌:</p><CodeBlock language="json" value={`{
|
||||
"plateNumbers": ["辽A00001", "辽A00002"],
|
||||
"date": "2026-07-19"${kind === "mileage" ? `,
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
|
||||
}`} />
|
||||
{kind === "mileage" && <><h2 id="协议优先级">协议优先级</h2><p><code>protocolPriority</code> 只允许 <code>GB32960</code>、<code>MQTT</code>、<code>JT808</code>。数组必须非空且不能重复;逐车按顺序选择第一个有效协议,未列出的协议完全禁用。不传时保持平台默认行为。</p><h2 id="缺日补齐">缺日补齐</h2><p>查询日没有有效里程但此前存在有效累计里程时,<code>dailyMileageKm</code> 返回 0,累计总里程、来源协议和数据时间沿用最近有效统计;<code>updatedAt</code> 保持为上一个统计周期的计算时间。此前也无有效累计里程时才返回 NO_DATA。</p></>}
|
||||
<h2 id="响应字段">响应字段</h2><table className="docs-table"><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td>车辆 VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td>请求中的车牌号</td></tr><tr><td><code>date</code></td><td>string</td><td>统计自然日</td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td>同一协议在当日最后有效时刻的累计总里程</td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td>统计实际采用的最后一条车辆源数据时间</td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td>日统计投影最后更新时间</td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL 或 NO_DATA</td></tr></tbody></table>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
${kind === "mileage" ? `"vin": "LNB00000000000001",\n ` : ""}"plateNumber": "辽A00001",
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
${fields},
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}`} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function MileageRangeReference() {
|
||||
return <>
|
||||
<h1>车辆区间日里程</h1><p className="docs-lead">按最长 366 天区间分页返回逐车逐日里程。授权车辆清单在首次请求时固化,翻页期间不会因授权变化产生重复或漏行。</p>
|
||||
<Endpoint method="POST" path="/api/v1/vehicles/mileage/range/query" />
|
||||
<h2 id="请求参数">请求参数</h2><table className="docs-table"><thead><tr><th>字段</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>startDate</code></td><td>是</td><td>开始自然日,YYYY-MM-DD</td></tr><tr><td><code>endDate</code></td><td>是</td><td>结束自然日,包含当日;最长 366 天</td></tr><tr><td><code>plateNumbers</code></td><td>否</td><td>省略时查询整个区间均有效授权的全部车辆;指定时最多 5000 个</td></tr><tr><td><code>protocolPriority</code></td><td>否</td><td>非空且不重复的 GB32960、MQTT、JT808 数组;未列出的协议禁用</td></tr><tr><td><code>cursor</code></td><td>否</td><td>首次省略或传 null;翻页时传上一页 nextCursor</td></tr><tr><td><code>pageSize</code></td><td>否</td><td>1–5000,默认 5000</td></tr></tbody></table>
|
||||
<h2 id="首次请求">首次请求</h2><CodeBlock language="json" value={`{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"],
|
||||
"pageSize": 5000
|
||||
}`} />
|
||||
<h2 id="游标翻页">游标翻页</h2><p>保持开始日期、结束日期、车牌列表、protocolPriority 和 pageSize 不变,只把上一页 <code>nextCursor</code> 放入下一次请求。同一次查询的 <code>snapshotId</code> 保持不变,最后一页 <code>nextCursor</code> 为 null。</p>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "沪A00001",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"totalMileageKm": 12345.679,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"sourceProtocol": "JT808",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
|
||||
"nextCursor": null,
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}`} />
|
||||
<h2 id="性能口径">性能口径</h2><p>接口只固化最多 5000 辆授权车辆,不预生成“车辆数 × 天数”的快照明细。每页直接读取已建立复合索引的日统计投影,不扫描原始时序明细。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function TotalMileageReference() {
|
||||
return <>
|
||||
<h1>指定时刻总里程</h1><p className="docs-lead">按已授权 VIN 查询不晚于指定北京时间的最近一条有效总里程,并返回实际采集协议、记录时间和时间差秒数。</p>
|
||||
<Endpoint method="POST" path="/api/v1/vehicles/total-mileage/query" />
|
||||
<h2 id="请求参数">请求参数</h2><table className="docs-table"><thead><tr><th>字段</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>vin</code></td><td>是</td><td>17 位已授权 VIN</td></tr><tr><td><code>time</code></td><td>是</td><td>北京时间,YYYY-MM-DD HH:mm:ss</td></tr><tr><td><code>protocol</code></td><td>否</td><td>唯一规范值:GB32960、YUTONG_MQTT 或 JT808;不传时自动选择</td></tr></tbody></table>
|
||||
<h2 id="请求示例">请求示例</h2><CodeBlock language="json" value={`{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}`} />
|
||||
<h2 id="协议口径">协议口径</h2><table className="docs-table"><thead><tr><th>协议</th><th>总里程含义</th></tr></thead><tbody><tr><td><code>GB32960</code></td><td>车辆仪表盘累计总里程</td></tr><tr><td><code>YUTONG_MQTT</code></td><td>车辆仪表盘或车端控制器累计总里程</td></tr><tr><td><code>JT808</code></td><td>GPS/定位终端侧计算的累计里程,不等同于仪表盘里程</td></tr></tbody></table><p>不同协议不能拼接为同一条连续里程曲线。未指定协议时,优先级代表数据源选择顺序,不代表数值可相互替代。</p>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"queryTime": "2026-07-21 09:30:00",
|
||||
"totalMileageKm": 12345.678,
|
||||
"protocol": "GB32960",
|
||||
"recordTime": "2026-07-21 09:29:45",
|
||||
"timeDifferenceSeconds": 15,
|
||||
"selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
|
||||
"status": "NORMAL"
|
||||
}
|
||||
}`} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function VehicleAuthGuide() {
|
||||
return <>
|
||||
<h1>车辆授权</h1><p className="docs-lead">平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。</p>
|
||||
<h2 id="授权关系">授权关系</h2><p>车辆授权属于开放应用,而不是合作伙伴账号。获得该应用角色的合作伙伴只能查询应用已经授权的车辆。</p>
|
||||
<h2 id="配置步骤">配置步骤</h2><ol className="docs-ordered"><li>选择目标开放应用。</li><li>设置车辆授权开始与截止日期。</li><li>从目录选择,或粘贴 VIN/车牌识别车辆。</li><li>核对右侧已选车辆并保存。</li></ol>
|
||||
<div className="docs-callout"><Icon name="shield" size={20} /><div><b>完整替换语义</b><p>保存时会用当前选中范围完整替换旧范围。未来新增主车辆不会自动获得权限,需要管理员再次确认。</p></div></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function KeyRotationGuide() {
|
||||
return <>
|
||||
<h1>密钥轮换</h1><p className="docs-lead">轮换会生成新的 32 位 AppKey,旧 Key 立即失效,完整新 Key 只展示一次。</p>
|
||||
<h2 id="推荐流程">推荐流程</h2><ol className="docs-ordered"><li>在调用方准备可快速更新的密钥配置。</li><li>在低流量窗口执行轮换。</li><li>立即复制新 Key 并更新调用方。</li><li>发送一条授权车辆测试请求。</li><li>在调用记录中确认成功。</li></ol>
|
||||
<h2 id="权限要求">权限要求</h2><p>平台管理员和应用 Owner 可以轮换密钥;Developer 与 Viewer 无权执行。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function QualityGuide() {
|
||||
return <>
|
||||
<h1>数据质量</h1><p className="docs-lead">HTTP 成功不等于每辆车都有有效统计值。请始终读取每个结果对象的 status。</p>
|
||||
<h2 id="状态定义">状态定义</h2><table className="docs-table"><thead><tr><th>状态</th><th>含义</th><th>建议</th></tr></thead><tbody><tr><td><code>NORMAL</code></td><td>存在满足口径的完整单日统计</td><td>正常使用数值</td></tr><tr><td><code>NO_DATA</code></td><td>授权有效,但当天没有足够有效样本</td><td>保留空值,后续补查</td></tr></tbody></table>
|
||||
<h2 id="统计口径">统计口径</h2><p>日里程根据有效累计里程差计算;多协议数据按平台质量策略选择。日用氢量按同一数据源内的有效氢质量变化统计,加氢上升不计为消耗。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
return <><h1>上线检查清单</h1><p className="docs-lead">在生产调度启用前,逐项完成以下检查。</p><ProductionChecklist /></>;
|
||||
}
|
||||
|
||||
function ProductionChecklist({ compact = false }: { compact?: boolean }) {
|
||||
const items = ["AppKey 已安全保存且应用未过期", "目标车辆已授权且日期范围有效", "请求日期使用 YYYY-MM-DD", "按 NORMAL / NO_DATA 处理结果", "记录 Trace ID 并实现 5xx 退避重试", "生产日志不会输出完整 AppKey"];
|
||||
return <section className={`production-checklist${compact ? " compact" : ""}`}><header><Icon name="check" size={20} /><div><h2 id="生产前检查">生产前检查</h2><p>完成后再启用定时任务。</p></div></header>{items.map((item) => <label key={item}><input type="checkbox" />{item}</label>)}</section>;
|
||||
}
|
||||
|
||||
function DocStep({ number, title, children }: { number: string; title: string; children: ReactNode }) {
|
||||
return <section className="doc-step"><span>{number}</span><div><h2 id={title}>{title}</h2>{children}</div></section>;
|
||||
}
|
||||
|
||||
function Endpoint({ method, path }: { method: string; path: string }) {
|
||||
return <div className="docs-endpoint"><b>{method}</b><code>{path}</code><button onClick={() => navigator.clipboard?.writeText(path)}><Icon name="copy" size={15} />复制</button></div>;
|
||||
}
|
||||
|
||||
function CodeBlock({ value, language }: { value: string; language: string }) {
|
||||
return <pre className="docs-code"><span>{language}</span><button onClick={() => navigator.clipboard?.writeText(value)}><Icon name="copy" size={14} />复制</button><code>{value}</code></pre>;
|
||||
}
|
||||
|
||||
function ParameterTable({ mileage = false }: { mileage?: boolean }) {
|
||||
return <table className="docs-table"><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>plateNumbers</code></td><td>string[]</td><td>否</td><td>省略或空数组时查询全部有效授权车辆;指定时最多 200 个且不允许重复</td></tr><tr><td><code>date</code></td><td>string</td><td>是</td><td>自然日,格式 YYYY-MM-DD</td></tr>{mileage && <tr><td><code>protocolPriority</code></td><td>string[]</td><td>否</td><td>协议选源顺序;只允许 GB32960、MQTT、JT808,未列出的协议禁用</td></tr>}</tbody></table>;
|
||||
}
|
||||
|
||||
function ProductTable() {
|
||||
return <table className="docs-table"><thead><tr><th>数据产品</th><th>方法</th><th>路径</th><th>单位</th></tr></thead><tbody>{products.map((item) => <tr key={item.code}><td>{item.name}</td><td><code>{item.method}</code></td><td><code>{item.path}</code></td><td>{item.unit}</td></tr>)}</tbody></table>;
|
||||
}
|
||||
|
||||
function DocsPagination({ topic, onSelect }: { topic: Topic; onSelect: (topic: Topic) => void }) {
|
||||
const topics = useMemo(() => groups.flatMap((group) => group.items), []);
|
||||
const index = topics.findIndex(([id]) => id === topic);
|
||||
const previous = topics[index - 1];
|
||||
const next = topics[index + 1];
|
||||
return <footer className="docs-pagination">{previous ? <button onClick={() => onSelect(previous[0])}><small>上一篇</small><b>← {previous[1]}</b></button> : <span />}{next && <button onClick={() => onSelect(next[0])}><small>下一篇</small><b>{next[1]} →</b></button>}</footer>;
|
||||
}
|
||||
|
||||
function groupFor(topic: Topic) {
|
||||
return groups.find((group) => group.items.some(([id]) => id === topic))?.title || "";
|
||||
}
|
||||
|
||||
function tocFor(topic: Topic) {
|
||||
const map: Partial<Record<Topic, string[]>> = {
|
||||
quickstart: ["获取 AppKey", "确认授权车辆", "发送请求", "验证响应", "生产前检查"],
|
||||
overview: ["核心模型", "开放能力", "安全边界"],
|
||||
auth: ["请求头", "有效性判断", "安全建议"],
|
||||
errors: ["错误码", "重试策略"],
|
||||
limits: ["参数限制", "调度建议"],
|
||||
hydrogen: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
||||
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
||||
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
|
||||
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
|
||||
"vehicle-auth": ["授权关系", "配置步骤"],
|
||||
"key-rotation": ["推荐流程", "权限要求"],
|
||||
quality: ["状态定义", "统计口径"],
|
||||
checklist: ["生产前检查"]
|
||||
};
|
||||
return map[topic] || [];
|
||||
}
|
||||
193
vehicle-data-platform/apps/open-portal/src/PublicSite.tsx
Normal file
193
vehicle-data-platform/apps/open-portal/src/PublicSite.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { DocumentationPage } from "./Documentation";
|
||||
import { Brand, Icon, navigate, type Route } from "./ui";
|
||||
|
||||
export function PublicSite({ route }: { route: Exclude<Route, "console"> }) {
|
||||
return (
|
||||
<div className="public-site">
|
||||
<PublicHeader route={route} />
|
||||
{route === "docs" ? <DocumentationPage /> : <HomePage />}
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function PublicHeader({ route }: { route: Exclude<Route, "console"> }) {
|
||||
return (
|
||||
<header className="public-header">
|
||||
<Brand />
|
||||
<nav aria-label="主导航">
|
||||
<button onClick={() => route === "home" ? scrollToSection("apis") : navigate("home")}>API</button>
|
||||
<button className={route === "docs" ? "active" : ""} onClick={() => navigate("docs")}>文档</button>
|
||||
</nav>
|
||||
<button className="header-console" onClick={() => navigate("console")}>控制台 <Icon name="arrow" size={14} /></button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<main className="home-v2">
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-copy">
|
||||
<h1>车辆数据,按需开放。</h1>
|
||||
<p>用统一 API 访问车辆日用氢量与日里程。每次调用受应用、车辆和日期范围约束。</p>
|
||||
<div className="home-actions">
|
||||
<button className="button home-primary" onClick={() => navigate("docs")}>阅读文档</button>
|
||||
<button className="home-text-link" onClick={() => navigate("console")}>进入控制台 <Icon name="arrow" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiWorkbench />
|
||||
</section>
|
||||
|
||||
<section className="fact-rail" aria-label="接口事实">
|
||||
<span><b>2</b> 项数据能力</span>
|
||||
<span>VIN / 车牌授权</span>
|
||||
<span>指定 1–200 辆 / 留空查全部</span>
|
||||
<span>Trace ID</span>
|
||||
</section>
|
||||
|
||||
<section className="api-directory" id="apis">
|
||||
<header className="home-section-heading">
|
||||
<span>01</span>
|
||||
<h2>可用接口</h2>
|
||||
</header>
|
||||
<ApiRow mark="H₂" name="车辆日用氢量" path="/api/v1/vehicles/hydrogen-consumption/query" unit="kg" />
|
||||
<ApiRow mark="KM" name="车辆日里程" path="/api/v1/vehicles/mileage/query" unit="km" />
|
||||
</section>
|
||||
|
||||
<section className="integration-section">
|
||||
<header className="home-section-heading">
|
||||
<span>02</span>
|
||||
<h2>开始接入</h2>
|
||||
</header>
|
||||
<div className="integration-line">
|
||||
{[
|
||||
["01", "获取 AppKey", "创建开放应用并保存一次性展示的 AppKey。"],
|
||||
["02", "授权车辆", "按 VIN 或车牌配置车辆和有效期。"],
|
||||
["03", "调用 API", "按自然日查询数据,并记录 Trace ID。"]
|
||||
].map(([number, title, body]) => (
|
||||
<article key={number}>
|
||||
<span>{number}</span>
|
||||
<div><h3>{title}</h3><p>{body}</p></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="docs-call-to-action">
|
||||
<div>
|
||||
<Icon name="book" size={34} />
|
||||
<span><h2>查看完整接口文档</h2><p>请求参数、响应结构、错误码与重试策略。</p></span>
|
||||
</div>
|
||||
<button onClick={() => navigate("docs")}>打开文档 <Icon name="arrow" size={17} /></button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type ApiKind = "mileage" | "hydrogen";
|
||||
|
||||
const apiExamples: Record<ApiKind, {
|
||||
tab: string;
|
||||
path: string;
|
||||
responseField: string;
|
||||
value: string;
|
||||
extraResponse?: string;
|
||||
}> = {
|
||||
mileage: {
|
||||
tab: "日里程",
|
||||
path: "/api/v1/vehicles/mileage/query",
|
||||
responseField: "dailyMileageKm",
|
||||
value: "215.6",
|
||||
extraResponse: `\n "totalMileageKm": 12345.6,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",`
|
||||
},
|
||||
hydrogen: {
|
||||
tab: "日用氢量",
|
||||
path: "/api/v1/vehicles/hydrogen-consumption/query",
|
||||
responseField: "hydrogenConsumptionKg",
|
||||
value: "8.42"
|
||||
}
|
||||
};
|
||||
|
||||
function ApiWorkbench() {
|
||||
const [kind, setKind] = useState<ApiKind>("mileage");
|
||||
const current = apiExamples[kind];
|
||||
return (
|
||||
<div className="api-workbench">
|
||||
<div className="workbench-tabs" role="tablist" aria-label="接口示例">
|
||||
{(Object.keys(apiExamples) as ApiKind[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
role="tab"
|
||||
aria-selected={kind === item}
|
||||
className={kind === item ? "active" : ""}
|
||||
onClick={() => setKind(item)}
|
||||
>
|
||||
{apiExamples[item].tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="workbench-grid">
|
||||
<div className="workbench-request">
|
||||
<header><b>POST</b><code>{current.path}</code></header>
|
||||
<div className="workbench-pane-title">请求</div>
|
||||
<label>
|
||||
<span>plateNumbers</span>
|
||||
<code>["辽A00001", "辽A00002"]</code>
|
||||
</label>
|
||||
<label>
|
||||
<span>date</span>
|
||||
<code>"2026-07-19"</code>
|
||||
</label>
|
||||
</div>
|
||||
<div className="workbench-response">
|
||||
<header><span>响应</span><b>200 OK</b></header>
|
||||
<pre aria-label={`${current.tab}响应示例`}><code>{`{
|
||||
"code": "SUCCESS",
|
||||
"data": [{
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
"${current.responseField}": ${current.value},
|
||||
${current.extraResponse || ""}
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13"
|
||||
}`}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiRow({ mark, name, path, unit }: { mark: string; name: string; path: string; unit: string }) {
|
||||
return (
|
||||
<article className="api-directory-row">
|
||||
<span className="api-mark">{mark}</span>
|
||||
<div className="api-row-main">
|
||||
<h3>{name}</h3>
|
||||
<p><b>POST</b><code>{path}</code></p>
|
||||
</div>
|
||||
<div className="api-unit"><small>单位</small><span>{unit}</span></div>
|
||||
<button onClick={() => navigate("docs")}>接口说明 <Icon name="arrow" size={17} /></button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicFooter() {
|
||||
return (
|
||||
<footer className="public-footer">
|
||||
<Brand compact />
|
||||
<nav aria-label="页脚导航">
|
||||
<button onClick={() => scrollToSection("apis")}>API</button>
|
||||
<button onClick={() => navigate("docs")}>文档</button>
|
||||
<button onClick={() => navigate("console")}>控制台</button>
|
||||
</nav>
|
||||
<span>© 2026 Lingniu Technology</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { api, type AdminApp, type VehicleCatalogItem, type VehicleGrant } from "./api";
|
||||
import { dateInput, errorMessage, Icon, PageHeader, rfcDate, Status } from "./ui";
|
||||
|
||||
type Mode = "directory" | "paste" | "all";
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const nextYear = () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export function VehicleAuthorization({
|
||||
apps,
|
||||
setNotice,
|
||||
refresh
|
||||
}: {
|
||||
apps: AdminApp[];
|
||||
setNotice: (value: string) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}) {
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [catalog, setCatalog] = useState<VehicleCatalogItem[]>([]);
|
||||
const [grants, setGrants] = useState<VehicleGrant[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [mode, setMode] = useState<Mode>("directory");
|
||||
const [query, setQuery] = useState("");
|
||||
const [batchText, setBatchText] = useState("");
|
||||
const [validFrom, setFrom] = useState(today());
|
||||
const [validTo, setTo] = useState(nextYear());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const deferredQuery = useDeferredValue(query.trim().toUpperCase());
|
||||
const deferredBatch = useDeferredValue(batchText);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminVehicles()
|
||||
.then(setCatalog)
|
||||
.catch((reason) => setNotice(errorMessage(reason)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId && apps[0]) setAppId(apps[0].id);
|
||||
}, [apps, appId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return;
|
||||
api.vehicles(appId).then((items) => {
|
||||
setGrants(items);
|
||||
setSelected(new Set(items.map((item) => item.vin)));
|
||||
setMode("directory");
|
||||
setBatchText("");
|
||||
if (items[0]) {
|
||||
setFrom(dateInput(items[0].validFrom));
|
||||
setTo(dateInput(items[0].validTo) || nextYear());
|
||||
}
|
||||
}).catch((reason) => setNotice(errorMessage(reason)));
|
||||
}, [appId]);
|
||||
|
||||
const catalogByVIN = useMemo(() => new Map(catalog.map((item) => [item.vin.toUpperCase(), item])), [catalog]);
|
||||
const catalogByPlate = useMemo(() => {
|
||||
const values = new Map<string, VehicleCatalogItem>();
|
||||
for (const item of catalog) {
|
||||
const plate = item.plate.trim().toUpperCase();
|
||||
if (plate && !values.has(plate)) values.set(plate, item);
|
||||
}
|
||||
return values;
|
||||
}, [catalog]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!deferredQuery) return catalog;
|
||||
return catalog.filter((item) => `${item.vin} ${item.plate} ${item.oem}`.toUpperCase().includes(deferredQuery));
|
||||
}, [catalog, deferredQuery]);
|
||||
const batchResult = useMemo(() => {
|
||||
const values = [...new Set(deferredBatch.split(/[\s,,;;]+/).map((item) => item.trim().toUpperCase()).filter(Boolean))];
|
||||
const matched: VehicleCatalogItem[] = [];
|
||||
const unmatched: string[] = [];
|
||||
for (const value of values) {
|
||||
const item = catalogByVIN.get(value) || catalogByPlate.get(value);
|
||||
if (item && !matched.some((vehicle) => vehicle.vin === item.vin)) matched.push(item);
|
||||
else if (!item) unmatched.push(value);
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}, [deferredBatch, catalogByVIN, catalogByPlate]);
|
||||
const selectedVehicles = useMemo(() => {
|
||||
const existingByVIN = new Map(grants.map((item) => [item.vin, item]));
|
||||
return [...selected].map((vin) => {
|
||||
const item = catalogByVIN.get(vin);
|
||||
const existing = existingByVIN.get(vin);
|
||||
return item || {
|
||||
vin,
|
||||
plate: existing?.plate || "",
|
||||
oem: "",
|
||||
source: "已有授权",
|
||||
status: "available"
|
||||
};
|
||||
}).sort((left, right) => (left.plate || left.vin).localeCompare(right.plate || right.vin, "zh-CN"));
|
||||
}, [selected, grants, catalogByVIN]);
|
||||
|
||||
function toggle(vin: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(vin)) next.delete(vin);
|
||||
else next.add(vin);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function chooseMode(next: Mode) {
|
||||
setMode(next);
|
||||
if (next === "all") setSelected(new Set(catalog.map((item) => item.vin)));
|
||||
}
|
||||
|
||||
function applyBatch() {
|
||||
setSelected(new Set(batchResult.matched.map((item) => item.vin)));
|
||||
if (batchResult.unmatched.length) {
|
||||
setNotice(`已识别 ${batchResult.matched.length} 辆,${batchResult.unmatched.length} 条未匹配。`);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setSelected(new Set(grants.map((item) => item.vin)));
|
||||
setBatchText("");
|
||||
setMode("directory");
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!validFrom || !validTo || validTo <= validFrom) {
|
||||
setNotice("车辆授权截止日期必须晚于生效日期。");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.replaceVehicles(appId, selectedVehicles.map((item) => ({
|
||||
vin: item.vin,
|
||||
validFrom: rfcDate(validFrom),
|
||||
validTo: rfcDate(validTo)
|
||||
})));
|
||||
setGrants(result);
|
||||
setSelected(new Set(result.map((item) => item.vin)));
|
||||
setNotice(`已保存 ${result.length} 辆车辆授权。`);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
setNotice(errorMessage(reason));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allFilteredSelected = filtered.length > 0 && filtered.every((item) => selected.has(item.vin));
|
||||
|
||||
return <>
|
||||
<PageHeader title="车辆授权" description="为开放应用配置可访问的车辆范围。" />
|
||||
{!apps.length ? <div className="empty-state"><h2>还没有可配置的应用</h2><p>请先创建开放应用。</p></div> :
|
||||
<form className="vehicle-auth" onSubmit={submit}>
|
||||
<section className="vehicle-auth-rail">
|
||||
<label><span>选择应用</span><select value={appId} onChange={(event) => setAppId(Number(event.target.value))}>{apps.map((app) => <option value={app.id} key={app.id}>{app.name}</option>)}</select></label>
|
||||
<div className="auth-dates"><label><span>授权开始</span><input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><i>—</i><label><span>授权截止</span><input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div>
|
||||
</section>
|
||||
|
||||
<div className="auth-mode-tabs" role="tablist" aria-label="车辆添加方式">
|
||||
<button type="button" role="tab" aria-selected={mode === "directory"} className={mode === "directory" ? "active" : ""} onClick={() => chooseMode("directory")}>从车辆目录选择</button>
|
||||
<button type="button" role="tab" aria-selected={mode === "paste"} className={mode === "paste" ? "active" : ""} onClick={() => chooseMode("paste")}>批量粘贴</button>
|
||||
<button type="button" role="tab" aria-selected={mode === "all"} className={mode === "all" ? "active" : ""} onClick={() => chooseMode("all")}>全部车辆</button>
|
||||
</div>
|
||||
|
||||
{mode === "paste" && <section className="batch-vehicle-entry">
|
||||
<div><h2>批量添加 VIN 或车牌</h2><p>每行一个 VIN 或车牌,也支持空格、逗号分隔。</p></div>
|
||||
<textarea aria-label="批量输入 VIN 或车牌" value={batchText} onChange={(event) => setBatchText(event.target.value)} placeholder={"LNB00000000000001\n辽A00001"} />
|
||||
<footer><span className={batchResult.unmatched.length ? "has-error" : ""}>识别 {batchResult.matched.length} 辆 · 未匹配 {batchResult.unmatched.length} 条</span><button type="button" className="button primary" onClick={applyBatch} disabled={!batchText.trim()}>应用识别结果</button></footer>
|
||||
{batchResult.unmatched.length > 0 && <p className="batch-unmatched">未匹配:{batchResult.unmatched.slice(0, 8).join("、")}{batchResult.unmatched.length > 8 ? "…" : ""}</p>}
|
||||
</section>}
|
||||
|
||||
{mode === "all" && <section className="all-vehicles-confirm">
|
||||
<span><Icon name="check" size={20} /></span><div><h2>已选择当前全部车辆</h2><p>本次保存将授权车辆主数据中的 {catalog.length} 辆车。未来新增车辆不会自动获得权限,需要管理员再次确认。</p></div><strong>{catalog.length}<small> 辆</small></strong>
|
||||
</section>}
|
||||
|
||||
<section className="vehicle-picker">
|
||||
<div className="vehicle-directory">
|
||||
<header>
|
||||
<div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索 VIN 或车牌" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索 VIN、车牌或品牌" /></div>
|
||||
<span>已选择 <b>{selected.size}</b> 辆</span>
|
||||
</header>
|
||||
<div className="vehicle-table-scroll">
|
||||
<table>
|
||||
<thead><tr><th><input aria-label="选择当前筛选的全部车辆" type="checkbox" checked={allFilteredSelected} onChange={() => setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (allFilteredSelected) filtered.forEach((item) => next.delete(item.vin));
|
||||
else filtered.forEach((item) => next.add(item.vin));
|
||||
return next;
|
||||
})} /></th><th>车辆</th><th>VIN</th><th>数据来源</th><th>状态</th></tr></thead>
|
||||
<tbody>{filtered.map((item) => <tr key={item.vin} className={selected.has(item.vin) ? "selected" : ""} onClick={() => toggle(item.vin)}>
|
||||
<td><input aria-label={`选择 ${item.plate || item.vin}`} type="checkbox" checked={selected.has(item.vin)} onChange={() => toggle(item.vin)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><b>{item.plate || "未绑定车牌"}</b><small>{item.oem || "品牌待补充"}</small></td>
|
||||
<td><code>{item.vin}</code></td><td>{item.source}</td><td><Status value={item.status} label="可授权" /></td>
|
||||
</tr>)}{!filtered.length && <tr><td colSpan={5}><div className="table-empty">{loading ? "正在加载车辆目录…" : "没有匹配的车辆"}</div></td></tr>}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer><span>共 {catalog.length} 辆主车辆</span>{deferredQuery && <button type="button" className="link-button" onClick={() => setQuery("")}>清除搜索</button>}</footer>
|
||||
</div>
|
||||
|
||||
<aside className="selected-vehicles">
|
||||
<header><div><h2>已选择</h2><p>{apps.find((app) => app.id === appId)?.name}</p></div><button type="button" className="link-button" onClick={() => setSelected(new Set())}>清空</button></header>
|
||||
<div>{selectedVehicles.slice(0, 12).map((item) => <article key={item.vin}><span><Icon name="car" size={17} /></span><div><b>{item.plate || "未绑定车牌"}</b><code>{item.vin}</code></div><button type="button" aria-label={`移除 ${item.plate || item.vin}`} onClick={() => toggle(item.vin)}><Icon name="close" size={15} /></button></article>)}</div>
|
||||
{selectedVehicles.length > 12 && <p className="selection-more">以及其他 {selectedVehicles.length - 12} 辆</p>}
|
||||
{!selectedVehicles.length && <div className="selection-empty"><Icon name="car" size={24} /><p>从左侧目录选择车辆,或使用批量粘贴。</p></div>}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer className="vehicle-auth-actions"><span>保存后将完整替换该应用当前的车辆范围。</span><div><button type="button" className="button secondary" onClick={reset}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : `保存 ${selected.size} 辆授权`}</button></div></footer>
|
||||
</form>}
|
||||
</>;
|
||||
}
|
||||
174
vehicle-data-platform/apps/open-portal/src/api.ts
Normal file
174
vehicle-data-platform/apps/open-portal/src/api.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
export type Session = {
|
||||
userId: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
userType: "admin" | "partner";
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type PortalApp = {
|
||||
appId: number;
|
||||
appName: string;
|
||||
appKeyPrefix: string;
|
||||
appStatus: string;
|
||||
role: "owner" | "developer" | "viewer";
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type VehicleGrant = {
|
||||
vin: string;
|
||||
plate: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type VehicleCatalogItem = {
|
||||
vin: string;
|
||||
plate: string;
|
||||
oem: string;
|
||||
status: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type AuditItem = {
|
||||
traceId: string;
|
||||
endpoint: string;
|
||||
result: string;
|
||||
vehicleCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
status: string;
|
||||
method: string;
|
||||
path: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type AdminApp = {
|
||||
id: number;
|
||||
name: string;
|
||||
appKeyPrefix: string;
|
||||
status: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type PortalUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
lastLoginAt?: string;
|
||||
};
|
||||
|
||||
type Envelope<T> = { data: T; error?: { code?: string; message?: string; detail?: string } };
|
||||
|
||||
const TOKEN_KEY = "lingniu-open-platform-token";
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => sessionStorage.getItem(TOKEN_KEY) || "",
|
||||
set: (token: string) => sessionStorage.setItem(TOKEN_KEY, token),
|
||||
clear: () => sessionStorage.removeItem(TOKEN_KEY)
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}, authenticated = true): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body) headers.set("Content-Type", "application/json");
|
||||
const token = tokenStore.get();
|
||||
if (authenticated && token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(path, { ...init, headers });
|
||||
const body = (await response.json().catch(() => ({}))) as Envelope<T>;
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && authenticated) tokenStore.clear();
|
||||
throw new Error(readableError(body.error, response.status));
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function readableError(error: Envelope<unknown>["error"], status: number) {
|
||||
const detail = error?.detail || "";
|
||||
if (detail.includes("请求内容不能为空")) return "请求内容不能为空,请刷新页面后重试";
|
||||
if (detail.includes("不是有效的 JSON") || detail.includes("无法解析")) return "提交内容无法解析,请刷新页面后重试";
|
||||
if (detail.includes("数据类型不正确")) return detail;
|
||||
if (detail.includes("未支持的字段")) return detail;
|
||||
if (detail.includes("password must be 12-128")) return "初始密码必须为 12–128 位,并包含大写字母、小写字母和数字";
|
||||
if (detail.includes("password requires upper, lower and digit")) return "初始密码必须同时包含大写字母、小写字母和数字";
|
||||
if (detail.includes("invalid portal user")) return "登录账号需为 3–64 位字母、数字、点、下划线或连字符,显示名称不能为空";
|
||||
if (detail.includes("invalid user status")) return "账号状态不正确,只能为启用或停用";
|
||||
if (detail.includes("validFrom must be RFC3339")) return "生效日期格式不正确,请重新选择";
|
||||
if (detail.includes("invalid validFrom")) return "生效日期格式不正确";
|
||||
if (detail.includes("invalid validTo")) return "截止日期必须晚于生效日期";
|
||||
if (detail.includes("username already exists")) return "登录账号已存在,请更换账号";
|
||||
if (detail.includes("username is reserved")) return "该账号是平台管理员账号,不能创建为合作伙伴";
|
||||
if (detail.includes("invalid or duplicate app membership")) return "应用授权或角色配置不正确";
|
||||
if (detail.includes("VIN") || detail.includes("vin")) return "车辆 VIN 不存在、格式不正确或包含重复值";
|
||||
return error?.message || detail || `请求失败(${status})`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ accessToken: string; expiresAt: string; session: Session }>(
|
||||
"/portal-api/auth/login",
|
||||
{ method: "POST", body: JSON.stringify({ username, password }) },
|
||||
false
|
||||
),
|
||||
session: () => request<Session>("/portal-api/session"),
|
||||
catalog: () => request<Product[]>("/portal-api/catalog", {}, false),
|
||||
apps: () => request<PortalApp[]>("/portal-api/apps"),
|
||||
vehicles: (appId: number) => request<VehicleGrant[]>(`/portal-api/apps/${appId}/vehicles`),
|
||||
audit: (appId: number) => request<AuditItem[]>(`/portal-api/apps/${appId}/audit`),
|
||||
rotateKey: (appId: number) =>
|
||||
request<{ appKey: string }>(`/portal-api/apps/${appId}/rotate-key`, { method: "POST" }),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ changed: boolean }>("/portal-api/account/password", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
}),
|
||||
logout: () => request<{ loggedOut: boolean }>("/portal-api/auth/logout", { method: "POST" }),
|
||||
adminApps: () => request<AdminApp[]>("/portal-api/admin/apps"),
|
||||
adminVehicles: () => request<VehicleCatalogItem[]>("/portal-api/admin/vehicles"),
|
||||
createApp: (input: { name: string; status: string; validFrom: string; validTo: string }) =>
|
||||
request<AdminApp & { appKey: string }>("/portal-api/admin/apps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
updateApp: (id: number, input: { name: string; status: string; validFrom: string; validTo: string }) =>
|
||||
request<AdminApp>(`/portal-api/admin/apps/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
replaceVehicles: (id: number, vehicles: Array<{ vin: string; validFrom: string; validTo: string }>) =>
|
||||
request<VehicleGrant[]>(`/portal-api/admin/apps/${id}/vehicles`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ vehicles })
|
||||
}),
|
||||
adminUsers: () => request<PortalUser[]>("/portal-api/admin/users"),
|
||||
createUser: (input: {
|
||||
username: string; displayName: string; password: string;
|
||||
status: string; validFrom: string; validTo: string;
|
||||
}) => request<PortalUser>("/portal-api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
updateUser: (id: number, input: {
|
||||
username: string; displayName: string; password: string;
|
||||
status: string; validFrom: string; validTo: string;
|
||||
}) => request<PortalUser>(`/portal-api/admin/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
userApps: (id: number) => request<PortalApp[]>(`/portal-api/admin/users/${id}/apps`),
|
||||
replaceUserApps: (id: number, apps: Array<{ appId: number; role: string }>) =>
|
||||
request<PortalApp[]>(`/portal-api/admin/users/${id}/apps`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apps })
|
||||
})
|
||||
};
|
||||
75
vehicle-data-platform/apps/open-portal/src/catalog.ts
Normal file
75
vehicle-data-platform/apps/open-portal/src/catalog.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Product } from "./api";
|
||||
|
||||
export const products: Product[] = [
|
||||
{
|
||||
code: "daily_hydrogen",
|
||||
name: "车辆日用氢量",
|
||||
description: "按自然日查询全部授权车辆或指定车牌的用氢量,并返回数据质量状态。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/hydrogen-consumption/query",
|
||||
unit: "kg"
|
||||
},
|
||||
{
|
||||
code: "daily_mileage",
|
||||
name: "车辆日里程",
|
||||
description: "按自然日查询当日里程、累计总里程及实际来源协议,支持自定义协议优先级。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/mileage/query",
|
||||
unit: "km"
|
||||
},
|
||||
{
|
||||
code: "mileage_range",
|
||||
name: "车辆区间日里程",
|
||||
description: "按最长 366 天区间分页查询逐日里程,支持逐车逐日自定义协议优先级。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/mileage/range/query",
|
||||
unit: "km"
|
||||
},
|
||||
{
|
||||
code: "total_mileage_at_time",
|
||||
name: "指定时刻总里程",
|
||||
description: "按 VIN 和北京时间查询最近一条总里程、实际采集协议、记录时间及时间差秒数。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/total-mileage/query",
|
||||
unit: "km"
|
||||
}
|
||||
];
|
||||
|
||||
export const curlExample = (product: Product) => {
|
||||
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}'`;
|
||||
if (product.code === "mileage_range") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||
"pageSize": 5000
|
||||
}'`;
|
||||
return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"plateNumbers": ["沪A12345"],
|
||||
"date": "2026-07-19"${product.code === "daily_mileage" ? `,
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]` : ""}
|
||||
}'`;
|
||||
};
|
||||
10
vehicle-data-platform/apps/open-portal/src/main.tsx
Normal file
10
vehicle-data-platform/apps/open-portal/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1016
vehicle-data-platform/apps/open-portal/src/styles.css
Normal file
1016
vehicle-data-platform/apps/open-portal/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1
vehicle-data-platform/apps/open-portal/src/test/setup.ts
Normal file
1
vehicle-data-platform/apps/open-portal/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
92
vehicle-data-platform/apps/open-portal/src/ui.tsx
Normal file
92
vehicle-data-platform/apps/open-portal/src/ui.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type Route = "home" | "docs" | "console";
|
||||
const companyLogo = new URL("../../web/public/brand-logo.svg", import.meta.url).href;
|
||||
|
||||
export function navigate(route: Route) {
|
||||
window.location.hash = route === "home" ? "/" : `/${route}`;
|
||||
}
|
||||
|
||||
export function currentRoute(): Route {
|
||||
const path = window.location.hash.replace(/^#/, "");
|
||||
if (path.startsWith("/docs")) return "docs";
|
||||
if (path.startsWith("/console")) return "console";
|
||||
return "home";
|
||||
}
|
||||
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<button className={`brand${compact ? " compact" : ""}`} onClick={() => navigate("home")} aria-label="返回开放平台首页">
|
||||
<img src={companyLogo} alt="羚牛智能" />
|
||||
<span className="brand-divider" aria-hidden="true" />
|
||||
<span className="brand-product">开放平台</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type IconName =
|
||||
| "home" | "apps" | "users" | "car" | "shield" | "book" | "key"
|
||||
| "terminal" | "activity" | "settings" | "plus" | "search" | "close"
|
||||
| "check" | "clock" | "copy" | "logout" | "arrow" | "menu";
|
||||
|
||||
const paths: Record<IconName, ReactNode> = {
|
||||
home: <><path d="m3 10 9-7 9 7" /><path d="M5 9v11h14V9M9 20v-6h6v6" /></>,
|
||||
apps: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8" /></>,
|
||||
car: <><path d="m5 17-1 2v2M19 17l1 2v2" /><path d="M3 17h18v-5l-2-5H5l-2 5v5Z" /><circle cx="7" cy="14" r="1" /><circle cx="17" cy="14" r="1" /></>,
|
||||
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
|
||||
book: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" /></>,
|
||||
key: <><circle cx="8" cy="15" r="5" /><path d="m12 11 9-9M18 5l3 3M15 8l3 3" /></>,
|
||||
terminal: <><path d="m4 17 6-5-6-5M12 19h8" /></>,
|
||||
activity: <path d="M3 12h4l3-8 4 16 3-8h4" />,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.1.37.3.72.6 1 .3.28.68.42 1.1.4H21v4h-.09A1.7 1.7 0 0 0 19.4 15Z" /></>,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
close: <path d="m6 6 12 12M18 6 6 18" />,
|
||||
check: <path d="m5 12 4 4L19 6" />,
|
||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||
copy: <><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></>,
|
||||
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 3h7v18h-7" /></>,
|
||||
arrow: <path d="m9 18 6-6-6-6" />,
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
|
||||
return <svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Status({ value, label }: { value: string; label?: string }) {
|
||||
const normalized = value === "enabled" || value === "success" || value === "available" ? "enabled" : value === "disabled" || value === "failed" ? "disabled" : value;
|
||||
const text = label || (value === "enabled" ? "启用" : value === "disabled" ? "已停用" : value === "success" ? "成功" : value);
|
||||
return <span className={`status ${normalized}`}><i />{text}</span>;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
|
||||
return <header className="workspace-header"><div><h1>{title}</h1>{description && <p>{description}</p>}</div>{action}</header>;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, body, action }: { title: string; body: string; action?: ReactNode }) {
|
||||
return <div className="empty-state"><span><Icon name="apps" size={25} /></span><h2>{title}</h2><p>{body}</p>{action}</div>;
|
||||
}
|
||||
|
||||
export function dateInput(value?: string) {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export function rfcDate(value: string) {
|
||||
return `${value}T00:00:00+08:00`;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) return "长期有效";
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatTime(value?: string) {
|
||||
if (!value) return "从未登录";
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function errorMessage(reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : "操作失败,请稍后重试";
|
||||
}
|
||||
20
vehicle-data-platform/apps/open-portal/tsconfig.json
Normal file
20
vehicle-data-platform/apps/open-portal/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
20
vehicle-data-platform/apps/open-portal/vite.config.ts
Normal file
20
vehicle-data-platform/apps/open-portal/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, searchForWorkspaceRoot } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
searchForWorkspaceRoot(process.cwd()),
|
||||
fileURLToPath(new URL("../web/public", import.meta.url))
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
"/portal-api": "http://127.0.0.1:20310",
|
||||
"/api/v1": "http://127.0.0.1:20310",
|
||||
"/open-api": "http://127.0.0.1:20310"
|
||||
}
|
||||
}
|
||||
});
|
||||
11
vehicle-data-platform/apps/open-portal/vitest.config.ts
Normal file
11
vehicle-data-platform/apps/open-portal/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user