Files
qitongxue-admin/src/views/system/SystemConfig.vue
2026-09-30 17:00:29 +08:00

262 lines
8.7 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>
/**
* 系统配置(迭代-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>