feat: harden enterprise control plane
This commit is contained in:
@@ -1,721 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Refine } from "@refinedev/core";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
ConfigProvider,
|
||||
Empty,
|
||||
Input,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
message,
|
||||
} from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AdminAccessPanel } from "@/components/admin/admin-access-panel";
|
||||
import { AdminSkillLifecyclePanel } from "@/components/admin/admin-skill-lifecycle-panel";
|
||||
import {
|
||||
type BossAdminOverview,
|
||||
createBossAdminDataProvider,
|
||||
} from "@/components/admin/boss-admin-data-provider";
|
||||
|
||||
type AdminRow = Record<string, unknown>;
|
||||
|
||||
type BossAdminAppProps = {
|
||||
initialOverview?: BossAdminOverview | null;
|
||||
};
|
||||
|
||||
type AdminSection = "dashboard" | "customers" | "permissions" | "governance";
|
||||
type RiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla";
|
||||
|
||||
const resources = [
|
||||
{ name: "companies", list: "/admin#companies", meta: { label: "公司" } },
|
||||
{ name: "accounts", list: "/admin#accounts", meta: { label: "账号" } },
|
||||
{ name: "devices", list: "/admin#devices", meta: { label: "设备" } },
|
||||
{ name: "risks", list: "/admin#risks", meta: { label: "风险" } },
|
||||
{ name: "notifications", list: "/admin#notifications", meta: { label: "通知" } },
|
||||
{ name: "auditLogs", list: "/admin#auditLogs", meta: { label: "审计日志" } },
|
||||
];
|
||||
|
||||
const adminShell = "min-h-screen bg-[#F3F5F2] p-5 text-[#101814]";
|
||||
const adminChrome =
|
||||
"mx-auto grid min-h-[calc(100vh-40px)] max-w-[1680px] grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[30px] border border-[#E0E6E1] bg-white shadow-[0_32px_100px_rgba(22,37,28,0.10)]";
|
||||
const adminSidebar = "border-r border-[#E3E8E4] bg-[#FBFCFB] px-4 py-5";
|
||||
const adminHeader = "flex min-h-[86px] items-center border-b border-[#E3E8E4] bg-white px-7";
|
||||
const adminCardClass = "boss-admin-card border-[#E3E8E4] shadow-[0_14px_42px_rgba(20,35,25,0.045)]";
|
||||
const adminDense = "boss-admin-dense";
|
||||
|
||||
const navItems: Array<{
|
||||
key: AdminSection;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
marker: string;
|
||||
}> = [
|
||||
{ key: "dashboard", title: "平台运营驾驶舱", subtitle: "全局健康与待处理事项", marker: "D" },
|
||||
{ key: "customers", title: "客户与账号", subtitle: "公司、老板账号与子账号", marker: "C" },
|
||||
{ key: "permissions", title: "授权工作台", subtitle: "设备、项目与 Skill 权限", marker: "P" },
|
||||
{ key: "governance", title: "风险与治理", subtitle: "风险、SLA、Skill", marker: "R" },
|
||||
];
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function rowId(row: AdminRow, index?: number) {
|
||||
return text(row.id ?? row.companyId ?? row.account ?? row.deviceId ?? row.riskId ?? row.auditId, String(index ?? 0));
|
||||
}
|
||||
|
||||
function statusTag(value: unknown) {
|
||||
const status = text(value, "unknown");
|
||||
const color =
|
||||
status === "online" || status === "active" || status === "healthy" || status === "completed"
|
||||
? "green"
|
||||
: status === "offline" || status === "disabled"
|
||||
? "default"
|
||||
: status === "failed" || status === "critical"
|
||||
? "red"
|
||||
: "orange";
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
}
|
||||
|
||||
function severityTag(value: unknown) {
|
||||
const severity = text(value, "info");
|
||||
const color = severity === "critical" || severity === "high" ? "red" : severity === "warning" || severity === "medium" ? "orange" : "blue";
|
||||
return <Tag color={color}>{severity}</Tag>;
|
||||
}
|
||||
|
||||
function riskTarget(row: AdminRow) {
|
||||
return text(row.target ?? row.deviceId ?? row.projectId ?? row.account ?? row.companyId);
|
||||
}
|
||||
|
||||
function sectionTitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.title ?? "平台运营驾驶舱";
|
||||
}
|
||||
|
||||
function currentSubtitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.subtitle ?? "全局健康与待处理事项";
|
||||
}
|
||||
|
||||
function customerHealthTone(company: AdminRow) {
|
||||
const riskCount = numberValue(company.openRiskCount);
|
||||
const deviceCount = numberValue(company.deviceCount);
|
||||
const onlineCount = numberValue(company.onlineDeviceCount);
|
||||
if (riskCount >= 3) return { label: "需介入", color: "red" };
|
||||
if (deviceCount > 0 && onlineCount === 0) return { label: "离线", color: "orange" };
|
||||
if (riskCount > 0) return { label: "观察", color: "gold" };
|
||||
return { label: "健康", color: "green" };
|
||||
}
|
||||
|
||||
const riskColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "风险", dataIndex: "title", render: (_, row) => text(row.title ?? row.name ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 104, render: severityTag },
|
||||
{ title: "对象", dataIndex: "target", width: 170, render: (_, row) => riskTarget(row) },
|
||||
{ title: "负责人", dataIndex: "ownerAccount", width: 150, render: (_, row) => text(row.ownerAccount, "未指派") },
|
||||
{ title: "SLA", dataIndex: "slaDueAt", width: 180, render: (_, row) => text(row.slaDueAt, "未设置") },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: statusTag },
|
||||
];
|
||||
|
||||
const deviceColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "设备", dataIndex: "name", render: (_, row) => text(row.name ?? row.deviceName ?? row.deviceId ?? row.id) },
|
||||
{ title: "状态", dataIndex: "status", width: 105, render: (_, row) => statusTag(row.status ?? row.onlineStatus) },
|
||||
{ title: "GUI", dataIndex: "codexGuiOnline", width: 86, render: (_, row) => statusTag(row.codexGuiOnline ? "online" : "offline") },
|
||||
{ title: "CLI", dataIndex: "codexCliOnline", width: 86, render: (_, row) => statusTag(row.codexCliOnline ? "online" : "offline") },
|
||||
{ title: "风险", dataIndex: "openRiskCount", width: 86, render: numberValue },
|
||||
{ title: "最近心跳", dataIndex: "lastSeenAt", width: 210, render: (_, row) => text(row.lastSeenAt ?? row.updatedAt) },
|
||||
];
|
||||
|
||||
const companyColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "公司", dataIndex: "name", render: (_, row) => text(row.name ?? row.companyName ?? row.companyId) },
|
||||
{ title: "健康", dataIndex: "health", width: 100, render: (_, row) => {
|
||||
const tone = customerHealthTone(row);
|
||||
return <Tag color={tone.color}>{tone.label}</Tag>;
|
||||
} },
|
||||
{ title: "账号", dataIndex: "accountCount", width: 86, render: numberValue },
|
||||
{ title: "在线设备", dataIndex: "onlineDeviceCount", width: 112, render: (_, row) => `${numberValue(row.onlineDeviceCount)}/${numberValue(row.deviceCount)}` },
|
||||
{ title: "开放风险", dataIndex: "openRiskCount", width: 104, render: numberValue },
|
||||
{ title: "客户成功", dataIndex: "successOwnerAccount", width: 150, render: (_, row) => text(row.successOwnerAccount, "未指派") },
|
||||
];
|
||||
|
||||
const accountColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "账号", dataIndex: "account", render: (_, row) => text(row.account ?? row.phone ?? row.id) },
|
||||
{ title: "角色", dataIndex: "role", width: 130, render: statusTag },
|
||||
{ title: "公司", dataIndex: "companyName", render: (_, row) => text(row.companyName ?? row.companyId) },
|
||||
{ title: "状态", dataIndex: "status", width: 118, render: statusTag },
|
||||
{ title: "最近登录", dataIndex: "lastLoginAt", width: 210, render: (_, row) => text(row.lastLoginAt, "暂无") },
|
||||
];
|
||||
|
||||
const notificationColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "通知", dataIndex: "title", render: (_, row) => text(row.title ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 110, render: severityTag },
|
||||
{ title: "公司", dataIndex: "companyId", width: 150, render: (_, row) => text(row.companyId) },
|
||||
{ title: "风险", dataIndex: "riskId", width: 220, render: (_, row) => text(row.riskId) },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 190, render: (_, row) => text(row.createdAt) },
|
||||
];
|
||||
|
||||
async function loadOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`后台总览读取失败:${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
tone = "default",
|
||||
hint,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
tone?: "default" | "green" | "red" | "orange";
|
||||
hint?: string;
|
||||
}) {
|
||||
const valueColor = tone === "green" ? "#07A85A" : tone === "red" ? "#E23D3D" : tone === "orange" ? "#D97706" : "#101814";
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<Statistic title={title} value={value} valueStyle={{ color: valueColor, fontWeight: 800 }} />
|
||||
{hint ? <div className="mt-2 text-xs text-[#758078]">{hint}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({ title, subtitle, extra }: { title: string; subtitle?: string; extra?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[18px] font-black tracking-[-0.02em] text-[#101814]">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-sm text-[#68746D]">{subtitle}</div> : null}
|
||||
</div>
|
||||
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ textValue }: { textValue: string }) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={textValue} />;
|
||||
}
|
||||
|
||||
type RiskActionsProps = {
|
||||
selectedRisk?: AdminRow;
|
||||
actionBusy: string;
|
||||
onSubmit: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
function RiskActionPanel({ selectedRisk, actionBusy, onSubmit }: RiskActionsProps) {
|
||||
const [ownerAccount, setOwnerAccount] = useState("");
|
||||
const [slaDueAt, setSlaDueAt] = useState("");
|
||||
const riskId = selectedRisk ? text(selectedRisk.riskId ?? selectedRisk.id, "") : "";
|
||||
const kind = selectedRisk ? text(selectedRisk.kind, "") : "";
|
||||
const canAckResolve = kind === "ops_fault" || kind === "thread_context_alert";
|
||||
const canCreateTicket = kind === "ops_fault";
|
||||
|
||||
if (!selectedRisk) {
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="选择左侧风险后,在这里指派负责人、设置 SLA 或创建修复工单。" />
|
||||
<EmptyBlock textValue="暂无选中风险" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="所有动作都会写入风险时间线和权限审计。" />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[#E3E8E4] bg-[#F8FAF8] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{severityTag(selectedRisk.severity)}
|
||||
<span className="font-bold text-[#101814]">{text(selectedRisk.title ?? selectedRisk.kind)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#5F6B64]">{text(selectedRisk.detail ?? selectedRisk.summary, "暂无详情")}</div>
|
||||
<div className="mt-3 text-xs text-[#7B857E]">对象:{riskTarget(selectedRisk)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">负责人账号</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={ownerAccount} onChange={(event) => setOwnerAccount(event.target.value)} placeholder="例如 ops@company.com" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !ownerAccount.trim()}
|
||||
loading={actionBusy === `${riskId}:assign_owner`}
|
||||
onClick={() => onSubmit(selectedRisk, "assign_owner", { ownerAccount: ownerAccount.trim() })}
|
||||
>
|
||||
指派
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">SLA 截止时间</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={slaDueAt} onChange={(event) => setSlaDueAt(event.target.value)} placeholder="2026-04-30T18:00:00+08:00" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !slaDueAt.trim()}
|
||||
loading={actionBusy === `${riskId}:set_sla`}
|
||||
onClick={() => onSubmit(selectedRisk, "set_sla", { slaDueAt: slaDueAt.trim() })}
|
||||
>
|
||||
设置 SLA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:ack`} onClick={() => onSubmit(selectedRisk, "ack")}>
|
||||
确认
|
||||
</Button>
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:resolve`} onClick={() => onSubmit(selectedRisk, "resolve")}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canCreateTicket}
|
||||
loading={actionBusy === `${riskId}:create_repair_ticket`}
|
||||
onClick={() => onSubmit(selectedRisk, "create_repair_ticket")}
|
||||
>
|
||||
工单
|
||||
</Button>
|
||||
</div>
|
||||
{!canAckResolve ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该风险类型当前只读"
|
||||
description="当前动作接口暂不支持该风险类型,后台保留展示但不会假装处置成功。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardView({
|
||||
stats,
|
||||
companies,
|
||||
devices,
|
||||
risks,
|
||||
notifications,
|
||||
timeline,
|
||||
onOpenRisk,
|
||||
}: {
|
||||
stats: AdminRow;
|
||||
companies: AdminRow[];
|
||||
devices: AdminRow[];
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
timeline: AdminRow[];
|
||||
onOpenRisk: () => void;
|
||||
}) {
|
||||
const topRisks = risks.slice(0, 5);
|
||||
const topCompanies = companies.slice().sort((left, right) => numberValue(right.openRiskCount) - numberValue(left.openRiskCount)).slice(0, 6);
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<section>
|
||||
<PanelTitle title="今日待处理" subtitle="先看需要平台侧介入的客户、设备和主 Agent 风险。" />
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<MetricCard title="客户公司" value={numberValue(stats.companies ?? companies.length)} hint="当前纳入平台管理的公司" />
|
||||
<MetricCard title="账号" value={numberValue(stats.accounts)} hint="含最高管理员与客户账号" />
|
||||
<MetricCard title="在线设备" value={numberValue(stats.onlineDevices)} tone="green" hint={`总设备 ${numberValue(stats.devices ?? devices.length)}`} />
|
||||
<MetricCard title="开放风险" value={numberValue(stats.openRisks ?? risks.length)} tone="red" hint={`关键 ${numberValue(stats.criticalRisks)}`} />
|
||||
<MetricCard title="风险通知" value={numberValue(stats.openNotifications ?? notifications.length)} tone="orange" hint="SLA 与主动通知" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户健康排行" subtitle="优先跟进开放风险最多或设备离线的客户。" />
|
||||
<div className="space-y-3">
|
||||
{topCompanies.length > 0 ? topCompanies.map((company) => {
|
||||
const tone = customerHealthTone(company);
|
||||
return (
|
||||
<div key={rowId(company)} className="flex items-center justify-between rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{text(company.name ?? company.companyName ?? company.companyId)}</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">
|
||||
账号 {numberValue(company.accountCount)} · 设备 {numberValue(company.onlineDeviceCount)}/{numberValue(company.deviceCount)} · 客户成功 {text(company.successOwnerAccount, "未指派")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag color={tone.color}>{tone.label}</Tag>
|
||||
<span className="text-sm font-bold text-[#E23D3D]">{numberValue(company.openRiskCount)} 风险</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <EmptyBlock textValue="暂无客户数据" />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="关键风险队列"
|
||||
subtitle="只展示最该处理的前几条,完整队列在风险与治理里。"
|
||||
extra={<Button onClick={onOpenRisk}>进入战情室</Button>}
|
||||
/>
|
||||
{topRisks.length > 0 ? (
|
||||
<Table rowKey={rowId} columns={riskColumns} dataSource={topRisks} pagination={false} size="small" />
|
||||
) : (
|
||||
<EmptyBlock textValue="暂无开放高优风险" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="节点健康" subtitle="集中查看客户电脑、Codex GUI/CLI 与最近心跳。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices.slice(0, 8)} pagination={false} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="最近事件" subtitle="风险通知和处置时间线,避免平台侧漏跟进。" />
|
||||
<div className="space-y-3">
|
||||
{[...notifications, ...timeline].slice(0, 7).map((event, index) => (
|
||||
<div key={rowId(event, index)} className="rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-[#101814]">{text(event.title ?? event.action ?? event.kind, "事件")}</div>
|
||||
{severityTag(event.severity ?? "info")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">{text(event.createdAt ?? event.updatedAt ?? event.time, "暂无时间")}</div>
|
||||
</div>
|
||||
))}
|
||||
{notifications.length === 0 && timeline.length === 0 ? <EmptyBlock textValue="暂无风险事件" /> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersView({ companies, accounts, devices }: { companies: AdminRow[]; accounts: AdminRow[]; devices: AdminRow[] }) {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户与账号" subtitle="先看客户公司,再进入账号、设备和权限配置。" />
|
||||
<Table rowKey={rowId} columns={companyColumns} dataSource={companies} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户开通任务流" subtitle="把公司、老板账号、设备和 Skill 权限串成一个交付动作。" />
|
||||
<div className="space-y-3">
|
||||
{["创建客户公司", "开通老板账号", "绑定客户电脑", "分配项目与 Skill 权限"].map((item, index) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#101814] text-xs font-black text-white">{index + 1}</span>
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{item}</div>
|
||||
<div className="text-xs text-[#68746D]">当前仍复用下方授权工作台写入接口,先保证链路稳定。</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="账号列表" subtitle="查看角色、状态、公司和最近登录。" />
|
||||
<Table rowKey={rowId} columns={accountColumns} dataSource={accounts} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户设备" subtitle="确认设备归属和在线状态。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsView() {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="授权工作台"
|
||||
subtitle="按账号分配设备、项目与 Skill 权限;高危动作保留二次确认和审计。"
|
||||
/>
|
||||
<AdminAccessPanel className={adminDense} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceView({
|
||||
risks,
|
||||
notifications,
|
||||
selectedRisk,
|
||||
setSelectedRisk,
|
||||
actionBusy,
|
||||
submitRiskAction,
|
||||
}: {
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
selectedRisk?: AdminRow;
|
||||
setSelectedRisk: (risk?: AdminRow) => void;
|
||||
actionBusy: string;
|
||||
submitRiskAction: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tabs
|
||||
className="boss-admin-governance-tabs"
|
||||
items={[
|
||||
{
|
||||
key: "risk",
|
||||
label: "风险战情室",
|
||||
children: (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险与治理" subtitle="按严重程度、客户影响、负责人和 SLA 推进处置。" />
|
||||
<Table
|
||||
rowKey={rowId}
|
||||
columns={riskColumns}
|
||||
dataSource={risks}
|
||||
pagination={{ pageSize: 8 }}
|
||||
size="small"
|
||||
onRow={(risk) => ({
|
||||
onClick: () => setSelectedRisk(risk),
|
||||
className: rowId(risk) === rowId(selectedRisk ?? {}) ? "cursor-pointer bg-[#F1FAF4]" : "cursor-pointer",
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
<div className="space-y-5">
|
||||
<RiskActionPanel
|
||||
key={selectedRisk ? rowId(selectedRisk) : "empty-risk"}
|
||||
selectedRisk={selectedRisk}
|
||||
actionBusy={actionBusy}
|
||||
onSubmit={submitRiskAction}
|
||||
/>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险通知" subtitle="SLA 扫描和主动通知生成的待跟进事项。" />
|
||||
<Table rowKey={rowId} columns={notificationColumns} dataSource={notifications} pagination={{ pageSize: 5 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: "Skill 生命周期",
|
||||
children: (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="Skill 生命周期" subtitle="安装、更新、卸载、回滚和版本锁定统一排队,设备端按安全策略执行。" />
|
||||
<AdminSkillLifecyclePanel className={adminDense} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BossAdminApp({ initialOverview = null }: BossAdminAppProps) {
|
||||
const [overview, setOverview] = useState<BossAdminOverview | null>(initialOverview);
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState("");
|
||||
const [activeSection, setActiveSection] = useState<AdminSection>("dashboard");
|
||||
const [selectedRiskId, setSelectedRiskId] = useState("");
|
||||
const [messageApi, messageContext] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
if (overview) return;
|
||||
|
||||
let active = true;
|
||||
loadOverview()
|
||||
.then((nextOverview) => {
|
||||
if (active) setOverview(nextOverview);
|
||||
})
|
||||
.catch((nextError: Error) => {
|
||||
if (active) setError(nextError.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [overview]);
|
||||
|
||||
const stats = overview?.summary ?? overview?.stats ?? {};
|
||||
const companies = overview?.companies ?? [];
|
||||
const accounts = overview?.accounts ?? [];
|
||||
const devices = overview?.devices ?? [];
|
||||
const risks = overview?.risks ?? [];
|
||||
const notifications = overview?.notifications ?? [];
|
||||
const timeline = Array.isArray((overview as { riskTimeline?: AdminRow[] } | null)?.riskTimeline)
|
||||
? ((overview as { riskTimeline?: AdminRow[] }).riskTimeline ?? [])
|
||||
: [];
|
||||
const selectedRisk = risks.find((risk) => rowId(risk) === selectedRiskId) ?? risks[0];
|
||||
|
||||
async function refreshOverview() {
|
||||
setOverview(await loadOverview());
|
||||
}
|
||||
|
||||
async function submitRiskAction(risk: AdminRow, action: RiskAction, extraBody: Record<string, unknown> = {}) {
|
||||
const riskId = text(risk.riskId ?? risk.id, "");
|
||||
if (!riskId) return;
|
||||
setActionBusy(`${riskId}:${action}`);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ riskId, action, ...extraBody }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as { ok?: boolean; message?: string } | null;
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
throw new Error(payload?.message || `风险动作失败:${response.status}`);
|
||||
}
|
||||
messageApi.success(
|
||||
action === "ack"
|
||||
? "已确认风险"
|
||||
: action === "resolve"
|
||||
? "已关闭风险"
|
||||
: action === "assign_owner"
|
||||
? "已指派负责人"
|
||||
: action === "set_sla"
|
||||
? "已设置 SLA"
|
||||
: "已创建修复工单",
|
||||
);
|
||||
await refreshOverview();
|
||||
} catch (nextError) {
|
||||
const messageText = nextError instanceof Error ? nextError.message : "风险动作失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function renderActiveSection() {
|
||||
if (activeSection === "customers") {
|
||||
return <CustomersView companies={companies} accounts={accounts} devices={devices} />;
|
||||
}
|
||||
if (activeSection === "permissions") {
|
||||
return <PermissionsView />;
|
||||
}
|
||||
if (activeSection === "governance") {
|
||||
return (
|
||||
<GovernanceView
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
selectedRisk={selectedRisk}
|
||||
setSelectedRisk={(risk) => setSelectedRiskId(risk ? rowId(risk) : "")}
|
||||
actionBusy={actionBusy}
|
||||
submitRiskAction={(risk, action, extraBody) => void submitRiskAction(risk, action, extraBody)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DashboardView
|
||||
stats={stats}
|
||||
companies={companies}
|
||||
devices={devices}
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
timeline={timeline}
|
||||
onOpenRisk={() => setActiveSection("governance")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#07C160",
|
||||
borderRadius: 14,
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
colorBgLayout: "#F3F5F2",
|
||||
colorBorderSecondary: "#E3E8E4",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
controlHeight: 34,
|
||||
borderRadius: 10,
|
||||
},
|
||||
Card: {
|
||||
headerFontSize: 16,
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#F7F8F7",
|
||||
cellPaddingBlock: 9,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
|
||||
<main className={adminShell}>
|
||||
{messageContext}
|
||||
<div className={adminChrome}>
|
||||
<aside className={adminSidebar}>
|
||||
<div className="mb-7 px-2">
|
||||
<div className="text-[24px] font-black tracking-[-0.04em] text-[#101814]">Boss</div>
|
||||
<div className="mt-1 text-xs font-semibold text-[#7A857D]">To B 总后台</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const active = activeSection === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(item.key)}
|
||||
className={[
|
||||
"flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition",
|
||||
active ? "bg-[#EAF8EF] text-[#075F31] shadow-[inset_0_0_0_1px_rgba(7,193,96,0.18)]" : "text-[#46524B] hover:bg-[#F2F5F2]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={active ? "grid size-9 place-items-center rounded-xl bg-[#07C160] text-xs font-black text-white" : "grid size-9 place-items-center rounded-xl bg-white text-xs font-black text-[#7A857D] ring-1 ring-[#E3E8E4]"}>
|
||||
{item.marker}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-black">{item.title}</span>
|
||||
<span className="mt-0.5 block truncate text-xs opacity-70">{item.subtitle}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-[#E3E8E4] bg-white p-4">
|
||||
<div className="text-xs font-bold text-[#7A857D]">当前身份</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#07C160] text-xs font-black text-white">k</span>
|
||||
<span className="text-sm font-bold text-[#101814]">highest_admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="min-w-0 bg-[#F8FAF8]">
|
||||
<header className={adminHeader}>
|
||||
<div>
|
||||
<div className="text-[26px] font-black tracking-[-0.04em] text-[#101814]">{sectionTitle(activeSection)}</div>
|
||||
<div className="mt-1 text-sm text-[#68746D]">{currentSubtitle(activeSection)}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Button onClick={() => void refreshOverview()}>刷新</Button>
|
||||
<div className="rounded-full border border-[#E3E8E4] bg-white px-4 py-2 text-sm font-semibold text-[#4B5750]">
|
||||
平台最高管理员
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-7">
|
||||
{error ? <Alert className="mb-5" type="warning" showIcon message="后台数据暂不可用" description={error} /> : null}
|
||||
{renderActiveSection()}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type {
|
||||
BaseRecord,
|
||||
CreateResponse,
|
||||
DataProvider,
|
||||
DeleteOneResponse,
|
||||
DeleteOneParams,
|
||||
GetListResponse,
|
||||
GetListParams,
|
||||
GetOneResponse,
|
||||
GetOneParams,
|
||||
CreateParams,
|
||||
UpdateParams,
|
||||
UpdateResponse,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type BossAdminSeverity = "critical" | "high" | "medium" | "low" | "info";
|
||||
|
||||
export type BossAdminOverview = {
|
||||
ok?: boolean;
|
||||
summary?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
stats?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
companies?: Array<Record<string, unknown>>;
|
||||
accounts?: Array<Record<string, unknown>>;
|
||||
devices?: Array<Record<string, unknown>>;
|
||||
risks?: Array<Record<string, unknown>>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
auditLogs?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const resourceKeys = new Set(["companies", "accounts", "devices", "risks", "notifications", "auditLogs"]);
|
||||
|
||||
async function fetchOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load admin overview: ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function listFromOverview(overview: BossAdminOverview | undefined, resource: string) {
|
||||
if (!resourceKeys.has(resource)) return [];
|
||||
const value = overview?.[resource as keyof BossAdminOverview];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function recordId(item: Record<string, unknown>, index: number) {
|
||||
return String(item.id ?? item.companyId ?? item.account ?? item.deviceId ?? item.riskId ?? item.auditId ?? index);
|
||||
}
|
||||
|
||||
export function createBossAdminDataProvider(initialOverview?: BossAdminOverview): DataProvider {
|
||||
let overviewCache = initialOverview;
|
||||
|
||||
return {
|
||||
getList: async <TData extends BaseRecord = BaseRecord>({ resource }: GetListParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const data = listFromOverview(overviewCache, resource).map((item, index) => ({
|
||||
id: recordId(item, index),
|
||||
...item,
|
||||
})) as TData[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
} satisfies GetListResponse<TData>;
|
||||
},
|
||||
getOne: async <TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const item = listFromOverview(overviewCache, resource).find((entry, index) => {
|
||||
return recordId(entry, index) === String(id);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
...(item ?? {}),
|
||||
} as TData,
|
||||
} satisfies GetOneResponse<TData>;
|
||||
},
|
||||
create: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
variables,
|
||||
}: CreateParams<TVariables>) =>
|
||||
({ data: variables as unknown as TData }) satisfies CreateResponse<TData>,
|
||||
update: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
variables,
|
||||
}: UpdateParams<TVariables>) =>
|
||||
({ data: { id, ...(variables as BaseRecord) } as TData }) satisfies UpdateResponse<TData>,
|
||||
deleteOne: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
}: DeleteOneParams<TVariables>) =>
|
||||
({ data: { id } as TData }) satisfies DeleteOneResponse<TData>,
|
||||
getApiUrl: () => "/api/v1/admin/overview",
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,9 @@ async function waitForLoginSessionReady(nativeClient: boolean) {
|
||||
}
|
||||
|
||||
function resolvePostLoginPath() {
|
||||
return window.location.hostname === "admin.boss.hyzq.net" ? "/admin" : "/conversations";
|
||||
return window.location.hostname === "admin.boss.hyzq.net"
|
||||
? "/"
|
||||
: "/conversations";
|
||||
}
|
||||
|
||||
function navigateAfterLogin(router: ReturnType<typeof useRouter>) {
|
||||
|
||||
248
src/components/enterprise-admin-login-shell.tsx
Normal file
248
src/components/enterprise-admin-login-shell.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
isNativeBossApp,
|
||||
persistNativeSessionSnapshot,
|
||||
} from "@/lib/boss-app-client";
|
||||
|
||||
type LoginResult = {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
account?: string;
|
||||
displayName?: string;
|
||||
sessionExpiresAt?: string;
|
||||
restoreToken?: string;
|
||||
};
|
||||
|
||||
function resolvePostLoginPath() {
|
||||
return window.location.hostname === "admin.boss.hyzq.net"
|
||||
? "/"
|
||||
: "/conversations";
|
||||
}
|
||||
|
||||
async function waitForLoginSessionReady(nativeClient: boolean) {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
cache: "no-store",
|
||||
headers: nativeClient ? { "x-boss-native-app": "1" } : undefined,
|
||||
}).catch(() => null);
|
||||
if (response?.ok) return true;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function EnterpriseAdminLoginShell() {
|
||||
const router = useRouter();
|
||||
const [account, setAccount] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
if (!account.trim() || !password) {
|
||||
setMessage("请填写账号和密码。");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage("");
|
||||
const nativeClient = await isNativeBossApp();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(nativeClient ? { "x-boss-native-app": "1" } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as LoginResult;
|
||||
if (!result.ok) {
|
||||
setMessage(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
nativeClient &&
|
||||
result.restoreToken &&
|
||||
result.account &&
|
||||
result.displayName &&
|
||||
result.sessionExpiresAt
|
||||
) {
|
||||
await persistNativeSessionSnapshot({
|
||||
restoreToken: result.restoreToken,
|
||||
account: result.account,
|
||||
displayName: result.displayName,
|
||||
expiresAt: result.sessionExpiresAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
await waitForLoginSessionReady(nativeClient);
|
||||
const targetPath = resolvePostLoginPath();
|
||||
router.replace(targetPath, { scroll: false });
|
||||
router.refresh();
|
||||
window.setTimeout(() => {
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.location.replace(targetPath);
|
||||
}
|
||||
}, 180);
|
||||
} catch {
|
||||
setMessage("登录链路发生异常,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-[100dvh] bg-[#eef7f1] px-5 py-6 text-[#102418] md:px-10 md:py-10">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-48px)] max-w-6xl overflow-hidden rounded-[34px] border border-white/80 bg-white shadow-[0_24px_80px_rgba(16,36,24,0.12)]">
|
||||
<section className="relative hidden flex-1 flex-col justify-between overflow-hidden bg-[#e9f8f0] px-12 py-12 lg:flex">
|
||||
<div className="absolute -left-20 top-12 h-72 w-72 rounded-full bg-[#c8f5dd] blur-3xl" />
|
||||
<div className="absolute -bottom-24 right-4 h-80 w-80 rounded-full bg-white/70 blur-2xl" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#12c66a] text-[22px] font-black text-white shadow-[0_16px_36px_rgba(18,198,106,0.28)]">
|
||||
B
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[25px] font-black tracking-[-0.04em]">
|
||||
Boss 企业管理后台
|
||||
</div>
|
||||
<div className="mt-1 text-[14px] font-medium text-[#66746c]">
|
||||
统一管理企业账号、电脑节点、Skill 与风险
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 max-w-xl">
|
||||
<div className="inline-flex rounded-full border border-[#bfead1] bg-white/72 px-4 py-2 text-[13px] font-semibold text-[#0b8f4a]">
|
||||
平台级权限 · 企业账号 · 设备治理 · 风险审计
|
||||
</div>
|
||||
<h1 className="mt-7 text-[48px] font-black leading-[1.08] tracking-[-0.06em] text-[#102418]">
|
||||
企业级电脑与 Agent 统一治理入口
|
||||
</h1>
|
||||
<p className="mt-5 text-[17px] leading-8 text-[#607269]">
|
||||
面向 To B 交付场景,集中完成企业开通、账号授权、设备接入、Skill 分发与风险处置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
|
||||
{["企业开通", "设备授权", "风险治理"].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-2xl border border-white/80 bg-white/72 px-4 py-4 text-[15px] font-bold text-[#17372a] shadow-sm"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-3xl border border-white/80 bg-white/70 p-5 text-[13px] leading-6 text-[#617168]">
|
||||
仅限授权管理员访问。所有登录行为会进入审计链路,用于企业安全、客户成功和异常追踪。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex w-full items-center justify-center px-5 py-8 md:px-10 lg:w-[470px]">
|
||||
<div className="w-full max-w-[390px]">
|
||||
<div className="mb-9 lg:hidden">
|
||||
<div className="flex h-13 w-13 items-center justify-center rounded-2xl bg-[#12c66a] text-[20px] font-black text-white">
|
||||
B
|
||||
</div>
|
||||
<h1 className="mt-5 text-[30px] font-black tracking-[-0.04em]">
|
||||
Boss 企业管理后台
|
||||
</h1>
|
||||
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
|
||||
统一管理企业账号、电脑节点、Skill 与风险
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-[#dfe9e3] bg-white p-6 shadow-[0_18px_48px_rgba(16,36,24,0.08)] md:p-8">
|
||||
<div>
|
||||
<div className="text-[28px] font-black tracking-[-0.04em]">
|
||||
登录企业后台
|
||||
</div>
|
||||
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
|
||||
仅限授权管理员访问
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-8 space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-bold text-[#42554a]">账号</span>
|
||||
<input
|
||||
value={account}
|
||||
onChange={(event) => setAccount(event.target.value)}
|
||||
placeholder="输入管理员账号"
|
||||
autoComplete="username"
|
||||
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-bold text-[#42554a]">密码</span>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入登录密码"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-[13px] font-semibold text-[#526258]">
|
||||
<input
|
||||
checked={remember}
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-[#12c66a]"
|
||||
/>
|
||||
记住登录状态
|
||||
</label>
|
||||
<span className="text-[12px] text-[#8b9990]">HTTPS 安全会话</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex h-13 w-full items-center justify-center rounded-2xl bg-[#12c66a] text-[16px] font-black text-white shadow-[0_16px_32px_rgba(18,198,106,0.22)] transition hover:bg-[#0fb85f] disabled:cursor-not-allowed disabled:bg-[#9adfba]"
|
||||
>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{message ? (
|
||||
<div className="mt-5 rounded-2xl border border-[#bfead1] bg-[#eefaf3] px-4 py-3 text-[13px] leading-6 text-[#1c6b3e]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-[#e0e9e4] bg-white/74 px-4 py-4 text-[12px] leading-6 text-[#6f7d75]">
|
||||
登录代表你正在访问企业级管理后台。请确认账号权限来自企业或平台管理员授权。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user