Files
blogflare/src/routes/admin/settings/+page.svelte
T
2026-09-27 21:09:40 +08:00

493 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import { addToast } from '$lib/stores/toast.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import DatePicker from '$lib/components/admin/DatePicker.svelte';
import TextInput from '$lib/components/admin/TextInput.svelte';
import Button from '$lib/components/admin/Button.svelte';
import AnalyticsPanel from '$lib/components/admin/AnalyticsPanel.svelte';
import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const initialData = $state.snapshot(untrack(() => data));
let activeTab = $state('basic');
let avatarEnabled = $state(initialData.settings['site.avatar_enabled'] !== 'false');
let avatarUrl = $state(initialData.settings['site.avatar_url'] || '');
let apiToken = $state(initialData.settings['agent.api_token'] || '');
let showApiToken = $state(false);
let autoWebp = $state(initialData.settings['upload.auto_webp'] === 'true');
function generateApiToken() {
const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
apiToken = 'bf_agent_' + randomPart;
showApiToken = true;
}
$effect(() => {
if (form?.success) {
addToast('设置保存成功!', 'success');
} else if (form?.error) {
addToast(form.error, 'error');
}
});
$effect(() => {
const purgeError = data.settings['cache.purge_error'];
if (purgeError) {
addToast(
`内容已保存,但缓存刷新失败:${purgeError}。请检查缓存清理权限后重试保存。`,
'error'
);
}
});
</script>
<div class="admin-page-header">
<h2>系统设置</h2>
</div>
<Tabs
tabs={[
{ id: 'basic', label: '基础信息' },
{ id: 'appearance', label: '外观与主页' },
{ id: 'terms', label: '术语设定' },
{ id: 'analytics', label: '数据统计' }
]}
bind:activeTab
/>
<div class="settings-container">
{#if activeTab === 'basic'}
<div class="settings-card full-width">
<h3 class="section-title">系统与安全环境</h3>
<div class="info-grid" style="margin: 0;">
<div class="info-item">
<span class="label">驱动框架</span>
<span class="value">SvelteKit 5 + Cloudflare</span>
</div>
<div class="info-item">
<span class="label">当前管理员</span>
<span class="value">{data.user?.email || '未知用户'}</span>
</div>
<div class="info-item">
<span class="label">数据库绑定</span>
<span class="value"><span class="badge">{data.platformEnv}</span></span>
</div>
<div class="info-item">
<span class="label">R2存储绑定</span>
<span class="value"><span class="badge">{data.r2Env}</span></span>
</div>
<div class="info-item">
<span class="label">鉴权模式</span>
<span class="value">Cloudflare Access (Zero Trust)</span>
</div>
</div>
</div>
<form
method="POST"
action="?/updateBasicSettings"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="settings-card full-width">
<h3 class="section-title">站点基础信息</h3>
<div class="form-row">
<div class="form-group">
<label for="site_title">网站标题</label>
<TextInput
type="text"
id="site_title"
name="site_title"
value={data.settings['site.title'] || 'BlogFlare'}
required
/>
</div>
<div class="form-group">
<label for="site_description">站点描述</label>
<TextInput
type="text"
id="site_description"
name="site_description"
value={data.settings['site.description'] ||
'A high-performance blog powered by Cloudflare D1, R2, and KV.'}
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="r2_public_domain">R2 自定义域名</label>
<TextInput
type="text"
id="r2_public_domain"
name="r2_public_domain"
value={data.settings['r2.public_domain'] || 'https://assets.yourdomain.com'}
placeholder="例如: https://assets.mydomain.com"
/>
</div>
<div class="form-group">
<label for="site_launch_date">建站日期</label>
<DatePicker
id="site_launch_date"
name="site_launch_date"
value={data.settings['site.launch_date'] || ''}
/>
</div>
</div>
</div>
<div class="settings-card full-width">
<h3 class="section-title">图片上传</h3>
<label class="toggle-label">
<input type="checkbox" name="upload_auto_webp" value="true" bind:checked={autoWebp} />
<span class="toggle-text">上传图片时自动转换为 WebP</span>
</label>
<p class="desc" style="margin-top: 10px; margin-bottom: 0;">
默认关闭。开启后,后台各入口上传的 JPEG / PNG 图片会在浏览器中以 80% 质量转换为
WebP,保持原尺寸及透明背景。GIF、动态 PNG 和已有 WebP 保持原格式。
仅影响新上传图片;直接调用上传 API 的客户端需自行转换。
</p>
</div>
<div class="settings-card full-width">
<h3 class="section-title">API 与 AI 集成</h3>
<p class="desc">
配置允许 AI 或第三方工具直接管理文章的 API 访问令牌 (Token)。请求 API 时请在 HTTP Header
中添加 <code>Authorization: Bearer &lt;Token&gt;</code>。
</p>
<div class="form-group" style="max-width: 500px;">
<label for="agent_api_token">API Token</label>
<div style="display: flex; gap: 10px; align-items: center; width: 100%;">
<TextInput
type={showApiToken ? 'text' : 'password'}
id="agent_api_token"
name="agent_api_token"
bind:value={apiToken}
placeholder="留空则禁用对外 API 访问"
/>
<Button
type="button"
variant="secondary"
style="white-space: nowrap;"
onclick={() => (showApiToken = !showApiToken)}
>
{showApiToken ? '隐藏' : '显示'}
</Button>
<Button
type="button"
variant="secondary"
style="white-space: nowrap;"
onclick={generateApiToken}>自动生成</Button
>
</div>
</div>
</div>
<div class="settings-card full-width">
<h3 class="section-title">Cloudflare Web Analytics</h3>
<p class="desc">
填写 Cloudflare Web Analytics 统计脚本中的 token,用于采集页面浏览量和访问次数。 请勿填写
Site Tag;它是查询标识,不能用于浏览器采集。后台查询还需配置 CF_ACCOUNT_ID, 并确保
CF_API_TOKEN 具有该账户的 Account Settings Read 和 Account Analytics Read 权限。
</p>
<div class="form-group" style="max-width: 500px;">
<label for="web_analytics_token">Web Analytics Token</label>
<TextInput
type="text"
id="web_analytics_token"
name="web_analytics_token"
value={data.settings['site.web_analytics_token'] || ''}
placeholder="留空则禁用数据拉取和自动注入"
/>
</div>
</div>
<div
class="form-actions"
style="margin-top: 30px; justify-content: flex-start; border-top: 1px solid var(--border-color, rgba(0,0,0,0.1)); padding-top: 20px;"
>
<Button type="submit" variant="primary">保存基础设置</Button>
</div>
</form>
{/if}
{#if activeTab === 'appearance'}
<div class="settings-card full-width">
<h3 class="section-title">首页顶部主图与外观偏好</h3>
<form
method="POST"
action="?/updateAppearance"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="site_avatar_enabled_cb" bind:checked={avatarEnabled} />
<input
type="hidden"
name="site_avatar_enabled"
value={avatarEnabled ? 'true' : 'false'}
/>
<span class="toggle-text">在网站首页顶部标题栏显示主图 / 站点头像</span>
</label>
</div>
{#if avatarEnabled}
<div class="form-group">
<label for="picker">主页显示图片</label>
<div class="picker-container" style="max-width: 300px;">
<ImagePicker
bind:cover={avatarUrl}
title=""
inputName="site_avatar_url"
aspectRatio="1/1"
/>
</div>
</div>
{:else}
<input type="hidden" name="site_avatar_url" value={avatarUrl} />
{/if}
<div class="form-actions" style="justify-content: flex-start;">
<Button type="submit" variant="primary">保存外观偏好</Button>
</div>
</form>
</div>
{/if}
{#if activeTab === 'terms'}
<div class="settings-card full-width">
<h3 class="section-title">个性化术语设定</h3>
<p class="desc">
您可以根据部署场景(如教学系统、文档库)自定义后台各模块的显示名称,例如将“文章”改为“实验手册”。
</p>
<form
method="POST"
action="?/updateAliases"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-row">
<div class="form-group">
<label for="alias_post">文章</label>
<TextInput
type="text"
id="alias_post"
name="alias_post"
value={data.settings['alias.post'] || '文章'}
required
/>
</div>
<div class="form-group">
<label for="alias_page">页面</label>
<TextInput
type="text"
id="alias_page"
name="alias_page"
value={data.settings['alias.page'] || '页面'}
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="alias_category">分类</label>
<TextInput
type="text"
id="alias_category"
name="alias_category"
value={data.settings['alias.category'] || '分类'}
required
/>
</div>
<div class="form-group">
<label for="alias_tag">标签</label>
<TextInput
type="text"
id="alias_tag"
name="alias_tag"
value={data.settings['alias.tag'] || '标签'}
required
/>
</div>
<div class="form-group">
<label for="alias_series">系列</label>
<TextInput
type="text"
id="alias_series"
name="alias_series"
value={data.settings['alias.series'] || '系列'}
required
/>
</div>
</div>
<div class="form-actions" style="justify-content: flex-start;">
<Button type="submit" variant="primary">保存</Button>
</div>
</form>
</div>
{:else if activeTab === 'analytics'}
<AnalyticsPanel analyticsPromise={data.analytics} />
{/if}
</div>
<style>
.section-title {
margin: 0 0 12px 0;
font-size: 1.15rem;
color: var(--text-color);
font-weight: 600;
}
.admin-page-header {
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.settings-container {
display: flex;
flex-direction: column;
gap: 25px;
width: 100%;
}
.full-width {
grid-column: 1 / -1;
}
.settings-card {
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
padding: 24px;
background: var(--bg-surface, #ffffff);
border-radius: 6px;
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px 20px;
background: transparent;
padding: 10px 0;
border: none;
}
.info-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item .label {
color: var(--text-muted, rgba(0, 0, 0, 0.6));
font-size: 0.85rem;
font-weight: 500;
}
.info-item .value {
color: var(--text-color);
font-weight: 600;
font-size: 0.95rem;
word-break: break-word;
}
.settings-card .desc {
font-size: 0.9rem;
color: var(--text-muted, rgba(0, 0, 0, 0.6));
margin-bottom: 24px;
line-height: 1.5;
}
.alias-form {
display: flex;
flex-direction: column;
gap: 25px;
}
.form-row {
display: flex;
gap: 20px;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
gap: 20px;
}
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-color);
}
.form-actions {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
}
.toggle-label input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
.picker-container {
border: 1px solid rgba(0, 0, 0, 0.12);
padding: 20px;
background: rgba(0, 0, 0, 0.01);
margin-top: 5px;
}
</style>