refactor: rebuild PrivHunter with React, Gin and SQLite

This commit is contained in:
Ed1s0nZ committed 2026-10-07 19:55:17 +08:00
1 parent 2c842cb1da
commit bea3940977
96 files changed
+9150 -2309

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PrivHunter · 安全工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1946
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "privhunter-console",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"format": "prettier --write src tests *.ts *.json index.html",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"prettier": "^3.9.9",
"typescript": "^5.9.2",
"vite": "^7.1.7"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "tests",
workers: 1,
use: { baseURL: "http://127.0.0.1:18222" },
webServer: {
command: `go run ../cmd/privhunter -listen 127.0.0.1:18222 -proxy 127.0.0.1:19080 -database ../data/e2e-${Date.now()}.db -frontend dist`,
url: "http://127.0.0.1:18222/api/auth/status",
reuseExistingServer: false,
timeout: 120000,
},
});
+116
View File
@@ -0,0 +1,116 @@
import PasswordDialog from "./components/PasswordDialog";
import Overview, { type ResultSelection } from "./pages/Overview";
import Findings from "./pages/Findings";
import Users from "./pages/Users";
import Audit from "./pages/Audit";
import Settings from "./pages/Settings";
import { useEffect, useState } from "react";
import { api, send, setCSRF, type Session } from "./api/client";
import Login from "./pages/Login";
import Layout, { type Page } from "./components/Layout";
import useWorkspaceNavigation, {
canLeaveWorkspace,
} from "./hooks/useWorkspaceNavigation";
export default function App() {
const [session, setSession] = useState<Session | null>(null);
const [setup, setSetup] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const {
page,
navigate: navigatePage,
reset,
} = useWorkspaceNavigation(session?.user.role);
const [selection, setSelection] = useState<ResultSelection>({});
const navigate = (next: Page, filter: ResultSelection = {}) => {
if (!navigatePage(next)) return;
setSelection(filter);
};
const [passwordOpen, setPasswordOpen] = useState(false);
const accept = (s: Session, restore = false) => {
setCSRF(s.csrf);
setSession(s);
if (!restore) reset();
};
useEffect(() => {
const expire = () => {
setSession(null);
setCSRF("");
};
window.addEventListener("session-expired", expire);
(async () => {
try {
const v = await api<{ setupRequired: boolean }>("/auth/status");
setSetup(v.setupRequired);
if (!v.setupRequired) {
try {
accept(await api<Session>("/auth/me"), true);
} catch {}
}
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
})();
return () => window.removeEventListener("session-expired", expire);
}, []);
if (loading) return <div className="loading">正在连接工作台…</div>;
if (error)
return (
<div className="loading">
<p role="alert">{error}</p>
<button onClick={() => location.reload()}>重试</button>
</div>
);
if (!session)
return (
<Login setup={setup} onSetup={() => setSetup(false)} onLogin={accept} />
);
return (
<Layout
user={session.user}
page={page}
setPage={navigate}
password={() => setPasswordOpen(true)}
logout={async () => {
if (!canLeaveWorkspace()) return;
try {
await api("/auth/logout", send("POST", {}));
setSession(null);
setCSRF("");
} catch (e) {
setError((e as Error).message);
}
}}
>
<section className="content">
{page === "overview" ? (
<Overview navigate={navigate} />
) : page === "findings" ? (
<Findings
key={JSON.stringify(selection)}
initial={selection}
admin={session.user.role === "admin"}
/>
) : page === "users" ? (
<Users currentUser={session.user} />
) : page === "audit" ? (
<Audit />
) : (
<Settings key={page} scanner={page === "scanner"} />
)}
</section>
{passwordOpen && (
<PasswordDialog
close={() => setPasswordOpen(false)}
changed={() => {
setPasswordOpen(false);
setSession(null);
setCSRF("");
}}
/>
)}
</Layout>
);
}
+55
View File
@@ -0,0 +1,55 @@
export type User = {
id: number;
username: string;
role: "admin" | "viewer";
disabled: boolean;
};
export type Session = { user: User; csrf: string };
export type Finding = {
id: number;
method: string;
url: string;
requestA: string;
requestB: string;
responseA: string;
responseB: string;
result: string;
reason: string;
confidence: string;
review: string;
note: string;
createdAt: string;
};
export type FindingSummary = Omit<
Finding,
"requestA" | "requestB" | "responseA" | "responseB"
>;
let csrf = "";
export function setCSRF(value: string) {
csrf = value;
}
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const response = await fetch("/api" + path, {
...options,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
...options.headers,
},
});
const body = await response.json();
if (!response.ok) {
if (response.status === 401)
window.dispatchEvent(new Event("session-expired"));
throw new Error(body.error || "请求失败");
}
return body as T;
}
export const send = (method: string, value: unknown): RequestInit => ({
method,
body: JSON.stringify(value),
});
@@ -0,0 +1,120 @@
import { useMemo, useState } from "react";
import type { Finding } from "../api/client";
function fields(value: unknown, prefix = "", out: Record<string, string> = {}) {
if (Object.keys(out).length >= 500) return out;
if (value && typeof value === "object") {
for (const [key, v] of Object.entries(value)) {
fields(v, prefix ? prefix + "." + key : key, out);
}
} else out[prefix || "$"] = JSON.stringify(value) ?? "null";
return out;
}
export default function EvidenceComparison({ finding }: { finding: Finding }) {
const [tab, setTab] = useState<"response" | "request" | "diff">("response");
const [message, setMessage] = useState("");
const diff = useMemo(() => {
try {
const a = fields(JSON.parse(finding.responseA));
const b = fields(JSON.parse(finding.responseB));
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
.filter((k) => a[k] !== b[k])
.map((k) => ({
path: k,
a: a[k] ?? "(不存在)",
b: b[k] ?? "(不存在)",
}))
.slice(0, 500);
} catch {
return null;
}
}, [finding]);
const values =
tab === "request"
? [finding.requestA || "", finding.requestB || ""]
: [finding.responseA || "", finding.responseB || ""];
return (
<>
<div className="evidence-tabs" role="tablist" aria-label="证据视图">
{[
["response", "响应对比"],
["request", "请求对比"],
["diff", "JSON 字段差异"],
].map(([key, label]) => (
<button
key={key}
role="tab"
aria-selected={tab === key}
onClick={() => setTab(key as typeof tab)}
>
{label}
{key === "diff" && diff !== null ? " · " + diff.length : ""}
</button>
))}
</div>
{tab === "diff" ? (
<div className="field-diff">
{diff === null ? (
<p>响应不是可解析的 JSON,请使用响应对比查看原文。</p>
) : diff.length === 0 ? (
<p>
未发现可见字段差异。脱敏后的内容一致不代表权限判断已得到证实。
</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>字段路径</th>
<th>账号 A</th>
<th>账号 B</th>
</tr>
</thead>
<tbody>
{diff.map((d) => (
<tr key={d.path}>
<td>{d.path}</td>
<td>
<code>{d.a}</code>
</td>
<td>
<code>{d.b}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<small>最多展示 500 个差异字段;脱敏字段不会恢复原值。</small>
</div>
) : (
<div className="comparison">
{values.map((body, i) => (
<div key={i}>
<div className="section-heading">
<h3>
账号 {i === 0 ? "A" : "B"}{" "}
{tab === "request" ? "请求" : "响应"}
</h3>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(body);
setMessage("已复制脱敏证据");
} catch {
setMessage("复制失败,请手动选择文本");
}
}}
>
复制
</button>
</div>
<pre>{body || "无内容"}</pre>
</div>
))}
</div>
)}
{message && <small role="status">{message}</small>}
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import type { User } from "../api/client";
export const navigation = [
["overview", "概览", "◈"],
["findings", "检测结果", "⌕"],
["scanner", "扫描控制", "◎"],
["settings", "系统设置", "⚙"],
["users", "用户管理", "♙"],
["audit", "审计日志", "≡"],
] as const;
export type Page = (typeof navigation)[number][0];
export default function Layout({
user,
page,
setPage,
logout,
password,
children,
}: {
user: User;
page: Page;
setPage: (p: Page) => void;
logout: () => void;
password: () => void;
children: ReactNode;
}) {
return (
<div className="shell">
<aside>
<div className="brand">
<b>PH</b>
<div>
PrivHunter<span>安全分析工作台</span>
</div>
</div>
<span className="nav-label">WORKSPACE</span>
<nav>
{navigation
.filter(
([key]) =>
user.role === "admin" ||
key === "overview" ||
key === "findings",
)
.map(([key, label, icon]) => (
<button
key={key}
className={page === key ? "active" : ""}
aria-current={page === key ? "page" : undefined}
onClick={() => setPage(key)}
>
<span>{icon}</span>
{label}
</button>
))}
</nav>
<div className="sidebar-footer">
<div className="avatar">
{user.username.slice(0, 1).toUpperCase()}
</div>
<div>
<button
className="account-button"
onClick={password}
title="修改密码"
>
{user.username}
</button>
<small>{user.role === "admin" ? "管理员" : "只读成员"}</small>
</div>
<button onClick={logout} aria-label="退出登录">
↪
</button>
</div>
</aside>
<main>
<header>
<span>工作台 / {navigation.find(([key]) => key === page)?.[1]}</span>
<span className="local-badge">● LOCAL WORKSPACE</span>
</header>
{children}
</main>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef, type ReactNode } from "react";
export default function Modal({
title,
onClose,
busy = false,
children,
}: {
title: string;
onClose: () => void;
busy?: boolean;
children: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const close = useRef(onClose);
close.current = onClose;
useEffect(() => {
const element = dialog.current;
if (!element) return;
const previous = document.activeElement as HTMLElement | null;
element.showModal();
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
element.close();
document.body.style.overflow = overflow;
if (previous?.isConnected) previous.focus();
};
}, []);
return (
<dialog
ref={dialog}
className="detail native-modal"
aria-label={title}
onCancel={(e) => {
e.preventDefault();
if (!busy) close.current();
}}
>
{children}
</dialog>
);
}
@@ -0,0 +1,73 @@
import Modal from "./Modal";
import { useState } from "react";
import { api, send } from "../api/client";
export default function PasswordDialog({
close,
changed,
}: {
close: () => void;
changed: () => void;
}) {
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<Modal title="修改密码" onClose={close} busy={busy}>
<form
className="form-grid"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await api("/auth/password", send("POST", { current, password }));
changed();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<div className="page-heading">
<h2>修改密码</h2>
<button type="button" disabled={busy} onClick={close}>
关闭
</button>
</div>
<label>
当前密码
<input
autoFocus
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
</label>
<label>
新密码
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
/>
</label>
<small>修改后所有现有会话失效,请使用新密码登录。</small>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button className="primary" disabled={busy}>
保存新密码
</button>
</form>
</Modal>
);
}
+45
View File
@@ -0,0 +1,45 @@
export const labels: Record<string, string> = {
true: "疑似越权",
false: "未发现越权",
unknown: "待分析",
error: "检测失败",
pending: "待复核",
confirmed: "已确认",
false_positive: "误报",
resolved: "已解决",
};
export function Badge({ value }: { value: string }) {
return (
<span className={"badge badge-" + value}>{labels[value] || value}</span>
);
}
export function QueryState({
loading,
error,
empty = false,
retry,
}: {
loading: boolean;
error: string;
empty?: boolean;
retry: () => void;
}) {
if (loading) return <div className="empty">正在加载…</div>;
if (error)
return (
<div className="empty">
<p className="error" role="alert">
{error}
</p>
<button onClick={retry}>重新加载</button>
</div>
);
if (empty)
return (
<div className="empty">
<b>暂无记录</b>
<p>符合条件的数据将在这里显示。</p>
</div>
);
return null;
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
type State<T> = {
path: string | null;
data: T | null;
error: string;
loading: boolean;
};
export function useQuery<T>(path: string | null) {
const [state, setState] = useState<State<T>>({
path: null,
data: null,
error: "",
loading: false,
});
const [version, setVersion] = useState(0);
useEffect(() => {
if (path === null) {
setState({ path: null, data: null, error: "", loading: false });
return;
}
let active = true;
const controller = new AbortController();
setState((prev) => ({
path,
data: prev.path === path ? prev.data : null,
error: "",
loading: true,
}));
api<T>(path, { signal: controller.signal })
.then((data) => {
if (active) setState({ path, data, error: "", loading: false });
})
.catch((e) => {
if (active && e.name !== "AbortError")
setState((prev) => ({ ...prev, error: e.message, loading: false }));
});
return () => {
active = false;
controller.abort();
};
}, [path, version]);
const reload = useCallback(() => setVersion((v) => v + 1), []);
const current =
state.path === path
? state
: { path, data: null, error: "", loading: path !== null };
return {
data: current.data,
error: current.error,
loading: current.loading,
reload,
};
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from "react";
export function useUnsavedChanges(dirty: boolean) {
useEffect(() => {
if (!dirty) return;
const unload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
const navigate = (e: Event) => {
if (!window.confirm("有未保存的修改,确定放弃并离开?"))
e.preventDefault();
};
window.addEventListener("beforeunload", unload);
window.addEventListener("workspace-before-navigate", navigate);
return () => {
window.removeEventListener("beforeunload", unload);
window.removeEventListener("workspace-before-navigate", navigate);
};
}, [dirty]);
}
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { navigation, type Page } from "../components/Layout";
export function canLeaveWorkspace() {
return window.dispatchEvent(
new Event("workspace-before-navigate", { cancelable: true }),
);
}
function readPage(): Page {
const key = window.location.hash.replace(/^#\/?/, "");
return navigation.find(([page]) => page === key)?.[0] ?? "overview";
}
function writePage(page: Page, replace = false) {
const url = `${window.location.pathname}${window.location.search}#/${page}`;
window.history[replace ? "replaceState" : "pushState"](null, "", url);
}
export default function useWorkspaceNavigation(role?: string) {
const [page, setPage] = useState<Page>(readPage);
const current = useRef(page);
const allowed = useCallback(
(next: Page): Page =>
role === "viewer" && next !== "overview" && next !== "findings"
? "overview"
: next,
[role],
);
const move = useCallback((next: Page, replace = false) => {
current.current = next;
writePage(next, replace);
setPage(next);
}, []);
const navigate = useCallback(
(next: Page) => {
if (next === current.current) return true;
if (!canLeaveWorkspace()) return false;
move(allowed(next));
return true;
},
[allowed, move],
);
useEffect(() => {
move(allowed(current.current), true);
const onHistory = () => {
const next = allowed(readPage());
if (next !== current.current && !canLeaveWorkspace()) {
writePage(current.current, true);
return;
}
move(next, true);
};
window.addEventListener("hashchange", onHistory);
return () => {
window.removeEventListener("hashchange", onHistory);
};
}, [allowed, move]);
useEffect(() => {
document.title = `${navigation.find(([key]) => key === page)?.[1]} · PrivHunter`;
}, [page]);
return { page: allowed(page), navigate, reset: () => move("overview", true) };
}
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./style.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+124
View File
@@ -0,0 +1,124 @@
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useState } from "react";
import { useQuery } from "../hooks/useQuery";
import { QueryState } from "../components/Status";
type Entry = {
id: number;
actor: string;
action: string;
target: string;
createdAt: string;
};
const actions: Record<string, string> = {
setup: "初始化工作台",
login: "登录",
password_changed: "修改密码",
password_reset: "重置密码",
create_user: "创建成员",
update_user: "调整成员",
review: "复核记录",
bulk_review: "批量复核",
delete_finding: "删除记录",
update_settings: "保存配置",
export: "导出报告",
};
export default function Audit() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [action, setAction] = useState("");
const debounced = useDebouncedValue(search);
const q = useQuery<{ data: Entry[]; total: number }>(
"/audit?" +
new URLSearchParams({ page: String(page), search: debounced, action }),
);
return (
<>
<div className="page-heading">
<div>
<span className="eyebrow">ACTIVITY LOG</span>
<h1>审计日志</h1>
<p>追踪登录、配置修改与证据复核操作。</p>
</div>
<button onClick={q.reload}>刷新</button>
</div>
<div className="panel">
<div className="toolbar">
<input
aria-label="搜索审计记录"
placeholder="搜索操作者或操作对象…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<select
aria-label="审计操作类型"
value={action}
onChange={(e) => {
setAction(e.target.value);
setPage(1);
}}
>
<option value="">全部操作</option>
{Object.entries(actions).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{(search || action) && (
<button
onClick={() => {
setSearch("");
setAction("");
setPage(1);
}}
>
清除筛选
</button>
)}
</div>
<QueryState {...q} empty={q.data?.data.length === 0} retry={q.reload} />
<div className="table-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>操作者</th>
<th>操作</th>
<th>对象</th>
</tr>
</thead>
<tbody>
{q.data?.data.map((v) => (
<tr key={v.id}>
<td>{new Date(v.createdAt).toLocaleString()}</td>
<td>{v.actor}</td>
<td>
{actions[v.action] || v.action}
<small className="audit-code">{v.action}</small>
</td>
<td>{v.target}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<span>共 {q.data?.total || 0} 条</span>
<button disabled={page === 1} onClick={() => setPage((v) => v - 1)}>
上一页
</button>
<span>{page}</span>
<button
disabled={page * 50 >= (q.data?.total || 0)}
onClick={() => setPage((v) => v + 1)}
>
下一页
</button>
</div>
</div>
</>
);
}
+355
View File
@@ -0,0 +1,355 @@
import Modal from "../components/Modal";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import EvidenceComparison from "../components/EvidenceComparison";
import { useEffect, useState } from "react";
import { api, send, type Finding, type FindingSummary } from "../api/client";
import { useQuery } from "../hooks/useQuery";
import { Badge, QueryState, labels } from "../components/Status";
export default function Findings({
admin,
initial = {},
}: {
admin: boolean;
initial?: { search?: string; result?: string; review?: string };
}) {
const [search, setSearch] = useState(initial.search || "");
const [result, setResult] = useState(initial.result || "");
const [review, setReview] = useState(initial.review || "");
const [checked, setChecked] = useState<number[]>([]);
const [bulkStatus, setBulkStatus] = useState("confirmed");
const [notice, setNotice] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<FindingSummary | null>(null);
const detailQuery = useQuery<Finding>(
selected ? "/findings/" + selected.id : null,
);
const [note, setNote] = useState("");
const [status, setStatus] = useState("pending");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const debouncedSearch = useDebouncedValue(search);
const q = useQuery<{ data: FindingSummary[]; total: number }>(
"/findings?" +
new URLSearchParams({
search: debouncedSearch,
result,
review,
page: String(page),
size: "20",
}),
);
useEffect(() => {
setChecked([]);
}, [search, result, review, page]);
const bulk = async () => {
setBusy(true);
setError("");
try {
await api(
"/findings/bulk-review",
send("POST", { ids: checked, review: bulkStatus }),
);
setNotice(`已更新 ${checked.length} 条记录`);
setChecked([]);
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const mutate = async (method: string, body?: unknown) => {
if (!selected) return;
setBusy(true);
setError("");
try {
await api("/findings/" + selected.id, send(method, body || {}));
setSelected(null);
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<>
<div className="page-heading">
<div>
<span className="eyebrow">EVIDENCE & REVIEW</span>
<h1>检测结果</h1>
<p>检索权限风险,对比证据并记录结论。</p>
</div>
<div className="toolbar">
<a
className="download"
href={
"/api/export?" +
new URLSearchParams({ search, result, review, format: "csv" })
}
>
导出 CSV
</a>
<a
className="download"
href={
"/api/export?" +
new URLSearchParams({ search, result, review, format: "json" })
}
>
导出 JSON
</a>
<button onClick={q.reload}>刷新</button>
</div>
</div>
<div className="panel">
<div className="toolbar">
<input
aria-label="搜索 URL"
placeholder="搜索 URL…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<select
aria-label="检测判定"
value={result}
onChange={(e) => {
setResult(e.target.value);
setPage(1);
}}
>
<option value="">全部判定</option>
{["true", "false", "unknown", "error"].map((v) => (
<option key={v} value={v}>
{labels[v]}
</option>
))}
</select>
<select
aria-label="复核状态"
value={review}
onChange={(e) => {
setReview(e.target.value);
setPage(1);
}}
>
<option value="">全部复核状态</option>
{["pending", "confirmed", "false_positive", "resolved"].map((v) => (
<option key={v} value={v}>
{labels[v]}
</option>
))}
</select>
</div>
{notice && (
<p className="success" role="status">
{notice}
</p>
)}
{error && !selected && (
<p className="error" role="alert">
{error}
</p>
)}
{(search || result || review) && (
<button
className="filter-reset"
onClick={() => {
setSearch("");
setResult("");
setReview("");
setPage(1);
}}
>
清除筛选条件 ×
</button>
)}
{admin && checked.length > 0 && (
<div className="bulk-toolbar">
<span>已选 {checked.length} 条(当前页)</span>
<select
aria-label="批量复核状态"
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
>
{["pending", "confirmed", "false_positive", "resolved"].map(
(v) => (
<option key={v} value={v}>
{labels[v]}
</option>
),
)}
</select>
<button className="primary" disabled={busy} onClick={bulk}>
应用复核状态
</button>
<button onClick={() => setChecked([])}>取消选择</button>
</div>
)}
<QueryState {...q} empty={q.data?.data.length === 0} retry={q.reload} />
{!q.loading && !q.error && !!q.data?.data.length && (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>
{admin && (
<input
type="checkbox"
aria-label="选择当前页全部记录"
checked={
!!q.data?.data.length &&
checked.length === q.data.data.length
}
onChange={(e) =>
setChecked(
e.target.checked
? q.data!.data.map((v) => v.id)
: [],
)
}
/>
)}{" "}
请求
</th>
<th>判定</th>
<th>复核</th>
<th>可信度</th>
<th>检测时间</th>
</tr>
</thead>
<tbody>
{q.data.data.map((v) => (
<tr
key={v.id}
onClick={() => {
setSelected(v);
setNote(v.note);
setStatus(v.review);
setError("");
}}
>
<td>
{admin && (
<input
type="checkbox"
aria-label={`选择记录 ${v.id}`}
checked={checked.includes(v.id)}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setChecked((prev) =>
e.target.checked
? [...prev, v.id]
: prev.filter((id) => id !== v.id),
)
}
/>
)}
<button
className="text-button"
onClick={() => {
setSelected(v);
setNote(v.note);
setStatus(v.review);
}}
>
<span className="method">{v.method}</span> {v.url}
</button>
</td>
<td>
<Badge value={v.result} />
</td>
<td>
<Badge value={v.review} />
</td>
<td>{v.confidence || "—"}</td>
<td>{new Date(v.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pagination">
<span>共 {q.data?.total || 0} 条</span>
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>
上一页
</button>
<span>{page}</span>
<button
disabled={page * 20 >= (q.data?.total || 0)}
onClick={() => setPage((p) => p + 1)}
>
下一页
</button>
</div>
</div>
{selected && (
<Modal title="检测证据" busy={busy} onClose={() => setSelected(null)}>
<div className="page-heading">
<h2>检测证据 #{selected.id}</h2>
<button autoFocus disabled={busy} onClick={() => setSelected(null)}>
关闭
</button>
</div>
<p className="url">
{selected.method} {selected.url}
</p>
<Badge value={selected.result} />
<p>{selected.reason}</p>
<QueryState {...detailQuery} retry={detailQuery.reload} />
{detailQuery.data && (
<EvidenceComparison finding={detailQuery.data} />
)}
{admin && (
<div className="review-form">
<label>
复核状态
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{["pending", "confirmed", "false_positive", "resolved"].map(
(v) => (
<option key={v} value={v}>
{labels[v]}
</option>
),
)}
</select>
</label>
<label>
备注
<textarea
maxLength={4000}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</label>
{error && <p className="error">{error}</p>}
<button
className="primary"
disabled={busy}
onClick={() => mutate("PATCH", { review: status, note })}
>
保存复核
</button>
<button
className="danger"
disabled={busy}
onClick={() => {
if (confirm("删除这条检测记录?")) mutate("DELETE");
}}
>
删除记录
</button>
</div>
)}
</Modal>
)}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { api, send, type Session } from "../api/client";
export default function Login({
setup,
onLogin,
onSetup,
}: {
setup: boolean;
onLogin: (s: Session) => void;
onSetup: () => void;
}) {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<div className="login">
<div className="login-surface">
<div className="login-story">
<div className="login-brand">
<b>PH</b>
<span>
PrivHunter<small>安全分析工作台</small>
</span>
</div>
<span className="eyebrow">SECURITY OPERATIONS</span>
<h1>
看清权限边界。
<br />
让证据说话。
</h1>
<p>
从被动流量到权限复核,将每一次检测变成可追踪、可验证的安全记录。
</p>
<div className="login-capabilities">
<span>被动流量检测</span>
<span>证据对比复核</span>
<span>本地数据存储</span>
</div>
</div>
<form
className="login-card"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
if (setup) {
await api(
"/auth/setup",
send("POST", { username: name, password }),
);
setPassword("");
onSetup();
} else
onLogin(
await api<Session>(
"/auth/login",
send("POST", { username: name, password }),
),
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<span className="eyebrow">WORKSPACE ACCESS</span>
<h2>{setup ? "创建管理员" : "欢迎回来"}</h2>
<p>
{setup
? "首次启动,请设置工作台管理员。"
: "登录后访问检测记录与扫描配置。"}
</p>
<label>
用户名
<input
autoComplete="username"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={3}
/>
</label>
<label>
密码
<input
type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={setup ? 12 : 1}
/>
</label>
{setup && <small>密码至少 12 字节,最长 72 字节。</small>}
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button className="primary" disabled={busy}>
{busy ? "正在处理…" : setup ? "初始化工作台" : "登录工作台 →"}
</button>
<small>会话受保护 · 数据保存在本地</small>
</form>
</div>
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useEffect } from "react";
import { useQuery } from "../hooks/useQuery";
import { Badge, QueryState } from "../components/Status";
import type { FindingSummary } from "../api/client";
import type { Page } from "../components/Layout";
export type ResultSelection = {
result?: string;
review?: string;
search?: string;
};
type Dashboard = {
counts: Record<string, number>;
summary: {
reviews: Record<string, number>;
trend: { date: string; total: number; suspected: number }[];
targets: { host: string; total: number; pending: number }[];
};
recent: FindingSummary[];
readiness?: {
scope: boolean;
identity: boolean;
model: boolean;
paused: boolean;
};
runtime?: { queued: number; processed: number; dropped: number };
};
export default function Overview({
navigate,
}: {
navigate: (page: Page, selection?: ResultSelection) => void;
}) {
const q = useQuery<Dashboard>("/dashboard");
useEffect(() => {
const timer = setInterval(() => {
if (document.visibilityState === "visible") q.reload();
}, 30000);
return () => clearInterval(timer);
}, []);
const data = q.data;
const max = Math.max(1, ...(data?.summary.trend.map((v) => v.total) || []));
return (
<>
<div className="page-heading">
<div>
<span className="eyebrow">WORKSPACE / OVERVIEW</span>
<h1>安全态势概览</h1>
<p>先确认采集准备,再处理待复核证据。数据每 30 秒自动更新。</p>
</div>
<div className="toolbar">
<button onClick={q.reload}>刷新数据</button>
<button
className="primary"
onClick={() => navigate("findings", { review: "pending" })}
>
进入复核队列 →
</button>
</div>
</div>
<QueryState {...q} retry={q.reload} />
{data && (
<>
{data.readiness && (
<div className="readiness-strip">
<div>
<span
className={
"live-dot " + (!data.readiness.paused ? "running" : "")
}
/>
<b>{data.readiness.paused ? "扫描已暂停" : "扫描已启用"}</b>
<small>
{data.runtime?.queued || 0} 个排队 · 本次处理{" "}
{data.runtime?.processed || 0} 条
</small>
</div>
<button onClick={() => navigate("scanner")}>
{data.readiness.scope && data.readiness.identity
? "管理采集"
: "完成采集配置"}{" "}
→
</button>
</div>
)}
<div className="stat-grid">
{[
{
label: "检测总数",
value: data.counts.total,
sub: "全部已保存证据",
selection: {},
},
{
label: "待人工复核",
value: data.summary.reviews.pending,
sub: "尚未形成复核结论",
selection: { review: "pending" },
},
{
label: "已确认风险",
value: data.summary.reviews.confirmed,
sub: "人工确认后的记录",
selection: { review: "confirmed" },
},
{
label: "检测失败",
value: data.counts.error,
sub: "查看原因,检查配置",
selection: { result: "error" },
},
].map((item) => (
<button
className="stat stat-action"
key={item.label}
onClick={() => navigate("findings", item.selection)}
>
<span>
{item.label}
<span>↗</span>
</span>
<strong>{item.value || 0}</strong>
<small>{item.sub}</small>
</button>
))}
</div>
<div className="dashboard-grid">
<section className="panel trend-panel">
<div className="section-heading">
<div>
<h2>近 7 日检测趋势</h2>
<small>按北京时间 · 已保存检测记录</small>
</div>
<span className="chart-key">
<i />
检测总数 <i className="suspect" />
疑似越权
</span>
</div>
<div
className="trend-chart"
role="img"
aria-label={data.summary.trend
.map((v) => `${v.date} 检测 ${v.total},疑似 ${v.suspected}`)
.join(";")}
>
{data.summary.trend.map((v) => (
<div className="trend-day" key={v.date}>
<span className="trend-count">{v.total}</span>
<div className="trend-track">
<div
className="trend-bar"
style={{ height: `${(v.total / max) * 100}%` }}
/>
<div
className="trend-bar suspect"
style={{ height: `${(v.suspected / max) * 100}%` }}
/>
</div>
<small>{v.date.slice(5)}</small>
</div>
))}
</div>
{data.counts.total === 0 && (
<p className="chart-note">
暂无检测数据。配置范围并连接上游代理后,这里将显示真实趋势。
</p>
)}
</section>
<section className="panel">
<div className="section-heading">
<div>
<h2>判定与复核进度</h2>
<small>疑似结果仍需人工验证</small>
</div>
</div>
<div className="distribution">
{[
["true", "疑似越权"],
["false", "权限拒绝 / 未发现"],
["unknown", "待分析"],
["error", "失败"],
].map(([key, label]) => (
<button
key={key}
onClick={() => navigate("findings", { result: key })}
>
<span>
<i className={"result-dot dot-" + key} />
{label}
</span>
<b>{data.counts[key] || 0}</b>
</button>
))}
</div>
<div className="review-progress">
<span>
已完成复核{" "}
<b>
{data.counts.total
? (
(100 *
(data.counts.total -
data.summary.reviews.pending)) /
data.counts.total
).toFixed(0)
: 0}
%
</b>
</span>
<progress
max={Math.max(1, data.counts.total)}
value={data.counts.total - data.summary.reviews.pending}
/>
<small>
已解决 {data.summary.reviews.resolved} · 误报{" "}
{data.summary.reviews.false_positive}
</small>
</div>
</section>
</div>
<div className="dashboard-grid">
<section className="panel">
<div className="section-heading">
<div>
<h2>最近检测</h2>
<small>按检测时间排序</small>
</div>
<button
className="text-button"
onClick={() => navigate("findings")}
>
查看全部 →
</button>
</div>
{data.recent.length ? (
<div className="recent-list">
{data.recent.map((v) => (
<button
key={v.id}
onClick={() => navigate("findings", { search: v.url })}
>
<span className="method">{v.method}</span>
<span className="recent-url">
{v.url}
<small>{new Date(v.createdAt).toLocaleString()}</small>
</span>
<Badge value={v.result} />
</button>
))}
</div>
) : (
<div className="action-empty">
<span>⌕</span>
<h3>第一条证据,从一次采集开始</h3>
<p>
{data.readiness
? "完成目标范围和账号 B 身份配置,再通过代理访问测试接口。"
: "工作台尚无检测记录,管理员完成采集后即可在这里查看证据。"}
</p>
<button
onClick={() =>
navigate(data.readiness ? "scanner" : "findings")
}
>
{data.readiness ? "设置采集范围" : "查看检测结果"} →
</button>
</div>
)}
</section>
<section className="panel">
<div className="section-heading">
<div>
<h2>目标复核热点</h2>
<small>按待复核数量排序 · 前 5 个域名</small>
</div>
</div>
{data.summary.targets.length ? (
<div className="target-list">
{data.summary.targets.map((v) => (
<button
key={v.host}
onClick={() =>
navigate("findings", {
search: v.host,
review: "pending",
})
}
>
<span>
{v.host}
<small>累计 {v.total} 条证据</small>
</span>
<b>
{v.pending}
<small>待复核</small>
</b>
</button>
))}
</div>
) : (
<div className="action-empty compact">
<span>◎</span>
<h3>尚未采集目标</h3>
<p>这里将帮助你识别积压最多的目标。</p>
</div>
)}
</section>
</div>
{data.readiness && (
<section className="panel">
<div className="section-heading">
<div>
<h2>采集就绪检查</h2>
<small>按顺序完成配置,模型分析为可选步骤。</small>
</div>
</div>
<div className="setup-grid">
{[
{
title: "目标范围",
ready: data.readiness.scope,
text: "明确允许检测的域名或 IP",
page: "scanner",
},
{
title: "账号 B 身份",
ready: data.readiness.identity,
text: "设置用于权限对比的请求头",
page: "scanner",
},
{
title: "分析模型",
ready: data.readiness.model,
text: "未配置时保留证据供人工复核",
page: "settings",
},
].map((v, i) => (
<button
key={v.title}
onClick={() => navigate(v.page as Page)}
>
<span className={"step-marker " + (v.ready ? "done" : "")}>
{v.ready ? "✓" : i + 1}
</span>
<div>
<b>{v.title}</b>
<small>{v.text}</small>
</div>
<span>{v.ready ? "已配置" : "去配置"} →</span>
</button>
))}
</div>
</section>
)}
</>
)}
</>
);
}
+428
View File
@@ -0,0 +1,428 @@
import { useUnsavedChanges } from "../hooks/useUnsavedChanges";
import { useEffect, useState } from "react";
import { api, send } from "../api/client";
import { useQuery } from "../hooks/useQuery";
import { QueryState } from "../components/Status";
type Config = {
revision: number;
paused: boolean;
domains: string[];
endpoint: string;
model: string;
skipSuffixes: string[];
denyKeywords: string[];
};
export default function Settings({ scanner = false }: { scanner?: boolean }) {
const runtime = useQuery<{
queued: number;
dropped: number;
processed: number;
}>("/scanner/status");
const q = useQuery<{
settings: Config;
hasAPIKey: boolean;
hasHeaders: boolean;
}>("/settings");
const [value, setValue] = useState<Config | null>(null);
const [domains, setDomains] = useState("");
const [suffixes, setSuffixes] = useState("");
const [keywords, setKeywords] = useState("");
const [dirty, setDirty] = useState(false);
const [clearKey, setClearKey] = useState(false);
const [clearHeaders, setClearHeaders] = useState(false);
useUnsavedChanges(dirty);
const [key, setKey] = useState("");
const [headers, setHeaders] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (q.data && !dirty) {
setValue(q.data.settings);
setDomains((q.data.settings.domains || []).join("\n"));
setSuffixes((q.data.settings.skipSuffixes || []).join("\n"));
setKeywords((q.data.settings.denyKeywords || []).join("\n"));
}
}, [q.data, dirty]);
const update = (v: Partial<Config>) => {
setDirty(true);
setValue((prev) => (prev ? { ...prev, ...v } : prev));
};
return (
<>
<div className="page-heading">
<div>
<span className="eyebrow">
{scanner ? "CAPTURE & SCOPE" : "CONFIGURATION"}
</span>
<h1>{scanner ? "扫描控制" : "系统设置"}</h1>
<p>
{scanner
? "限定目标范围,管理被动流量检测。"
: "配置模型与过滤规则,敏感字段仅在本地保存。"}
</p>
</div>
<div className="toolbar config-reload">
<button
disabled={busy}
onClick={() => {
if (dirty && !confirm("重新加载会放弃未保存修改,确定继续?"))
return;
setDirty(false);
setValue(null);
setKey("");
setHeaders("");
setClearKey(false);
setClearHeaders(false);
setError("");
q.reload();
}}
>
重新加载配置
</button>
</div>
</div>
<QueryState {...q} retry={q.reload} />
{value && (
<form
className="configuration-form"
onChange={() => setDirty(true)}
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
setMessage("");
try {
const lines = (s: string) =>
s
.split("\n")
.map((v) => v.trim())
.filter(Boolean);
const payload: Record<string, unknown> = {
...value,
domains: lines(domains),
skipSuffixes: lines(suffixes),
denyKeywords: lines(keywords),
};
if (key) payload.apiKey = key;
if (headers) {
const parsed = JSON.parse(headers);
if (
!parsed ||
Array.isArray(parsed) ||
typeof parsed !== "object" ||
Object.values(parsed).some((v) => typeof v !== "string")
)
throw new Error("请求头必须是名称到文本值的 JSON 对象");
payload.headers = parsed;
}
if (clearKey) payload.apiKey = "";
if (clearHeaders) payload.headers = {};
await api("/settings", send("PUT", payload));
setDirty(false);
setClearKey(false);
setClearHeaders(false);
setKey("");
setHeaders("");
setMessage("设置已保存");
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<div className="configuration-main">
{scanner ? (
<>
<section className="config-card">
<div className="config-card-heading">
<span className="config-icon">◎</span>
<div>
<h2>采集运行状态</h2>
<p>控制被动检测,暂停后代理仍可转发流量。</p>
</div>
<span
className={
"badge " + (value.paused ? "" : "badge-resolved")
}
>
{value.paused ? "已暂停" : "启用中"}
</span>
</div>
<div className="runtime-metrics">
{[
["本次已处理", runtime.data?.processed || 0],
["等待处理", runtime.data?.queued || 0],
["队列满丢弃", runtime.data?.dropped || 0],
].map(([label, n]) => (
<div key={label}>
<span>{label}</span>
<b>{n}</b>
</div>
))}
</div>
<div className="config-row">
<label className="switch-label">
<input
type="checkbox"
checked={!value.paused}
onChange={(e) => update({ paused: !e.target.checked })}
/>
<span>启用扫描</span>
</label>
<button
type="button"
className="subtle-button"
onClick={runtime.reload}
>
刷新运行状态
</button>
</div>
</section>
<section className="config-card">
<div className="config-card-heading">
<span className="config-icon">⌖</span>
<div>
<h2>目标范围</h2>
<p>只有范围内的流量会进入检测队列。</p>
</div>
<span className="count-pill">
{domains.split("\n").filter((v) => v.trim()).length}{" "}
个目标
</span>
</div>
<label className="config-field">
目标域名(每行一个精确域名或 IP)
<textarea
className="code-input"
rows={4}
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={"api.example.com\n10.0.0.10"}
/>
<small>输入主机名,不包含协议、路径或通配符。</small>
</label>
</section>
<section className="config-card">
<div className="config-card-heading">
<span className="config-icon">♙</span>
<div>
<h2>测试身份 · 账号 B</h2>
<p>重放请求时替换凭证,与账号 A 的响应进行对比。</p>
</div>
<span
className={
"badge " + (q.data?.hasHeaders ? "badge-resolved" : "")
}
>
{q.data?.hasHeaders ? "已配置" : "待配置"}
</span>
</div>
<label className="config-field">
账号 B 请求头(JSON)
<textarea
className="code-input"
rows={4}
spellCheck={false}
value={headers}
onChange={(e) => setHeaders(e.target.value)}
placeholder={
q.data?.hasHeaders
? "已有凭证已隐藏;留空保留当前值"
: '{\n "Authorization": "Bearer …"\n}'
}
/>
<small>
凭证不会以明文回显。留空保留当前值,输入新内容则整体替换。
</small>
</label>
<details className="advanced-options">
<summary>凭证管理</summary>
<label className="checkbox">
<input
type="checkbox"
checked={clearHeaders}
onChange={(e) => setClearHeaders(e.target.checked)}
/>
保存时清除已有账号 B 请求头
</label>
</details>
</section>
</>
) : (
<>
<section className="config-card">
<div className="config-card-heading">
<span className="config-icon">✧</span>
<div>
<h2>模型连接</h2>
<p>使用兼容 Chat Completions 的模型辅助分析。</p>
</div>
<span
className={
"badge " + (q.data?.hasAPIKey ? "badge-resolved" : "")
}
>
{q.data?.hasAPIKey ? "密钥已配置" : "密钥待配置"}
</span>
</div>
<label className="config-field">
模型端点
<input
type="url"
value={value.endpoint}
onChange={(e) => update({ endpoint: e.target.value })}
placeholder="https://provider.example/v1/chat/completions"
/>
<small>填写完整接口地址,而非供应商首页。</small>
</label>
<div className="config-field-grid">
<label className="config-field">
模型名称
<input
value={value.model}
onChange={(e) => update({ model: e.target.value })}
placeholder="输入实际模型 ID"
/>
</label>
<label className="config-field">
API Key
<input
type="password"
autoComplete="off"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder={
q.data?.hasAPIKey
? "留空保留当前密钥"
: "输入供应商密钥"
}
/>
</label>
</div>
<details className="advanced-options">
<summary>密钥管理</summary>
<label className="checkbox">
<input
type="checkbox"
checked={clearKey}
onChange={(e) => setClearKey(e.target.checked)}
/>
保存时清除已有模型密钥
</label>
<small>清除后可继续采集证据,通过人工复核完成判断。</small>
</details>
</section>
<section className="config-card">
<div className="config-card-heading">
<span className="config-icon">⌕</span>
<div>
<h2>检测过滤规则</h2>
<p>减少无关流量,让检测聚焦业务接口。</p>
</div>
</div>
<div className="config-field-grid">
<label className="config-field">
过滤后缀(每行一个)
<textarea
className="code-input"
rows={6}
value={suffixes}
onChange={(e) => setSuffixes(e.target.value)}
/>
<small>匹配 URL 路径后缀;匹配到的流量不检测。</small>
</label>
<label className="config-field">
权限拒绝关键词(每行一个)
<textarea
rows={6}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder={"权限不足\n无访问权限"}
/>
<small>命中后保留证据供人工核实,不直接确认安全。</small>
</label>
</div>
</section>
</>
)}
</div>
<div className="configuration-rail">
<section className="help-card">
<span className="eyebrow">
{scanner ? "CONNECTION GUIDE" : "ANALYSIS GUIDE"}
</span>
<h3>{scanner ? "连接你的测试流量" : "让模型辅助判断"}</h3>
{scanner ? (
<>
<ol>
<li>
<b>配置范围与身份</b>
<p>限定目标,并提供账号 B 的测试凭证。</p>
</li>
<li>
<b>连接上游代理</b>
<p>在 Burp 或浏览器代理设置中填写下方地址。</p>
<code>127.0.0.1:9080</code>
</li>
<li>
<b>保存并启用</b>
<p>访问测试接口,在检测结果中复核证据。</p>
</li>
</ol>
<div className="help-note">
HTTPS 流量采集需要信任本机代理证书。
</div>
</>
) : (
<>
<p>
模型收到脱敏后的对比证据。模型结论作为线索,需结合业务归属进行人工复核。
</p>
<div className="help-note">
未配置模型时仍可采集和对比证据。
</div>
</>
)}
</section>
<section className="help-card neutral">
<h3>配置保存规则</h3>
<p>保存后生效;未保存的更改会在离开页面时提醒。</p>
<div className="help-rule">
<span>凭证回显</span>
<b>已隐藏</b>
</div>
<div className="help-rule">
<span>并发保护</span>
<b>版本校验</b>
</div>
<small>配置版本 {value.revision}</small>
</section>
</div>
<div className="configuration-feedback">
{error && (
<p className="error" role="alert">
{error}
</p>
)}
{message && (
<p className="success" role="status">
{message}
</p>
)}
</div>
<div className="configuration-savebar">
<div className="draft-status">
<small>{dirty ? "有未保存的修改" : "所有修改已保存"}</small>
</div>
<button className="primary" disabled={busy || q.loading}>
{busy ? "保存中…" : "保存设置"}
</button>
</div>
</form>
)}
</>
);
}
+289
View File
@@ -0,0 +1,289 @@
import Modal from "../components/Modal";
import { useState } from "react";
import { api, send, type User } from "../api/client";
import { useQuery } from "../hooks/useQuery";
import { QueryState } from "../components/Status";
export default function Users({ currentUser }: { currentUser: User }) {
const q = useQuery<{ data: User[] }>("/users");
const [createOpen, setCreateOpen] = useState(false);
const [query, setQuery] = useState("");
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("viewer");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<>
<div className="page-heading">
<div>
<span className="eyebrow">ACCESS CONTROL</span>
<h1>用户管理</h1>
<p>最小权限访问,账号调整后已有会话将失效。</p>
</div>
<button
className="primary"
onClick={() => {
setError("");
setCreateOpen(true);
}}
>
+ 添加成员
</button>
</div>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
{message && (
<p className="success" role="status">
{message}
</p>
)}
<div className="member-summary">
<div>
<span>工作台成员</span>
<b>{q.data?.data.length || 0}</b>
</div>
<div>
<span>管理员</span>
<b>
{q.data?.data.filter((v) => v.role === "admin" && !v.disabled)
.length || 0}
</b>
</div>
<div>
<span>启用账号</span>
<b>{q.data?.data.filter((v) => !v.disabled).length || 0}</b>
</div>
<p>
按职责分配权限,
<br />
协作过程可在审计日志追踪。
</p>
</div>
<div className="panel member-panel">
<div className="section-heading">
<h2>成员列表</h2>
<span className="count-pill">{q.data?.data.length || 0} 位成员</span>
</div>
<div className="toolbar">
<input
aria-label="搜索成员"
placeholder="搜索用户名…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<small>管理员可调整权限;始终保留至少一名启用的管理员。</small>
</div>
<QueryState {...q} retry={q.reload} />
<div className="table-wrap">
<table>
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{q.data?.data
.filter((u) =>
u.username.toLowerCase().includes(query.toLowerCase()),
)
.map((u) => (
<tr key={u.id}>
<td>
<div className="member-identity">
<span className="member-avatar">
{u.username[0].toUpperCase()}
</span>
<div>
<b>{u.username}</b>
<small>
{u.id === currentUser.id
? "当前账号"
: "工作台成员"}
</small>
</div>
</div>
</td>
<td>
<select
aria-label={`${u.username} 的角色`}
value={u.role}
disabled={busy || u.id === currentUser.id}
onChange={async (e) => {
const next = e.target.value;
if (
!confirm(
`将 ${u.username} 调整为${next === "admin" ? "管理员" : "只读成员"}?已有会话将失效。`,
)
)
return;
setBusy(true);
setError("");
try {
await api(
"/users/" + u.id,
send("PATCH", {
role: next,
disabled: u.disabled,
}),
);
setMessage("角色已更新");
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<option value="admin">管理员</option>
<option value="viewer">只读成员</option>
</select>
</td>
<td>
<span
className={
"badge " + (u.disabled ? "" : "badge-resolved")
}
>
{u.disabled ? "已禁用" : "启用中"}
</span>
</td>
<td>
<button
disabled={busy || u.id === currentUser.id}
title={
u.id === currentUser.id
? "当前账号不能在这里停用自身"
: undefined
}
onClick={async () => {
if (
!u.disabled &&
!confirm(`停用 ${u.username}?已有会话将失效。`)
)
return;
setBusy(true);
setError("");
try {
await api(
"/users/" + u.id,
send("PATCH", {
role: u.role,
disabled: !u.disabled,
}),
);
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
{u.disabled ? "启用" : "禁用"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="permission-note">
<span>ⓘ</span>
<p>
管理员可管理配置、成员与证据;只读成员可查看和导出检测结果。当前账号与最后一名管理员受到保护。
</p>
</div>
{createOpen && (
<Modal
title="添加成员"
busy={busy}
onClose={() => {
setCreateOpen(false);
setPassword("");
}}
>
<form
className="form-grid member-create"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await api(
"/users",
send("POST", { username: name, password, role }),
);
setMessage("成员已创建,可使用初始密码登录");
setCreateOpen(false);
setName("");
setPassword("");
q.reload();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<div className="section-heading">
<div>
<h2>添加成员</h2>
<small>创建一个工作台账号,并指定访问权限。</small>
</div>
<button
type="button"
disabled={busy}
onClick={() => {
setCreateOpen(false);
setPassword("");
}}
>
关闭
</button>
</div>
<label>
用户名
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={3}
/>
</label>
<label>
初始密码
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
/>
</label>
<label>
角色
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="viewer">只读成员</option>
<option value="admin">管理员</option>
</select>
</label>
{error && <p className="error">{error}</p>}
<button className="primary" disabled={busy}>
创建用户
</button>
</form>
</Modal>
)}
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
@import "./styles/base.css";
@import "./styles/login.css";
@import "./styles/layout.css";
@import "./styles/components.css";
@import "./styles/dashboard.css";
@import "./styles/evidence.css";
@import "./styles/configuration.css";
@import "./styles/members.css";
@import "./styles/admin-responsive.css";
+117
View File
@@ -0,0 +1,117 @@
@media (min-width: 1600px) {
.configuration-form {
grid-template-columns: minmax(0, 1fr) 310px;
}
.config-card {
padding: 30px;
}
}
@media (max-width: 1050px) {
.configuration-form {
grid-template-columns: 1fr;
}
.configuration-rail {
grid-template-columns: 1fr 1fr;
}
.member-summary {
gap: 28px;
}
.member-summary p {
display: none;
}
}
@media (max-width: 650px) {
.config-card {
padding: 18px;
}
.config-field-grid {
grid-template-columns: 1fr;
gap: 4px;
}
.configuration-rail {
grid-template-columns: 1fr;
}
.runtime-metrics {
gap: 8px;
}
.runtime-metrics > div {
padding: 12px;
}
.config-card-heading {
gap: 8px;
flex-wrap: wrap;
}
.config-card-heading h2 {
font-size: 14px;
}
.config-card-heading > .badge {
margin-left: 48px;
}
.member-summary {
padding: 18px;
gap: 30px;
}
.member-panel {
padding: 16px;
}
.member-panel .toolbar small {
display: none;
}
.configuration-savebar {
bottom: 8px;
padding: 12px;
}
.config-card-heading .count-pill {
margin-left: auto;
}
}
.config-reload {
margin: 0;
align-items: center;
}
.config-reload button {
font-size: 11px;
padding: 9px 13px;
background: #fff9;
border-color: #e1e5ee;
color: #7f889f;
}
.config-card-heading > div {
flex: 1;
min-width: 0;
}
.configuration-form {
padding-bottom: 20px;
}
@media (max-width: 650px) {
.config-card-heading {
flex-wrap: nowrap;
}
.config-card-heading > .badge,
.config-card-heading > .count-pill {
display: none;
}
.config-icon {
width: 30px;
height: 30px;
font-size: 17px;
}
.runtime-metrics span {
font-size: 9px;
white-space: nowrap;
}
.runtime-metrics > div {
padding: 10px 7px;
}
.config-row .subtle-button {
font-size: 9px;
padding: 6px;
}
.config-reload button {
font-size: 9px;
padding: 8px;
}
.page-heading:has(.config-reload) {
gap: 12px;
}
}
+78
View File
@@ -0,0 +1,78 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #24314a;
background: #f4f6fa;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
border: 1px solid #dce2eb;
border-radius: 8px;
background: white;
padding: 10px 16px;
color: inherit;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
input,
textarea,
select {
padding: 12px;
border: 1px solid #dce2eb;
border-radius: 7px;
background: #fff;
color: inherit;
}
input:focus,
button:focus-visible {
outline: 2px solid #6c72ed;
outline-offset: 2px;
}
.primary {
background: #6259df;
color: white;
border-color: #6259df;
}
.eyebrow {
font-size: 11px;
letter-spacing: 2px;
font-weight: 700;
color: #7a8398;
}
h1 {
font-size: 32px;
letter-spacing: -1px;
}
p {
color: #778299;
line-height: 1.8;
}
small {
font-size: 12px;
color: #8390a5;
}
.error {
color: #b33c4e;
background: #fff0f1;
padding: 10px;
border-radius: 6px;
}
.loading {
min-height: 100vh;
display: grid;
place-content: center;
}
+282
View File
@@ -0,0 +1,282 @@
.page-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 26px;
}
.page-heading h1 {
margin: 10px 0;
}
.page-heading p {
margin: 0;
font-size: 13px;
}
.panel {
background: #fff;
border: 1px solid #e7ebf2;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 3px 10px #24314a03;
}
.panel h2 {
font-size: 17px;
margin: 0 0 16px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18px;
margin-bottom: 24px;
}
.stat {
background: #fff;
border: 1px solid #e7ebf2;
border-radius: 12px;
padding: 24px;
display: grid;
gap: 18px;
}
.stat > span {
font-size: 13px;
color: #7a8398;
}
.stat strong {
font-size: 36px;
font-weight: 600;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.guide-grid b {
font-size: 13px;
}
.guide-grid p {
font-size: 12px;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.toolbar input {
flex: 1;
min-width: 160px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
text-align: left;
}
th {
font-weight: 500;
color: #8b95a8;
padding: 14px 12px;
background: #f8f9fc;
white-space: nowrap;
}
td {
padding: 18px 12px;
border-bottom: 1px solid #eef0f5;
}
tbody tr:hover {
background: #fafaff;
}
.text-button {
border: 0;
background: transparent;
text-align: left;
padding: 0;
max-width: 450px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method {
font-size: 10px;
color: #6259df;
font-weight: 700;
background: #eeecff;
padding: 4px;
border-radius: 4px;
}
.badge {
font-size: 10px;
padding: 5px 9px;
border-radius: 5px;
background: #eef1f6;
color: #7a8398;
white-space: nowrap;
}
.badge-true,
.badge-confirmed {
background: #ffefee;
color: #cf635c;
}
.badge-false,
.badge-resolved {
background: #eaf6f0;
color: #479c79;
}
.badge-unknown,
.badge-pending {
background: #fff5df;
color: #b78a33;
}
.badge-error {
background: #f9e9f1;
color: #b65c81;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
margin-top: 20px;
font-size: 12px;
color: #8b95a8;
}
.pagination > span:first-child {
margin-right: auto;
}
.empty {
padding: 48px;
text-align: center;
color: #8b95a8;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: #161d3655;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.detail {
background: white;
border-radius: 14px;
padding: 28px;
width: min(1100px, 100%);
max-height: 90vh;
overflow: auto;
}
.detail h2 {
margin: 0;
}
.url {
word-break: break-all;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.comparison h3 {
font-size: 13px;
}
pre {
font-size: 11px;
line-height: 1.8;
background: #f7f8fc;
border: 1px solid #e9ecf3;
border-radius: 7px;
padding: 16px;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.review-form,
.form-grid {
display: grid;
gap: 18px;
}
.form-grid {
max-width: 800px;
}
.form-grid label,
.review-form label {
display: grid;
gap: 10px;
font-size: 13px;
}
.form-grid textarea {
min-height: 100px;
resize: vertical;
}
.form-grid .primary {
justify-self: start;
}
.checkbox {
display: flex !important;
align-items: center;
}
.notice {
background: #f1efff;
color: #6259df;
padding: 14px;
font-size: 12px;
border-radius: 7px;
}
.success {
background: #eaf6f0;
color: #479c79;
padding: 12px;
border-radius: 7px;
}
.danger {
color: #ba4a56;
}
.review-form {
margin-top: 24px;
border-top: 1px solid #e7ebf2;
padding-top: 24px;
}
@media (max-width: 1000px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.guide-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-heading {
align-items: flex-start;
}
.page-heading h1 {
font-size: 24px;
}
.panel {
padding: 16px;
}
.stat {
padding: 16px;
}
.comparison {
grid-template-columns: 1fr;
}
.modal-backdrop {
padding: 8px;
}
.detail {
padding: 18px;
}
.toolbar select {
width: 100%;
}
.pagination {
gap: 6px;
}
}
+314
View File
@@ -0,0 +1,314 @@
.configuration-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 24px;
align-items: start;
max-width: 1320px;
}
.configuration-main {
display: grid;
gap: 20px;
min-width: 0;
}
.config-card {
background: #fff;
border: 1px solid #e4e8f0;
border-radius: 14px;
padding: 26px;
box-shadow: 0 3px 12px #24314a03;
}
.config-card-heading {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 24px;
}
.config-card-heading h2 {
font-size: 16px;
margin: 0 0 7px;
font-weight: 600;
}
.config-card-heading p {
font-size: 12px;
margin: 0;
line-height: 1.6;
}
.config-card-heading > .badge,
.config-card-heading > .count-pill {
margin-left: auto;
}
.config-icon {
display: grid;
place-items: center;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 10px;
background: #f0edfb;
color: #8071c7;
font-size: 20px;
}
.config-field {
display: grid;
gap: 10px;
font-size: 12px;
font-weight: 500;
margin-top: 18px;
min-width: 0;
}
.config-field input,
.config-field textarea {
font-size: 12px;
background: #fdfdff;
border-color: #e2e6ef;
border-radius: 8px;
width: 100%;
font-weight: 400;
}
.config-field textarea {
resize: vertical;
min-height: 108px;
line-height: 1.8;
}
.config-field small {
font-size: 10px;
font-weight: 400;
line-height: 1.7;
}
.code-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.config-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
}
.config-row {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #eef0f5;
padding-top: 18px;
margin-top: 20px;
}
.switch-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.switch-label input {
appearance: none;
width: 34px;
height: 20px;
border: 0;
border-radius: 20px;
background: #d4d9e5;
padding: 0;
position: relative;
cursor: pointer;
}
.switch-label input:before {
content: "";
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
position: absolute;
top: 3px;
left: 3px;
transition: transform 0.15s;
}
.switch-label input:checked {
background: #7364db;
}
.switch-label input:checked:before {
transform: translateX(14px);
}
.subtle-button {
font-size: 11px;
padding: 7px 10px;
border-color: #e6e8f0;
color: #7e879c;
}
.runtime-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.runtime-metrics > div {
background: #f7f8fc;
border: 1px solid #eef0f5;
border-radius: 9px;
padding: 16px;
display: grid;
gap: 14px;
}
.runtime-metrics span {
font-size: 10px;
color: #9199ac;
}
.runtime-metrics b {
font-size: 24px;
font-weight: 500;
}
.count-pill {
border-radius: 20px;
background: #f2f3f8;
color: #9198aa;
font-size: 10px;
padding: 5px 9px;
white-space: nowrap;
}
.configuration-rail {
display: grid;
gap: 18px;
}
.help-card {
padding: 24px;
border-radius: 12px;
background: linear-gradient(145deg, #f0eefb, #f8f7fc);
border: 1px solid #e5e0f5;
}
.help-card .eyebrow {
font-size: 9px;
color: #9b8dc1;
}
.help-card h3 {
font-size: 14px;
font-weight: 600;
margin: 16px 0;
}
.help-card p {
font-size: 11px;
line-height: 1.8;
margin: 8px 0;
color: #89859b;
}
.help-card ol {
padding-left: 18px;
margin: 24px 0 0;
color: #9c8fc5;
font-size: 11px;
}
.help-card li {
padding-left: 5px;
margin-bottom: 20px;
}
.help-card li b {
color: #716880;
font-weight: 500;
}
.help-card code {
display: inline-block;
margin-top: 4px;
padding: 8px 10px;
background: #fff9;
border: 1px solid #e4ddf1;
border-radius: 6px;
color: #7d6d9e;
font-size: 11px;
}
.help-note {
padding-top: 15px;
border-top: 1px solid #e2dcee;
font-size: 10px;
color: #9484ac;
line-height: 1.8;
}
.help-card.neutral {
background: #f8f9fc;
border-color: #e8ebf2;
}
.help-card.neutral h3 {
margin-top: 0;
}
.help-rule {
display: flex;
justify-content: space-between;
font-size: 10px;
padding: 12px 0;
border-bottom: 1px solid #e9ebf3;
color: #959cae;
}
.help-rule b {
font-weight: 400;
color: #738275;
}
.help-card.neutral > small {
display: block;
margin-top: 15px;
font-size: 9px;
}
.advanced-options {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #edf0f5;
font-size: 10px;
color: #98a0b2;
}
.advanced-options summary {
cursor: pointer;
list-style: none;
}
.advanced-options summary:before {
content: "+ ";
color: #9990b8;
}
.advanced-options[open] summary:before {
content: "− ";
}
.advanced-options .checkbox {
margin-top: 16px;
font-size: 11px;
color: #81899c;
}
.advanced-options > small {
display: block;
margin-top: 12px;
font-size: 10px;
}
.configuration-feedback {
grid-column: 1/-1;
}
.configuration-feedback:empty {
display: none;
}
.configuration-feedback p {
margin: 0;
}
.configuration-savebar {
grid-column: 1/-1;
position: sticky;
bottom: 18px;
z-index: 2;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 22px;
background: #ffffffed;
backdrop-filter: blur(12px);
border: 1px solid #e1e5ef;
border-radius: 12px;
box-shadow: 0 8px 28px #26304b0b;
}
.configuration-savebar .primary {
padding: 10px 24px;
font-size: 12px;
}
.draft-status {
display: flex;
align-items: center;
gap: 8px;
}
.draft-status:before {
content: "";
background: #a0abc0;
border-radius: 50%;
width: 6px;
height: 6px;
}
.draft-status small {
font-size: 11px;
}
+335
View File
@@ -0,0 +1,335 @@
.download {
font-size: 12px;
text-decoration: none;
color: #6259df;
border: 1px solid #ddd9f5;
border-radius: 7px;
padding: 10px 12px;
display: inline-block;
}
.account-button {
border: 0 !important;
background: transparent;
padding: 0 !important;
font-size: 12px;
text-align: left;
}
.readiness-strip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
margin-bottom: 20px;
border: 1px solid #e4e1f5;
background: #f1effb;
border-radius: 10px;
}
.readiness-strip > div {
display: flex;
align-items: center;
gap: 12px;
}
.readiness-strip b {
font-size: 13px;
}
.readiness-strip button {
background: transparent;
border: 0;
color: #6259df;
font-size: 12px;
}
.live-dot {
height: 8px;
width: 8px;
border-radius: 50%;
background: #a4a8b7;
}
.live-dot.running {
background: #4caa84;
box-shadow: 0 0 0 4px #4caa8418;
}
.stat-action {
text-align: left;
}
.stat-action > span {
display: flex;
justify-content: space-between;
}
.stat-action:hover {
border-color: #b4acec;
box-shadow: 0 6px 20px #6259df0b;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 22px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.section-heading h2 {
margin-bottom: 7px;
}
.section-heading .text-button {
color: #6259df;
font-size: 12px;
}
.chart-key {
display: flex;
gap: 7px;
align-items: center;
font-size: 10px;
color: #8b95a8;
}
.chart-key i {
width: 8px;
height: 8px;
border-radius: 2px;
background: #a59aeb;
}
.chart-key i.suspect {
background: #ef9f94;
}
.trend-chart {
display: flex;
gap: 16px;
height: 180px;
align-items: stretch;
}
.trend-day {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
}
.trend-count {
font-size: 11px;
color: #9a9fb0;
}
.trend-track {
height: 130px;
display: flex;
gap: 3px;
width: 100%;
justify-content: center;
align-items: flex-end;
border-bottom: 1px solid #e8ebf2;
background: repeating-linear-gradient(
to top,
transparent 0,
transparent 31px,
#f2f3f8 32px
);
}
.trend-bar {
width: 22%;
min-height: 2px;
background: #a59aeb;
border-radius: 3px 3px 0 0;
}
.trend-bar.suspect {
background: #ef9f94;
}
.trend-day small {
font-size: 10px;
}
.chart-note {
font-size: 11px;
margin: 12px 0 0;
}
.distribution {
display: grid;
gap: 7px;
}
.distribution button {
display: flex;
justify-content: space-between;
border: 0;
padding: 8px 0;
font-size: 12px;
}
.distribution button > span {
display: flex;
gap: 9px;
align-items: center;
}
.result-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #b9bcc8;
}
.dot-true {
background: #ef9f94;
}
.dot-false {
background: #70b99b;
}
.dot-unknown {
background: #e6c16e;
}
.dot-error {
background: #c788a3;
}
.review-progress {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid #eef0f5;
display: grid;
gap: 10px;
font-size: 12px;
}
.review-progress > span {
display: flex;
justify-content: space-between;
}
progress {
width: 100%;
height: 7px;
accent-color: #8a7edd;
}
.recent-list button {
display: flex;
align-items: center;
gap: 12px;
border: 0;
border-bottom: 1px solid #eef0f5;
border-radius: 0;
width: 100%;
padding: 14px 0;
text-align: left;
}
.recent-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-size: 12px;
}
.recent-url small {
display: block;
font-size: 10px;
margin-top: 7px;
}
.action-empty {
padding: 28px 18px;
text-align: center;
}
.action-empty > span {
font-size: 30px;
color: #b6afd5;
}
.action-empty h3 {
font-size: 14px;
font-weight: 500;
}
.action-empty p {
font-size: 12px;
max-width: 370px;
margin: 12px auto;
}
.action-empty button {
font-size: 12px;
color: #6259df;
}
.target-list button {
display: flex;
justify-content: space-between;
text-align: left;
width: 100%;
padding: 16px 0;
border: 0;
border-bottom: 1px solid #eef0f5;
border-radius: 0;
font-size: 12px;
}
.target-list small {
display: block;
font-size: 10px;
margin-top: 6px;
}
.target-list b {
text-align: right;
color: #6259df;
font-weight: 500;
}
.setup-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.setup-grid button {
display: flex;
gap: 10px;
align-items: center;
padding: 16px;
text-align: left;
background: #fafbfe;
}
.setup-grid b {
font-size: 12px;
}
.setup-grid small {
display: block;
font-size: 10px;
margin-top: 8px;
line-height: 1.6;
}
.setup-grid button > span:last-child {
margin-left: auto;
font-size: 10px;
white-space: nowrap;
color: #8c83bc;
}
.step-marker {
width: 25px;
height: 25px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: #eeecf8;
color: #968cbf;
font-size: 11px;
}
.step-marker.done {
background: #e9f5ef;
color: #4ca480;
}
@media (max-width: 1100px) {
.setup-grid {
grid-template-columns: 1fr;
}
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
.readiness-strip small {
display: none;
}
}
@media (max-width: 850px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.page-heading .toolbar {
margin: 0;
}
.chart-key {
flex-wrap: wrap;
}
.readiness-strip {
padding: 12px;
}
.setup-grid button {
padding: 12px;
}
.trend-chart {
gap: 8px;
}
}
+83
View File
@@ -0,0 +1,83 @@
.evidence-tabs {
display: flex;
gap: 7px;
border-bottom: 1px solid #e6e9f2;
margin: 20px 0;
padding-bottom: 12px;
flex-wrap: wrap;
}
.evidence-tabs button {
border: 0;
background: transparent;
font-size: 12px;
color: #8b95a8;
}
.evidence-tabs button[aria-selected="true"] {
background: #efedff;
color: #6259df;
}
.comparison .section-heading {
margin-bottom: 8px;
}
.comparison .section-heading button {
padding: 5px 8px;
font-size: 10px;
}
.field-diff {
background: #fafbfe;
border-radius: 8px;
padding: 16px;
}
.field-diff td {
max-width: 300px;
word-break: break-all;
}
.bulk-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f1effb;
border-radius: 8px;
margin: 16px 0;
font-size: 12px;
flex-wrap: wrap;
}
.bulk-toolbar select {
padding: 8px;
}
.filter-reset {
border: 0;
color: #7e73bf;
font-size: 11px;
margin-bottom: 14px;
padding: 6px 0;
background: transparent;
}
td > input[type="checkbox"] {
margin-right: 12px;
accent-color: #6259df;
}
th > input[type="checkbox"] {
accent-color: #6259df;
margin-right: 12px;
}
dialog.native-modal {
border: 1px solid #e7ebf2;
max-height: 90dvh;
max-width: calc(100vw - 32px);
box-shadow: 0 24px 80px #171d3540;
}
dialog.native-modal::backdrop {
background: #161d3655;
backdrop-filter: blur(3px);
}
dialog.native-modal .form-grid {
max-width: none;
}
.audit-code {
display: block;
font-size: 9px;
color: #a2a8b8;
margin-top: 5px;
}
+164
View File
@@ -0,0 +1,164 @@
.shell {
display: flex;
min-height: 100vh;
}
aside {
width: 238px;
background: #fff;
border-right: 1px solid #e7ebf2;
display: flex;
flex-direction: column;
padding: 30px 18px;
position: fixed;
inset: 0 auto 0 0;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
font-weight: 700;
font-size: 18px;
padding: 0 10px 44px;
}
.brand b {
background: #6259df;
color: white;
border-radius: 10px;
padding: 12px 8px;
font-size: 16px;
}
.brand span {
display: block;
font-size: 10px;
color: #8b95a8;
margin-top: 6px;
font-weight: 400;
}
.nav-label {
font-size: 10px;
color: #9ba5b6;
letter-spacing: 1.4px;
margin: 0 14px 12px;
}
nav {
display: grid;
gap: 6px;
}
nav button {
border: 0;
text-align: left;
padding: 13px 16px;
color: #778299;
background: transparent;
}
nav button span {
display: inline-block;
width: 28px;
}
nav button.active {
background: #efedff;
color: #6259df;
font-weight: 600;
}
.sidebar-footer {
margin-top: auto;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
padding-top: 20px;
border-top: 1px solid #eef0f5;
}
.sidebar-footer small {
display: block;
margin-top: 5px;
}
.sidebar-footer button {
margin-left: auto;
border: 0;
padding: 8px;
}
.avatar {
background: #e8e7f8;
color: #6259df;
padding: 12px;
border-radius: 9px;
}
main {
margin-left: 238px;
width: calc(100% - 238px);
}
header {
height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-bottom: 1px solid #e7ebf2;
padding: 0 38px;
font-size: 12px;
color: #8b95a8;
}
.local-badge {
font-size: 10px;
letter-spacing: 1px;
color: #559680;
}
.content {
padding: 38px;
max-width: 1600px;
}
@media (max-width: 760px) {
.login {
padding: 24px 16px;
}
.login-surface {
grid-template-columns: 1fr;
max-width: 460px;
}
.login-story {
display: none;
}
.login-card {
padding: 36px 28px;
margin: 0;
border-left: 0;
}
aside {
width: 76px;
padding: 20px 8px;
}
.brand {
padding: 0 4px 30px;
}
.brand > div,
.nav-label,
.sidebar-footer > div,
.sidebar-footer small {
display: none;
}
nav button {
font-size: 0;
padding: 14px;
}
nav button span {
font-size: 20px;
width: auto;
}
.sidebar-footer button {
margin: auto;
}
main {
margin-left: 76px;
width: calc(100% - 76px);
}
header {
padding: 0 18px;
}
.local-badge {
display: none;
}
.content {
padding: 24px 18px;
}
}
+100
View File
@@ -0,0 +1,100 @@
.login {
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 28px;
background:
radial-gradient(ellipse at 18% 20%, #e9e6fb 0, transparent 55%),
radial-gradient(ellipse at 90% 85%, #edf0fa 0, transparent 50%), #f5f6fa;
}
.login-surface {
width: min(1080px, 100%);
display: grid;
grid-template-columns: 1.1fr 1fr;
align-items: center;
background: linear-gradient(110deg, #f8f7ff 0%, #fff 68%);
border: 1px solid #fff;
border-radius: 24px;
box-shadow:
0 24px 80px #33335b10,
0 1px 4px #33335b06;
overflow: hidden;
}
.login-story {
padding: 64px 56px;
color: #24314a;
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 62px;
font-weight: 700;
font-size: 20px;
}
.login-brand b {
background: #6259df;
color: #fff;
border-radius: 12px;
padding: 12px 9px;
font-size: 18px;
}
.login-brand small {
display: block;
font-size: 10px;
font-weight: 400;
margin-top: 5px;
letter-spacing: 1px;
}
.login-story .eyebrow {
color: #8c86ad;
font-size: 10px;
}
.login-story h1 {
font-size: 40px;
line-height: 1.5;
margin: 20px 0 22px;
letter-spacing: -1.5px;
}
.login-story p {
font-size: 14px;
max-width: 350px;
color: #81869b;
}
.login-capabilities {
display: flex;
flex-wrap: wrap;
gap: 9px;
margin-top: 36px;
}
.login-capabilities span {
font-size: 10px;
padding: 7px 10px;
border: 1px solid #e8e5f4;
border-radius: 20px;
color: #8a84a4;
}
.login-card {
width: auto;
display: grid;
gap: 18px;
padding: 52px 54px;
border-left: 1px solid #eeedf5;
margin: 48px 0;
}
.login-card h2 {
font-size: 30px;
margin: 0;
}
.login-card p {
margin: 0;
}
.login-card label {
display: grid;
gap: 9px;
font-size: 13px;
}
.login-card .primary {
margin-top: 8px;
padding: 14px;
}
+117
View File
@@ -0,0 +1,117 @@
.member-summary {
display: flex;
align-items: center;
gap: 50px;
margin-bottom: 24px;
padding: 20px 28px;
border: 1px solid #e5e8f1;
background: linear-gradient(110deg, #f0eefb, #f8f9fc);
border-radius: 12px;
}
.member-summary > div {
display: grid;
gap: 10px;
}
.member-summary span {
font-size: 10px;
color: #9296ac;
}
.member-summary b {
font-size: 24px;
font-weight: 500;
}
.member-summary p {
margin: 0 0 0 auto;
font-size: 11px;
color: #9296ac;
}
.member-panel {
padding: 26px;
}
.member-panel .toolbar input {
max-width: 350px;
font-size: 12px;
}
.member-panel .toolbar {
align-items: center;
justify-content: space-between;
}
.member-panel .toolbar small {
font-size: 10px;
}
.member-panel table th {
background: #f8f9fc;
font-size: 11px;
}
.member-panel table td {
padding: 18px 12px;
}
.member-identity {
display: flex;
align-items: center;
gap: 12px;
}
.member-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 9px;
background: #edeaf8;
color: #9382bd;
font-size: 12px;
}
.member-identity b {
font-size: 12px;
font-weight: 500;
}
.member-identity small {
display: block;
font-size: 9px;
margin-top: 5px;
}
.member-panel select {
padding: 8px 10px;
font-size: 11px;
background: #fbfcfe;
border-color: #e9ecf3;
}
.member-panel td > button {
font-size: 11px;
padding: 7px 12px;
color: #8b94a9;
}
.permission-note {
display: flex;
gap: 10px;
align-items: center;
padding: 0 6px;
color: #a0a8bb;
}
.permission-note p {
font-size: 10px;
line-height: 1.8;
margin: 0;
}
.native-modal:has(.member-create) {
width: min(480px, calc(100vw - 32px));
}
.member-create {
gap: 22px;
}
.member-create .section-heading {
margin: 0;
}
.member-create label {
display: grid;
gap: 10px;
font-size: 12px;
}
.member-create input,
.member-create select {
font-size: 12px;
}
.member-create .primary {
margin-top: 8px;
width: 100%;
}
+226
View File
@@ -0,0 +1,226 @@
import { test, expect } from "@playwright/test";
test.describe.configure({ mode: "serial" });
test("initialize, navigate, configure, manage access, export, change password", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "创建管理员" })).toBeVisible();
await page.getByLabel("用户名").fill("testadmin");
await page.getByLabel("密码", { exact: true }).fill("fictional-password-123");
await page.getByRole("button", { name: "初始化工作台" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
await page.getByLabel("密码", { exact: true }).fill("fictional-password-123");
await page.getByRole("button", { name: "登录工作台" }).click();
await expect(
page.getByRole("heading", { name: "安全态势概览" }),
).toBeVisible();
await page.getByRole("button", { name: "检测结果", exact: false }).click();
await expect(page.getByText("暂无记录")).toBeVisible();
await page.getByRole("button", { name: "系统设置", exact: false }).click();
await expect(page.getByLabel("模型端点")).toBeVisible();
await expect(page).toHaveURL(/#\/settings$/);
await page.reload();
await expect(page.getByLabel("模型端点")).toBeVisible();
await page.goBack();
await expect(page.getByText("暂无记录")).toBeVisible();
await page.goForward();
await expect(page.getByLabel("模型端点")).toBeVisible();
await page.screenshot({
path: "../data/settings-redesign.png",
fullPage: true,
});
await page.getByRole("button", { name: "扫描控制", exact: false }).click();
await expect(page.getByLabel("目标域名")).toBeVisible();
await page.screenshot({
path: "../data/scanner-redesign.png",
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: "../data/scanner-redesign-mobile.png",
fullPage: true,
});
await page.setViewportSize({ width: 1280, height: 720 });
await page.getByLabel("目标域名").fill("example.test");
await page.getByLabel("目标域名").press("End");
await page.getByLabel("目标域名").press("Enter");
await page.getByLabel("目标域名").pressSequentially("api.example.test");
await expect(page.getByLabel("目标域名")).toHaveValue(
"example.test\napi.example.test",
);
page.once("dialog", (dialog) => dialog.dismiss());
await page.goBack();
await expect(page).toHaveURL(/#\/scanner$/);
await expect(page.getByLabel("目标域名")).toHaveValue(
"example.test\napi.example.test",
);
page.once("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "系统设置", exact: false }).click();
await expect(page.getByRole("heading", { name: "扫描控制" })).toBeVisible();
await expect(page.getByLabel("目标域名")).toHaveValue(
"example.test\napi.example.test",
);
await page
.getByRole("textbox", { name: "账号 B 请求头" })
.fill('{"Authorization":"Bearer fictional-fixture"}');
await page.getByRole("button", { name: "保存设置" }).click();
await expect(page.getByRole("status")).toHaveText("设置已保存");
await page.getByRole("button", { name: "用户管理", exact: false }).click();
await page.screenshot({ path: "../data/users-redesign.png", fullPage: true });
await page.getByRole("button", { name: "添加成员", exact: false }).click();
await page.getByLabel("用户名").fill("viewer");
await page.getByLabel("初始密码").fill("fictional-viewer-123");
await page.getByRole("button", { name: "创建用户" }).click();
await expect(
page.getByRole("cell").filter({ hasText: "viewer" }),
).toBeVisible();
await page.getByRole("button", { name: "审计日志", exact: false }).click();
await expect(
page.getByRole("cell").filter({ hasText: "create_user" }),
).toBeVisible();
await page.getByRole("button", { name: "检测结果", exact: false }).click();
const download = page.waitForEvent("download");
await page.getByRole("link", { name: "导出 CSV" }).click();
expect((await download).suggestedFilename()).toBe("privhunter-results.csv");
await page.getByRole("button", { name: "testadmin", exact: true }).click();
await expect(page.getByRole("dialog", { name: "修改密码" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "testadmin", exact: true }),
).toBeFocused();
await page.getByRole("button", { name: "testadmin", exact: true }).click();
await page.getByLabel("当前密码").fill("fictional-password-123");
await page.getByLabel("新密码").fill("fictional-changed-123");
await page.getByRole("button", { name: "保存新密码" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
await page.getByLabel("用户名").fill("viewer");
await page.getByLabel("密码", { exact: true }).fill("fictional-viewer-123");
await page.getByRole("button", { name: "登录工作台" }).click();
await expect(
page.getByRole("heading", { name: "安全态势概览" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "用户管理", exact: false }),
).toHaveCount(0);
await page.goto("/#/users");
await expect(
page.getByRole("heading", { name: "安全态势概览" }),
).toBeVisible();
await expect(page).toHaveURL(/#\/overview$/);
await page.goto("/#/findings");
await expect(page.getByText("暂无记录")).toBeVisible();
await page.getByRole("button", { name: "概览", exact: false }).click();
await page.screenshot({ path: "../data/e2e-desktop.png", fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: "../data/e2e-mobile.png", fullPage: true });
const denied = await page.request.get("/api/users");
expect(denied.status()).toBe(403);
await page.getByRole("button", { name: "退出登录" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
});
test("proxy evidence appears in UI, review and reload persistence", async ({
page,
}) => {
const { createServer, request } = await import("node:http");
const target = createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.statusCode =
req.headers.authorization === "Bearer synthetic-b" ? 403 : 200;
res.end(JSON.stringify({ id: 1, token: "synthetic-hidden" }));
});
await new Promise<void>((resolve) => target.listen(0, "127.0.0.1", resolve));
try {
await page.goto("/");
await page.getByLabel("用户名").fill("testadmin");
await page
.getByLabel("密码", { exact: true })
.fill("fictional-changed-123");
await page.getByRole("button", { name: "登录工作台" }).click();
await expect(
page.getByRole("heading", { name: "安全态势概览" }),
).toBeVisible();
await page.getByRole("button", { name: "扫描控制", exact: false }).click();
await page.getByLabel("目标域名").fill("127.0.0.1");
await page
.getByRole("textbox", { name: "账号 B 请求头" })
.fill('{"Authorization":"Bearer synthetic-b"}');
await page.getByLabel("启用扫描").check();
await page.getByRole("button", { name: "保存设置" }).click();
await expect(page.getByRole("status")).toHaveText("设置已保存");
const addr = target.address() as { port: number };
await new Promise<void>((resolve, reject) => {
const req = request(
{
hostname: "127.0.0.1",
port: 19080,
path: `http://127.0.0.1:${addr.port}/orders`,
headers: { Authorization: "Bearer synthetic-a" },
},
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end();
});
await page.getByRole("button", { name: "检测结果", exact: false }).click();
await expect
.poll(async () => {
await page.getByRole("button", { name: "刷新", exact: true }).click();
return page.getByRole("button", { name: /GET.*orders/ }).count();
})
.toBe(1);
await page.getByRole("button", { name: /GET.*orders/ }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page.getByText("[REDACTED]", { exact: false }).first(),
).toBeVisible();
await expect(
page.getByText("synthetic-hidden", { exact: false }),
).toHaveCount(0);
await page
.getByRole("dialog")
.getByRole("combobox")
.selectOption("confirmed");
await page.getByLabel("备注").fill("已复核测试证据");
await page.getByRole("button", { name: "保存复核" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByRole("cell").filter({ hasText: "已确认" }),
).toBeVisible();
await page.reload();
await page.getByRole("button", { name: "检测结果", exact: false }).click();
await expect(
page.getByRole("cell").filter({ hasText: "已确认" }),
).toBeVisible();
await page.screenshot({ path: "../data/e2e-findings.png", fullPage: true });
await page
.getByRole("button", { name: "概览", exact: false })
.first()
.click();
await expect(
page.getByRole("button", { name: /已确认风险/ }),
).toContainText("1");
await page.getByRole("button", { name: /已确认风险/ }).click();
await expect(
page.getByRole("combobox", { name: "复核状态", exact: true }),
).toHaveValue("confirmed");
await page.getByRole("checkbox", { name: "选择当前页全部记录" }).check();
await page
.getByRole("combobox", { name: "批量复核状态" })
.selectOption("false_positive");
await page.getByRole("button", { name: "应用复核状态" }).click();
await expect(page.getByRole("status")).toHaveText("已更新 1 条记录");
await page.getByRole("button", { name: "清除筛选条件" }).click();
await expect(
page.getByRole("cell").filter({ hasText: "误报" }),
).toBeVisible();
} finally {
await new Promise<void>((resolve) => target.close(() => resolve()));
}
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://127.0.0.1:8222" } },
});