262 lines
8.7 KiB
Vue
262 lines
8.7 KiB
Vue
<script setup>
|
||
/**
|
||
* 系统配置(迭代-2026-09-30 重构)。
|
||
*
|
||
* 左侧 tab 分区(选中 tab 持久化到 localStorage `tb:syscfg:tab`,下次进入自动恢复):
|
||
* - 基础配置:site.base_url(图片等静态资源的对外拼接前缀);
|
||
* - 首页功能卡:便捷功能卡背景图,key 对齐小程序 `features.ts` 的 FEATURE_ENTRIES
|
||
* (note 我的笔记 / event 年会查座 / service 软件开发),存 settings 的
|
||
* `home.feature.covers`(JSON:key → 相对路径),小程序端经 /app/config 拉取。
|
||
*
|
||
* 配置一律走 settings 表(约定 S9);保存立即生效(后端 30s 缓存自动失效)。
|
||
*/
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { adminUploadApi } from '@/api/request/attachment'
|
||
import { settingGetApi, settingSaveApi } from '@/api/request/setting'
|
||
import PageShell from '@/components/PageShell.vue'
|
||
import GlassCard from '@/components/GlassCard.vue'
|
||
import StateBlock from '@/components/StateBlock.vue'
|
||
|
||
const TAB_KEY = 'tb:syscfg:tab'
|
||
/** 首页功能卡清单:key 必须与小程序 src/config/features.ts 的 FEATURE_ENTRIES 逐字对齐 */
|
||
const FEATURE_TABS = [
|
||
{ key: 'note', label: '我的笔记', hint: '建议横图 750×480 以上,深色背景配白字更清晰' },
|
||
{ key: 'event', label: '年会查座', hint: '建议横图 750×480 以上' },
|
||
{ key: 'service', label: '软件开发', hint: '建议竖向大卡图 600×960 以上(三卡时为左侧大图卡)' },
|
||
]
|
||
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
// 左侧 tab 本地记忆:进页面恢复上次所在分区
|
||
const activeTab = ref(localStorage.getItem(TAB_KEY) || 'base')
|
||
|
||
// ===== 基础配置 =====
|
||
const form = reactive({ baseUrl: '' })
|
||
const savingBase = ref(false)
|
||
|
||
// ===== 首页功能卡背景图 =====
|
||
const covers = reactive({ data: {} }) // { note: '/static/...', ... }(相对路径,同源 /static 代理可直接预览)
|
||
const coversDirty = ref(false)
|
||
const uploadingKey = ref('')
|
||
|
||
function onTabChange(key) {
|
||
activeTab.value = key
|
||
// 本地记忆:下次进入自动恢复上次所在分区
|
||
localStorage.setItem(TAB_KEY, key)
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const res = await settingGetApi()
|
||
for (const item of res?.items || []) {
|
||
if (item.key === 'site.base_url') form.baseUrl = item.value || ''
|
||
if (item.key === 'home.feature.covers') {
|
||
let parsed = {}
|
||
try {
|
||
parsed = JSON.parse(item.value || '{}') || {}
|
||
} catch {
|
||
parsed = {}
|
||
}
|
||
covers.data = { ...parsed }
|
||
}
|
||
}
|
||
coversDirty.value = false
|
||
} catch {
|
||
loadError.value = '加载失败,请稍后重试'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
onMounted(load)
|
||
|
||
async function customUploadCover(key, { file, onSuccess, onError }) {
|
||
uploadingKey.value = key
|
||
try {
|
||
const res = await adminUploadApi(file, 'cover')
|
||
if (res) {
|
||
covers.data[key] = res.path
|
||
coversDirty.value = true
|
||
message.success('已上传,记得点「保存配置」')
|
||
}
|
||
onSuccess?.(res)
|
||
} catch (e) {
|
||
onError?.(e)
|
||
} finally {
|
||
uploadingKey.value = ''
|
||
}
|
||
}
|
||
|
||
function beforeCoverUpload(file) {
|
||
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
|
||
else if (file.size > 5 * 1024 * 1024) message.warning('图片不能超过 5MB')
|
||
return ok && file.size <= 5 * 1024 * 1024
|
||
}
|
||
|
||
function removeCover(key) {
|
||
delete covers.data[key]
|
||
coversDirty.value = true
|
||
}
|
||
|
||
async function saveCovers() {
|
||
savingCovers.value = true
|
||
try {
|
||
await settingSaveApi({ key: 'home.feature.covers', value: JSON.stringify(covers.data) })
|
||
message.success('已保存,小程序端下次拉取即生效')
|
||
coversDirty.value = false
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
savingCovers.value = false
|
||
}
|
||
}
|
||
|
||
async function saveBase() {
|
||
const v = (form.baseUrl || '').trim()
|
||
if (v && !/^https?:\/\//.test(v)) {
|
||
message.warning('请填写以 http(s):// 开头的完整地址,或留空')
|
||
return
|
||
}
|
||
savingBase.value = true
|
||
try {
|
||
await settingSaveApi({ key: 'site.base_url', value: v })
|
||
message.success('已保存,立即生效')
|
||
load()
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
savingBase.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<PageShell title="系统配置" desc="站点级参数。配置存 settings 表,保存后立即生效(约 30 秒内全网生效)">
|
||
<GlassCard>
|
||
<StateBlock :loading="loading" :error="loadError" :empty="false" @retry="load">
|
||
<a-tabs tab-position="left" v-model:activeKey="activeTab" class="cfg-tabs" @tab-click="onTabChange">
|
||
<!-- 基础配置 -->
|
||
<a-tab-pane key="base" tab="基础配置">
|
||
<a-form layout="vertical" style="max-width: 560px">
|
||
<a-form-item label="图片域名(site.base_url)">
|
||
<a-input
|
||
v-model:value="form.baseUrl" allow-clear
|
||
placeholder="如 https://api.example.com(留空 = 按请求域名动态拼接)"
|
||
/>
|
||
<div class="field-tip">
|
||
图片等静态资源的对外拼接前缀。数据库只存相对路径(/static/...),输出时用它拼成完整地址;
|
||
留空则按当前请求的域名动态拼接(本地联调推荐留空)。末尾的 / 会自动去掉。
|
||
</div>
|
||
</a-form-item>
|
||
<a-button type="primary" :loading="savingBase" @click="saveBase">保存</a-button>
|
||
</a-form>
|
||
</a-tab-pane>
|
||
|
||
<!-- 首页功能卡背景图 -->
|
||
<a-tab-pane key="featureCovers" tab="首页功能卡">
|
||
<a-alert
|
||
type="info" show-icon class="covers-tip"
|
||
message="小程序首页「便捷功能」卡的背景图。三卡时为「1 大 2 小」拼图布局,四卡及以上为横滑卡片;未配置的卡使用主题渐变色兜底。"
|
||
/>
|
||
<div class="cover-list">
|
||
<div v-for="f in FEATURE_TABS" :key="f.key" class="cover-item">
|
||
<div class="cover-item__preview">
|
||
<img v-if="covers.data[f.key]" :src="covers.data[f.key]" alt="背景图预览">
|
||
<div v-else class="cover-item__empty">未配置</div>
|
||
</div>
|
||
<div class="cover-item__meta">
|
||
<div class="cover-item__label">{{ f.label }}</div>
|
||
<div class="cover-item__hint">{{ f.hint }}</div>
|
||
<a-space class="cover-item__acts">
|
||
<a-upload
|
||
accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
|
||
:custom-request="(o) => customUploadCover(f.key, o)"
|
||
:before-upload="beforeCoverUpload"
|
||
>
|
||
<a-button size="small" :loading="uploadingKey === f.key">
|
||
{{ covers.data[f.key] ? '更换图片' : '上传图片' }}
|
||
</a-button>
|
||
</a-upload>
|
||
<a-button v-if="covers.data[f.key]" size="small" danger @click="removeCover(f.key)">移除</a-button>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a-button type="primary" :loading="savingCovers" :disabled="!coversDirty" @click="saveCovers">
|
||
保存配置
|
||
</a-button>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</StateBlock>
|
||
</GlassCard>
|
||
</PageShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.cfg-tabs {
|
||
min-height: 380px;
|
||
}
|
||
.cfg-tabs :deep(.ant-tabs-tab) {
|
||
padding: 10px 18px;
|
||
}
|
||
.field-tip {
|
||
margin-top: var(--space-2);
|
||
color: var(--text-3);
|
||
font-size: var(--font-xs);
|
||
line-height: 1.6;
|
||
}
|
||
.covers-tip {
|
||
margin-bottom: var(--space-4);
|
||
}
|
||
.cover-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
margin-bottom: var(--space-5);
|
||
}
|
||
.cover-item {
|
||
display: flex;
|
||
gap: var(--space-4);
|
||
align-items: center;
|
||
padding: var(--space-3);
|
||
border: 1px solid var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-surface-2);
|
||
}
|
||
.cover-item__preview {
|
||
flex-shrink: 0;
|
||
width: 148px;
|
||
height: 84px;
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
border: 1px solid var(--border-1);
|
||
background: var(--bg-page);
|
||
}
|
||
.cover-item__preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.cover-item__empty {
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-4);
|
||
font-size: var(--font-xs);
|
||
}
|
||
.cover-item__label {
|
||
color: var(--text-1);
|
||
font-weight: 600;
|
||
}
|
||
.cover-item__hint {
|
||
margin: 4px 0 10px;
|
||
color: var(--text-3);
|
||
font-size: var(--font-xs);
|
||
}
|
||
</style>
|