wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b)

This commit is contained in:
郑博文 committed 2026-10-20 11:06:01 +08:00
1 parent 927592b472
commit 27533eb465
43 files changed
+2959 -434

No files matched your search

+425 -86
View File
@@ -1,42 +1,85 @@
<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-15 补充需求:公司名称、logo)。
* 公司信息与站点信息设置(2026-10-20 定稿)。
*
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
*
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
* 两张设置卡**各自独立保存**(后端为部分更新语义):
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
* 匿名端点带 300s 浏览器缓存)。
*/
const formRef = ref<FormInstance>()
const submitting = ref(false)
const route = useRoute()
const settingsStore = useSettingsStore()
const form = reactive({
// ==================== 登录页信息 ====================
const loginFormRef = ref<FormInstance>()
const savingLogin = ref(false)
const loginForm = reactive({
company_name: '',
brand_slogan: '',
footer_text: '',
icp_no: '',
})
const rules: FormRules = {
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 uploading = ref(false)
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()
form.company_name = data.company_name
logoUrl.value = data.logo_url
fillAll(data)
// 管理端打开设置页即用最新值校准全局品牌/站点信息
settingsStore.apply(data)
} catch (error) {
handledError(error)
}
@@ -44,118 +87,309 @@ async function loadSettings(): Promise<void> {
loadSettings()
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
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
submitting.value = true
savingLogin.value = true
try {
const data = await settingsApi.updateSettings(form.company_name.trim())
form.company_name = data.company_name
ElMessage.success('已保存')
} catch (error) {
handledError(error)
} finally {
submitting.value = false
}
}
function beforeUpload(file: UploadRawFile): boolean {
const okType = ['image/png', 'image/jpeg'].includes(file.type)
if (!okType) {
ElMessage.warning('logo 仅支持 png / jpg 格式')
return false
}
if (file.size > 2 * 1024 * 1024) {
ElMessage.warning('logo 文件不能超过 2MB')
return false
}
return true
}
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
uploading.value = true
try {
const data = await settingsApi.uploadLogo(options.file)
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
ElMessage.success('logo 已更新')
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('登录页信息已保存')
} catch (error) {
handledError(error)
} finally {
uploading.value = false
savingLogin.value = false
}
}
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
const previewName = computed(() => form.company_name.trim() || '衍水集团')
/** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
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-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<!-- 左一:登录页信息 -->
<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
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
v-model="loginForm.company_name"
maxlength="64"
placeholder="展示在登录页与导出页眉"
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="logo-field">
<div class="asset-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
:http-request="handleLogoUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
<span v-else class="asset-plus">+</span>
</div>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
<p class="asset-hint">
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
</p>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="card-actions">
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
保存
</el-button>
</div>
</el-card>
</el-form>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
<el-card shadow="never" class="settings-card preview-card">
<template #header>
<span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</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">把每一笔回款,算成一份踏实。</p>
<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>
@@ -163,9 +397,41 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
gap: 16px;
align-items: start;
/* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
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 {
@@ -178,13 +444,19 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
color: var(--ys-ink-3);
}
.logo-field {
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.asset-field {
display: flex;
align-items: flex-start;
gap: 14px;
}
.logo-box {
.asset-box {
display: flex;
align-items: center;
justify-content: center;
@@ -198,24 +470,29 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
transition: border-color 150ms ease;
}
.logo-box:hover {
.asset-box--small {
width: 64px;
height: 64px;
}
.asset-box:hover {
border-color: var(--ys-indigo);
}
.logo-box img {
.asset-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-plus {
.asset-plus {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--ys-ink-3);
}
.logo-hint {
.asset-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.7;
@@ -268,7 +545,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
}
.mock-field {
height: 32px;
flex: 1;
min-height: 32px;
margin-top: 12px;
background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07);
@@ -287,13 +565,74 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
border-radius: 6px;
}
.preview-caption {
margin: 10px 0 0;
.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;