feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Database, HardDrive, Trash2, Activity } from "lucide-react";
|
||||
import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api";
|
||||
import { StatsState, formatBytes, formatInt } from "../ui";
|
||||
|
||||
/** 保留天数输入边界(与后端 service 一致) */
|
||||
const RETENTION_MIN = 7;
|
||||
const RETENTION_MAX = 365;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [data, setData] = useState<VisitSettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [retention, setRetention] = useState(90);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
// 立即清理:天数输入 + 确认弹窗 + 受理提示
|
||||
const [purgeDays, setPurgeDays] = useState(90);
|
||||
const [purgeOpen, setPurgeOpen] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [purgeMsg, setPurgeMsg] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiStatsSettings()
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setEnabled(d.enabled);
|
||||
setRetention(d.retention_days);
|
||||
setPurgeDays(d.retention_days);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const save = (nextEnabled: boolean, nextRetention: number) => {
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
apiStatsSaveSettings(nextEnabled, nextRetention)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setEnabled(d.enabled);
|
||||
setRetention(d.retention_days);
|
||||
setSavedMsg("已保存");
|
||||
setTimeout(() => setSavedMsg(""), 2000);
|
||||
})
|
||||
.catch(() => setError("保存失败,请稍后重试"))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleEnabled = () => {
|
||||
if (saving) return;
|
||||
save(!enabled, retention);
|
||||
};
|
||||
|
||||
const submitRetention = () => {
|
||||
if (saving) return;
|
||||
const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90)));
|
||||
setRetention(v);
|
||||
save(enabled, v);
|
||||
};
|
||||
|
||||
const doPurge = () => {
|
||||
setPurging(true);
|
||||
apiStatsPurge(purgeDays)
|
||||
.then(() => {
|
||||
setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`);
|
||||
setTimeout(load, 3000);
|
||||
})
|
||||
.catch(() => setPurgeMsg("清理请求失败,请稍后重试。"))
|
||||
.finally(() => {
|
||||
setPurging(false);
|
||||
setPurgeOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const clampPurge = (v: number) =>
|
||||
Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
{purgeMsg ? (
|
||||
<div className="panel p-4 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||
{purgeMsg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="明细行数" value={formatInt(data.usage.rows)} icon={<Database size={17} />} />
|
||||
<AdminStatCard
|
||||
label="磁盘占用"
|
||||
value={formatBytes(data.usage.bytes)}
|
||||
hint="含索引(pg_total_relation_size)"
|
||||
icon={<HardDrive size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard label="保留天数" value={`${data.retention_days} 天`} hint="超期明细每日 04:30 自动清理" icon={<Activity size={17} />} color="var(--gold)" soft="var(--gold-soft)" />
|
||||
<AdminStatCard
|
||||
label="采集丢弃"
|
||||
value={formatInt(data.dropped)}
|
||||
hint="写入队列满时丢弃的事件数(进程启动以来)"
|
||||
icon={<Trash2 size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminSettingRow
|
||||
title="访问统计采集"
|
||||
description="关闭后前端埋点与后端捕获中间件立即停止写入(返回 204),已有数据保留。"
|
||||
control={<AdminSwitch checked={enabled} disabled={saving} onChange={toggleEnabled} label="访问统计采集开关" />}
|
||||
/>
|
||||
|
||||
<AdminSettingRow
|
||||
title="明细保留期"
|
||||
description={`超过保留期的访问明细每日 04:30 自动分批清理;日聚合趋势不受影响。范围 ${RETENTION_MIN}–${RETENTION_MAX} 天。`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min={RETENTION_MIN}
|
||||
max={RETENTION_MAX}
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(Number(e.target.value))}
|
||||
onBlur={submitRetention}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitRetention()}
|
||||
className="admin-field-input"
|
||||
style={{ width: 120 }}
|
||||
aria-label="明细保留天数"
|
||||
/>
|
||||
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
天
|
||||
</span>
|
||||
{saving ? <span className="meta text-[12px]">保存中…</span> : null}
|
||||
{savedMsg ? (
|
||||
<span className="text-[12px]" style={{ color: "var(--ok)" }}>
|
||||
{savedMsg}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingRow>
|
||||
|
||||
<AdminSettingRow
|
||||
title="立即清理"
|
||||
description="手动删除指定天数之前的明细记录,异步执行,不影响正常访问。"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={RETENTION_MAX}
|
||||
value={purgeDays}
|
||||
onChange={(e) => setPurgeDays(clampPurge(Number(e.target.value)))}
|
||||
className="admin-field-input"
|
||||
style={{ width: 120 }}
|
||||
aria-label="清理多少天前的明细"
|
||||
/>
|
||||
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
天前的明细
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setPurgeOpen(true)}
|
||||
disabled={purging}
|
||||
>
|
||||
<Trash2 size={14} /> 立即清理
|
||||
</button>
|
||||
</div>
|
||||
</AdminSettingRow>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeOpen}
|
||||
title="确认立即清理"
|
||||
message={`将删除 ${purgeDays} 天前的全部访问明细(当前共 ${formatInt(data.usage.rows)} 行)。该操作不可恢复,日聚合趋势不受影响。确认执行?`}
|
||||
confirmLabel="确认清理"
|
||||
danger
|
||||
busy={purging}
|
||||
onConfirm={doPurge}
|
||||
onCancel={() => setPurgeOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user