Files
ONE/frontend/src/admin/SettingsView.vue
T
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- 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)
2026-10-01 22:35:37 +08:00

648 lines
26 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, 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&#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>
<!-- 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://&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_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>