feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -165,6 +165,16 @@ export default function TimelineEditor({
onClose();
};
const keptCount = items.filter((it) => it.selected !== false).length;
const urlCount = urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean).length;
const hasExistingContent = items.some(
(it) => it.title.trim() || it.body.trim()
);
const showReplaceWarning = importMode === "replace" && hasExistingContent;
return (
<>
<Modal
@@ -173,19 +183,36 @@ export default function TimelineEditor({
onClose={onClose}
zIndexClass="z-[1200]"
maxWidthClass="max-w-2xl"
panelClassName="p-0 overflow-hidden"
description="从提交记录批量导入,或手动编辑后插入正文。"
panelClassName="p-5 sm:p-6 max-h-[88vh] flex flex-col overflow-hidden"
bodyClassName="flex-1 min-h-0 flex flex-col"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</>
<div className="w-full flex items-center justify-between gap-2">
<span
className="text-[12.5px]"
style={{ color: keptCount > 0 ? "var(--ink-3)" : "var(--danger)" }}
>
{keptCount > 0
? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
: "没有可保留的条目"}
</span>
<div className="flex items-center gap-2">
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleInsert}
disabled={keptCount === 0}
>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</div>
</div>
}
>
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* 导入区 */}
<section className="j13-tl-import" aria-label="从提交记录导入">
<div className="flex items-center gap-2 mb-2">
@@ -195,16 +222,22 @@ export default function TimelineEditor({
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
</p>
<textarea
value={urlsText}
onChange={(e) => setUrlsText(e.target.value)}
onChange={(e) => {
setUrlsText(e.target.value);
setImportError(null);
}}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
className="j13-tl-urls"
disabled={importing}
/>
{urlCount > 0 ? (
<p className="j13-tl-hint">已识别 {urlCount} 条 URL</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<input
@@ -242,18 +275,24 @@ export default function TimelineEditor({
)}
</button>
</div>
{importError ? (
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
{importError}
{showReplaceWarning ? (
<p className="j13-tl-banner is-warn">
替换模式会丢弃当前已编辑的 {items.length} 条内容;如需保留请改选「追加」。
</p>
) : null}
{importError ? (
<p className="j13-tl-banner is-err">{importError}</p>
) : null}
</section>
{/* 条目列表 */}
<div className="flex items-center justify-between gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<div className="flex items-center gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<span className="j13-tl-count is-muted">{items.length} 项</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
按日期整理
@@ -268,80 +307,99 @@ export default function TimelineEditor({
</div>
</div>
<ul className="space-y-3">
{items.map((it, idx) => (
<li key={idx} className="j13-tl-edit-card">
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap gap-2">
{items.length === 0 ? (
<div className="j13-tl-empty">
<p>还没有时间线条目</p>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems([emptyItem()])}
>
<Plus size={14} /> 添加一条
</button>
</div>
) : (
<ul className="space-y-3">
{items.map((it, idx) => (
<li
key={idx}
className={`j13-tl-edit-card${it.selected === false ? " is-excluded" : ""}`}
>
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{it.selected === false ? (
<span className="j13-tl-tag">已排除</span>
) : null}
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
/>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</Modal>