Files
ONE/frontend/src/admin/SettingsView.vue
T

514 lines
18 KiB
Vue
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 setup>
import { computed, onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } 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('')
// ---------- 社交链接(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)
}
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
const lightSkins = [
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
// ---------- 界面与自定义(第二套 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 {
settings.value = await adminApi.settings()
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)
const s = await adminApi.saveSettings(settings.value)
settings.value = s
socialText.value = socialToText(s.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] = ''
}
}
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>
</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.author_name" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者简介</label>
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
</div>
<div class="field">
<label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" />
</div>
</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
v-for="sk in lightSkins"
:key="sk.id"
class="sw"
:class="{ on: settings.light_skin_id === sk.id }"
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
@click="settings.light_skin_id = sk.id"
>
<span :style="{ background: sk.fg }">{{ sk.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&#10;邮箱 | 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=&quot;https://www.googletagmanager.com/gtag/js?id=G-XXXX&quot;></script>&#10;<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments)} gtag('js', new Date()); gtag('config', 'G-XXXX');<\/script>"
></textarea>
<p class="field-hint">
贴完整的 &lt;script&gt; 片段(Google Analytics / Plausible / Umami 等),
会在前台每个页面的 &lt;head&gt; 里执行,后台不注入。保存后刷新前台生效。
</p>
</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>