PayrollManagementSystem/frontend/src/views/system/settings/index.vue

642 lines
18 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 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>