514 lines
18 KiB
Vue
514 lines
18 KiB
Vue
<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 邮箱 | 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>
|
||
|
||
<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> |