feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View 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>

View 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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false

View 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 "登录账号需为 364 位,只能包含字母、数字、点、下划线或连字符。";
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 "初始密码必须为 12128 位,并包含大写字母、小写字母和数字。";
if (password && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "新密码必须为 12128 位,并包含大写字母、小写字母和数字。";
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="364 位字母、数字或 . _ -" /></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>
</>;
}

View 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("初始密码必须为 12128 位");
});
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();
});
});

View 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} />;
}

View 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>;
}

View 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 &gt; YUTONG_MQTT &gt; 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 &gt; MQTT &gt; JT808</option><option value="JT808,GB32960,MQTT">JT808 &gt; GB32960 &gt; MQTT</option><option value="GB32960,MQTT">GB32960 &gt; 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> 12128 </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>;
}

View 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 400401403 </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 15000 5000 </td></tr><tr><td></td><td>YYYY-MM-DDAsia/Shanghai</td></tr><tr><td></td><td>JSONContent-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> GB32960MQTT JT808NO_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> GB32960MQTTJT808 </td></tr><tr><td><code>cursor</code></td><td></td><td> null nextCursor</td></tr><tr><td><code>pageSize</code></td><td></td><td>15000 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>GB32960YUTONG_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> GB32960MQTTJT808</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] || [];
}

View 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> 1200 / </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>
);
}

View File

@@ -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>}
</>;
}

View 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 "初始密码必须为 12128 位,并包含大写字母、小写字母和数字";
if (detail.includes("password requires upper, lower and digit")) return "初始密码必须同时包含大写字母、小写字母和数字";
if (detail.includes("invalid portal user")) return "登录账号需为 364 位字母、数字、点、下划线或连字符,显示名称不能为空";
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 })
})
};

View 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"]` : ""}
}'`;
};

View 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>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View 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 : "操作失败,请稍后重试";
}

View 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"]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View 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"
}
}
});

View 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"
}
});