- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
648 lines
26 KiB
Vue
648 lines
26 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { toastOk, toastErr } from './toast'
|
||
import { adminApi, session } from '../api'
|
||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||
import { SECTIONS } from '../ui/sections'
|
||
|
||
const tab = ref('basic')
|
||
const settings = ref(null)
|
||
const loading = ref(true)
|
||
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 '未配置'
|
||
}
|
||
|
||
|
||
// ---------- 社交链接(settings.social_links <-> 多行文本) ----------
|
||
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
|
||
const socialText = ref('')
|
||
|
||
function socialToText(ls) {
|
||
return (Array.isArray(ls) ? ls : [])
|
||
.filter((l) => l && l.label && l.url)
|
||
.map((l) => `${l.label} | ${l.url}`)
|
||
.join('\n')
|
||
}
|
||
|
||
function textToSocial(text) {
|
||
return String(text || '')
|
||
.split('\n')
|
||
.map((line) => {
|
||
const i = line.indexOf('|')
|
||
if (i < 0) return null
|
||
const label = line.slice(0, i).trim()
|
||
const url = line.slice(i + 1).trim()
|
||
return label && url ? { label, url } : null
|
||
})
|
||
.filter(Boolean)
|
||
}
|
||
|
||
// 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
|
||
const lightSkins = [
|
||
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
|
||
]
|
||
const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
|
||
|
||
// ---------- 界面与自定义(第二套 UI) ----------
|
||
|
||
// 两套 UI 都保留,这里整站切换。后台自身外观不受影响。
|
||
const uiOptions = [
|
||
{
|
||
id: 'classic',
|
||
label: '经典',
|
||
desc: '经典(极简):三栏布局、纸感分区线,内容是主角',
|
||
bg: '#faf7f1',
|
||
fg: '#3d7f9c'
|
||
},
|
||
{
|
||
id: 'vivid',
|
||
label: '现代',
|
||
desc: '现代(余白):顶部导航 + 详情三栏纸张、单强调色、可写自定义 CSS',
|
||
bg: '#faf7f0',
|
||
fg: '#69a6cc'
|
||
}
|
||
]
|
||
|
||
// 分区 CSS 编辑器当前选中的分区
|
||
const cssSection = ref('global')
|
||
|
||
// vivid 暴露给站主的 CSS 变量。写进「全局」段即可覆盖整站。
|
||
const styleTokens = [
|
||
{ name: '--v-accent', desc: '主色(唯一强调色)' },
|
||
{ name: '--v-accent-2', desc: '主色 hover / 深一档' },
|
||
{ name: '--v-accent-3', desc: '状态点缀(在线点等)' },
|
||
{ name: '--v-bg', desc: '页面底色' },
|
||
{ name: '--v-surface', desc: '卡片/面板底色' },
|
||
{ name: '--v-surface-2', desc: '次级底色(代码块等)' },
|
||
{ name: '--v-text', desc: '正文颜色' },
|
||
{ name: '--v-text-soft', desc: '次级文字' },
|
||
{ name: '--v-muted', desc: '弱化文字' },
|
||
{ name: '--v-line', desc: '描边颜色' },
|
||
{ name: '--v-radius', desc: '圆角' },
|
||
{ name: '--v-radius-pill', desc: '胶囊圆角' },
|
||
{ name: '--v-content-width', desc: '内容区最大宽度' },
|
||
{ name: '--v-measure', desc: '文章正文行宽' },
|
||
{ name: '--v-motion', desc: '动效强度倍数(0 = 全关)' },
|
||
{ name: '--v-spring', desc: '弹簧缓动曲线' },
|
||
{ name: '--v-noise-opacity', desc: '噪点透明度' },
|
||
{ name: '--v-grad', desc: '同色微渐变(一般只读取)' }
|
||
]
|
||
|
||
// 一键插入的片段:不落库,只是把文本塞进当前分区的文本框,站主可以接着改。
|
||
const snippets = [
|
||
{
|
||
label: '换一套主色',
|
||
hint: '整站强调色改成青绿',
|
||
css: `:root {
|
||
--v-accent: #0d9488;
|
||
--v-accent-2: #0f766e;
|
||
}`
|
||
},
|
||
{
|
||
label: '方正的卡片',
|
||
hint: '圆角收小,气质更硬朗',
|
||
css: `:root {
|
||
--v-radius: 4px;
|
||
--v-radius-sm: 2px;
|
||
}`
|
||
},
|
||
{
|
||
label: '关掉噪点与晕染',
|
||
hint: '纯色底 + 更大的正文行宽',
|
||
css: `body {
|
||
background-image: none;
|
||
}
|
||
body::before {
|
||
content: none;
|
||
}
|
||
:root {
|
||
--v-measure: 820px;
|
||
}`
|
||
},
|
||
{
|
||
label: '首页单列',
|
||
hint: '时间线不再挤成多列',
|
||
css: `.main:has(> .row-feed) {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}`
|
||
}
|
||
]
|
||
|
||
function applySnippet(css) {
|
||
const cur = settings.value.custom_css[cssSection.value] || ''
|
||
settings.value.custom_css[cssSection.value] = cur.trim() ? cur.replace(/\s*$/, '\n\n') + css : css
|
||
}
|
||
|
||
const currentSection = computed(() => SECTIONS.find((s) => s.id === cssSection.value) || SECTIONS[0])
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await adminApi.settings()
|
||
settings.value = res.settings
|
||
credMeta.value = res.credential_meta || {}
|
||
secrets.value = {}
|
||
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
|
||
settings.value.light_skin_id = 'paper'
|
||
}
|
||
if (!UI_CONSTANTS.UIS.includes(settings.value.ui_id)) {
|
||
settings.value.ui_id = UI_CONSTANTS.DEFAULT
|
||
}
|
||
socialText.value = socialToText(settings.value.social_links)
|
||
settings.value.comments_enabled = !!settings.value.comments_enabled
|
||
settings.value.comments_review = !!settings.value.comments_review
|
||
// 服务端保证非 null,这里再兜一层,并预建分区键,
|
||
// 这样 v-model 绑定的文本框一开始就有值可写。
|
||
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
|
||
settings.value.custom_css = {}
|
||
}
|
||
for (const s of SECTIONS) {
|
||
if (typeof settings.value.custom_css[s.id] !== 'string') {
|
||
settings.value.custom_css[s.id] = ''
|
||
}
|
||
}
|
||
} catch (e) {
|
||
error.value = e.message || '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
onMounted(load)
|
||
|
||
async function save() {
|
||
if (!settings.value) return
|
||
saving.value = true
|
||
error.value = ''
|
||
try {
|
||
settings.value.social_links = textToSocial(socialText.value)
|
||
// 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理)
|
||
const payload = { ...settings.value, secrets: {} }
|
||
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 = {}
|
||
}
|
||
for (const sec of SECTIONS) {
|
||
if (typeof settings.value.custom_css[sec.id] !== 'string') {
|
||
settings.value.custom_css[sec.id] = ''
|
||
}
|
||
}
|
||
secrets.value = {}
|
||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||
toastOk('设置已保存')
|
||
} catch (e) {
|
||
toastErr('保存失败:' + (e.message || ''))
|
||
error.value = e.message || '保存失败'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section v-if="loading" class="loading">载入中…</section>
|
||
<section v-else-if="error" class="empty">{{ error }}</section>
|
||
<section v-else-if="settings">
|
||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">设置</h1>
|
||
|
||
<div class="settings-grid">
|
||
<div class="settings-tabs">
|
||
<button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</button>
|
||
<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 v-if="session.isOwner" :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||
</div>
|
||
|
||
<div>
|
||
<!-- basic -->
|
||
<div v-if="tab === 'basic'" class="panel">
|
||
<div class="panel-title"><h2>站点信息</h2></div>
|
||
<div class="field">
|
||
<label>站点标题</label>
|
||
<input v-model="settings.site_title" class="input" spellcheck="false" />
|
||
</div>
|
||
<div class="field">
|
||
<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">
|
||
<label>页脚备注</label>
|
||
<input v-model="settings.footer_note" class="input" spellcheck="false" />
|
||
</div>
|
||
<p class="field-hint">
|
||
作者昵称、简介与头像在
|
||
<RouterLink to="/admin/account">账户</RouterLink>
|
||
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- theme -->
|
||
<div v-if="tab === 'theme'" class="panel">
|
||
<div class="panel-title"><h2>主题外观</h2></div>
|
||
|
||
<div class="field">
|
||
<label>亮色皮肤</label>
|
||
<div class="theme-swatches">
|
||
<div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
|
||
<span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
|
||
</div>
|
||
</div>
|
||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||
亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>暗色皮肤</label>
|
||
<div class="theme-swatches">
|
||
<div
|
||
class="sw on"
|
||
:style="{ background: darkSkin.bg, borderColor: darkSkin.fg }"
|
||
:title="darkSkin.label + '(当前固定)'"
|
||
>
|
||
<span :style="{ background: darkSkin.fg }">{{ darkSkin.label }}</span>
|
||
</div>
|
||
</div>
|
||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||
暗色端固定为墨色。访客在前台选择「深色」或「自动 + 系统暗」时启用。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||
<label>访客端的覆盖</label>
|
||
<p style="margin: 0; font-size: 12.5px; color: var(--admin-muted); line-height: 1.7;">
|
||
访客在前台右下角的主题开关会锁定「浅色」或「深色」,
|
||
这两种情况下你选的皮肤会被忽略;他们切回「自动」时才会按上面的规则显示。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ui:第二套 UI + 分区自定义 CSS -->
|
||
<div v-if="tab === 'ui'" class="panel">
|
||
<div class="panel-title"><h2>界面</h2></div>
|
||
|
||
<div class="field">
|
||
<label>整站使用的界面</label>
|
||
<div class="theme-swatches">
|
||
<div
|
||
v-for="u in uiOptions"
|
||
:key="u.id"
|
||
class="sw"
|
||
:class="{ on: settings.ui_id === u.id }"
|
||
:style="{ background: u.bg, borderColor: settings.ui_id === u.id ? u.fg : 'var(--admin-line)' }"
|
||
@click="settings.ui_id = u.id"
|
||
>
|
||
<span :style="{ background: u.fg }">{{ u.label }}</span>
|
||
</div>
|
||
</div>
|
||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted); line-height: 1.7;">
|
||
<span v-for="u in uiOptions" :key="u.id">
|
||
<template v-if="settings.ui_id === u.id">{{ u.desc }}</template>
|
||
</span>
|
||
<br />
|
||
两套界面都保留,随时可以换回来。后台界面不受这里影响。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||
<label>自定义 CSS(仅 vivid 生效)</label>
|
||
<p style="margin: 0 0 10px; font-size: 12.5px; color: var(--admin-muted); line-height: 1.7;">
|
||
按分区写样式,「全局」每页都会加载。只在选择了 vivid 时生效,
|
||
并且永远不会注入到后台,所以写坏样式不会影响管理界面。
|
||
</p>
|
||
|
||
<div class="css-tabs">
|
||
<button
|
||
v-for="s in SECTIONS"
|
||
:key="s.id"
|
||
:class="{ on: cssSection === s.id }"
|
||
@click="cssSection = s.id"
|
||
>
|
||
{{ s.label }}
|
||
<span v-if="(settings.custom_css[s.id] || '').trim()" class="dot" aria-hidden="true"></span>
|
||
</button>
|
||
</div>
|
||
|
||
<textarea
|
||
v-model="settings.custom_css[cssSection]"
|
||
class="textarea"
|
||
rows="12"
|
||
spellcheck="false"
|
||
style="font-family: var(--mono); font-size: 13px; line-height: 1.7; margin-top: 10px;"
|
||
:placeholder="`/* ${currentSection.hint} */`"
|
||
/>
|
||
|
||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||
{{ currentSection.hint }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>一键插入片段</label>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||
<button
|
||
v-for="sn in snippets"
|
||
:key="sn.label"
|
||
class="btn"
|
||
style="font-size: 12.5px; padding: 5px 12px;"
|
||
type="button"
|
||
:title="sn.hint"
|
||
@click="applySnippet(sn.css)"
|
||
>
|
||
{{ sn.label }}
|
||
</button>
|
||
</div>
|
||
<p style="margin: 8px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||
插入到当前分区({{ currentSection.label }})的末尾,插入后仍可自由编辑。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||
<label>可用的 CSS 变量</label>
|
||
<div class="token-list">
|
||
<div v-for="t in styleTokens" :key="t.name" class="token-row">
|
||
<code>{{ t.name }}</code>
|
||
<span>{{ t.desc }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- advanced -->
|
||
<div v-if="tab === 'advanced'" class="panel">
|
||
<div class="panel-title"><h2>高级</h2></div>
|
||
<div class="field">
|
||
<label>每页文章数</label>
|
||
<input v-model.number="settings.posts_per_page" type="number" min="1" max="50" class="input" />
|
||
</div>
|
||
<div class="field">
|
||
<label>ICP 备案号</label>
|
||
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
|
||
</div>
|
||
<div class="field">
|
||
<label>评论</label>
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
|
||
<input v-model="settings.comments_enabled" type="checkbox" />
|
||
开放评论(关着时前台整个评论区不渲染)
|
||
</label>
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
|
||
<input v-model="settings.comments_review" type="checkbox" />
|
||
新评论需审核,通过后公开
|
||
</label>
|
||
</div>
|
||
<div class="field">
|
||
<label>社交链接(每行一条:名称 | 链接)</label>
|
||
<textarea
|
||
v-model="socialText"
|
||
class="input"
|
||
rows="4"
|
||
spellcheck="false"
|
||
placeholder="GitHub | https://github.com/yourname 邮箱 | mailto:you@example.com"
|
||
></textarea>
|
||
<p class="field-hint">
|
||
显示在经典版左栏「社交账号」和右栏页脚小字区。链接按域名自动配图标
|
||
(GitHub / Telegram / X / 邮箱等),没有图标的显示名称文字。
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label>自定义 JS 代码(统计脚本等)</label>
|
||
<textarea
|
||
v-model="settings.custom_js"
|
||
class="input"
|
||
rows="6"
|
||
spellcheck="false"
|
||
placeholder="<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script> <script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments)} gtag('js', new Date()); gtag('config', 'G-XXXX');<\/script>"
|
||
></textarea>
|
||
<p class="field-hint">
|
||
贴完整的 <script> 片段(Google Analytics / Plausible / Umami 等),
|
||
会在前台每个页面的 <head> 里执行,后台不注入。保存后刷新前台生效。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项)。站主专属 -->
|
||
<div v-if="tab === 'system' && session.isOwner" 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://<账户ID>.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_USER 不再生效)。
|
||
密码在「账户」页修改;给其他人开账号去「用户」页。
|
||
</p>
|
||
<div class="field" style="padding: 0; border: 0;">
|
||
<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>
|
||
</div>
|
||
|
||
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
|
||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||
{{ saving ? '保存中…' : '保存设置' }}
|
||
</button>
|
||
<span v-if="savedAt" style="font-size: 12.5px; color: var(--admin-muted);">已保存 {{ savedAt }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 「界面与自定义」里新增的三个小组件。沿用后台既有的 token 与观感,
|
||
不引入新的视觉语言,也不动 styles.css。 */
|
||
|
||
.css-tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
|
||
.css-tabs button {
|
||
position: relative;
|
||
padding: 5px 12px;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--admin-ink-soft);
|
||
}
|
||
|
||
.css-tabs button:hover {
|
||
background: var(--admin-paper-sunken);
|
||
}
|
||
|
||
.css-tabs button.on {
|
||
background: var(--admin-accent-soft);
|
||
color: var(--admin-accent);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 有内容的分区标一个小点,一眼看出哪几段写过东西 */
|
||
.css-tabs .dot {
|
||
display: inline-block;
|
||
width: 5px;
|
||
height: 5px;
|
||
margin-left: 5px;
|
||
border-radius: 50%;
|
||
background: var(--admin-accent);
|
||
vertical-align: 1px;
|
||
}
|
||
|
||
.token-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||
gap: 6px 18px;
|
||
}
|
||
|
||
.token-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
|
||
.token-row code {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--admin-accent);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.token-row span {
|
||
font-size: 12px;
|
||
color: var(--admin-muted);
|
||
}
|
||
|
||
.field-hint {
|
||
margin: 6px 0 0;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: var(--admin-muted);
|
||
}
|
||
</style> |