Merge PR #421: feat: add Turso (libSQL) storage support for EdgeOne deployment
This commit is contained in:
@@ -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' | 'clarity' | '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,194 @@ 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'>
|
||||
开启后将在页面中注入统计脚本,支持 Umami、Google 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' | 'clarity' | '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='clarity'>Microsoft Clarity(免费,热力图+会话回放)</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 === 'clarity' && (
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={siteSettings.AnalyticsWebsiteId}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
AnalyticsWebsiteId: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='e.g. abc1234567'
|
||||
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'>
|
||||
Microsoft Clarity 的 Project ID,在 clarity.microsoft.com 项目设置中获取
|
||||
</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'>
|
||||
支持任意第三方统计服务的脚本代码,将直接注入到页面 <head> 中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
@@ -249,8 +249,8 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// D1 存储:使用 getUserPasswordHash 方法
|
||||
if (storageType === 'd1') {
|
||||
// D1/Turso 存储:使用 getUserPasswordHash 方法
|
||||
if (storageType === 'd1' || storageType === 'turso') {
|
||||
if (typeof storage.getUserPasswordHash === 'function') {
|
||||
return await storage.getUserPasswordHash(username);
|
||||
}
|
||||
|
||||
@@ -180,8 +180,8 @@ export async function POST(req: NextRequest) {
|
||||
const createdAt = userV2?.created_at || Date.now();
|
||||
|
||||
// 根据存储类型使用不同的导入方法
|
||||
if (storageType === 'd1') {
|
||||
// D1 存储:使用 createUserWithHashedPassword 方法
|
||||
if (storageType === 'd1' || storageType === 'turso') {
|
||||
// D1/Turso 存储:使用 createUserWithHashedPassword 方法
|
||||
if (typeof storage.createUserWithHashedPassword === 'function') {
|
||||
await storage.createUserWithHashedPassword(
|
||||
username,
|
||||
@@ -193,9 +193,9 @@ export async function POST(req: NextRequest) {
|
||||
userV2?.enabledApis,
|
||||
userV2?.banned
|
||||
);
|
||||
console.log(`用户 ${username} 导入成功 (D1)`);
|
||||
console.log(`用户 ${username} 导入成功 (${storageType})`);
|
||||
} else {
|
||||
console.error(`D1 storage 缺少 createUserWithHashedPassword 方法`);
|
||||
console.error(`${storageType} storage 缺少 createUserWithHashedPassword 方法`);
|
||||
return false;
|
||||
}
|
||||
} else if (storageType === 'postgres') {
|
||||
|
||||
@@ -83,6 +83,11 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret,
|
||||
OIDCButtonText,
|
||||
OIDCMinTrustLevel,
|
||||
AnalyticsEnabled,
|
||||
AnalyticsProvider,
|
||||
AnalyticsScriptUrl,
|
||||
AnalyticsWebsiteId,
|
||||
AnalyticsCustomScript,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
@@ -138,6 +143,11 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret?: string;
|
||||
OIDCButtonText?: string;
|
||||
OIDCMinTrustLevel?: number;
|
||||
AnalyticsEnabled?: boolean;
|
||||
AnalyticsProvider?: 'umami' | 'google' | 'clarity' | 'custom';
|
||||
AnalyticsScriptUrl?: string;
|
||||
AnalyticsWebsiteId?: string;
|
||||
AnalyticsCustomScript?: string;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
@@ -224,7 +234,16 @@ export async function POST(request: NextRequest) {
|
||||
(OIDCClientSecret !== undefined &&
|
||||
typeof OIDCClientSecret !== 'string') ||
|
||||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
|
||||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
|
||||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number') ||
|
||||
(AnalyticsEnabled !== undefined && typeof AnalyticsEnabled !== 'boolean') ||
|
||||
(AnalyticsProvider !== undefined &&
|
||||
AnalyticsProvider !== 'umami' &&
|
||||
AnalyticsProvider !== 'google' &&
|
||||
AnalyticsProvider !== 'clarity' &&
|
||||
AnalyticsProvider !== 'custom') ||
|
||||
(AnalyticsScriptUrl !== undefined && typeof AnalyticsScriptUrl !== 'string') ||
|
||||
(AnalyticsWebsiteId !== undefined && typeof AnalyticsWebsiteId !== 'string') ||
|
||||
(AnalyticsCustomScript !== undefined && typeof AnalyticsCustomScript !== 'string')
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -295,6 +314,11 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret,
|
||||
OIDCButtonText,
|
||||
OIDCMinTrustLevel,
|
||||
AnalyticsEnabled,
|
||||
AnalyticsProvider,
|
||||
AnalyticsScriptUrl,
|
||||
AnalyticsWebsiteId,
|
||||
AnalyticsCustomScript,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
@@ -244,6 +244,7 @@ function DuanjuPageClient() {
|
||||
year={item.year}
|
||||
from='source-search'
|
||||
type='tv'
|
||||
isDuanju
|
||||
cmsData={{
|
||||
desc: item.desc,
|
||||
episodes: item.episodes,
|
||||
|
||||
@@ -115,6 +115,11 @@ export default async function RootLayout({
|
||||
let liveEnabled = true;
|
||||
let webLiveEnabled = false;
|
||||
let customAdFilterVersion = 0;
|
||||
let analyticsEnabled = false;
|
||||
let analyticsProvider: 'umami' | 'google' | 'clarity' | 'custom' = 'umami';
|
||||
let analyticsScriptUrl = '';
|
||||
let analyticsWebsiteId = '';
|
||||
let analyticsCustomScript = '';
|
||||
let musicFeatureEnabled = false;
|
||||
let suwayomiEnabled = false;
|
||||
let booksEnabled =
|
||||
@@ -202,6 +207,12 @@ export default async function RootLayout({
|
||||
webLiveEnabled = config.WebLiveEnabled ?? false;
|
||||
// 自定义去广告代码版本号
|
||||
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
|
||||
// 流量统计配置
|
||||
analyticsEnabled = config.SiteConfig?.AnalyticsEnabled || false;
|
||||
analyticsProvider = config.SiteConfig?.AnalyticsProvider || 'umami';
|
||||
analyticsScriptUrl = config.SiteConfig?.AnalyticsScriptUrl || '';
|
||||
analyticsWebsiteId = config.SiteConfig?.AnalyticsWebsiteId || '';
|
||||
analyticsCustomScript = config.SiteConfig?.AnalyticsCustomScript || '';
|
||||
// 音乐功能配置
|
||||
musicFeatureEnabled = config.MusicConfig?.Enabled || false;
|
||||
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
|
||||
@@ -335,6 +346,44 @@ export default async function RootLayout({
|
||||
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
||||
}}
|
||||
/>
|
||||
{/* 流量统计脚本 */}
|
||||
{analyticsEnabled && analyticsProvider === 'umami' && analyticsScriptUrl && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-website-id={analyticsWebsiteId}
|
||||
src={analyticsScriptUrl}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{analyticsEnabled && analyticsProvider === 'google' && analyticsWebsiteId && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
async
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${analyticsWebsiteId}`}
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '${analyticsWebsiteId}');`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{analyticsEnabled && analyticsProvider === 'clarity' && analyticsWebsiteId && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,"clarity","script","${analyticsWebsiteId}");`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{analyticsEnabled && analyticsProvider === 'custom' && analyticsCustomScript && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: analyticsCustomScript }}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
|
||||
@@ -8736,6 +8736,12 @@ function PlayPageClient() {
|
||||
// 条件:当前播放时间 < 10秒 且 播放记录时间 > 10秒
|
||||
const checkPlayRecordJump = async () => {
|
||||
try {
|
||||
// 短剧不显示"上次播放到"提示(短剧单集太短,提示意义不大)
|
||||
if (searchParams.get('duanju') === '1') {
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在进入播放后的首次检查时处理,避免本次会话新生成的记录触发恢复按钮
|
||||
if (!playRecordJumpInitialCheckRef.current) {
|
||||
return;
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface VideoCardProps {
|
||||
episodes_titles?: string[];
|
||||
};
|
||||
onBeforeNavigate?: () => void;
|
||||
isDuanju?: boolean; // 短剧标识,用于播放页跳过"上次播放到"提示
|
||||
}
|
||||
|
||||
export type VideoCardHandle = {
|
||||
@@ -134,6 +135,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
totalTime,
|
||||
cmsData,
|
||||
onBeforeNavigate,
|
||||
isDuanju,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
@@ -447,7 +449,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isDuanju ? '&duanju=1' : ''}`;
|
||||
|
||||
if (isCurrentlyOnPlayPage) {
|
||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||
@@ -471,6 +473,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
isDuanju,
|
||||
]);
|
||||
|
||||
// 新标签页播放处理函数
|
||||
@@ -509,7 +512,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isDuanju ? '&duanju=1' : ''}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}, [
|
||||
@@ -524,6 +527,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
isDuanju,
|
||||
]);
|
||||
|
||||
// 检查搜索结果的收藏状态
|
||||
|
||||
@@ -73,6 +73,12 @@ export interface AdminConfig {
|
||||
OIDCClientSecret?: string; // OIDC Client Secret
|
||||
OIDCButtonText?: string; // OIDC登录按钮文字
|
||||
OIDCMinTrustLevel?: number; // 最低信任等级(仅LinuxDo网站有效,为0时不判断)
|
||||
// 流量统计配置
|
||||
AnalyticsEnabled?: boolean; // 是否启用流量统计
|
||||
AnalyticsProvider?: 'umami' | 'google' | 'clarity' | 'custom'; // 统计服务提供商
|
||||
AnalyticsScriptUrl?: string; // 脚本URL(Umami: umami.js地址; GA: gtag URL; 自定义: 脚本src)
|
||||
AnalyticsWebsiteId?: string; // 网站ID(Umami: website_id; GA: Measurement ID如G-XXXX; 自定义: 留空)
|
||||
AnalyticsCustomScript?: string; // 自定义统计代码(仅custom模式使用,完整的HTML脚本内容)
|
||||
};
|
||||
UserConfig: {
|
||||
Users: {
|
||||
|
||||
@@ -328,6 +328,12 @@ async function getInitConfig(
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
DefaultUserTags: [],
|
||||
// 流量统计配置
|
||||
AnalyticsEnabled: false,
|
||||
AnalyticsProvider: 'umami',
|
||||
AnalyticsScriptUrl: '',
|
||||
AnalyticsWebsiteId: '',
|
||||
AnalyticsCustomScript: '',
|
||||
},
|
||||
UserConfig: {
|
||||
Users: [],
|
||||
@@ -588,6 +594,22 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
|
||||
adminConfig.SiteConfig.DefaultUserTags = [];
|
||||
}
|
||||
// 流量统计配置补全
|
||||
if (adminConfig.SiteConfig.AnalyticsEnabled === undefined) {
|
||||
adminConfig.SiteConfig.AnalyticsEnabled = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.AnalyticsProvider === undefined) {
|
||||
adminConfig.SiteConfig.AnalyticsProvider = 'umami';
|
||||
}
|
||||
if (adminConfig.SiteConfig.AnalyticsScriptUrl === undefined) {
|
||||
adminConfig.SiteConfig.AnalyticsScriptUrl = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.AnalyticsWebsiteId === undefined) {
|
||||
adminConfig.SiteConfig.AnalyticsWebsiteId = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.AnalyticsCustomScript === undefined) {
|
||||
adminConfig.SiteConfig.AnalyticsCustomScript = '';
|
||||
}
|
||||
if (!adminConfig.TelegramConfig) {
|
||||
adminConfig.TelegramConfig = {
|
||||
enabled: process.env.TELEGRAM_BOT_ENABLED === 'true' || Boolean(process.env.TELEGRAM_BOT_TOKEN),
|
||||
|
||||
+34
-1
@@ -17,7 +17,7 @@ import {
|
||||
SkipConfig,
|
||||
} from './types';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage'
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres' | 'turso',默认 'localstorage'
|
||||
const IS_CLOUDFLARE_BUILD =
|
||||
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||
const STORAGE_TYPE =
|
||||
@@ -28,6 +28,7 @@ const STORAGE_TYPE =
|
||||
| 'kvrocks'
|
||||
| 'd1'
|
||||
| 'postgres'
|
||||
| 'turso'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 创建存储实例
|
||||
@@ -70,6 +71,15 @@ function createStorage(): IStorage {
|
||||
// 动态导入 PostgresStorage 以避免客户端打包
|
||||
const { PostgresStorage } = require('./postgres.db');
|
||||
return new PostgresStorage(postgresAdapter);
|
||||
case 'turso':
|
||||
// TursoStorage 只能在服务端使用,客户端会报错
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error('TursoStorage can only be used on the server side');
|
||||
}
|
||||
const tursoAdapter = getTursoAdapter();
|
||||
// 复用 D1Storage(Turso 基于 libSQL/SQLite,SQL 语法完全兼容)
|
||||
const { D1Storage: TursoD1Storage } = require('./d1.db');
|
||||
return new TursoD1Storage(tursoAdapter);
|
||||
case 'localstorage':
|
||||
default:
|
||||
return null as unknown as IStorage;
|
||||
@@ -89,6 +99,29 @@ function getPostgresAdapter(): any {
|
||||
return new PostgresAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Turso 适配器
|
||||
* 使用 @libsql/client 连接 Turso (libSQL) 远程数据库
|
||||
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
|
||||
*/
|
||||
function getTursoAdapter(): any {
|
||||
// 动态导入适配器以避免客户端打包
|
||||
const { TursoAdapter } = require('./turso-adapter');
|
||||
|
||||
const tursoUrl = process.env.TURSO_URL;
|
||||
const tursoToken = process.env.TURSO_TOKEN;
|
||||
|
||||
if (!tursoUrl || !tursoToken) {
|
||||
throw new Error(
|
||||
'TURSO_URL and TURSO_TOKEN env variables must be set for Turso storage'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Using Turso (libSQL) database');
|
||||
|
||||
return new TursoAdapter(tursoUrl, tursoToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 D1 适配器
|
||||
* 开发环境:使用 better-sqlite3
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Turso (libSQL) 适配器
|
||||
*
|
||||
* 将 @libsql/client API 转换为与 D1 兼容的接口
|
||||
* Turso 基于 libSQL(SQLite 开源分支),SQL 语法与 D1/SQLite 完全兼容
|
||||
*
|
||||
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
|
||||
*
|
||||
* 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包
|
||||
*/
|
||||
|
||||
import { DatabaseAdapter, D1PreparedStatement, D1Result } from './d1-adapter';
|
||||
|
||||
/**
|
||||
* 动态加载 @libsql/client 的 createClient 函数
|
||||
*
|
||||
* 使用 require 而非 import,避免 webpack 在 EdgeOne 构建中
|
||||
* 因条件导出 (edge-light) 错误解析 ESM 模块
|
||||
*
|
||||
* 使用 @libsql/client/http 子路径而非主入口,避免拉入原生 libsql
|
||||
* 模块和 isomorphic-ws/isomorphic-fetch 等不兼容边缘环境的依赖
|
||||
*/
|
||||
function getLibsqlClient(): any {
|
||||
const mod = require('@libsql/client/http');
|
||||
return mod.createClient || mod.default?.createClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turso 适配器
|
||||
*
|
||||
* 使用 @libsql/client 包装为 D1 兼容接口
|
||||
*/
|
||||
export class TursoAdapter implements DatabaseAdapter {
|
||||
private client: any;
|
||||
|
||||
constructor(url: string, authToken: string) {
|
||||
const createClient = getLibsqlClient();
|
||||
this.client = createClient({
|
||||
url,
|
||||
authToken,
|
||||
});
|
||||
}
|
||||
|
||||
prepare(query: string): D1PreparedStatement {
|
||||
return new TursoPreparedStatement(this.client, query);
|
||||
}
|
||||
|
||||
async batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
|
||||
// Turso/libSQL 原生支持 batch
|
||||
const libsqlStatements = statements.map(
|
||||
(stmt) => (stmt as TursoPreparedStatement).toLibSQLBatch()
|
||||
);
|
||||
const results = await this.client.batch(libsqlStatements, 'write');
|
||||
return results.map((result: any) => ({
|
||||
success: true,
|
||||
results: result.rows || [],
|
||||
meta: {
|
||||
changes: result.rowsAffected,
|
||||
last_row_id:
|
||||
result.lastInsertRowid !== undefined
|
||||
? Number(result.lastInsertRowid)
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async exec(query: string): Promise<void> {
|
||||
await this.client.executeMultiple(query);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turso PreparedStatement 包装器
|
||||
* 将 @libsql/client API 转换为 D1 兼容 API
|
||||
*/
|
||||
class TursoPreparedStatement implements D1PreparedStatement {
|
||||
private params: any[] = [];
|
||||
|
||||
constructor(
|
||||
private client: any,
|
||||
private query: string
|
||||
) {}
|
||||
|
||||
bind(...values: any[]): D1PreparedStatement {
|
||||
this.params = values;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询并返回第一行
|
||||
*/
|
||||
async first<T = any>(colName?: string): Promise<T | null> {
|
||||
try {
|
||||
const result = await this.client.execute({
|
||||
sql: this.query,
|
||||
args: this.params,
|
||||
});
|
||||
|
||||
if (!result.rows || result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
if (colName) return (row as any)[colName] ?? null;
|
||||
|
||||
return row as T;
|
||||
} catch (err) {
|
||||
console.error('Turso first() error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询并返回结果
|
||||
*/
|
||||
async run<T = any>(): Promise<D1Result<T>> {
|
||||
try {
|
||||
const result = await this.client.execute({
|
||||
sql: this.query,
|
||||
args: this.params,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
changes: result.rowsAffected,
|
||||
last_row_id:
|
||||
result.lastInsertRowid !== undefined
|
||||
? Number(result.lastInsertRowid)
|
||||
: null,
|
||||
},
|
||||
results: result.rows as T[],
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('Turso run() error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询并返回所有行
|
||||
*/
|
||||
async all<T = any>(): Promise<D1Result<T>> {
|
||||
try {
|
||||
const result = await this.client.execute({
|
||||
sql: this.query,
|
||||
args: this.params,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results: (result.rows || []) as T[],
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('Turso all() error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 libSQL batch 格式
|
||||
*/
|
||||
toLibSQLBatch(): { sql: string; args: any[] } {
|
||||
return { sql: this.query, args: this.params };
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -399,6 +399,11 @@ export function processImageUrl(originalUrl: string): string {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 如果不是 http/https 开头(相对路径、data URI 等),直接返回
|
||||
if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://')) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 处理 TMDB 图片 URL 替换
|
||||
if (originalUrl.includes('image.tmdb.org')) {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -424,12 +429,21 @@ export function processImageUrl(originalUrl: string): string {
|
||||
}
|
||||
|
||||
// 处理豆瓣图片代理
|
||||
if (!originalUrl.includes('doubanio.com')) {
|
||||
return originalUrl;
|
||||
if (originalUrl.includes('doubanio.com')) {
|
||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||
}
|
||||
|
||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||
// 其他图片(视频源封面图等)走本站代理,带缓存
|
||||
// 可通过 localStorage 'proxyAllImages' = 'false' 关闭
|
||||
if (typeof window !== 'undefined') {
|
||||
const proxyAll = localStorage.getItem('proxyAllImages');
|
||||
if (proxyAll !== 'false') {
|
||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user