feat: add traffic analytics configuration (Umami/GA/Custom)

Add traffic analytics support to the admin panel with three provider
options:
- Umami (open-source, self-hosted) — script URL + website ID
- Google Analytics 4 — Measurement ID (G-XXXXXXXXXX)
- Custom — any third-party analytics script (百度统计, Plausible, 51la, etc.)

Changes:
- admin.types.ts: Add AnalyticsEnabled, AnalyticsProvider,
  AnalyticsScriptUrl, AnalyticsWebsiteId, AnalyticsCustomScript to
  SiteConfig interface
- config.ts: Add default values in getInitConfig() and configSelfCheck()
- api/admin/site/route.ts: Add analytics fields to destructuring, type
  validation, and SiteConfig update
- admin/page.tsx: Add SiteConfig interface fields, state initialization,
  config sync, and analytics configuration UI with provider-specific
  input fields
- layout.tsx: Inject analytics scripts into <head> based on provider
  type (Umami: async script with data-website-id; GA: gtag.js + config;
  Custom: dangerouslySetInnerHTML)
This commit is contained in:
mtvpls-turso
2026-07-11 18:47:11 +00:00
parent e624ffb087
commit d98f5082a5
5 changed files with 269 additions and 1 deletions
+175
View File
@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
BarChart3,
BookMarked,
BookOpen,
Bot,
@@ -406,6 +407,11 @@ interface SiteConfig {
OIDCClientId?: string;
OIDCClientSecret?: string;
OIDCButtonText?: string;
AnalyticsEnabled?: boolean;
AnalyticsProvider?: 'umami' | 'google' | 'custom';
AnalyticsScriptUrl?: string;
AnalyticsWebsiteId?: string;
AnalyticsCustomScript?: string;
}
// 视频源数据类型
@@ -10198,6 +10204,11 @@ const SiteConfigComponent = ({
OIDCClientId: '',
OIDCClientSecret: '',
OIDCButtonText: '',
AnalyticsEnabled: false,
AnalyticsProvider: 'umami',
AnalyticsScriptUrl: '',
AnalyticsWebsiteId: '',
AnalyticsCustomScript: '',
});
// 豆瓣数据源相关状态
@@ -11528,6 +11539,170 @@ const SiteConfigComponent = ({
</div>
</details>
{/* 流量统计配置 */}
<details className='group rounded-lg border border-gray-200 p-4 dark:border-gray-700'>
<summary className='flex cursor-pointer items-center justify-between font-medium text-gray-900 dark:text-gray-100'>
<span className='flex items-center gap-2'>
<BarChart3 className='h-5 w-5' />
</span>
<ChevronDown className='h-5 w-5 transition-transform group-open:rotate-180' />
</summary>
<div className='mt-4 space-y-4'>
{/* 启用开关 */}
<div className='flex items-center justify-between'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
UmamiGoogle Analytics
</p>
</div>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
AnalyticsEnabled: !prev.AnalyticsEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.AnalyticsEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.AnalyticsEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
{siteSettings.AnalyticsEnabled && (
<>
{/* 统计服务提供商 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<select
value={siteSettings.AnalyticsProvider}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsProvider: e.target.value as 'umami' | 'google' | 'custom',
}))
}
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
>
<option value='umami'>Umami</option>
<option value='google'>Google Analytics</option>
<option value='custom'></option>
</select>
</div>
{siteSettings.AnalyticsProvider === 'umami' && (
<>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Umami
</label>
<input
type='text'
value={siteSettings.AnalyticsScriptUrl}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsScriptUrl: e.target.value,
}))
}
placeholder='https://your-umami-server.com/script.js'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Umami script.js URL
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
ID (Website ID)
</label>
<input
type='text'
value={siteSettings.AnalyticsWebsiteId}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsWebsiteId: e.target.value,
}))
}
placeholder='e.g. 12345678-abcd-efgh-ijkl-1234567890ab'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Umami Website ID
</p>
</div>
</>
)}
{siteSettings.AnalyticsProvider === 'google' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Measurement ID
</label>
<input
type='text'
value={siteSettings.AnalyticsWebsiteId}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsWebsiteId: e.target.value,
}))
}
placeholder='G-XXXXXXXXXX'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Google Analytics 4 Measurement ID GA
</p>
</div>
)}
{siteSettings.AnalyticsProvider === 'custom' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<textarea
value={siteSettings.AnalyticsCustomScript}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsCustomScript: e.target.value,
}))
}
placeholder='粘贴完整的统计脚本代码,如百度统计、Plausible、51la 等...'
rows={6}
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&lt;head&gt;
</p>
</div>
)}
</>
)}
</div>
</details>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button