系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启

- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
This commit is contained in:
Sakurasan committed 2026-10-01 12:14:10 +08:00
1 parent bf3ce934dd
commit e68400b389
23 files changed
+957 -126

No files matched your search

+178 -4
View File
@@ -12,6 +12,45 @@ const error = ref('')
const saving = ref(false)
const savedAt = ref('')
// ---------- 系统设置(从环境变量迁进后台的部分) ----------
// 秘密项永不明文回显:输入框留空 = 保持现值。credential_meta 告诉每一项
// 现在是否生效、来自后台(db)还是环境变量(env)兜底。
const credMeta = ref({})
const secrets = ref({})
const secretFields = [
{ key: 'github_client_secret', label: 'GitHub Client Secret' },
{ key: 'google_client_secret', label: 'Google Client Secret' },
{ key: 'telegram_bot_token', label: 'Telegram Bot Token' },
{ key: 'r2_access_key', label: 'R2 Access Key' },
{ key: 'r2_secret_key', label: 'R2 Secret Key' }
]
function srcOf(key) {
const m = credMeta.value[key]
return m ? m.source : 'unset'
}
// 非秘密项的提示:值存哪、改了是否立即生效
function srcHint(key) {
const s = srcOf(key)
if (s === 'db') return '已在此配置'
if (s === 'env') return '当前来自环境变量,在此填写后将覆盖'
return '未配置'
}
// 秘密项输入框的 placeholder:不回显值,只报状态
function secretPlaceholder(key) {
const s = srcOf(key)
if (s === 'db') return '已配置(后台)· 留空保持不变'
if (s === 'env') return '已通过环境变量配置 · 填写后覆盖'
return '未配置'
}
// 管理员账号:新密码 + 确认,只在前端做一次一致性与长度预检
const newPassword = ref('')
const confirmPassword = ref('')
// ---------- 社交链接(settings.social_links <-> 多行文本) ----------
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
const socialText = ref('')
@@ -137,7 +176,12 @@ const currentSection = computed(() => SECTIONS.find((s) => s.id === cssSection.v
async function load() {
loading.value = true
try {
settings.value = await adminApi.settings()
const res = await adminApi.settings()
settings.value = res.settings
credMeta.value = res.credential_meta || {}
newPassword.value = ''
confirmPassword.value = ''
secrets.value = {}
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
settings.value.light_skin_id = 'paper'
}
@@ -167,13 +211,32 @@ onMounted(load)
async function save() {
if (!settings.value) return
// 管理员改密码:两次输入一致才提交
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
toastErr('两次输入的新密码不一致')
return
}
if (newPassword.value.length < 6) {
toastErr('新密码至少 6 位')
return
}
}
saving.value = true
error.value = ''
try {
settings.value.social_links = textToSocial(socialText.value)
const s = await adminApi.saveSettings(settings.value)
settings.value = s
socialText.value = socialToText(s.social_links)
// 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理)
const payload = { ...settings.value, secrets: {} }
if (newPassword.value) payload.secrets.admin_password = newPassword.value
for (const f of secretFields) {
const v = (secrets.value[f.key] || '').trim()
if (v) payload.secrets[f.key] = v
}
const res = await adminApi.saveSettings(payload)
settings.value = res.settings
credMeta.value = res.credential_meta || {}
socialText.value = socialToText(settings.value.social_links)
// 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
settings.value.custom_css = {}
@@ -183,6 +246,9 @@ async function save() {
settings.value.custom_css[sec.id] = ''
}
}
secrets.value = {}
newPassword.value = ''
confirmPassword.value = ''
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
toastOk('设置已保存')
} catch (e) {
@@ -206,6 +272,7 @@ async function save() {
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
<button :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
</div>
<div>
@@ -220,6 +287,15 @@ async function save() {
<label>站点副标题</label>
<input v-model="settings.site_desc" class="input" spellcheck="false" />
</div>
<div class="field">
<label>站点地址</label>
<input v-model="settings.site_url" class="input" spellcheck="false"
:placeholder="srcOf('site_url') === 'env' ? '当前来自环境变量 ONE_SITE_URL' : 'https://your.domain'" />
<p class="field-hint">
RSS 订阅链接、GitHub / Google 登录的回调地址都以它为基座。
{{ srcHint('site_url') }},保存后立即生效,无需重启。
</p>
</div>
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
<div class="field">
@@ -417,6 +493,104 @@ async function save() {
</div>
</div>
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项) -->
<div v-if="tab === 'system'" class="panel">
<div class="panel-title"><h2>登录方式</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。
GitHub / Google 的回调地址以「站点地址 + /api/auth/callback/平台名」为准,改完站点地址无需再去平台改回调。
</p>
<div class="field">
<label>GitHub OAuth App</label>
<input v-model="settings.github_client_id" class="input" spellcheck="false"
placeholder="Client ID" style="margin-bottom: 8px;" />
<input v-model="secrets.github_client_secret" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('github_client_secret')" />
<p class="field-hint">Client ID:{{ srcHint('github_client_id') }}</p>
</div>
<div class="field">
<label>Google OAuth 客户端</label>
<input v-model="settings.google_client_id" class="input" spellcheck="false"
placeholder="Client ID" style="margin-bottom: 8px;" />
<input v-model="secrets.google_client_secret" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('google_client_secret')" />
<p class="field-hint">Client ID:{{ srcHint('google_client_id') }}</p>
</div>
<div class="field">
<label>Telegram 登录 Widget</label>
<input v-model="settings.telegram_bot" class="input" spellcheck="false"
placeholder="Bot 用户名(不含 @)" style="margin-bottom: 8px;" />
<input v-model="secrets.telegram_bot_token" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('telegram_bot_token')" />
<p class="field-hint">Bot 用户名:{{ srcHint('telegram_bot') }};Token 在 @BotFather 里创建 Bot 后获得。</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<div class="panel-title"><h2>对象存储</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
文件上传的存储端。四项凭据齐全走 R2,缺任一项回落本地磁盘
(data/uploads),切换不影响已上传文件的链接。保存后立即生效,无需重启。
</p>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>S3 API 端点</label>
<input v-model="settings.s3_endpoint" class="input" spellcheck="false"
placeholder="https://&lt;账户ID&gt;.r2.cloudflarestorage.com" />
<p class="field-hint">{{ srcHint('s3_endpoint') }}</p>
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>桶名(Bucket)</label>
<input v-model="settings.r2_bucket" class="input" spellcheck="false" />
<p class="field-hint">{{ srcHint('r2_bucket') }}</p>
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>访问密钥</label>
<input v-model="secrets.r2_access_key" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('r2_access_key')" />
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>私密密钥</label>
<input v-model="secrets.r2_secret_key" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('r2_secret_key')" />
</div>
<div class="field" style="padding: 0; border: 0;">
<label>公开访问域名</label>
<input v-model="settings.uploads_public_base" class="input" spellcheck="false"
placeholder="https://cdn.example.com(r2.dev 或绑定的自定义域名)" />
<p class="field-hint">
{{ srcHint('uploads_public_base') }}。配置后 R2 文件直链走 CDN;
不配则由本站 /uploads/ 路由代为输出。
</p>
</div>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<div class="panel-title"><h2>管理员账号</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
登录后台用的用户名与密码。在这里设置密码后,环境变量
ONE_ADMIN_PASSWORD 只作解锁后路保留(仍可用);不填则维持现状。
</p>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>用户名</label>
<input v-model="settings.admin_username" class="input" spellcheck="false"
:placeholder="srcOf('admin_username') === 'env' ? '当前来自环境变量 ONE_ADMIN_USER' : ''" />
<p class="field-hint">{{ srcHint('admin_username') }}</p>
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>新密码</label>
<input v-model="newPassword" class="input" type="password"
autocomplete="new-password" placeholder="留空保持不变(6-72 位)" />
</div>
<div class="field" style="padding: 0; border: 0;">
<label>确认新密码</label>
<input v-model="confirmPassword" class="input" type="password"
autocomplete="new-password" placeholder="再输入一遍" />
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存设置' }}