PayrollManagementSystem/frontend/src/views/system/settings/index.vue
liuwenxin a00fca942d refactor(frontend): 侧栏层级与表单页版式调整
侧栏层级口径改版(视觉稿 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>
2026-10-23 16:21:19 +08:00

539 lines
14 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 { 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>