侧栏层级口径改版(视觉稿 v4.2):组标题与菜单项同字号 13px,层级改由 「折叠箭头 + 组间留白 + 子项缩进」表达,不靠字号/字重压过父级。 - 去掉组标题的靛蓝竖标与激活项左侧 2px 竖条(竖条只能落在侧栏内边缘、 与白底悬空,是重复的第二个信号),激活只留白底浮起 + 靛蓝字 - 子项缩进 30px 收到 22px,图标父子统一 16px - AppMenu 的 .app-menu 提供横向 10px 留白,BasicLayout 的 .app-aside__menu 只写上下内距——两处都用 padding 简写会同权重靠注入顺序决胜,横向留白 会被整条抹掉、条目贴到侧栏边缘 公司信息页字段自适应分列:卡片够宽时两字段并排,窄时折回单列;说明文字 紧贴控件下方,不再顶到卡片最右缘(宽屏下与输入框隔上千像素,视线关联断掉)。 设计稿同步升 v4.2。 Co-Authored-By: Claude Code <noreply@anthropic.com>
539 lines
14 KiB
Vue
539 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
import type { UploadRawFile } from 'element-plus'
|
||
|
||
import * as settingsApi from '@/api/settings'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
||
*
|
||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
||
* **1:1 等比**的登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||
* 预览按登录页的真实版式复刻(品牌区 / 欢迎回来 / 三项输入 / 登录按钮 /
|
||
* 版权行),控件尺寸与登录页一致,logo 展示位也按登录页的 ≤56×36 约束。
|
||
*
|
||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
||
*
|
||
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
||
*/
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
|
||
const form = reactive({
|
||
company_name: '',
|
||
})
|
||
|
||
const rules: FormRules = {
|
||
company_name: [
|
||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
||
],
|
||
}
|
||
|
||
const logoUrl = ref<string | null>(null)
|
||
const uploading = ref(false)
|
||
|
||
async function loadSettings(): Promise<void> {
|
||
try {
|
||
const data = await settingsApi.getSettings()
|
||
form.company_name = data.company_name
|
||
logoUrl.value = data.logo_url
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
|
||
loadSettings()
|
||
|
||
async function handleSubmit(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
submitting.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)
|
||
logoUrl.value = data.logo_url
|
||
ElMessage.success('logo 已更新')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
|
||
const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<!-- 页面头:这是表单页不是列表页,所以没有筛选条,只有标题 + 影响面说明 -->
|
||
<div class="page-head">
|
||
<h1 class="page-title">公司信息</h1>
|
||
<p class="page-desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉。</p>
|
||
<div class="waterline"></div>
|
||
</div>
|
||
|
||
<div class="settings-grid">
|
||
<!-- 左列:基础信息(表单)+ 生效范围(这份设置会出现在哪里) -->
|
||
<div class="settings-col">
|
||
<div class="card">
|
||
<!-- 卡片标题刻意不写「公司信息」:页面标题已经说明了,再重复一遍只是噪音 -->
|
||
<div class="card-head">
|
||
<span class="card-title">基础信息</span>
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<el-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="rules"
|
||
label-width="92px"
|
||
@submit.prevent
|
||
>
|
||
<!--
|
||
字段自适应分列:卡片够宽时两个字段并排(各占一列),窄时自动折回单列。
|
||
说明文字紧贴控件下方 —— 早前把它顶到卡片最右缘,宽屏下与输入框隔了
|
||
上千像素,视线关联直接断掉。
|
||
-->
|
||
<div class="field-grid">
|
||
<el-form-item label="公司名称" prop="company_name">
|
||
<el-input
|
||
v-model="form.company_name"
|
||
class="field-control__main"
|
||
maxlength="64"
|
||
placeholder="展示在登录页与导出页眉"
|
||
clearable
|
||
/>
|
||
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="logo">
|
||
<el-upload
|
||
class="logo-uploader"
|
||
:show-file-list="false"
|
||
:http-request="handleUpload"
|
||
:before-upload="beforeUpload"
|
||
accept="image/png,image/jpeg"
|
||
>
|
||
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
|
||
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
||
<span v-else class="logo-plus">+</span>
|
||
</div>
|
||
</el-upload>
|
||
<p class="field-control__note">
|
||
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
|
||
</p>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
|
||
<!--
|
||
保存按钮移进 card-foot:表单项里的按钮会跟着 label 列缩进、并对齐到输入框左缘,
|
||
作为整张表单的收尾动作用卡片底栏右对齐更符合阅读顺序。
|
||
按钮在 form 之外仍安全:handleSubmit 走 formRef 校验,且 el-button 默认 type="button",
|
||
不会触发浏览器原生提交。
|
||
-->
|
||
<div class="card-foot">
|
||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<span class="card-title">生效范围</span>
|
||
<span class="preview-note">保存后对所有人生效</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<dl class="scope-list">
|
||
<div class="scope-item">
|
||
<dt>公司名称</dt>
|
||
<dd>登录页品牌位与版权行 · Excel 导出页眉 · 打印稿页眉</dd>
|
||
</div>
|
||
<div class="scope-item">
|
||
<dt>logo</dt>
|
||
<dd>登录页品牌位(≤56×36)</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 签名元素:登录页实景预览(1:1 等比登录卡,非 iframe 截图) -->
|
||
<div class="card preview-card">
|
||
<div class="card-head">
|
||
<span class="card-title">登录页预览</span>
|
||
<span class="preview-note">1:1 等比</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="login-stage" aria-hidden="true">
|
||
<div class="mock-card">
|
||
<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>
|
||
<span class="mock-waterline"></span>
|
||
</div>
|
||
|
||
<div class="mock-head">
|
||
<div class="mock-title">欢迎回来</div>
|
||
<div class="mock-hint">请输入账号密码进入系统</div>
|
||
</div>
|
||
|
||
<div class="mock-field">
|
||
<span class="mock-label">用户名</span>
|
||
<span class="mock-box"></span>
|
||
</div>
|
||
<div class="mock-field">
|
||
<span class="mock-label">密码</span>
|
||
<span class="mock-box"></span>
|
||
</div>
|
||
<div class="mock-field">
|
||
<span class="mock-label">验证码</span>
|
||
<span class="mock-captcha-row">
|
||
<span class="mock-box"></span>
|
||
<span class="mock-captcha"></span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="mock-submit">登 录</div>
|
||
</div>
|
||
<div class="mock-foot">© {{ previewName }} · 内部系统</div>
|
||
</div>
|
||
<p class="preview-caption">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 480px;
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
|
||
.settings-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-width: 0;
|
||
}
|
||
|
||
/*
|
||
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
|
||
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
|
||
*/
|
||
.preview-note {
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/*
|
||
* 控件行:主控件在左、说明在右,两者共享一行高度。
|
||
* 说明用 flex-grow 撑满剩余宽度并右对齐 —— 宽屏下整行铺到卡片右缘,
|
||
* 不会出现「输入框缩在左上角、右侧留一整块空白」的观感;
|
||
* 窄屏放不下时自动换行,说明退回左对齐。
|
||
*/
|
||
.field-control {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 20px;
|
||
width: 100%;
|
||
}
|
||
|
||
.field-control__main {
|
||
flex: 0 1 420px;
|
||
min-width: 240px;
|
||
}
|
||
|
||
.field-control__note {
|
||
flex: 1 1 240px;
|
||
min-width: 240px;
|
||
margin: 0;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: var(--ys-ink-3);
|
||
text-align: left;
|
||
}
|
||
|
||
.logo-uploader {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.logo-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;
|
||
}
|
||
|
||
.logo-box:hover {
|
||
border-color: var(--ys-indigo);
|
||
}
|
||
|
||
.logo-box img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.logo-plus {
|
||
font-size: 26px;
|
||
font-weight: 300;
|
||
line-height: 1;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* ── 生效范围:定义列表(项名 / 取值) ── */
|
||
.scope-list {
|
||
margin: 0;
|
||
}
|
||
|
||
.scope-item {
|
||
display: grid;
|
||
grid-template-columns: 92px minmax(0, 1fr);
|
||
column-gap: 20px;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.scope-item + .scope-item {
|
||
padding-top: 10px;
|
||
margin-top: 10px;
|
||
border-top: 1px solid var(--ys-line-soft);
|
||
}
|
||
|
||
.scope-item dt {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.scope-item dd {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* ── 登录页实景预览:按登录页真实版式 1:1 复刻 ── */
|
||
.login-stage {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 24px 0 20px;
|
||
overflow: hidden;
|
||
background-color: #f2f4f8;
|
||
/* 点阵网格:与登录页同一套秩序感 */
|
||
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
border-radius: var(--ys-radius-sm);
|
||
user-select: none;
|
||
}
|
||
|
||
/* 顶部天光:登录页的落点光源,等比搬过来 */
|
||
.login-stage::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -180px;
|
||
left: 50%;
|
||
width: 860px;
|
||
height: 460px;
|
||
transform: translateX(-50%);
|
||
pointer-events: none;
|
||
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||
}
|
||
|
||
.mock-card {
|
||
position: relative;
|
||
width: 400px;
|
||
max-width: 100%;
|
||
padding: 30px 36px 32px;
|
||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||
border: 1px solid rgba(28, 35, 51, 0.09);
|
||
border-radius: 14px;
|
||
box-shadow:
|
||
0 2px 4px rgba(28, 35, 51, 0.04),
|
||
0 16px 40px rgba(28, 35, 51, 0.1);
|
||
}
|
||
|
||
.mock-brand {
|
||
text-align: center;
|
||
}
|
||
|
||
.mock-logo {
|
||
display: block;
|
||
max-width: 56px;
|
||
max-height: 36px;
|
||
margin: 0 auto 10px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.mock-name {
|
||
font-family: var(--ys-font-brand);
|
||
font-size: 17px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.34em;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.mock-slogan {
|
||
margin: 8px 0 0;
|
||
font-size: 12.5px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.mock-waterline {
|
||
display: block;
|
||
width: 40px;
|
||
height: 2px;
|
||
margin: 14px auto 0;
|
||
border-radius: 1px;
|
||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||
}
|
||
|
||
.mock-head {
|
||
margin-top: 26px;
|
||
}
|
||
|
||
.mock-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.mock-hint {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.mock-field {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.mock-field + .mock-field {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.mock-label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
line-height: 22px;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.mock-box {
|
||
display: block;
|
||
height: 38px;
|
||
background: #f4f5f8;
|
||
border: 1px solid rgba(28, 35, 51, 0.07);
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.mock-captcha-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.mock-captcha-row .mock-box {
|
||
flex: 1;
|
||
}
|
||
|
||
/* 验证码位:留空壳 + 斜纹质感,不伪造字符,避免与真实验证码混淆 */
|
||
.mock-captcha {
|
||
flex: 0 0 104px;
|
||
height: 38px;
|
||
background-color: #f1f3f8;
|
||
background-image: repeating-linear-gradient(
|
||
45deg,
|
||
rgba(28, 35, 51, 0.07) 0 1px,
|
||
transparent 1px 7px
|
||
);
|
||
border: 1px solid var(--ys-line);
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.mock-submit {
|
||
height: 38px;
|
||
margin-top: 20px;
|
||
font-size: 13px;
|
||
line-height: 38px;
|
||
letter-spacing: 0.4em;
|
||
text-align: center;
|
||
color: #fff;
|
||
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.mock-foot {
|
||
margin-top: 16px;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.preview-caption {
|
||
margin: 12px 0 0;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/*
|
||
* 宽屏(≥1600px)说明文字靠右缘对齐,与左侧输入框形成「两端锚点」,
|
||
* 整行铺满卡片而不是缩在左上角;1600px 以下说明跟着输入框走,
|
||
* 放不下时整体换行到控件下方。
|
||
*/
|
||
@media (min-width: 1600px) {
|
||
.field-control__note {
|
||
text-align: right;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.settings-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|