feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<Ana
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiTelemetryPageView(): Promise<void> {
|
||||
export async function apiTelemetryPageView(path: string, ref: string): Promise<void> {
|
||||
try {
|
||||
await fetchWithRefresh("/api/telemetry/pageview", {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ path, ref }),
|
||||
});
|
||||
} catch {
|
||||
/* 埋点失败不影响浏览 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 访问统计(管理端,仅超管/站长) =====
|
||||
|
||||
export interface VisitDayPoint {
|
||||
date: string;
|
||||
pv: number;
|
||||
uv: number;
|
||||
}
|
||||
|
||||
export interface VisitNamedCount {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VisitPageRow {
|
||||
path: string;
|
||||
pv: number;
|
||||
uv: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface VisitOverview {
|
||||
range_days: number;
|
||||
trend: VisitDayPoint[];
|
||||
today_pv: number;
|
||||
today_uv: number;
|
||||
yesterday_pv: number;
|
||||
yesterday_uv: number;
|
||||
logged_in_pv: number;
|
||||
devices: VisitNamedCount[];
|
||||
sources: VisitNamedCount[];
|
||||
top_pages: VisitPageRow[];
|
||||
}
|
||||
|
||||
export interface VisitMinutePoint {
|
||||
minute: string;
|
||||
pv: number;
|
||||
}
|
||||
|
||||
export interface VisitEventItem {
|
||||
id: number;
|
||||
created_at: string;
|
||||
kind: string;
|
||||
path: string;
|
||||
ref_host: string;
|
||||
ref_kind: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
ip: string;
|
||||
device: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
bot: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface VisitRealtime {
|
||||
minutes: VisitMinutePoint[];
|
||||
pv_5min: number;
|
||||
pv_30min: number;
|
||||
online: number;
|
||||
top_pages: VisitNamedCount[];
|
||||
recent: VisitEventItem[];
|
||||
}
|
||||
|
||||
export interface VisitSourcesKindDay {
|
||||
date: string;
|
||||
items: VisitNamedCount[];
|
||||
}
|
||||
|
||||
export interface VisitSources {
|
||||
range_days: number;
|
||||
kinds: VisitNamedCount[];
|
||||
search_hosts: VisitNamedCount[];
|
||||
external: VisitNamedCount[];
|
||||
trend: VisitSourcesKindDay[];
|
||||
}
|
||||
|
||||
export interface VisitPages {
|
||||
range_days: number;
|
||||
total: number;
|
||||
items: VisitPageRow[];
|
||||
}
|
||||
|
||||
export interface VisitVisitors {
|
||||
range_days: number;
|
||||
devices: VisitNamedCount[];
|
||||
oses: VisitNamedCount[];
|
||||
browsers: VisitNamedCount[];
|
||||
logged_in_pv: number;
|
||||
guest_pv: number;
|
||||
new_visitors: number;
|
||||
returning: number;
|
||||
hourly: VisitNamedCount[];
|
||||
}
|
||||
|
||||
export interface VisitSecurity {
|
||||
range_days: number;
|
||||
bots: VisitNamedCount[];
|
||||
bot_trend: VisitDayPoint[];
|
||||
probe_status: VisitNamedCount[];
|
||||
probe_ips: VisitNamedCount[];
|
||||
login_failed: number;
|
||||
failed_users: number;
|
||||
}
|
||||
|
||||
export interface VisitEventsQuery {
|
||||
kind?: string;
|
||||
bot?: string;
|
||||
path?: string;
|
||||
ip?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface VisitEventsResult {
|
||||
total: number;
|
||||
items: VisitEventItem[];
|
||||
}
|
||||
|
||||
export interface VisitUsage {
|
||||
rows: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface VisitSettingsPayload {
|
||||
enabled: boolean;
|
||||
retention_days: number;
|
||||
usage: VisitUsage;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
// SSR 首拉:透传 cookie
|
||||
async function fetchVisitStats<T>(path: string, cookieHeader?: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (!res.ok) throw new Error("failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 客户端拉取:带 CSRF + 自动续期
|
||||
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = init?.body
|
||||
? clientHeaders({ "Content-Type": "application/json" })
|
||||
: clientHeaders();
|
||||
const res = await fetchWithRefresh(path, {
|
||||
method: init?.method ?? "GET",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) throw new Error("failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function fetchStatsOverview(cookieHeader?: string, range = "7d") {
|
||||
return fetchVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
|
||||
}
|
||||
export function apiStatsOverview(range = "7d") {
|
||||
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
|
||||
}
|
||||
export function fetchStatsRealtime(cookieHeader?: string) {
|
||||
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
|
||||
}
|
||||
export function apiStatsRealtime() {
|
||||
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
|
||||
}
|
||||
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
|
||||
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
|
||||
}
|
||||
export function apiStatsSources(range = "7d") {
|
||||
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
|
||||
}
|
||||
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
|
||||
return fetchVisitStats<VisitPages>(
|
||||
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
|
||||
cookieHeader
|
||||
);
|
||||
}
|
||||
export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) {
|
||||
return apiVisitStats<VisitPages>(
|
||||
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
|
||||
);
|
||||
}
|
||||
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
|
||||
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
|
||||
}
|
||||
export function apiStatsVisitors(range = "7d") {
|
||||
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
|
||||
}
|
||||
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
|
||||
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
|
||||
}
|
||||
export function apiStatsSecurity(range = "7d") {
|
||||
return apiVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`);
|
||||
}
|
||||
export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) {
|
||||
const p = new URLSearchParams();
|
||||
if (query.kind) p.set("kind", query.kind);
|
||||
if (query.bot) p.set("bot", query.bot);
|
||||
if (query.path) p.set("path", query.path);
|
||||
if (query.ip) p.set("ip", query.ip);
|
||||
if (query.start) p.set("start", query.start);
|
||||
if (query.end) p.set("end", query.end);
|
||||
p.set("page", String(query.page ?? 1));
|
||||
p.set("size", String(query.size ?? 50));
|
||||
return fetchVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`, cookieHeader);
|
||||
}
|
||||
export function apiStatsEvents(query: VisitEventsQuery = {}) {
|
||||
const p = new URLSearchParams();
|
||||
if (query.kind) p.set("kind", query.kind);
|
||||
if (query.bot) p.set("bot", query.bot);
|
||||
if (query.path) p.set("path", query.path);
|
||||
if (query.ip) p.set("ip", query.ip);
|
||||
if (query.start) p.set("start", query.start);
|
||||
if (query.end) p.set("end", query.end);
|
||||
p.set("page", String(query.page ?? 1));
|
||||
p.set("size", String(query.size ?? 50));
|
||||
return apiVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
|
||||
}
|
||||
export function fetchStatsSettings(cookieHeader?: string) {
|
||||
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
|
||||
}
|
||||
export function apiStatsSettings() {
|
||||
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
|
||||
}
|
||||
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
|
||||
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled, retention_days: retentionDays }),
|
||||
});
|
||||
}
|
||||
export function apiStatsPurge(days: number) {
|
||||
return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ days }),
|
||||
});
|
||||
}
|
||||
|
||||
export type AdminChatRoomType = "group" | "direct";
|
||||
|
||||
export interface AdminChatParticipant {
|
||||
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise<Sponsor
|
||||
if (!res.ok) throw new Error("加载赞助商配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ===== 侧栏管理(布局配置 + 自定义 HTML 工具) =====
|
||||
|
||||
import type {
|
||||
SidebarConfig,
|
||||
SidebarPublicPayload,
|
||||
SidebarWidgetRecord,
|
||||
} from "@/lib/sidebarMeta";
|
||||
|
||||
export type {
|
||||
SidebarConfig,
|
||||
SidebarScope,
|
||||
SidebarScopeConfig,
|
||||
SidebarBlockRef,
|
||||
SidebarPublicPayload,
|
||||
SidebarWidgetPublic,
|
||||
SidebarWidgetRecord,
|
||||
} from "@/lib/sidebarMeta";
|
||||
|
||||
/** SSR 公开侧栏载荷;失败由调用方回落默认布局 */
|
||||
export async function fetchSidebar(cookieHeader?: string): Promise<SidebarPublicPayload> {
|
||||
const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (!res.ok) throw new Error("获取侧栏配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */
|
||||
export async function fetchAdminSidebar(
|
||||
cookie?: string
|
||||
): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/sidebar`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载侧栏配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminGetSidebar(): Promise<{
|
||||
config: SidebarConfig;
|
||||
widgets: SidebarWidgetRecord[];
|
||||
}> {
|
||||
return apiOperations("/api/admin/sidebar");
|
||||
}
|
||||
|
||||
export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> {
|
||||
return apiOperations("/api/admin/sidebar/config", "PUT", { config });
|
||||
}
|
||||
|
||||
export async function apiAdminCreateSidebarWidget(
|
||||
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js">
|
||||
): Promise<{ widget: SidebarWidgetRecord }> {
|
||||
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateSidebarWidget(
|
||||
id: number,
|
||||
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
|
||||
): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body);
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
179
frontend/lib/sidebarMeta.ts
Normal file
179
frontend/lib/sidebarMeta.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// 侧栏管理:内置块元数据与默认布局(管理页与前台注册表共用,勿在此引入组件)
|
||||
|
||||
export type SidebarScope = "home" | "board" | "post";
|
||||
|
||||
export interface SidebarBlockRef {
|
||||
key?: string;
|
||||
widget?: number;
|
||||
}
|
||||
|
||||
export interface SidebarScopeConfig {
|
||||
left?: SidebarBlockRef[] | null;
|
||||
right?: SidebarBlockRef[] | null;
|
||||
}
|
||||
|
||||
export interface SidebarConfig {
|
||||
version: number;
|
||||
scopes: Partial<Record<SidebarScope, SidebarScopeConfig>>;
|
||||
}
|
||||
|
||||
export interface SidebarWidgetPublic {
|
||||
id: number;
|
||||
name: string;
|
||||
html: string;
|
||||
css: string;
|
||||
js: string;
|
||||
}
|
||||
|
||||
export interface SidebarPublicPayload {
|
||||
config: SidebarConfig;
|
||||
widgets: SidebarWidgetPublic[];
|
||||
}
|
||||
|
||||
export interface SidebarWidgetRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
html: string;
|
||||
css: string;
|
||||
js: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 每侧最多放置块数(与后端 SidebarMaxBlocksPerSide 一致)
|
||||
export const SIDEBAR_MAX_BLOCKS_PER_SIDE = 24;
|
||||
|
||||
export const SIDEBAR_SCOPE_LABELS: Record<SidebarScope, string> = {
|
||||
home: "首页",
|
||||
board: "板块页",
|
||||
post: "帖子详情",
|
||||
};
|
||||
|
||||
export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
|
||||
left: "左栏",
|
||||
right: "右栏",
|
||||
};
|
||||
|
||||
// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
|
||||
export const SIDEBAR_WIDGET_META: Record<SidebarScope, Record<string, string>> = {
|
||||
home: {
|
||||
board_nav: "板块目录",
|
||||
pulse: "站点数据",
|
||||
announcements: "站点公告",
|
||||
ads: "自助推广",
|
||||
sponsors: "赞助商",
|
||||
hot: "热门讨论",
|
||||
new_users: "最新注册",
|
||||
active_users: "活跃成员",
|
||||
friend_links: "友情链接",
|
||||
about_site: "关于本站",
|
||||
},
|
||||
board: {
|
||||
board_moderators: "板块管理员",
|
||||
board_intro: "板块概况",
|
||||
board_pulse: "板块数据",
|
||||
board_hot: "本板热门",
|
||||
board_active: "活跃成员",
|
||||
},
|
||||
post: {
|
||||
post_board: "所属板块",
|
||||
post_author: "作者名片",
|
||||
post_author_posts: "作者其他帖",
|
||||
post_toc: "文章目录",
|
||||
},
|
||||
};
|
||||
|
||||
// 内置块 icon(字符串引用,渲染方 import lucide-react 时映射)
|
||||
// 保持轻量:不在 sidebarMeta 里引入组件
|
||||
export const SIDEBAR_BLOCK_ICON: Record<string, string> = {
|
||||
board_nav: "layout-grid",
|
||||
pulse: "bar-chart-3",
|
||||
announcements: "megaphone",
|
||||
ads: "circle-dollar-sign",
|
||||
sponsors: "handshake",
|
||||
hot: "flame",
|
||||
new_users: "user-plus",
|
||||
active_users: "users",
|
||||
friend_links: "link2",
|
||||
about_site: "info",
|
||||
board_moderators: "shield-check",
|
||||
board_intro: "file-text",
|
||||
board_pulse: "bar-chart-3",
|
||||
board_hot: "flame",
|
||||
board_active: "users",
|
||||
post_board: "layers",
|
||||
post_author: "user",
|
||||
post_author_posts: "pen-line",
|
||||
post_toc: "list",
|
||||
};
|
||||
|
||||
// 默认布局(= 历史硬编码渲染;后端 DefaultSidebarConfig 的前端镜像)
|
||||
export function defaultSidebarConfig(): SidebarConfig {
|
||||
const refs = (keys: string[]): SidebarBlockRef[] => keys.map((key) => ({ key }));
|
||||
return {
|
||||
version: 1,
|
||||
scopes: {
|
||||
home: {
|
||||
left: refs(["board_nav"]),
|
||||
right: refs([
|
||||
"pulse",
|
||||
"announcements",
|
||||
"ads",
|
||||
"sponsors",
|
||||
"hot",
|
||||
"new_users",
|
||||
"active_users",
|
||||
"friend_links",
|
||||
"about_site",
|
||||
]),
|
||||
},
|
||||
board: {
|
||||
left: [],
|
||||
right: refs(["board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"]),
|
||||
},
|
||||
post: {
|
||||
left: refs(["post_board", "post_author", "post_author_posts"]),
|
||||
right: refs(["post_toc"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 按页面范围解析左右两列的块列表:
|
||||
// - 未配置(nil)的侧回落默认布局,显式空数组 = 站长清空该侧;
|
||||
// - board 范围左栏跟随 home.left(板块态仅管理右栏)。
|
||||
export function sidebarScopeLists(
|
||||
config: SidebarConfig,
|
||||
scope: SidebarScope
|
||||
): { left: SidebarBlockRef[]; right: SidebarBlockRef[] } {
|
||||
const def = defaultSidebarConfig().scopes;
|
||||
if (scope === "board") {
|
||||
return {
|
||||
left: config.scopes.home?.left ?? def.home?.left ?? [],
|
||||
right: config.scopes.board?.right ?? def.board?.right ?? [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: config.scopes[scope]?.left ?? def[scope]?.left ?? [],
|
||||
right: config.scopes[scope]?.right ?? def[scope]?.right ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// 块的展示名:内置取 META,自定义取备注名;未知返回 null(渲染时跳过)
|
||||
export function sidebarBlockLabel(
|
||||
ref: SidebarBlockRef,
|
||||
widgets: { id: number; name: string }[]
|
||||
): { label: string; custom: boolean } | null {
|
||||
if (ref.widget) {
|
||||
const w = widgets.find((x) => x.id === ref.widget);
|
||||
return w ? { label: w.name, custom: true } : null;
|
||||
}
|
||||
if (ref.key) {
|
||||
for (const scope of Object.keys(SIDEBAR_WIDGET_META) as SidebarScope[]) {
|
||||
const label = SIDEBAR_WIDGET_META[scope][ref.key];
|
||||
if (label) return { label, custom: false };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
91
frontend/lib/widgetApi.ts
Normal file
91
frontend/lib/widgetApi.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// 自定义侧栏工具的站点开放接口:
|
||||
// - CSS:工具内直接使用站点主题变量(var(--accent) 等),随明暗主题与
|
||||
// 后台自定义主题色自动变化(CSS 变量沿 DOM 继承,无需任何 JS);
|
||||
// - JS:window.J13 提供当前用户、主题读取/监听、站点概览懒加载。
|
||||
// 本模块只在浏览器端(CustomWidgetBlock 的 effect 内)执行。
|
||||
import type { OverviewResponse, User } from "@/lib/api";
|
||||
|
||||
export interface WidgetViewer {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface WidgetApi {
|
||||
version: 1;
|
||||
/** 当前登录用户(未登录为 null);每次工具挂载时刷新 */
|
||||
user: WidgetViewer | null;
|
||||
theme: {
|
||||
get: () => "light" | "dark";
|
||||
/** 监听明暗切换,返回取消监听函数 */
|
||||
onChange: (cb: (theme: "light" | "dark") => void) => () => void;
|
||||
};
|
||||
/** 站点公开概览统计(结果模块级缓存,多个工具共享一次请求) */
|
||||
getOverview: () => Promise<OverviewResponse | null>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
J13?: WidgetApi;
|
||||
}
|
||||
}
|
||||
|
||||
function toViewer(user: User | null | undefined): WidgetViewer | null {
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
role: user.role,
|
||||
avatar: user.avatar || null,
|
||||
};
|
||||
}
|
||||
|
||||
// 概览请求全站只发一次(无论多少个工具、多少次挂载)
|
||||
let overviewPromise: Promise<OverviewResponse | null> | null = null;
|
||||
|
||||
function loadOverview(): Promise<OverviewResponse | null> {
|
||||
if (!overviewPromise) {
|
||||
overviewPromise = fetch("/api/overview", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.catch(() => null);
|
||||
}
|
||||
return overviewPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等初始化 window.J13 并刷新当前用户信息。
|
||||
* 必须在注入站长 JS 之前调用;多个工具共享同一个实例。
|
||||
*/
|
||||
export function ensureWidgetApi(viewer: User | null | undefined): WidgetApi {
|
||||
const root = document.documentElement;
|
||||
const readTheme = (): "light" | "dark" =>
|
||||
root.classList.contains("dark") ? "dark" : "light";
|
||||
|
||||
if (!window.J13) {
|
||||
const listeners = new Set<(theme: "light" | "dark") => void>();
|
||||
// 主题切换 = html 元素 .dark 类的增删,观察其变化广播给工具
|
||||
const observer = new MutationObserver(() => {
|
||||
const theme = readTheme();
|
||||
listeners.forEach((cb) => cb(theme));
|
||||
});
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
window.J13 = {
|
||||
version: 1,
|
||||
user: null,
|
||||
theme: {
|
||||
get: readTheme,
|
||||
onChange(cb) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
},
|
||||
getOverview: loadOverview,
|
||||
};
|
||||
}
|
||||
window.J13.user = toViewer(viewer);
|
||||
return window.J13;
|
||||
}
|
||||
102
frontend/lib/widgetCode.test.ts
Normal file
102
frontend/lib/widgetCode.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
|
||||
|
||||
describe("parseMixedWidgetCode", () => {
|
||||
it("拆分混写的 style/script 段", () => {
|
||||
const r = parseMixedWidgetCode(
|
||||
`<div class="a">hi</div>\n<style>.a { color: red; }</style>\n<script>console.log(1);</script>`
|
||||
);
|
||||
assert.equal(r.html, `<div class="a">hi</div>`);
|
||||
assert.equal(r.css, `.a { color: red; }`);
|
||||
assert.equal(r.js, `console.log(1);`);
|
||||
});
|
||||
|
||||
it("多段 style/script 按序拼接", () => {
|
||||
const r = parseMixedWidgetCode(`<style>.a{}</style><style>.b{}</style><p>x</p><script>a();</script><script>b();</script>`);
|
||||
assert.equal(r.css, ".a{}\n\n.b{}");
|
||||
assert.equal(r.js, "a();\n\nb();");
|
||||
assert.equal(r.html, "<p>x</p>");
|
||||
});
|
||||
|
||||
it("纯 HTML 不误伤", () => {
|
||||
const r = parseMixedWidgetCode(`<p>hello</p>`);
|
||||
assert.deepEqual(r, { html: "<p>hello</p>", css: "", js: "" });
|
||||
});
|
||||
|
||||
it("外链与非 JS type 的 script 留在 html", () => {
|
||||
const code = `<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script><script>z();</script>`;
|
||||
const r = parseMixedWidgetCode(code);
|
||||
assert.equal(r.js, "z();");
|
||||
assert.equal(
|
||||
r.html,
|
||||
`<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script>`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWidgetCode", () => {
|
||||
it("合并后再解析可还原三字段", () => {
|
||||
const parts = { html: "<b>go</b>", css: "b { font-size: 18px; }", js: "x();" };
|
||||
assert.deepEqual(parseMixedWidgetCode(mergeWidgetCode(parts)), parts);
|
||||
});
|
||||
|
||||
it("空段不产生标签", () => {
|
||||
assert.equal(mergeWidgetCode({ html: "hi", css: "", js: "" }), "hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopeWidgetCss", () => {
|
||||
const p = ".j13-widget-7";
|
||||
|
||||
it("普通规则加前缀", () => {
|
||||
assert.equal(scopeWidgetCss(".a { color: red; }", 7), `${p} .a { color: red; }`);
|
||||
});
|
||||
|
||||
it("选择器列表逐个前缀,括号内逗号不分割", () => {
|
||||
assert.equal(
|
||||
scopeWidgetCss(".a, .b {}", 7),
|
||||
`${p} .a, ${p} .b {}`
|
||||
);
|
||||
assert.equal(
|
||||
scopeWidgetCss(':is(.a, .b) { color: red }', 7),
|
||||
`${p} :is(.a, .b) { color: red }`
|
||||
);
|
||||
});
|
||||
|
||||
it("root/body 选择器替换为容器本身", () => {
|
||||
assert.equal(scopeWidgetCss(":root { --x: 1; }", 7), `${p} { --x: 1; }`);
|
||||
assert.equal(scopeWidgetCss("body { margin: 0 }", 7), `${p} { margin: 0 }`);
|
||||
assert.equal(scopeWidgetCss("body .card {}", 7), `${p} .card {}`);
|
||||
});
|
||||
|
||||
it("容器自身伪类紧贴前缀", () => {
|
||||
assert.equal(scopeWidgetCss(":hover { opacity: .8 }", 7), `${p}:hover { opacity: .8 }`);
|
||||
});
|
||||
|
||||
it("@media 内层前缀化,头部原样", () => {
|
||||
assert.equal(
|
||||
scopeWidgetCss("@media (min-width: 10px) { .a {} }", 7),
|
||||
`@media (min-width: 10px) { ${p} .a {} }`
|
||||
);
|
||||
});
|
||||
|
||||
it("@keyframes 原样保留", () => {
|
||||
const css = "@keyframes spin { from { opacity: 0 } to { opacity: 1 } }";
|
||||
assert.equal(scopeWidgetCss(css, 7), css);
|
||||
});
|
||||
|
||||
it("@import 语句原样保留", () => {
|
||||
assert.equal(
|
||||
scopeWidgetCss('@import url("a.css"); .a {}', 7),
|
||||
'@import url("a.css"); .j13-widget-7 .a {}'
|
||||
);
|
||||
});
|
||||
|
||||
it("注释与字符串内的花括号不干扰配对", () => {
|
||||
assert.equal(
|
||||
scopeWidgetCss("/* { nope } */ .a::after { content: \"{\" }", 7),
|
||||
`/* { nope } */ ${p} .a::after { content: "{" }`
|
||||
);
|
||||
});
|
||||
});
|
||||
212
frontend/lib/widgetCode.ts
Normal file
212
frontend/lib/widgetCode.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// 自定义侧栏工具代码处理:
|
||||
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
|
||||
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
|
||||
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
|
||||
// 逐条规则加前缀,避免污染页面其他区域。
|
||||
|
||||
export interface WidgetCodeParts {
|
||||
html: string;
|
||||
css: string;
|
||||
js: string;
|
||||
}
|
||||
|
||||
const STYLE_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
const TYPE_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
|
||||
|
||||
const JS_TYPES = new Set(["", "text/javascript", "application/javascript", "module"]);
|
||||
|
||||
function isNonJsScript(attrs: string): boolean {
|
||||
const m = attrs.match(TYPE_RE);
|
||||
if (!m) return false;
|
||||
return !JS_TYPES.has((m[1] ?? m[2] ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
/** 混写代码 → 三字段:<style>/<script> 段被摘出,剩余全部视为 HTML */
|
||||
export function parseMixedWidgetCode(code: string): WidgetCodeParts {
|
||||
const cssParts: string[] = [];
|
||||
const jsParts: string[] = [];
|
||||
let html = code.replace(STYLE_RE, (_m, body: string) => {
|
||||
cssParts.push(body);
|
||||
return "";
|
||||
});
|
||||
html = html.replace(SCRIPT_RE, (m: string, attrs: string, body: string) => {
|
||||
// 外链脚本、模板/数据脚本(type 非 JS)原样留在 HTML 中
|
||||
if (/\bsrc\s*=/i.test(attrs) || isNonJsScript(attrs)) return m;
|
||||
jsParts.push(body);
|
||||
return "";
|
||||
});
|
||||
return {
|
||||
html: html.trim(),
|
||||
css: cssParts.join("\n\n").trim(),
|
||||
js: jsParts.join("\n\n").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 三字段 → 混写视图:HTML 在前,CSS/JS 分别以标准标签包裹 */
|
||||
export function mergeWidgetCode(parts: WidgetCodeParts): string {
|
||||
const out: string[] = [];
|
||||
if (parts.html.trim()) out.push(parts.html.trim());
|
||||
if (parts.css.trim()) out.push(`<style>\n${parts.css.trim()}\n</style>`);
|
||||
if (parts.js.trim()) out.push(`<script>\n${parts.js.trim()}\n</script>`);
|
||||
return out.join("\n\n");
|
||||
}
|
||||
|
||||
/* ─────────────────────────── CSS 作用域 ─────────────────────────── */
|
||||
|
||||
// 按 , 分割选择器列表,忽略 () / [] 与引号内的逗号(:is(a,b)、[attr="a,b"])
|
||||
function splitSelectorList(sel: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let start = 0;
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
const ch = sel[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "[") depth++;
|
||||
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
|
||||
else if (ch === "," && depth === 0) {
|
||||
out.push(sel.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(sel.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
// 单个选择器加前缀:
|
||||
// - :root / :scope / html / body 开头 → 视作容器本身(变量定义在容器上,板块内可用);
|
||||
// - 以伪类/伪元素(:)开头 → 紧贴容器(容器自身状态,如 .j13-widget-1:hover);
|
||||
// - 其余 → 后代选择器。
|
||||
function scopeOneSelector(sel: string, prefix: string): string | null {
|
||||
const s = sel.trim();
|
||||
if (!s) return null;
|
||||
const rootLike = s.match(/^(?::root\b|:scope\b|html\b|body\b)([\s\S]*)$/);
|
||||
if (rootLike) {
|
||||
const rest = rootLike[1].replace(/^\s+/, "");
|
||||
return rest ? `${prefix} ${rest}` : prefix;
|
||||
}
|
||||
if (s.startsWith(":")) {
|
||||
// 功能型伪类开头(:is( / :where( / :has( / :not( )→ 按容器后代处理;
|
||||
// 简单伪类/伪元素(:hover、::before…)→ 紧贴容器(容器自身状态)。
|
||||
return /^::?[a-zA-Z-]+\(/.test(s) ? `${prefix} ${s}` : prefix + s;
|
||||
}
|
||||
return `${prefix} ${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 CSS 限定到容器内:
|
||||
* - 普通规则:选择器逐个加 `.j13-widget-{id}` 前缀;
|
||||
* - @media/@supports/@container/@layer 块:头部原样,内部规则递归前缀化;
|
||||
* - @keyframes/@font-face/@property 等:整块原样(内部是帧/描述符,不是选择器);
|
||||
* - 注释、引号内容(如 content:"{")不影响括号配对。
|
||||
* 已知取舍:同名 @keyframes 在多个工具间仍是全局的,命名请自带区分。
|
||||
*/
|
||||
export function scopeWidgetCss(css: string, id: number): string {
|
||||
const prefix = `.j13-widget-${id}`;
|
||||
const n = css.length;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
|
||||
// 从 from 起读一个平衡的 {...} 块(引号与转义内的括号不计入深度)
|
||||
const readBlock = (from: number): { body: string; end: number } => {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let j = from; j < n; j++) {
|
||||
const ch = css[j];
|
||||
if (quote) {
|
||||
if (ch === "\\") j++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return { body: css.slice(from, j + 1), end: j + 1 };
|
||||
}
|
||||
}
|
||||
return { body: css.slice(from), end: n };
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
if (css.startsWith("/*", i)) {
|
||||
const close = css.indexOf("*/", i + 2);
|
||||
const stop = close === -1 ? n : close + 2;
|
||||
out += css.slice(i, stop);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
if (css[i] === "}") {
|
||||
// 多余闭括号容错,原样透传
|
||||
out += css[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 读取选择器/语句头直到 { ; } 或结尾(引号内不截断)
|
||||
let j = i;
|
||||
let quote = "";
|
||||
for (; j < n; j++) {
|
||||
const c = css[j];
|
||||
if (quote) {
|
||||
if (c === "\\") j++;
|
||||
else if (c === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{" || c === ";" || c === "}") break;
|
||||
}
|
||||
const head = css.slice(i, j);
|
||||
if (j >= n) {
|
||||
out += head;
|
||||
break;
|
||||
}
|
||||
if (css[j] === ";") {
|
||||
// @import / @charset / @layer a, b; 等语句原样保留
|
||||
out += head + ";";
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (css[j] === "}") {
|
||||
out += head;
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const { body, end } = readBlock(j);
|
||||
const trimmedHead = head.trim();
|
||||
if (trimmedHead.startsWith("@")) {
|
||||
const at = trimmedHead.split(/[\s(]/)[0].toLowerCase();
|
||||
if (at === "@media" || at === "@supports" || at === "@container" || at === "@layer") {
|
||||
const inner = body.slice(1, -1);
|
||||
out += head + "{" + scopeWidgetCss(inner, id) + "}";
|
||||
} else {
|
||||
out += head + body;
|
||||
}
|
||||
} else {
|
||||
const scoped = splitSelectorList(head)
|
||||
.map((s) => scopeOneSelector(s, prefix))
|
||||
.filter((s): s is string => s !== null);
|
||||
if (scoped.length) {
|
||||
// 保留选择器前后的原始空白,尽量不改变站长代码的排版
|
||||
const lead = head.match(/^\s*/)?.[0] ?? "";
|
||||
const trail = head.match(/\s*$/)?.[0] ?? "";
|
||||
out += lead + scoped.join(", ") + trail + body;
|
||||
} else {
|
||||
out += head + body;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user