diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fdadbf2..faff6f9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.4.1" + ".": "2.4.2" } diff --git a/client/package.json b/client/package.json index 0aaa2cc..5d94802 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "monolith-client", - "version": "2.4.1", + "version": "2.4.2", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/admin-gate.tsx b/client/src/components/admin-gate.tsx index 1b26fe4..cdabb70 100644 --- a/client/src/components/admin-gate.tsx +++ b/client/src/components/admin-gate.tsx @@ -121,7 +121,7 @@ export function AdminGate({
-
+
@@ -191,7 +191,7 @@ export function AdminGate({ placeholder="输入密码" autoComplete="current-password" aria-label="管理员密码" - className="h-[46px] w-full rounded-md border border-border/45 bg-background/55 px-[14px] pr-[48px] text-[15px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-cyan-400/45 focus:ring-1 focus:ring-cyan-400/20" + className="h-[46px] w-full rounded-md border border-border/45 bg-background/55 px-[14px] pr-[48px] text-[15px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-foreground/35 focus:ring-1 focus:ring-foreground/12" /> {error && ( diff --git a/client/src/components/admin-layout.tsx b/client/src/components/admin-layout.tsx index b1aea0e..6145d71 100644 --- a/client/src/components/admin-layout.tsx +++ b/client/src/components/admin-layout.tsx @@ -24,26 +24,26 @@ interface AdminLayoutProps { const NAV_GROUPS = [ { - title: "内容管理", + title: "内容运营", items: [ - { href: "/admin", icon: LayoutDashboard, label: "控制台" }, - { href: "/admin/pages", icon: StickyNote, label: "独立页面" }, - { href: "/admin/comments", icon: MessageCircle, label: "评论审核" }, + { href: "/admin", icon: LayoutDashboard, label: "运营总览" }, + { href: "/admin/pages", icon: StickyNote, label: "页面管理" }, + { href: "/admin/comments", icon: MessageCircle, label: "互动审核" }, ], }, { - title: "资源与数据", + title: "增长工具", items: [ - { href: "/admin/media", icon: ImageIcon, label: "媒体库" }, - { href: "/admin/analytics", icon: BarChart3, label: "数据分析" }, - { href: "/admin/seo", icon: Sparkles, label: "SEO 优化" }, - { href: "/admin/backup", icon: HardDrive, label: "安全备份" }, + { href: "/admin/media", icon: ImageIcon, label: "媒体资产" }, + { href: "/admin/analytics", icon: BarChart3, label: "运营洞察" }, + { href: "/admin/seo", icon: Sparkles, label: "搜索优化" }, + { href: "/admin/backup", icon: HardDrive, label: "备份恢复" }, ], }, { - title: "系统配置", + title: "系统维护", items: [ - { href: "/admin/settings", icon: Settings, label: "站点设置" }, + { href: "/admin/settings", icon: Settings, label: "站点配置" }, ], }, ]; @@ -113,7 +113,7 @@ export function AdminLayout({ children }: AdminLayoutProps) {
Monolith - Admin Console + Operations Desk
diff --git a/client/src/components/seo-head.tsx b/client/src/components/seo-head.tsx index aa8636b..3a30ccf 100644 --- a/client/src/components/seo-head.tsx +++ b/client/src/components/seo-head.tsx @@ -43,8 +43,8 @@ export function SeoHead({ breadcrumbs, noindex = false, }: SeoProps) { - const fullTitle = title ? `${title} | ${siteName}` : `${siteName} — ${DEFAULT_DESCRIPTION}`; const metaDescription = description || DEFAULT_DESCRIPTION; + const fullTitle = title ? `${title} | ${siteName}` : `${siteName} — ${metaDescription}`; const canonicalUrl = url ? `${window.location.origin}${url}` : window.location.href; const ogImage = image || `${window.location.origin}/og-default.png`; diff --git a/client/src/components/toc.tsx b/client/src/components/toc.tsx index 558543c..112280e 100644 --- a/client/src/components/toc.tsx +++ b/client/src/components/toc.tsx @@ -100,7 +100,8 @@ export function TableOfContents({ headings }: Props) {
{mobileOpen && ( -
+
    {headings.map((h) => (
  • diff --git a/client/src/globals.css b/client/src/globals.css index 5e36b22..1269500 100644 --- a/client/src/globals.css +++ b/client/src/globals.css @@ -1169,9 +1169,9 @@ } .toc-active { - color: oklch(0.85 0.1 220) !important; - border-left-color: oklch(0.65 0.18 220) !important; - background: oklch(0.65 0.18 220 / 6%) !important; + color: oklch(0.88 0 220) !important; + border-left-color: color-mix(in oklch, var(--foreground) 58%, transparent) !important; + background: color-mix(in oklch, var(--foreground) 6%, transparent) !important; font-weight: 500; } @@ -1191,9 +1191,9 @@ } :root[data-theme="light"] .toc-active { - color: oklch(0.36 0.15 250) !important; - background: oklch(0.55 0.15 250 / 8%) !important; - border-left-color: oklch(0.50 0.17 250) !important; + color: oklch(0.20 0.006 250) !important; + background: oklch(0 0 0 / 5%) !important; + border-left-color: oklch(0 0 0 / 32%) !important; } /* 标题锚点偏移(避免被顶部导航遮挡) */ @@ -1530,9 +1530,13 @@ flex-direction: column; gap: 4px; padding: 16px; - border-radius: 10px; - border: 1px solid oklch(0.35 0 0 / 30%); - background: oklch(0.18 0 0 / 50%); + border-radius: 8px; + border: 1px solid oklch(0.42 0 0 / 46%); + background: oklch(0.17 0 0 / 72%); +} + +.analytics-card--kpi { + min-height: 120px; } .analytics-card__label { @@ -1549,10 +1553,23 @@ color: oklch(0.9 0 0); } +.analytics-card__unit { + margin-left: 2px; + font-size: 13px; + color: oklch(0.58 0 0); +} + +.analytics-card__explain { + margin-top: auto; + font-size: 12px; + line-height: 1.55; + color: oklch(0.64 0 0); +} + .analytics-section { - border: 1px solid oklch(0.35 0 0 / 30%); - border-radius: 10px; - background: oklch(0.18 0 0 / 50%); + border: 1px solid oklch(0.42 0 0 / 46%); + border-radius: 8px; + background: oklch(0.17 0 0 / 72%); overflow: hidden; } @@ -1564,7 +1581,220 @@ font-size: 13px; font-weight: 600; color: oklch(0.7 0 0); - border-bottom: 1px solid oklch(0.35 0 0 / 20%); + border-bottom: 1px solid oklch(0.42 0 0 / 36%); +} + +.analytics-delta { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid oklch(0.38 0 0 / 50%); + font-size: 11px; + font-variant-numeric: tabular-nums; + color: oklch(0.68 0 0); +} + +.analytics-delta--up { + color: oklch(0.78 0.08 125); + border-color: oklch(0.78 0.08 125 / 30%); + background: oklch(0.78 0.08 125 / 8%); +} + +.analytics-delta--down { + color: oklch(0.72 0.16 25); + border-color: oklch(0.72 0.16 25 / 30%); + background: oklch(0.72 0.16 25 / 8%); +} + +.analytics-skeleton { + border-radius: 8px; + border: 1px solid oklch(0.42 0 0 / 32%); + background: linear-gradient(90deg, oklch(0.2 0 0 / 60%), oklch(0.28 0 0 / 60%), oklch(0.2 0 0 / 60%)); + background-size: 200% 100%; + animation: analytics-pulse 1.4s cubic-bezier(0.16, 1, 0.3, 1) infinite; +} + +@keyframes analytics-pulse { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .analytics-skeleton { + animation: none; + } +} + +.analytics-empty { + display: flex; + gap: 12px; + padding: 24px 16px; + color: oklch(0.62 0 0); +} + +.analytics-empty__title { + font-size: 13px; + font-weight: 600; + color: oklch(0.78 0 0); +} + +.analytics-empty__detail { + margin-top: 2px; + font-size: 12px; + line-height: 1.55; + color: oklch(0.55 0 0); +} + +.analytics-error { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 112px; + border: 1px solid oklch(0.62 0.18 25 / 36%); + border-radius: 8px; + background: oklch(0.62 0.18 25 / 10%); + color: oklch(0.78 0.14 25); + font-size: 13px; +} + +.analytics-insight-list, +.analytics-suggestion-list { + display: grid; + gap: 8px; + padding: 12px; +} + +.analytics-insight, +.analytics-suggestion { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 12px; + border-radius: 8px; + border: 1px solid oklch(0.38 0 0 / 42%); + background: oklch(0.14 0 0 / 48%); +} + +.analytics-insight__marker { + width: 8px; + height: 8px; + margin-top: 5px; + border-radius: 99px; + background: oklch(0.7 0 0); +} + +.analytics-insight--good .analytics-insight__marker { + background: oklch(0.76 0.08 125); +} + +.analytics-insight--warning .analytics-insight__marker, +.analytics-insight--neutral .analytics-insight__marker { + background: oklch(0.78 0.11 78); +} + +.analytics-insight--danger .analytics-insight__marker { + background: oklch(0.7 0.16 25); +} + +.analytics-insight__title, +.analytics-suggestion__title { + font-size: 13px; + font-weight: 650; + color: oklch(0.84 0 0); +} + +.analytics-insight__description, +.analytics-suggestion__reason { + margin-top: 3px; + font-size: 12px; + line-height: 1.55; + color: oklch(0.63 0 0); +} + +.analytics-insight__action, +.analytics-suggestion__action { + margin-top: 5px; + font-size: 12px; + line-height: 1.55; + color: oklch(0.76 0 0); +} + +.analytics-insight__metric, +.analytics-suggestion__metric { + white-space: nowrap; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 12px; + color: oklch(0.82 0 0); +} + +.analytics-priority { + min-width: 44px; + border-radius: 6px; + border: 1px solid oklch(0.4 0 0 / 50%); + padding: 3px 6px; + text-align: center; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 10px; + text-transform: uppercase; + color: oklch(0.66 0 0); +} + +.analytics-priority--high { + color: oklch(0.84 0.11 78); + border-color: oklch(0.84 0.11 78 / 34%); + background: oklch(0.84 0.11 78 / 8%); +} + +.analytics-priority--medium { + color: oklch(0.74 0 0); +} + +.analytics-priority--low { + color: oklch(0.54 0 0); +} + +.analytics-legend { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.analytics-legend__line { + display: inline-block; + width: 18px; + height: 2px; + border-radius: 99px; +} + +.analytics-legend__line--current { + background: oklch(0.82 0 0); +} + +.analytics-legend__line--previous { + background: repeating-linear-gradient(90deg, oklch(0.58 0 0), oklch(0.58 0 0) 4px, transparent 4px, transparent 7px); +} + +.analytics-legend__dot { + width: 8px; + height: 8px; + border-radius: 99px; + border: 1px solid oklch(0.78 0.11 78); +} + +.analytics-chart-point { + fill: oklch(0.88 0 0); + stroke: oklch(0.12 0 0); + stroke-width: 1.5; + transition: r 0.16s cubic-bezier(0.16, 1, 0.3, 1); +} + +.analytics-chart-point--anomaly { + fill: oklch(0.78 0.13 65); } /* 柱状图 */ @@ -1601,7 +1831,7 @@ max-width: 32px; min-height: 2px; border-radius: 3px 3px 0 0; - background: linear-gradient(to top, oklch(0.55 0.18 220), oklch(0.65 0.15 200)); + background: linear-gradient(to top, oklch(0.5 0 0), oklch(0.74 0 0)); transition: height 0.4s ease; } @@ -1637,6 +1867,27 @@ padding: 6px 16px; } +.analytics-list__row--stack { + display: grid; + gap: 7px; + padding-top: 10px; + padding-bottom: 10px; +} + +.analytics-list__main, +.analytics-list__meaning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.analytics-list__meaning { + font-size: 11px; + line-height: 1.45; + color: oklch(0.52 0 0); +} + .analytics-list__name { flex-shrink: 0; width: 90px; @@ -1647,6 +1898,11 @@ text-overflow: ellipsis; } +.analytics-list__name--wide { + width: auto; + min-width: 0; +} + .analytics-list__name--mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 11px; @@ -1666,10 +1922,14 @@ transition: width 0.4s ease; } -.analytics-list__bar-fill--blue { background: oklch(0.6 0.18 220); } -.analytics-list__bar-fill--green { background: oklch(0.6 0.18 160); } -.analytics-list__bar-fill--violet { background: oklch(0.6 0.18 280); } -.analytics-list__bar-fill--amber { background: oklch(0.7 0.16 80); } +.analytics-list__bar-fill--blue, +.analytics-list__bar-fill--green, +.analytics-list__bar-fill--violet, +.analytics-list__bar-fill--neutral { + background: oklch(0.72 0 0); +} + +.analytics-list__bar-fill--amber { background: oklch(0.76 0.1 78); } .analytics-list__count { flex-shrink: 0; @@ -1681,10 +1941,32 @@ color: oklch(0.65 0 0); } +.settings-input { + width: 100%; + border-radius: 8px; + border: 1px solid color-mix(in oklch, var(--border) 72%, transparent); + background: color-mix(in oklch, var(--background) 78%, transparent); + padding-inline: 12px; + color: var(--foreground); + font-size: 13px; + outline: none; + transition: border-color 180ms cubic-bezier(0.16, 1, 0.3, 1), background 180ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.settings-input::placeholder { + color: color-mix(in oklch, var(--muted-foreground) 42%, transparent); +} + +.settings-input:focus { + border-color: color-mix(in oklch, var(--foreground) 38%, transparent); + background: color-mix(in oklch, var(--background) 92%, transparent); + box-shadow: 0 0 0 2px color-mix(in oklch, var(--foreground) 8%, transparent); +} + /* 亮色模式 */ [data-theme="light"] .analytics-card { - background: oklch(0.97 0 0); - border-color: oklch(0.88 0 0 / 40%); + background: oklch(0.985 0 0); + border-color: oklch(0.72 0 0 / 58%); } [data-theme="light"] .analytics-card__value { @@ -1692,17 +1974,17 @@ } [data-theme="light"] .analytics-section { - background: oklch(0.97 0 0); - border-color: oklch(0.88 0 0 / 40%); + background: oklch(0.985 0 0); + border-color: oklch(0.72 0 0 / 58%); } [data-theme="light"] .analytics-section__title { color: oklch(0.35 0 0); - border-bottom-color: oklch(0.88 0 0 / 30%); + border-bottom-color: oklch(0.78 0 0 / 46%); } [data-theme="light"] .analytics-chart__bar { - background: linear-gradient(to top, oklch(0.5 0.18 220), oklch(0.6 0.15 200)); + background: linear-gradient(to top, oklch(0.42 0 0), oklch(0.68 0 0)); } [data-theme="light"] .analytics-list__bar-track { @@ -1717,6 +1999,336 @@ color: oklch(0.35 0 0); } +[data-theme="light"] .analytics-card__explain, +[data-theme="light"] .analytics-insight__description, +[data-theme="light"] .analytics-suggestion__reason, +[data-theme="light"] .analytics-list__meaning, +[data-theme="light"] .analytics-empty__detail { + color: oklch(0.42 0 0); +} + +[data-theme="light"] .analytics-insight, +[data-theme="light"] .analytics-suggestion { + background: oklch(0.96 0 0); + border-color: oklch(0.74 0 0 / 50%); +} + +[data-theme="light"] .analytics-insight__title, +[data-theme="light"] .analytics-suggestion__title, +[data-theme="light"] .analytics-empty__title { + color: oklch(0.22 0 0); +} + +[data-theme="light"] .analytics-insight__action, +[data-theme="light"] .analytics-suggestion__action { + color: oklch(0.3 0 0); +} + +[data-theme="light"] .analytics-skeleton { + border-color: oklch(0.74 0 0 / 45%); + background: linear-gradient(90deg, oklch(0.94 0 0), oklch(0.88 0 0), oklch(0.94 0 0)); + background-size: 200% 100%; +} + +.analytics-concentration, +.analytics-quality-grid { + display: grid; + gap: 12px; + padding: 16px; +} + +.analytics-filter-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 12px; +} + +.analytics-filter { + min-height: 76px; + border-radius: 8px; + border: 1px solid oklch(0.38 0 0 / 42%); + background: oklch(0.14 0 0 / 48%); + padding: 10px; + text-align: left; + transition: border-color 0.18s cubic-bezier(0.16, 1, 0.3, 1), background 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} + +.analytics-filter:hover, +.analytics-filter--active { + border-color: oklch(0.78 0 0 / 62%); + background: oklch(0.24 0 0 / 62%); +} + +.analytics-filter__label, +.analytics-lifecycle__heading, +.analytics-report__title { + display: block; + font-size: 12px; + font-weight: 650; + color: oklch(0.82 0 0); +} + +.analytics-filter__value { + display: block; + margin-top: 6px; + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: oklch(0.9 0 0); +} + +.analytics-filter__detail { + display: block; + margin-top: 4px; + font-size: 11px; + line-height: 1.45; + color: oklch(0.58 0 0); +} + +.analytics-lifecycle, +.analytics-search-grid, +.analytics-report { + display: grid; + gap: 12px; + padding: 12px; +} + +.analytics-lifecycle { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.analytics-search-grid, +.analytics-report { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.analytics-lifecycle__column, +.analytics-report > div, +.analytics-search-grid > div { + min-width: 0; + border-radius: 8px; + border: 1px solid oklch(0.38 0 0 / 42%); + background: oklch(0.14 0 0 / 48%); + padding: 12px; +} + +.analytics-lifecycle__item { + margin-top: 10px; + border-top: 1px solid oklch(0.38 0 0 / 34%); + padding-top: 10px; +} + +.analytics-lifecycle__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 620; + color: oklch(0.82 0 0); +} + +.analytics-lifecycle__meta, +.analytics-search-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 5px; + font-size: 11px; + color: oklch(0.56 0 0); +} + +.analytics-lifecycle__item p, +.analytics-report p { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.55; + color: oklch(0.66 0 0); +} + +.analytics-lifecycle__empty { + margin-top: 10px; + border-radius: 6px; + background: oklch(0.2 0 0 / 42%); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: oklch(0.52 0 0); +} + +.analytics-search-row { + border-top: 1px solid oklch(0.38 0 0 / 34%); + padding-top: 8px; +} + +.analytics-search-row span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: oklch(0.78 0 0); +} + +.analytics-report__actions { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.analytics-report__actions span { + border-radius: 6px; + background: oklch(0.22 0 0 / 52%); + padding: 7px 8px; + font-size: 12px; + line-height: 1.5; + color: oklch(0.7 0 0); +} + +.analytics-report textarea { + min-height: 220px; + resize: vertical; + border-radius: 8px; + border: 1px solid oklch(0.38 0 0 / 42%); + background: oklch(0.12 0 0 / 78%); + padding: 12px; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 11px; + line-height: 1.6; + color: oklch(0.76 0 0); + outline: none; +} + +.analytics-concentration { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.analytics-quality-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.analytics-concentration__value, +.analytics-quality-grid strong { + display: block; + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: oklch(0.9 0 0); +} + +.analytics-quality-grid small { + margin-left: 2px; + font-size: 13px; + color: oklch(0.58 0 0); +} + +.analytics-concentration__label, +.analytics-quality-grid p { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: oklch(0.58 0 0); +} + +.analytics-concentration__explain { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding-top: 12px; + border-top: 1px solid oklch(0.42 0 0 / 34%); + font-size: 12px; + line-height: 1.55; + color: oklch(0.68 0 0); +} + +[data-theme="light"] .analytics-concentration__value, +[data-theme="light"] .analytics-quality-grid strong { + color: oklch(0.16 0 0); +} + +[data-theme="light"] .analytics-concentration__label, +[data-theme="light"] .analytics-quality-grid p, +[data-theme="light"] .analytics-concentration__explain { + color: oklch(0.38 0 0); +} + +[data-theme="light"] .analytics-filter, +[data-theme="light"] .analytics-lifecycle__column, +[data-theme="light"] .analytics-report > div, +[data-theme="light"] .analytics-search-grid > div { + background: oklch(0.96 0 0); + border-color: oklch(0.74 0 0 / 50%); +} + +[data-theme="light"] .analytics-filter:hover, +[data-theme="light"] .analytics-filter--active { + border-color: oklch(0.36 0 0 / 50%); + background: oklch(0.92 0 0); +} + +[data-theme="light"] .analytics-filter__label, +[data-theme="light"] .analytics-lifecycle__heading, +[data-theme="light"] .analytics-report__title, +[data-theme="light"] .analytics-lifecycle__title, +[data-theme="light"] .analytics-search-row span:first-child { + color: oklch(0.22 0 0); +} + +[data-theme="light"] .analytics-filter__value { + color: oklch(0.16 0 0); +} + +[data-theme="light"] .analytics-filter__detail, +[data-theme="light"] .analytics-lifecycle__meta, +[data-theme="light"] .analytics-lifecycle__item p, +[data-theme="light"] .analytics-report p, +[data-theme="light"] .analytics-search-row { + color: oklch(0.42 0 0); +} + +[data-theme="light"] .analytics-lifecycle__empty, +[data-theme="light"] .analytics-report__actions span { + background: oklch(0.9 0 0); + color: oklch(0.36 0 0); +} + +[data-theme="light"] .analytics-report textarea { + border-color: oklch(0.74 0 0 / 50%); + background: oklch(0.94 0 0); + color: oklch(0.26 0 0); +} + +@media (max-width: 640px) { + .analytics-concentration, + .analytics-quality-grid, + .analytics-filter-grid, + .analytics-lifecycle, + .analytics-search-grid, + .analytics-report { + grid-template-columns: 1fr; + } + + .analytics-insight, + .analytics-suggestion { + grid-template-columns: auto minmax(0, 1fr); + } + + .analytics-insight__metric, + .analytics-suggestion__metric { + grid-column: 2; + justify-self: start; + } +} + +@media (min-width: 641px) and (max-width: 1024px) { + .analytics-filter-grid, + .analytics-lifecycle { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* ── 图片懒加载渐进淡入 ──────────────────── */ .lazy-img { @@ -1745,6 +2357,8 @@ .reading-mode .post-layout { max-width: var(--rm-width, 780px) !important; display: block; + padding-left: 16px; + padding-right: 16px; transition: max-width 0.3s ease; } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index e7194d0..fe93532 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -289,12 +289,142 @@ export async function fetchViewStats(): Promise { return res.json(); } +export type AnalyticsTone = "good" | "warning" | "danger" | "neutral"; + +export type AnalyticsInsight = { + id: string; + tone: AnalyticsTone; + title: string; + description: string; + action: string; + metric?: string; + path?: string; +}; + +export type AnalyticsKpi = { + key: string; + label: string; + value: number; + unit?: string; + change: number; + changePercent: number | null; + interpretation: string; +}; + +export type AnalyticsTrendPoint = { + date: string; + count: number; + previousCount: number; + change: number; + isPeak: boolean; + isAnomaly: boolean; +}; + +export type AnalyticsShareItem = { + name: string; + count: number; + share: number; + meaning: string; +}; + +export type ContentSuggestion = { + id: string; + priority: "high" | "medium" | "low"; + title: string; + reason: string; + action: string; + path?: string; + metric?: string; +}; + +export type AnalyticsPageFilter = { + key: "all" | "posts" | "pages" | "search" | "other"; + label: string; + count: number; + share: number; + description: string; +}; + +export type ContentLifecycleItem = { + path: string; + title: string; + count: number; + ageDays: number | null; + change?: number; + stage: "new" | "growing" | "evergreen" | "declining"; + action: string; +}; + +export type SearchAnalytics = { + status: "tracked" | "not_configured" | "empty"; + totalSearches: number; + zeroResultRate: number; + topQueries: { query: string; count: number; avgResults: number }[]; + zeroResultQueries: { query: string; count: number }[]; + suggestions: ContentSuggestion[]; +}; + +export type OperationalReport = { + title: string; + summary: string; + highlights: string[]; + nextActions: string[]; + markdown: string; +}; + +export type AnalyticsDerived = { + period: { + days: number; + total: number; + previousTotal: number; + change: number; + changePercent: number | null; + average: number; + previousAverage: number; + }; + kpis: AnalyticsKpi[]; + trend: AnalyticsTrendPoint[]; + anomalies: AnalyticsInsight[]; + topRisingPages: { path: string; count: number; previousCount: number; change: number; changePercent: number | null }[]; + concentration: { + topPageShare: number; + topRefererShare: number; + topDeviceShare: number; + label: "healthy" | "watch" | "concentrated"; + explanation: string; + }; + shares: { + devices: AnalyticsShareItem[]; + referers: AnalyticsShareItem[]; + countries: AnalyticsShareItem[]; + }; + quality: { + score: number; + label: string; + avgDuration?: number; + bounceRate?: number; + pagesPerVisitor?: number; + }; + pageFilters: AnalyticsPageFilter[]; + contentLifecycle: { + newPosts: ContentLifecycleItem[]; + growing: ContentLifecycleItem[]; + evergreen: ContentLifecycleItem[]; + declining: ContentLifecycleItem[]; + }; + search: SearchAnalytics; + report: OperationalReport; + insights: AnalyticsInsight[]; + contentSuggestions: ContentSuggestion[]; +}; + export type AnalyticsData = { visitsByDay: { date: string; count: number }[]; topCountries: { country: string; count: number }[]; topReferers: { referer: string; count: number }[]; deviceBreakdown: { device: string; count: number }[]; topPages: { path: string; count: number }[]; + derived: AnalyticsDerived; }; export async function fetchAnalytics(days = 7): Promise { @@ -327,6 +457,7 @@ export type AEAnalyticsData = { bounceRate: number; pagesPerVisitor: number; topReferersFull: { referer: string; count: number }[]; + derived: AnalyticsDerived; }; export type AEAnalyticsError = { diff --git a/client/src/pages/admin/analytics-ae.tsx b/client/src/pages/admin/analytics-ae.tsx index 21300bf..de9d90b 100644 --- a/client/src/pages/admin/analytics-ae.tsx +++ b/client/src/pages/admin/analytics-ae.tsx @@ -1,13 +1,6 @@ -import { useState, useEffect } from "react"; -import { fetchAEAnalytics, type AEAnalyticsData, type AEAnalyticsError } from "@/lib/api"; -import { Globe, Monitor, ExternalLink, Cloud, Users, Clock, Languages, MonitorSmartphone, Activity, LogIn, LogOut, UserPlus, Repeat, Hourglass, CalendarClock } from "lucide-react"; - -function countryFlag(code: string): string { - if (!code || code === "XX" || code.length !== 2) return "🌍"; - return String.fromCodePoint( - ...code.toUpperCase().split("").map((c) => 0x1f1e6 + c.charCodeAt(0) - 65) - ); -} +import { useState } from "react"; +import type { AEAnalyticsData } from "@/lib/api"; +import { Activity, CalendarClock, Clock, ExternalLink, Gauge, Globe, Hourglass, Languages, LogIn, LogOut, Monitor, MonitorSmartphone, Repeat, TrendingDown, TrendingUp, UserPlus, Users } from "lucide-react"; function formatDuration(ms: number): string { if (!ms || ms <= 0) return "-"; @@ -59,19 +52,19 @@ function DualTrendChart({ data, maxValue }: { data: { date: string; count: numbe {/* 图例 */}
    - + PV 总访问 - + UV 独立访客
    - - + + @@ -83,14 +76,14 @@ function DualTrendChart({ data, maxValue }: { data: { date: string; count: numbe ))} {pvArea && } - {pvLine && } - {uvLine && } + {pvLine && } + {uvLine && } {pvPoints.map((p, i) => ( - + ))} {uvPoints.map((p, i) => ( - + ))} {/* hover 时显示当日 PV/UV 数值在 PV 点上方 */} @@ -212,7 +205,7 @@ function HeatmapChart({ data }: { data: { dow: number; hour: number; count: numb width={cellW - 2} height={cellH - 2} rx={2} - fill="oklch(0.7 0.15 200)" + fill="oklch(0.78 0 0)" opacity={opacity} > {`周${dows[di]} ${String(h).padStart(2, "0")}:00 — ${v} 次访问`} @@ -246,7 +239,7 @@ function DurationBucketsChart({ buckets }: { buckets: { bucket: string; count: n className="h-full rounded-full" style={{ width: `${pct}%`, - background: "linear-gradient(90deg, oklch(0.65 0.15 200), oklch(0.55 0.18 220))", + background: "linear-gradient(90deg, oklch(0.72 0 0), oklch(0.58 0 0))", transition: "width .4s cubic-bezier(0.16, 1, 0.3, 1)", }} /> @@ -274,7 +267,7 @@ function VisitorRatio({ types }: { types: { type: "new" | "returning"; count: nu className="h-full" style={{ width: `${newPct}%`, - background: "linear-gradient(90deg, oklch(0.7 0.16 200), oklch(0.6 0.18 220))", + background: "linear-gradient(90deg, oklch(0.78 0 0), oklch(0.58 0 0))", transition: "width .4s cubic-bezier(0.16, 1, 0.3, 1)", }} /> @@ -282,20 +275,20 @@ function VisitorRatio({ types }: { types: { type: "new" | "returning"; count: nu className="h-full" style={{ width: `${retPct}%`, - background: "linear-gradient(90deg, oklch(0.75 0.18 80), oklch(0.65 0.18 60))", + background: "linear-gradient(90deg, oklch(0.78 0.1 78), oklch(0.62 0.08 78))", transition: "width .4s cubic-bezier(0.16, 1, 0.3, 1)", }} />
- + 新访客 {newCount} ({newPct.toFixed(1)}%) - + 回访 {retCount} ({retPct.toFixed(1)}%) @@ -305,94 +298,46 @@ function VisitorRatio({ types }: { types: { type: "new" | "returning"; count: nu ); } -export function AnalyticsAEView({ days }: { days: number }) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - setLoading(true); - fetchAEAnalytics(days) - .then((d) => { setData(d); setError(null); }) - .catch((e: AEAnalyticsError) => { setData(null); setError(e); }) - .finally(() => setLoading(false)); - }, [days]); - - if (loading) { - return
加载中...
; - } - - if (error) { - if (error.status === 501) { - return ( -
- -
AE 增强分析仅支持 Cloudflare 部署
-
当前后端:{error.message}
-
- ); - } - if (error.status === 503) { - return ( -
-
AE 配置缺失
-
- 请通过 wrangler secret put 注入 CLOUDFLARE_ACCOUNT_ID 与 CLOUDFLARE_API_TOKEN - (需 Account Analytics:Read 权限) -
-
- ); - } - return ( -
- {error.message} -
- ); - } +function formatPercent(value: number | null): string { + if (value === null) return "新增基线"; + return `${value >= 0 ? "+" : ""}${Math.round(value * 100)}%`; +} - if (!data) { - return
暂无数据
; - } +export function AnalyticsAEAdvancedView({ data }: { data: AEAnalyticsData }) { const maxDay = Math.max(...data.visitsByDay.map((d) => Math.max(d.count, d.uv)), 1); const bounceRatePct = (data.bounceRate * 100).toFixed(1); const pagesPerVisitor = data.pagesPerVisitor.toFixed(2); + const periodDeltaIcon = data.derived.period.change >= 0 ? TrendingUp : TrendingDown; + const PeriodDeltaIcon = periodDeltaIcon; return (
- {/* 核心指标 8 张卡 */} -
-
- 总访问 (PV) - {data.totalVisits} -
-
- 独立访客 (UV) - {data.uniqueVisitors} -
-
- 平均停留 - {formatDuration(data.avgDuration)} -
-
- 国家/地区 - {data.topCountries.length} -
-
- 跳出率 - {bounceRatePct}% -
-
- 人均页数 - {pagesPerVisitor} -
-
- 浏览器种类 - {data.browserBreakdown.length} -
-
- 引荐来源 - {data.topReferersFull.length} +
+

+ + Cloudflare 高级维度 + + + {formatPercent(data.derived.period.changePercent)} + +

+
+
+ 阅读质量 + {data.derived.quality.score}/100 +

{data.derived.quality.label}

+
+
+ 跳出率解释 + {bounceRatePct}% +

{data.bounceRate > 0.65 ? "首屏或入口匹配度偏弱" : "用户愿意继续浏览"}

+
+
+ 人均页数 + {pagesPerVisitor} +

{data.pagesPerVisitor >= 2 ? "站内路径有效" : "需要补内链和下一篇入口"}

+
@@ -484,7 +429,7 @@ export function AnalyticsAEView({ days }: { days: number }) { title="国家 / 地区" icon={Globe} accent="blue" - items={data.topCountries.map((i) => ({ name: `${countryFlag(i.country)} ${i.country}`, count: i.count }))} + items={data.topCountries.map((i) => ({ name: i.country || "未知地区", count: i.count }))} /> = { - desktop: Monitor, - mobile: Smartphone, - tablet: Tablet, - bot: Bot, -}; +import { useEffect, useState } from "react"; +import { + fetchAEAnalytics, + fetchAnalytics, + type AEAnalyticsData, + type AEAnalyticsError, + type AnalyticsData, + type AnalyticsInsight, + type AnalyticsKpi, + type AnalyticsPageFilter, + type AnalyticsShareItem, + type AnalyticsTrendPoint, + type ContentLifecycleItem, + type ContentSuggestion, + type SearchAnalytics, +} from "@/lib/api"; +import { + AlertTriangle, + BarChart3, + BookOpen, + CheckCircle2, + Cloud, + ClipboardList, + ExternalLink, + FileText, + Filter, + Globe, + Lightbulb, + Monitor, + MousePointer2, + Search, + TrendingDown, + TrendingUp, +} from "lucide-react"; +import { AnalyticsAEAdvancedView } from "./analytics-ae"; + +type AEStatus = "loading" | "ready" | "unavailable" | "error"; +type RankItem = { name: string; count: number; share?: number; meaning?: string }; +type PageFilterKey = AnalyticsPageFilter["key"]; -const DEVICE_LABELS: Record = { - desktop: "桌面端", - mobile: "移动端", - tablet: "平板", - bot: "爬虫", +const toneClass: Record = { + good: "analytics-insight--good", + warning: "analytics-insight--warning", + danger: "analytics-insight--danger", + neutral: "analytics-insight--neutral", }; -// 国旗 emoji 转换 -function countryFlag(code: string): string { - if (!code || code === "XX" || code.length !== 2) return "🌍"; - return String.fromCodePoint( - ...code.toUpperCase().split("").map((c) => 0x1f1e6 + c.charCodeAt(0) - 65) +function formatChange(value: number, percent: number | null): string { + if (percent === null) return value > 0 ? "新增基线" : "持平"; + return `${value >= 0 ? "+" : ""}${value} · ${percent >= 0 ? "+" : ""}${Math.round(percent * 100)}%`; +} + +function formatShare(value?: number): string { + if (typeof value !== "number") return ""; + return `${(value * 100).toFixed(1)}%`; +} + +function EmptyState({ title, detail }: { title: string; detail: string }) { + return ( +
+ +
+
{title}
+
{detail}
+
+
+ ); +} + +function LoadingState() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+
+ ); +} + +function ConclusionPanel({ insights }: { insights: AnalyticsInsight[] }) { + return ( +
+
+ + 本期结论 + 可直接执行 +
+ {insights.length === 0 ? ( + + ) : ( +
+ {insights.map((insight) => ( +
+
+ ))} +
+ )} +
+ ); +} + +function KpiCard({ item }: { item: AnalyticsKpi }) { + const positive = item.change >= 0; + const TrendIcon = positive ? TrendingUp : TrendingDown; + return ( +
+
+ {item.label} + + + {formatChange(item.change, item.changePercent)} + +
+
+ {item.value} + {item.unit ? {item.unit} : null} +
+

{item.interpretation}

+
); } -// 访问趋势图:柱状 + 折线叠加 + 网格参考线,hover 高亮当日 -function TrendChart({ data, max }: { data: { date: string; count: number }[]; max: number }) { +function TrendChart({ data }: { data: AnalyticsTrendPoint[] }) { const [hover, setHover] = useState(null); - const W = 720; - const H = 200; - const PAD_T = 16; - const PAD_B = 36; - const PAD_X = 24; + const W = 760; + const H = 240; + const PAD_T = 20; + const PAD_B = 42; + const PAD_X = 32; const innerH = H - PAD_T - PAD_B; const innerW = W - PAD_X * 2; const n = data.length; const step = n > 1 ? innerW / (n - 1) : 0; - const barW = n > 0 ? Math.min(40, (innerW / n) * 0.55) : 0; - const safeMax = max || 1; - - // 网格 4 等分(含顶/底) - const gridLines = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ + const safeMax = Math.max(...data.map((d) => Math.max(d.count, d.previousCount)), 1); + const grid = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ y: PAD_T + innerH * (1 - p), label: Math.round(safeMax * p), })); - - const points = data.map((d, i) => ({ - x: PAD_X + (n > 1 ? step * i : innerW / 2), - y: PAD_T + innerH * (1 - d.count / safeMax), - count: d.count, - date: d.date, - })); - - const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); - const areaPath = points.length > 0 - ? `${linePath} L ${points[points.length - 1].x.toFixed(1)} ${PAD_T + innerH} L ${points[0].x.toFixed(1)} ${PAD_T + innerH} Z` - : ""; + const point = (value: number, index: number) => ({ + x: PAD_X + (n > 1 ? step * index : innerW / 2), + y: PAD_T + innerH * (1 - value / safeMax), + }); + const current = data.map((d, i) => ({ ...point(d.count, i), value: d.count, date: d.date, isPeak: d.isPeak, isAnomaly: d.isAnomaly })); + const previous = data.map((d, i) => ({ ...point(d.previousCount, i), value: d.previousCount })); + const path = (points: { x: number; y: number }[]) => points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); + const currentPath = path(current); + const previousPath = path(previous); return ( -
- - - - - - - - - - - - - {/* 网格 + Y 轴刻度 */} - {gridLines.map((g, i) => ( +
+
+ 本期 + 对照期 + 峰值 / 异常 +
+ + {grid.map((g, i) => ( - - {g.label} + + {g.label} ))} - - {/* 柱子 */} - {points.map((p, i) => { - const barH = PAD_T + innerH - p.y; - return ( - : null} + {currentPath ? : null} + {current.map((p, i) => ( + + - ); - })} - - {/* 面积 + 折线 */} - {areaPath && } - {linePath && } - - {/* 数据点 + 数值 */} - {points.map((p, i) => ( - - - {p.count} + {p.isPeak || p.isAnomaly ? ( + + {p.isAnomaly ? "异常" : "峰值"} + + ) : null} ))} - - {/* X 轴日期 */} - {points.map((p, i) => ( - {p.date.slice(5)} + {current.map((p) => ( + {p.date.slice(5)} ))} - - {/* 透明 hover 区域 */} - {points.map((p, i) => { - const w = step > 0 ? step : innerW; + {current.map((p, i) => { + const width = step > 0 ? step : innerW; return ( setHover(i)} onMouseLeave={() => setHover(null)} - /> + > + {`${p.date}: 本期 ${p.value},对照期 ${previous[i]?.value || 0}`} + ); })} @@ -138,19 +213,302 @@ function TrendChart({ data, max }: { data: { date: string; count: number }[]; ma ); } +function RankSection({ title, icon: Icon, items, empty }: { + title: string; + icon: typeof Globe; + items: RankItem[]; + empty: string; +}) { + const max = items.reduce((value, item) => Math.max(value, item.count), 1); + return ( +
+
+ + {title} +
+
+ {items.length === 0 ? ( +
{empty}
+ ) : ( + items.map((item) => ( +
+
+ {item.name} + {item.count} +
+ + )) + )} +
+
+ ); +} + +function ContentSuggestions({ suggestions }: { suggestions: ContentSuggestion[] }) { + return ( +
+
+ + 内容建议 + 置顶 / 内链 / 系列 +
+ {suggestions.length === 0 ? ( + + ) : ( +
+ {suggestions.map((item) => ( +
+
{item.priority}
+
+
{item.title}
+

{item.reason}

+

{item.action}

+
+ {item.metric ? {item.metric} : null} +
+ ))} +
+ )} +
+ ); +} + +function sharesToRank(items: AnalyticsShareItem[]): RankItem[] { + return items.map((item) => ({ + name: item.name, + count: item.count, + share: item.share, + meaning: item.meaning, + })); +} + +function pageMatchesFilter(path: string, filter: PageFilterKey): boolean { + if (filter === "all") return true; + if (filter === "posts") return path.startsWith("/posts/"); + if (filter === "pages") return path.startsWith("/page/") || path === "/about" || path === "/privacy"; + if (filter === "search") return path.startsWith("/search"); + return !path.startsWith("/posts/") && !path.startsWith("/page/") && !path.startsWith("/search") && path !== "/about" && path !== "/privacy"; +} + +function PageFilterBar({ filters, value, onChange }: { + filters: AnalyticsPageFilter[]; + value: PageFilterKey; + onChange: (value: PageFilterKey) => void; +}) { + return ( +
+
+ + 页面筛选 + 联动排行与建议 +
+
+ {filters.map((item) => ( + + ))} +
+
+ ); +} + +function LifecycleColumn({ title, items, empty }: { + title: string; + items: ContentLifecycleItem[]; + empty: string; +}) { + return ( +
+
{title}
+ {items.length === 0 ? ( +
{empty}
+ ) : ( + items.map((item) => ( +
+
{item.title}
+
+ {item.count} 次访问 + {item.ageDays === null ? "未知年龄" : `${item.ageDays} 天`} + {typeof item.change === "number" ? {item.change >= 0 ? "+" : ""}{item.change} : null} +
+

{item.action}

+
+ )) + )} +
+ ); +} + +function LifecycleSection({ lifecycle }: { lifecycle: AnalyticsData["derived"]["contentLifecycle"] }) { + return ( +
+
+ + 内容生命周期 + 新文 / 增长 / 常青 / 衰退 +
+
+ + + + +
+
+ ); +} + +function SearchDemandSection({ search }: { search: SearchAnalytics }) { + return ( +
+
+ + 站内搜索需求 + + 零结果率 {(search.zeroResultRate * 100).toFixed(1)}% + +
+ {search.status !== "tracked" ? ( + + ) : ( +
+
+
热门搜索词
+ {search.topQueries.map((item) => ( +
+ {item.query} + {item.count} 次 · 平均 {item.avgResults} 个结果 +
+ ))} +
+
+
零结果词
+ {search.zeroResultQueries.length === 0 ? ( +
暂无零结果搜索
+ ) : ( + search.zeroResultQueries.map((item) => ( +
+ {item.query} + {item.count} 次 +
+ )) + )} +
+
+ )} +
+ ); +} + +function ReportSection({ report }: { report: AnalyticsData["derived"]["report"] }) { + return ( +
+
+ + 运营周报 + Markdown 可直接复盘 +
+
+
+
{report.title}
+

{report.summary}

+
+ {report.nextActions.length > 0 ? report.nextActions.map((item) => {item}) : 暂无明确动作} +
+
+