642 lines
18 KiB
Vue
642 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
import type { UploadRawFile } from 'element-plus'
|
||
|
||
import * as settingsApi from '@/api/settings'
|
||
import type { CompanySettings } from '@/api/settings'
|
||
import { useSettingsStore } from '@/store/settings'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 公司信息与站点信息设置(2026-10-20 定稿)。
|
||
*
|
||
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
|
||
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
|
||
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
|
||
*
|
||
* 两张设置卡**各自独立保存**(后端为部分更新语义):
|
||
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
|
||
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
|
||
* 匿名端点带 300s 浏览器缓存)。
|
||
*/
|
||
const route = useRoute()
|
||
const settingsStore = useSettingsStore()
|
||
|
||
// ==================== 登录页信息 ====================
|
||
|
||
const loginFormRef = ref<FormInstance>()
|
||
const savingLogin = ref(false)
|
||
|
||
const loginForm = reactive({
|
||
company_name: '',
|
||
brand_slogan: '',
|
||
footer_text: '',
|
||
icp_no: '',
|
||
})
|
||
|
||
const loginRules: FormRules = {
|
||
company_name: [
|
||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
||
],
|
||
brand_slogan: [{ max: 64, message: '标语最长 64 个字符', trigger: 'blur' }],
|
||
footer_text: [{ max: 64, message: '页脚文本最长 64 个字符', trigger: 'blur' }],
|
||
icp_no: [{ max: 32, message: '备案号最长 32 个字符', trigger: 'blur' }],
|
||
}
|
||
|
||
// ==================== 网页站点信息 ====================
|
||
|
||
const siteFormRef = ref<FormInstance>()
|
||
const savingSite = ref(false)
|
||
|
||
const siteForm = reactive({
|
||
site_title: '',
|
||
})
|
||
|
||
const siteRules: FormRules = {
|
||
site_title: [{ max: 64, message: '站点标题最长 64 个字符', trigger: 'blur' }],
|
||
}
|
||
|
||
// ==================== 图片资源 ====================
|
||
|
||
const logoUrl = ref<string | null>(null)
|
||
const faviconUrl = ref<string | null>(null)
|
||
const uploadingLogo = ref(false)
|
||
const uploadingFavicon = ref(false)
|
||
|
||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||
|
||
/**
|
||
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
|
||
* 与 settings store 同口径 —— 未单独上传图标时,换 logo 标签页图标跟着变。
|
||
*/
|
||
const effectiveFavicon = computed(() => faviconUrl.value ?? logoUrl.value ?? '/favicon.svg')
|
||
|
||
async function loadSettings(): Promise<void> {
|
||
try {
|
||
const data = await settingsApi.getSettings()
|
||
fillAll(data)
|
||
// 管理端打开设置页即用最新值校准全局品牌/站点信息
|
||
settingsStore.apply(data)
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
|
||
loadSettings()
|
||
|
||
function fillAll(data: CompanySettings): void {
|
||
loginForm.company_name = data.company_name
|
||
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||
loginForm.footer_text = data.footer_text ?? ''
|
||
loginForm.icp_no = data.icp_no ?? ''
|
||
siteForm.site_title = data.site_title ?? ''
|
||
logoUrl.value = data.logo_url
|
||
faviconUrl.value = data.favicon_url
|
||
}
|
||
|
||
/** 保存登录页信息(只提交本卡字段,不触碰站点标题) */
|
||
async function saveLoginInfo(): Promise<void> {
|
||
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
savingLogin.value = true
|
||
try {
|
||
const data = await settingsApi.updateSettings({
|
||
company_name: loginForm.company_name.trim(),
|
||
brand_slogan: loginForm.brand_slogan.trim(),
|
||
footer_text: loginForm.footer_text.trim(),
|
||
icp_no: loginForm.icp_no.trim(),
|
||
})
|
||
// 只回填本卡字段:另一张卡未保存的编辑不被覆盖
|
||
loginForm.company_name = data.company_name
|
||
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||
loginForm.footer_text = data.footer_text ?? ''
|
||
loginForm.icp_no = data.icp_no ?? ''
|
||
logoUrl.value = data.logo_url
|
||
faviconUrl.value = data.favicon_url
|
||
settingsStore.apply(data)
|
||
ElMessage.success('登录页信息已保存')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
savingLogin.value = false
|
||
}
|
||
}
|
||
|
||
/** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
|
||
async function saveSiteInfo(): Promise<void> {
|
||
const valid = await siteFormRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
savingSite.value = true
|
||
try {
|
||
const data = await settingsApi.updateSettings({
|
||
site_title: siteForm.site_title.trim(),
|
||
})
|
||
siteForm.site_title = data.site_title ?? ''
|
||
logoUrl.value = data.logo_url
|
||
faviconUrl.value = data.favicon_url
|
||
settingsStore.apply(data)
|
||
ElMessage.success('站点信息已保存')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
savingSite.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 图片上传(logo / 站点图标) ====================
|
||
|
||
function handleLogoUpload(options: { file: UploadRawFile }): Promise<void> {
|
||
const file = options.file
|
||
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
||
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
||
return Promise.resolve()
|
||
}
|
||
if (file.size > MAX_IMAGE_BYTES) {
|
||
ElMessage.warning('logo 文件不能超过 2MB')
|
||
return Promise.resolve()
|
||
}
|
||
uploadingLogo.value = true
|
||
return settingsApi
|
||
.uploadLogo(file)
|
||
.then((data) => {
|
||
logoUrl.value = data.logo_url
|
||
faviconUrl.value = data.favicon_url
|
||
settingsStore.apply(data)
|
||
ElMessage.success('logo 已更新')
|
||
})
|
||
.catch(handledError)
|
||
.finally(() => {
|
||
uploadingLogo.value = false
|
||
})
|
||
}
|
||
|
||
/** 浏览器对 .ico 的 MIME 常为空,改用扩展名判断(后端仍有 magic bytes 兜底) */
|
||
function handleFaviconUpload(options: { file: UploadRawFile }): Promise<void> {
|
||
const file = options.file
|
||
if (!/\.(png|jpe?g|ico)$/i.test(file.name)) {
|
||
ElMessage.warning('站点图标仅支持 png / jpg / ico 格式')
|
||
return Promise.resolve()
|
||
}
|
||
if (file.size > MAX_IMAGE_BYTES) {
|
||
ElMessage.warning('站点图标文件不能超过 2MB')
|
||
return Promise.resolve()
|
||
}
|
||
uploadingFavicon.value = true
|
||
return settingsApi
|
||
.uploadFavicon(file)
|
||
.then((data) => {
|
||
faviconUrl.value = data.favicon_url
|
||
logoUrl.value = data.logo_url
|
||
settingsStore.apply(data)
|
||
ElMessage.success('站点图标已更新')
|
||
})
|
||
.catch(handledError)
|
||
.finally(() => {
|
||
uploadingFavicon.value = false
|
||
})
|
||
}
|
||
|
||
// ==================== 预览 ====================
|
||
|
||
/** 当前页面名(本页路由标题为「公司信息」),用于标签页标题预览 */
|
||
const previewPageTitle = computed(() => (route.meta.title as string | undefined) ?? '公司信息')
|
||
|
||
const previewName = computed(() => loginForm.company_name.trim() || '衍水集团')
|
||
const previewSlogan = computed(
|
||
() => loginForm.brand_slogan.trim() || '把每一笔回款,算成一份踏实。',
|
||
)
|
||
const previewFooter = computed(
|
||
() => loginForm.footer_text.trim() || `© ${previewName.value} · 内部系统`,
|
||
)
|
||
const previewIcp = computed(() => loginForm.icp_no.trim())
|
||
const previewSiteTitle = computed(
|
||
() => siteForm.site_title.trim() || '营销人员工资管理系统',
|
||
)
|
||
const previewTabTitle = computed(
|
||
() => `${previewPageTitle.value} - ${previewName.value} ${previewSiteTitle.value}`,
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<div class="settings-grid">
|
||
<!-- 左一:登录页信息 -->
|
||
<el-form
|
||
ref="loginFormRef"
|
||
:model="loginForm"
|
||
:rules="loginRules"
|
||
label-width="110px"
|
||
class="settings-form"
|
||
@submit.prevent
|
||
>
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<span class="card-title">登录页信息</span>
|
||
</template>
|
||
|
||
<el-form-item label="公司名称" prop="company_name">
|
||
<el-input
|
||
v-model="loginForm.company_name"
|
||
maxlength="64"
|
||
placeholder="展示在登录页、侧边栏与浏览器标题"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="品牌标语" prop="brand_slogan">
|
||
<el-input
|
||
v-model="loginForm.brand_slogan"
|
||
maxlength="64"
|
||
placeholder="留空使用默认:把每一笔回款,算成一份踏实。"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="页脚版权" prop="footer_text">
|
||
<el-input
|
||
v-model="loginForm.footer_text"
|
||
maxlength="64"
|
||
placeholder="留空自动生成:© 公司名 · 内部系统"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="ICP 备案号" prop="icp_no">
|
||
<el-input
|
||
v-model="loginForm.icp_no"
|
||
maxlength="32"
|
||
placeholder="选填,展示在登录页页脚"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="logo">
|
||
<div class="asset-field">
|
||
<el-upload
|
||
:show-file-list="false"
|
||
:http-request="handleLogoUpload"
|
||
accept="image/png,image/jpeg"
|
||
>
|
||
<div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
|
||
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
||
<span v-else class="asset-plus">+</span>
|
||
</div>
|
||
</el-upload>
|
||
<p class="asset-hint">
|
||
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
|
||
</p>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<div class="card-actions">
|
||
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
|
||
保存
|
||
</el-button>
|
||
</div>
|
||
</el-card>
|
||
</el-form>
|
||
|
||
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
|
||
<el-card shadow="never" class="settings-card preview-card">
|
||
<template #header>
|
||
<span class="card-title">登录页预览</span>
|
||
<span class="preview-note">对应左侧「登录页信息」</span>
|
||
</template>
|
||
<div class="login-mock">
|
||
<div class="mock-brand">
|
||
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
||
<div class="mock-name">{{ previewName }}</div>
|
||
<p class="mock-slogan">{{ previewSlogan }}</p>
|
||
<span class="mock-waterline"></span>
|
||
</div>
|
||
<div class="mock-field"></div>
|
||
<div class="mock-field"></div>
|
||
<div class="mock-submit">登 录</div>
|
||
</div>
|
||
<p class="mock-foot">
|
||
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
|
||
</p>
|
||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||
</el-card>
|
||
|
||
<!-- 左二:网页站点信息 -->
|
||
<el-form
|
||
ref="siteFormRef"
|
||
:model="siteForm"
|
||
:rules="siteRules"
|
||
label-width="110px"
|
||
class="settings-form"
|
||
@submit.prevent
|
||
>
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<span class="card-title">网页站点信息</span>
|
||
</template>
|
||
|
||
<el-form-item label="站点标题" prop="site_title">
|
||
<el-input
|
||
v-model="siteForm.site_title"
|
||
maxlength="64"
|
||
placeholder="留空使用默认:营销人员工资管理系统"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="站点图标">
|
||
<div class="asset-field">
|
||
<el-upload
|
||
:show-file-list="false"
|
||
:http-request="handleFaviconUpload"
|
||
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,.ico"
|
||
>
|
||
<div
|
||
v-loading="uploadingFavicon"
|
||
class="asset-box asset-box--small"
|
||
title="点击上传 png / jpg / ico(≤2MB)"
|
||
>
|
||
<!-- 未自定义时显示公司 logo(跟随 logo 变化) -->
|
||
<img :src="effectiveFavicon" alt="站点图标" />
|
||
</div>
|
||
</el-upload>
|
||
<p class="asset-hint">
|
||
png / jpg / ico,不超过 2MB;<br />
|
||
未上传时跟随公司 logo,展示于浏览器标签页。
|
||
</p>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<div class="card-actions">
|
||
<el-button type="primary" :loading="savingSite" @click="saveSiteInfo">保存</el-button>
|
||
</div>
|
||
</el-card>
|
||
</el-form>
|
||
|
||
<!-- 右二:浏览器标签页预览(由左侧「网页站点信息」控制) -->
|
||
<el-card shadow="never" class="settings-card preview-card tab-preview-card">
|
||
<template #header>
|
||
<span class="card-title">浏览器标签页预览</span>
|
||
<span class="preview-note">对应左侧「网页站点信息」</span>
|
||
</template>
|
||
<div class="tab-bar">
|
||
<div class="tab-bar__tab">
|
||
<img :src="effectiveFavicon" alt="" class="tab-bar__icon" />
|
||
<span class="tab-bar__title">{{ previewTabTitle }}</span>
|
||
<span class="tab-bar__close">×</span>
|
||
</div>
|
||
<span class="tab-bar__plus">+</span>
|
||
</div>
|
||
<p class="preview-caption">浏览器标签页实景(标题与图标)</p>
|
||
</el-card>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
|
||
gap: 16px;
|
||
/* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
|
||
align-items: stretch;
|
||
}
|
||
|
||
.settings-form {
|
||
display: flex;
|
||
min-width: 0;
|
||
}
|
||
|
||
.settings-form :deep(.el-card) {
|
||
width: 100%;
|
||
}
|
||
|
||
/* 预览卡铺满行高,内容区可纵向伸展 */
|
||
.preview-card,
|
||
.tab-preview-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.preview-card :deep(.el-card__body),
|
||
.tab-preview-card :deep(.el-card__body) {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 登录页实景卡随可用高度放大:两个模拟输入框吃掉多余高度,
|
||
整张登录卡纵向撑满预览卡,底边与左侧「登录页信息」对齐 */
|
||
.login-mock {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.card-title {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.preview-note {
|
||
float: right;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.asset-field {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
}
|
||
|
||
.asset-box {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 96px;
|
||
height: 96px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||
border: 1px dashed rgba(28, 35, 51, 0.22);
|
||
border-radius: 10px;
|
||
transition: border-color 150ms ease;
|
||
}
|
||
|
||
.asset-box--small {
|
||
width: 64px;
|
||
height: 64px;
|
||
}
|
||
|
||
.asset-box:hover {
|
||
border-color: var(--ys-indigo);
|
||
}
|
||
|
||
.asset-box img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.asset-plus {
|
||
font-size: 26px;
|
||
font-weight: 300;
|
||
line-height: 1;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.asset-hint {
|
||
margin: 4px 0 0;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
|
||
.login-mock {
|
||
padding: 22px 26px 24px;
|
||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||
border: 1px solid var(--ys-line);
|
||
border-radius: 12px;
|
||
box-shadow:
|
||
0 2px 4px rgba(28, 35, 51, 0.04),
|
||
0 10px 26px rgba(28, 35, 51, 0.08);
|
||
}
|
||
|
||
.mock-brand {
|
||
text-align: center;
|
||
}
|
||
|
||
.mock-logo {
|
||
display: block;
|
||
max-width: 64px;
|
||
max-height: 40px;
|
||
margin: 0 auto 8px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.mock-name {
|
||
font-family: var(--ys-font-brand);
|
||
font-size: 14px;
|
||
letter-spacing: 0.34em;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.mock-slogan {
|
||
margin: 6px 0 0;
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.mock-waterline {
|
||
display: block;
|
||
width: 34px;
|
||
height: 2px;
|
||
margin: 10px auto 0;
|
||
border-radius: 1px;
|
||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||
}
|
||
|
||
.mock-field {
|
||
flex: 1;
|
||
min-height: 32px;
|
||
margin-top: 12px;
|
||
background: #f4f5f8;
|
||
border: 1px solid rgba(28, 35, 51, 0.07);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.mock-submit {
|
||
height: 32px;
|
||
margin-top: 14px;
|
||
font-size: 12px;
|
||
line-height: 32px;
|
||
letter-spacing: 0.4em;
|
||
text-align: center;
|
||
color: #fff;
|
||
background: var(--ys-indigo);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.mock-foot {
|
||
margin: 12px 0 0;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.preview-caption {
|
||
margin: 8px 0 0;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* ── 浏览器标签页预览(示意;与登录页预览分成两张卡,对应左侧第二张卡) ── */
|
||
.tab-preview-card :deep(.el-card__body) {
|
||
/* 行高由左侧「网页站点信息」决定:标签页示意在卡内垂直居中 */
|
||
justify-content: center;
|
||
}
|
||
|
||
.tab-bar {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 6px;
|
||
padding: 10px 12px 0;
|
||
background: #e9ebf1;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.tab-bar__tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 9px 12px;
|
||
background: #ffffff;
|
||
border-radius: 8px 8px 0 0;
|
||
box-shadow: 0 -1px 2px rgba(28, 35, 51, 0.06);
|
||
}
|
||
|
||
.tab-bar__icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
flex-shrink: 0;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.tab-bar__title {
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.tab-bar__close {
|
||
flex-shrink: 0;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.tab-bar__plus {
|
||
padding: 0 6px 10px;
|
||
font-size: 14px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.settings-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|