feat(frontend): 审批与系统设置模块页面接入及业务列表调整
This commit is contained in:
1 parent
8ef26e22fb
commit
74f1ef1cf9
15 files changed
+1215
-28
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import request from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 审批单状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回 */
|
||||
export const APPROVAL_STATUS = {
|
||||
PENDING_FINANCE: 1,
|
||||
PENDING_LEADER: 2,
|
||||
APPROVED: 3,
|
||||
REJECTED: 4,
|
||||
WITHDRAWN: 5,
|
||||
} as const
|
||||
|
||||
export function approvalStatusLabel(status: number): string {
|
||||
return (
|
||||
{
|
||||
1: '待财务复核',
|
||||
2: '待领导审核',
|
||||
3: '已通过',
|
||||
4: '已驳回',
|
||||
5: '已撤回',
|
||||
}[status] ?? String(status)
|
||||
)
|
||||
}
|
||||
|
||||
export function approvalStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' {
|
||||
return (
|
||||
({ 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ??
|
||||
'info'
|
||||
) as 'primary' | 'success' | 'danger' | 'info'
|
||||
}
|
||||
|
||||
/** 节点动作:1通过 2驳回 3自动通过(系统) 4撤回 */
|
||||
export function stepActionLabel(action: number): string {
|
||||
return ({ 1: '通过', 2: '驳回', 3: '自动通过', 4: '撤回' } as const)[action] ?? String(action)
|
||||
}
|
||||
|
||||
/** 节点名:0 仅用于撤回留痕 */
|
||||
export function stepNodeLabel(node: number): string {
|
||||
return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}`
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
id: number
|
||||
node: number
|
||||
action: number
|
||||
opinion: string | null
|
||||
operator_id: number | null
|
||||
operator_name: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface ApprovalOrder {
|
||||
id: number
|
||||
order_no: string
|
||||
/** 1二次分配方案 2月度结算(预留) */
|
||||
order_type: number
|
||||
ref_id: number
|
||||
refer_no: string
|
||||
status: number
|
||||
submitter_id: number | null
|
||||
submitter_name: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface ApprovalOrderDetail extends ApprovalOrder {
|
||||
steps: ApprovalStep[]
|
||||
}
|
||||
|
||||
export interface ApprovalQueryParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
/** 审批单号/引用单号,模糊 */
|
||||
keyword?: string
|
||||
status?: number
|
||||
order_type?: number
|
||||
}
|
||||
|
||||
export function listApprovals(params: ApprovalQueryParams): Promise<PageData<ApprovalOrder>> {
|
||||
return request({ url: '/v1/system/approvals', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getApproval(orderId: number): Promise<ApprovalOrderDetail> {
|
||||
return request({ url: `/v1/system/approvals/${orderId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function actApproval(
|
||||
orderId: number,
|
||||
data: { action: 1 | 2; opinion?: string | null },
|
||||
): Promise<ApprovalOrderDetail> {
|
||||
return request({ url: `/v1/system/approvals/${orderId}/action`, method: 'post', data })
|
||||
}
|
||||
@@ -50,9 +50,20 @@ export interface BusinessItem {
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
/** 业务详情:附产品明细行(编辑弹窗一次回显) */
|
||||
/** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */
|
||||
export interface SecondaryAllocationRow {
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
/** 百分比数字字符串(两位小数) */
|
||||
ratio: string
|
||||
}
|
||||
|
||||
/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */
|
||||
export interface BusinessDetail extends BusinessItem {
|
||||
items: BusinessItemRow[]
|
||||
secondary_allocations: SecondaryAllocationRow[]
|
||||
/** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */
|
||||
allocation_hint: string | null
|
||||
}
|
||||
|
||||
export interface BusinessQueryParams {
|
||||
@@ -96,6 +107,8 @@ export interface BusinessCreateParams {
|
||||
is_special?: number
|
||||
special_note?: string | null
|
||||
items: BusinessItemParams[]
|
||||
/** 二次分配方案(只出现在合同刚建立时;空=不设置) */
|
||||
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
@@ -111,6 +124,8 @@ export interface BusinessUpdateParams {
|
||||
is_special?: number
|
||||
special_note?: string | null
|
||||
items: BusinessItemParams[]
|
||||
/** 二次分配方案(全量替换;空数组=清空) */
|
||||
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import service, { request } from './request'
|
||||
|
||||
/** 公司信息(管理端) */
|
||||
export interface CompanySettings {
|
||||
company_name: string
|
||||
/** 未上传时为 null */
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */
|
||||
export interface PublicSettings {
|
||||
company_name: string
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<CompanySettings> {
|
||||
return request({ url: '/v1/system/settings', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateSettings(companyName: string): Promise<CompanySettings> {
|
||||
return request({
|
||||
url: '/v1/system/settings',
|
||||
method: 'put',
|
||||
data: { company_name: companyName },
|
||||
})
|
||||
}
|
||||
|
||||
/** logo 上传走独立的 FormData(multipart,非 JSON) */
|
||||
export function uploadLogo(file: File): Promise<CompanySettings> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return service.post('/v1/system/settings/logo', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点) */
|
||||
export function getPublicSettings(): Promise<PublicSettings> {
|
||||
return request({ url: '/v1/system/settings/public', method: 'get' })
|
||||
}
|
||||
@@ -31,12 +31,18 @@ export function useAppMenu() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||||
const userRoles = computed(() => userStore.roles)
|
||||
|
||||
const menuNodes = computed<MenuNode[]>(() =>
|
||||
router
|
||||
.getRoutes()
|
||||
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
||||
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
||||
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
|
||||
.filter(
|
||||
(record) =>
|
||||
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
|
||||
)
|
||||
.map((record) => ({
|
||||
path: record.path,
|
||||
title: record.meta.title ?? '',
|
||||
|
||||
@@ -12,6 +12,9 @@ declare module 'vue-router' {
|
||||
title?: string
|
||||
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
||||
requiresAdmin?: boolean
|
||||
/** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)——
|
||||
* 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */
|
||||
allowedRoles?: string[]
|
||||
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
|
||||
menu?: boolean
|
||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||
@@ -64,49 +67,67 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/params/index.vue'),
|
||||
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
||||
},
|
||||
{
|
||||
path: '/system/settings',
|
||||
name: 'system-settings',
|
||||
component: () => import('@/views/system/settings/index.vue'),
|
||||
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
},
|
||||
{
|
||||
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
||||
path: '/system/approvals',
|
||||
name: 'system-approvals',
|
||||
component: () => import('@/views/system/approvals/index.vue'),
|
||||
meta: {
|
||||
title: '审批管理',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
order: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: () => import('@/views/system/logs/index.vue'),
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
|
||||
},
|
||||
{
|
||||
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
||||
path: '/base/personnel',
|
||||
name: 'base-personnel',
|
||||
component: () => import('@/views/base/personnel/index.vue'),
|
||||
meta: { title: '人员管理', group: '基础数据', order: 8 },
|
||||
meta: { title: '人员管理', group: '基础数据', order: 10 },
|
||||
},
|
||||
{
|
||||
path: '/base/products',
|
||||
name: 'base-products',
|
||||
component: () => import('@/views/base/products/index.vue'),
|
||||
meta: { title: '产品管理', group: '基础数据', order: 9 },
|
||||
meta: { title: '产品管理', group: '基础数据', order: 11 },
|
||||
},
|
||||
{
|
||||
path: '/base/customers',
|
||||
name: 'base-customers',
|
||||
component: () => import('@/views/base/customers/index.vue'),
|
||||
meta: { title: '客户管理', group: '基础数据', order: 10 },
|
||||
meta: { title: '客户管理', group: '基础数据', order: 12 },
|
||||
},
|
||||
{
|
||||
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
||||
path: '/business/businesses',
|
||||
name: 'business-businesses',
|
||||
component: () => import('@/views/business/businesses/index.vue'),
|
||||
meta: { title: '业务管理', group: '业务数据', order: 11 },
|
||||
meta: { title: '业务管理', group: '业务数据', order: 13 },
|
||||
},
|
||||
{
|
||||
path: '/business/shipments',
|
||||
name: 'business-shipments',
|
||||
component: () => import('@/views/business/shipments/index.vue'),
|
||||
meta: { title: '发货管理', group: '业务数据', order: 12 },
|
||||
meta: { title: '发货管理', group: '业务数据', order: 14 },
|
||||
},
|
||||
{
|
||||
path: '/business/receipts',
|
||||
name: 'business-receipts',
|
||||
component: () => import('@/views/business/receipts/index.vue'),
|
||||
meta: { title: '回款管理', group: '业务数据', order: 13 },
|
||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||
},
|
||||
{
|
||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
||||
@@ -114,7 +135,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/business/rating',
|
||||
name: 'business-rating',
|
||||
component: () => import('@/views/business/rating/index.vue'),
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 14 },
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -163,6 +184,12 @@ router.beforeEach(async (to) => {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
|
||||
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
||||
ElMessage.error('无权访问该页面')
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -131,6 +131,69 @@ const form = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// ==================== 二次分配方案(合同刚建立时可录入;不强制合计 100%) ====================
|
||||
|
||||
interface AllocationDraft {
|
||||
personnel_id: number | undefined
|
||||
/** 百分比数字字符串(两位小数),与后端 NUMERIC(5,2) 同口径 */
|
||||
ratio: string
|
||||
}
|
||||
|
||||
const allocationRows = ref<AllocationDraft[]>([])
|
||||
|
||||
/** 合计(0-999.99 不做上限:需求原文不要求合计 100%);余额 = 100 - 合计 */
|
||||
const allocationTotal = computed(() => {
|
||||
let total = 0
|
||||
for (const row of allocationRows.value) {
|
||||
const value = Number(row.ratio)
|
||||
if (Number.isFinite(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const allocationHint = computed(() => {
|
||||
if (allocationRows.value.length === 0) return ''
|
||||
const total = Number(allocationTotal.value.toFixed(2))
|
||||
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
||||
const rest = Number((100 - total).toFixed(2))
|
||||
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
|
||||
})
|
||||
|
||||
function addAllocationRow(): void {
|
||||
allocationRows.value.push({ personnel_id: undefined, ratio: '' })
|
||||
}
|
||||
|
||||
function removeAllocationRow(index: number): void {
|
||||
allocationRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 分配行校验:人员非重、比例 0-100 两位小数;空行(未选人员且比例为 0)自动忽略 */
|
||||
function validateAllocationRows(rows: AllocationDraft[]): string | null {
|
||||
const active = rows.filter((r) => r.personnel_id !== undefined || Number(r.ratio) > 0)
|
||||
const seen = new Set<number>()
|
||||
for (let i = 0; i < active.length; i += 1) {
|
||||
const row = active[i]
|
||||
const no = `第 ${i + 1} 个`
|
||||
if (row.personnel_id === undefined) return `${no}参与人未选择`
|
||||
if (seen.has(row.personnel_id)) return `${no}参与人重复`
|
||||
seen.add(row.personnel_id)
|
||||
const value = row.ratio.trim()
|
||||
if (!value) return `${no}分配比例未填写`
|
||||
if (!AMOUNT_PATTERN.test(value) || ZERO_PATTERN.test(value)) {
|
||||
return `${no}分配比例须为大于 0 的数字,最多两位小数`
|
||||
}
|
||||
if (Number(value) > 100) return `${no}分配比例不能超过 100%`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 提交行:空行剔除;人员/比例均按提交格式直出 */
|
||||
function buildAllocationParams(rows: AllocationDraft[]): { personnel_id: number; ratio: string }[] {
|
||||
return rows
|
||||
.filter((r) => r.personnel_id !== undefined)
|
||||
.map((r) => ({ personnel_id: r.personnel_id as number, ratio: r.ratio.trim() }))
|
||||
}
|
||||
|
||||
/** 默认落在首个类别上,避免空类别提交被后端拒 */
|
||||
function emptyItem(): ItemDraft {
|
||||
return {
|
||||
@@ -172,6 +235,7 @@ function openCreate(): void {
|
||||
remark: '',
|
||||
})
|
||||
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
||||
allocationRows.value = []
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
@@ -203,6 +267,10 @@ async function openEdit(row: BusinessItem): Promise<void> {
|
||||
cost_amount: it.cost_amount ?? '',
|
||||
guided_price: it.guided_price ?? '',
|
||||
}))
|
||||
allocationRows.value = (detail.secondary_allocations ?? []).map((a) => ({
|
||||
personnel_id: a.personnel_id,
|
||||
ratio: a.ratio,
|
||||
}))
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
@@ -266,6 +334,11 @@ async function handleSubmit(): Promise<void> {
|
||||
ElMessage.warning(rowError)
|
||||
return
|
||||
}
|
||||
const allocError = validateAllocationRows(allocationRows.value)
|
||||
if (allocError) {
|
||||
ElMessage.warning(allocError)
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -277,6 +350,7 @@ async function handleSubmit(): Promise<void> {
|
||||
is_special: form.is_special,
|
||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||
items: buildItemParams(itemRows.value),
|
||||
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||
remark: form.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
@@ -289,6 +363,7 @@ async function handleSubmit(): Promise<void> {
|
||||
is_special: form.is_special,
|
||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||
items: buildItemParams(itemRows.value),
|
||||
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||
remark: form.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('业务已创建')
|
||||
@@ -752,6 +827,58 @@ onMounted(() => {
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- 二次分配方案:合同刚建立时可录入(编辑为全量替换);不强制合计 100% -->
|
||||
<div class="items-head">
|
||||
<span class="items-title">
|
||||
二次分配方案
|
||||
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
|
||||
</span>
|
||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||
</div>
|
||||
<el-table :data="allocationRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="参与业务员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.personnel_id"
|
||||
placeholder="选择参与人"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in personnelOptions"
|
||||
:key="option.id"
|
||||
:label="personnelLabel(option)"
|
||||
:value="option.id"
|
||||
:disabled="option.status !== 1"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分配比例(%)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.ratio"
|
||||
placeholder="两位小数,0-100"
|
||||
maxlength="6"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeAllocationRow($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="alloc-empty">未设置二次分配 —— 该业务的全部绩效归负责营销人员。</span>
|
||||
</template>
|
||||
</el-table>
|
||||
<div v-if="allocationRows.length > 0" class="alloc-total" :class="{ 'is-full': allocationTotal === 100 }">
|
||||
{{ allocationHint }}
|
||||
</div>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||||
@@ -1209,6 +1336,32 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alloc-note {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.alloc-total {
|
||||
margin: -8px 0 16px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
background: #f4f5f8;
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.alloc-total.is-full {
|
||||
color: var(--ys-indigo);
|
||||
background: rgba(46, 75, 168, 0.07);
|
||||
}
|
||||
|
||||
.alloc-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.items-table {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { getPublicSettings } from '@/api/settings'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -23,6 +24,20 @@ const form = reactive({
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
|
||||
const brandName = ref('衍水集团')
|
||||
const brandLogo = ref<string | null>(null)
|
||||
|
||||
async function loadBrand(): Promise<void> {
|
||||
try {
|
||||
const data = await getPublicSettings()
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@@ -72,7 +87,10 @@ async function handleSubmit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshCaptcha)
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
loadBrand()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
actApproval,
|
||||
approvalStatusLabel,
|
||||
approvalStatusTagType,
|
||||
getApproval,
|
||||
listApprovals,
|
||||
stepActionLabel,
|
||||
stepNodeLabel,
|
||||
type ApprovalOrder,
|
||||
type ApprovalOrderDetail,
|
||||
} from '@/api/approvals'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 审批管理(2026-10-15 复核审批补充需求)。
|
||||
*
|
||||
* 提单不在此页:创建/编辑业务带二次分配方案时由后端自动生成审批单
|
||||
* (开关关闭=自动通过留痕,开启=待财务复核)。本页负责查看与两级
|
||||
* 节点动作;节点准入由后端按角色与单据状态双重校验。
|
||||
*
|
||||
* 签名元素是抽屉里的竖排节点时间线 —— 财务复核 → 领导审核的处理链
|
||||
* 一眼可读(节点/动作/操作人/意见按发生顺序排列)。
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
order_type: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
||||
listApprovals,
|
||||
() => ({
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
status: filters.status,
|
||||
order_type: filters.order_type,
|
||||
}),
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
filters.order_type = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 详情抽屉(节点时间线) ====================
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ApprovalOrderDetail | null>(null)
|
||||
|
||||
async function openDetail(row: ApprovalOrder): Promise<void> {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getApproval(row.id)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 节点动作 ====================
|
||||
|
||||
const actingId = ref<number | null>(null)
|
||||
const actionSubmitting = ref(false)
|
||||
const actionForm = reactive({
|
||||
action: 1 as 1 | 2,
|
||||
opinion: '',
|
||||
})
|
||||
|
||||
/** 仅在途单展示动作表单;动作随单据状态定(1 待财务 / 2 待领导) */
|
||||
const canAct = (order: ApprovalOrderDetail): boolean =>
|
||||
order.status === 1 || order.status === 2
|
||||
|
||||
function openAction(order: ApprovalOrderDetail, action: 1 | 2): void {
|
||||
actingId.value = order.id
|
||||
actionForm.action = action
|
||||
actionForm.opinion = ''
|
||||
}
|
||||
|
||||
async function submitAction(): Promise<void> {
|
||||
if (actingId.value === null) return
|
||||
if (actionForm.action === 2 && !actionForm.opinion.trim()) {
|
||||
ElMessage.warning('驳回必须填写审批意见')
|
||||
return
|
||||
}
|
||||
actionSubmitting.value = true
|
||||
try {
|
||||
const run = () =>
|
||||
actApproval(actingId.value as number, {
|
||||
action: actionForm.action,
|
||||
opinion: actionForm.opinion.trim() || null,
|
||||
})
|
||||
await confirmAndRun(run, {
|
||||
successMessage: actionForm.action === 1 ? '已通过' : '已驳回',
|
||||
})
|
||||
// 动作后刷新抽屉与列表(状态可能已流转或终态)
|
||||
detail.value = await getApproval(actingId.value)
|
||||
actingId.value = null
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
actionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="审批单号 / 业务编号"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="待财务复核" :value="1" />
|
||||
<el-option label="待领导审核" :value="2" />
|
||||
<el-option label="已通过" :value="3" />
|
||||
<el-option label="已驳回" :value="4" />
|
||||
<el-option label="已撤回" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="二次分配方案" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="order_no" label="审批单号" min-width="150" />
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
|
||||
{{ approvalStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submitter_name" label="提交人" width="110" />
|
||||
<el-table-column label="提交时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
v-model:page-num="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@change="load"
|
||||
@size-change="onSizeChange"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-no">{{ detail.order_no }}</span>
|
||||
<el-tag
|
||||
:type="approvalStatusTagType(detail.status)"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
{{ approvalStatusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>业务编号:{{ detail.refer_no }}</span>
|
||||
<span>提交人:{{ detail.submitter_name }}</span>
|
||||
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="step in detail.steps"
|
||||
:key="step.id"
|
||||
class="timeline-row"
|
||||
:class="{ 'is-withdraw': step.node === 0 }"
|
||||
>
|
||||
<div class="timeline-rail">
|
||||
<span
|
||||
class="timeline-dot"
|
||||
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
|
||||
></span>
|
||||
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-node">{{ stepNodeLabel(step.node) }}</span>
|
||||
<span class="timeline-action" :class="{ 'is-reject-text': step.action === 2 }">
|
||||
{{ stepActionLabel(step.action) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
{{ step.operator_name }} · {{ formatDateTime(step.create_time) }}
|
||||
</div>
|
||||
<p v-if="step.opinion" class="timeline-opinion">「{{ step.opinion }}」</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detail.steps.length === 0" class="timeline-empty">暂无节点记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 节点动作:仅在途单展示 -->
|
||||
<template v-if="canAct(detail)">
|
||||
<div class="action-bar">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="actionSubmitting && actionForm.action === 1"
|
||||
@click="openAction(detail, 1)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
:loading="actionSubmitting && actionForm.action === 2"
|
||||
@click="openAction(detail, 2)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
|
||||
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
|
||||
<el-input
|
||||
v-model="actionForm.opinion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
placeholder="审批意见将随节点留痕"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">
|
||||
确认{{ actionForm.action === 1 ? '通过' : '驳回' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
<p v-else class="readonly-note">该单已结束,节点记录只读。</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-no {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||||
.timeline {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
background: var(--ys-indigo);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-dot.is-auto {
|
||||
background: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.timeline-dot.is-reject {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 26px;
|
||||
background: rgba(28, 35, 51, 0.14);
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.timeline-action {
|
||||
font-size: 12px;
|
||||
color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.timeline-action.is-reject-text {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.timeline-opinion {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink);
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.is-withdraw .timeline-node {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.action-form {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.readonly-note {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, 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 改动即时反映在一张
|
||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
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="settings-grid">
|
||||
<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"
|
||||
maxlength="64"
|
||||
placeholder="展示在登录页与导出页眉"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="logo">
|
||||
<div class="logo-field">
|
||||
<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="logo-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>
|
||||
|
||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
|
||||
<el-card shadow="never" class="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">把每一笔回款,算成一份踏实。</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="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.logo-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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.preview-caption {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user