优化
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { loadDashboardOverview } from './overview.ts'
|
||||
|
||||
const dashboardView = readFileSync(new URL('./views/DashboardView.vue', import.meta.url), 'utf8')
|
||||
|
||||
const profile = 'company:profile:view'
|
||||
const list = 'order:first-leg:list'
|
||||
const edit = 'order:first-leg:edit'
|
||||
const company = { tenantName: '示例企业', status: 'ACTIVE' }
|
||||
|
||||
function order(overrides = {}) {
|
||||
return {
|
||||
orderId: 101,
|
||||
orderNo: 'ORDER-101',
|
||||
inboundCode: 'INBOUND-101',
|
||||
destinationWarehouseName: '示例目的仓',
|
||||
channelName: '示例渠道',
|
||||
mainStatus: 'WAIT_MEASURE',
|
||||
customerFeeConfirmationRequired: false,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function draft(overrides = {}) {
|
||||
return { ...order(), status: 'DRAFT', ...overrides }
|
||||
}
|
||||
|
||||
function requests(overrides = {}) {
|
||||
return {
|
||||
company: async () => company,
|
||||
orders: async () => ({ records: [order()] }),
|
||||
drafts: async () => ({ records: [draft()] }),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('only calls sources allowed by their complete permission requirements', async (t) => {
|
||||
const cases = [
|
||||
{ permissions: [], expected: [] },
|
||||
{ permissions: [profile], expected: ['company'] },
|
||||
{ permissions: [list], expected: ['orders'] },
|
||||
{ permissions: [edit], expected: [] },
|
||||
{ permissions: [list, edit], expected: ['orders', 'drafts'] },
|
||||
{ permissions: [profile, list, edit], expected: ['company', 'orders', 'drafts'] },
|
||||
]
|
||||
for (const { permissions, expected } of cases) {
|
||||
await t.test(permissions.join(',') || 'no permissions', async () => {
|
||||
const calls = []
|
||||
const result = await loadDashboardOverview(permissions, requests({
|
||||
company: async () => { calls.push('company'); return company },
|
||||
orders: async () => { calls.push('orders'); return { records: [order()] } },
|
||||
drafts: async () => { calls.push('drafts'); return { records: [draft()] } },
|
||||
}))
|
||||
assert.deepEqual(calls, expected)
|
||||
assert.equal(result.company, expected.includes('company') ? company : null)
|
||||
assert.equal(result.orders.length, expected.includes('orders') ? 1 : 0)
|
||||
assert.equal(result.drafts.length, expected.includes('drafts') ? 1 : 0)
|
||||
assert.deepEqual(result.errors, {})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('one failed source preserves successful sources and exposes its error', async (t) => {
|
||||
for (const failedSource of ['company', 'orders', 'drafts']) {
|
||||
await t.test(failedSource, async () => {
|
||||
const result = await loadDashboardOverview([profile, list, edit], requests({
|
||||
[failedSource]: async () => { throw new Error(`${failedSource} unavailable`) },
|
||||
}))
|
||||
assert.deepEqual(result.errors, { [failedSource]: `${failedSource} unavailable` })
|
||||
assert.equal(result.company, failedSource === 'company' ? null : company)
|
||||
assert.equal(result.orders.length, failedSource === 'orders' ? 0 : 1)
|
||||
assert.equal(result.drafts.length, failedSource === 'drafts' ? 0 : 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('loads sources concurrently and distinguishes a failed source from an empty result', async () => {
|
||||
const pending = new Map()
|
||||
const operation = loadDashboardOverview([profile, list, edit], requests(Object.fromEntries(
|
||||
['company', 'orders', 'drafts'].map((source) => [source, () => new Promise((resolve, reject) => pending.set(source, { resolve, reject }))]),
|
||||
)))
|
||||
assert.deepEqual([...pending.keys()], ['company', 'orders', 'drafts'])
|
||||
pending.get('company').resolve(company)
|
||||
pending.get('orders').resolve({ records: [] })
|
||||
pending.get('drafts').reject(null)
|
||||
const result = await operation
|
||||
assert.deepEqual(result.orders, [])
|
||||
assert.equal(result.errors.orders, undefined)
|
||||
assert.deepEqual(result.drafts, [])
|
||||
assert.equal(result.errors.drafts, '订单草稿加载失败,请重试')
|
||||
})
|
||||
|
||||
test('sorts each source by creation time and includes only actual drafts', async () => {
|
||||
const orders = [
|
||||
order({ orderId: 1, createdAt: '2026-09-16T08:00:00Z' }),
|
||||
order({ orderId: 2, createdAt: '2026-09-18T08:00:00Z', mainStatus: 'COMPLETED', customerFeeConfirmationRequired: true }),
|
||||
]
|
||||
const drafts = [
|
||||
draft({ orderId: 3, createdAt: '2026-09-15T08:00:00Z' }),
|
||||
draft({ orderId: 4, status: 'SUBMITTED', createdAt: '2026-09-19T08:00:00Z' }),
|
||||
draft({ orderId: 5, createdAt: '2026-09-17T08:00:00Z' }),
|
||||
]
|
||||
const result = await loadDashboardOverview([list, edit], requests({
|
||||
orders: async () => ({ records: orders }),
|
||||
drafts: async () => ({ records: drafts }),
|
||||
}))
|
||||
assert.deepEqual(result.orders.map(({ id }) => id), ['2', '1'])
|
||||
assert.deepEqual(result.drafts.map(({ id }) => id), ['5', '3'])
|
||||
assert.equal(result.orders[0].status, 'COMPLETED')
|
||||
assert.equal(result.orders[0].requiresFeeConfirmation, true)
|
||||
assert.ok(result.drafts.every(({ status, requiresFeeConfirmation }) => status === 'DRAFT' && !requiresFeeConfirmation))
|
||||
assert.deepEqual(orders.map(({ orderId }) => orderId), [1, 2])
|
||||
assert.deepEqual(drafts.map(({ orderId }) => orderId), [3, 4, 5])
|
||||
})
|
||||
|
||||
test('keeps absent business numbers null and internal identifiers out of display fields', async () => {
|
||||
const missing = { orderId: 987654, orderNo: null, inboundCode: null, destinationWarehouseName: null, channelName: null }
|
||||
const result = await loadDashboardOverview([list, edit], requests({
|
||||
orders: async () => ({ records: [order(missing)] }),
|
||||
drafts: async () => ({ records: [draft(missing)] }),
|
||||
}))
|
||||
for (const item of [...result.orders, ...result.drafts]) {
|
||||
assert.equal(item.id, '987654')
|
||||
assert.equal(item.orderNo, null)
|
||||
assert.equal(item.inboundCode, null)
|
||||
assert.equal(item.destination, null)
|
||||
assert.equal(item.channel, null)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps all six quick actions visually neutral until hover or keyboard focus', () => {
|
||||
assert.doesNotMatch(dashboardView, /featured|quick-action--featured/)
|
||||
assert.match(dashboardView, /\.quick-action:hover \{[^}]*border-color:[^}]*transform: translateY\(-5px\);/)
|
||||
assert.match(dashboardView, /\.client-dashboard a:focus-visible/)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FirstLegOrderStatus } from '@chongchong/shared'
|
||||
import type { MerchantFirstLegOrderRow, MerchantUnifiedOrderRow } from '@/modules/first-leg/public'
|
||||
import type { MerchantTenantStatus } from '@/modules/tenant-management/public'
|
||||
|
||||
export interface DashboardCompany {
|
||||
tenantName: string
|
||||
status: MerchantTenantStatus
|
||||
}
|
||||
|
||||
export interface DashboardOrder {
|
||||
id: string
|
||||
orderNo: string | null
|
||||
inboundCode: string | null
|
||||
destination: string | null
|
||||
channel: string | null
|
||||
status: FirstLegOrderStatus
|
||||
createdAt: string
|
||||
requiresFeeConfirmation: boolean
|
||||
}
|
||||
|
||||
export type DashboardSource = 'company' | 'orders' | 'drafts'
|
||||
|
||||
export interface DashboardOverview {
|
||||
company: DashboardCompany | null
|
||||
orders: DashboardOrder[]
|
||||
drafts: DashboardOrder[]
|
||||
errors: Partial<Record<DashboardSource, string>>
|
||||
}
|
||||
|
||||
interface DashboardRequests {
|
||||
company: () => Promise<DashboardCompany>
|
||||
orders: () => Promise<{ records: MerchantUnifiedOrderRow[] }>
|
||||
drafts: () => Promise<{ records: MerchantFirstLegOrderRow[] }>
|
||||
}
|
||||
|
||||
const sourceErrorMessages: Record<DashboardSource, string> = {
|
||||
company: '企业资料加载失败,请重试',
|
||||
orders: '订单动态加载失败,请重试',
|
||||
drafts: '订单草稿加载失败,请重试',
|
||||
}
|
||||
|
||||
function newestFirst(records: DashboardOrder[]) {
|
||||
return records.sort((left, right) => (Date.parse(right.createdAt) || 0) - (Date.parse(left.createdAt) || 0))
|
||||
}
|
||||
|
||||
export async function loadDashboardOverview(permissions: readonly string[], requests: DashboardRequests): Promise<DashboardOverview> {
|
||||
const result: DashboardOverview = { company: null, orders: [], drafts: [], errors: {} }
|
||||
const allowed = new Set(permissions)
|
||||
const tasks: Array<{ source: DashboardSource; run: () => Promise<void> }> = []
|
||||
|
||||
if (allowed.has('company:profile:view')) {
|
||||
tasks.push({ source: 'company', run: async () => { result.company = await requests.company() } })
|
||||
}
|
||||
if (allowed.has('order:first-leg:list')) {
|
||||
tasks.push({ source: 'orders', run: async () => {
|
||||
const response = await requests.orders()
|
||||
result.orders = newestFirst(response.records.map((order) => ({
|
||||
id: String(order.orderId),
|
||||
orderNo: order.orderNo ?? null,
|
||||
inboundCode: order.inboundCode ?? null,
|
||||
destination: order.destinationWarehouseName ?? null,
|
||||
channel: order.channelName ?? null,
|
||||
status: order.mainStatus,
|
||||
createdAt: order.createdAt,
|
||||
requiresFeeConfirmation: order.customerFeeConfirmationRequired === true,
|
||||
})))
|
||||
} })
|
||||
if (allowed.has('order:first-leg:edit')) {
|
||||
tasks.push({ source: 'drafts', run: async () => {
|
||||
const response = await requests.drafts()
|
||||
result.drafts = newestFirst(response.records.filter((order) => order.status === 'DRAFT').map((order) => ({
|
||||
id: String(order.orderId),
|
||||
orderNo: order.orderNo ?? null,
|
||||
inboundCode: order.inboundCode ?? null,
|
||||
destination: order.destinationWarehouseName ?? null,
|
||||
channel: order.channelName ?? null,
|
||||
status: 'DRAFT',
|
||||
createdAt: order.createdAt,
|
||||
requiresFeeConfirmation: false,
|
||||
})))
|
||||
} })
|
||||
}
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(tasks.map(async (task) => task.run()))
|
||||
settled.forEach((outcome, index) => {
|
||||
if (outcome.status === 'fulfilled') return
|
||||
const source = tasks[index]!.source
|
||||
result.errors[source] = outcome.reason instanceof Error && outcome.reason.message.trim()
|
||||
? outcome.reason.message
|
||||
: sourceErrorMessages[source]
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const dashboardModule = {
|
||||
name: 'dashboard',
|
||||
component: () => import('./views/DashboardView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' },
|
||||
meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view', workspaceTab: false },
|
||||
navigation: { key: 'dashboard', icon: Gauge },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./DashboardView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('loads real dashboard data allowed by the current permissions', () => {
|
||||
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
|
||||
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
|
||||
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
|
||||
assert.match(source, /onMounted\(async \(\) => \{\s*await loadDashboardData\(\)\s*showQualificationReminderOnce\(\)\s*\}\)/)
|
||||
})
|
||||
|
||||
test('prompts once per session for incomplete company qualification and opens the upload dialog', () => {
|
||||
assert.match(source, /\['PROFILE_REQUIRED', 'REJECTED'\]\.includes\(tenant\.company\.auditStatus\)/)
|
||||
assert.match(source, /sessionStorage\.getItem\(reminderKey\)/)
|
||||
assert.match(source, /sessionStorage\.setItem\(reminderKey, 'shown'\)/)
|
||||
assert.match(source, /title="完善企业认证资料"/)
|
||||
assert.match(source, /立即上传认证资料/)
|
||||
assert.match(source, /router\.push\(\{ path: '\/company\/profile', query: \{ qualification: 'upload' \} \}\)/)
|
||||
})
|
||||
|
||||
test('isolates recoverable dashboard failures without hiding successful sections', () => {
|
||||
assert.match(source, /Promise\.allSettled/)
|
||||
assert.match(source, /Partial<Record<DashboardDataSource, string>>/)
|
||||
assert.match(source, /:aria-busy="loading"/)
|
||||
assert.match(source, /role="alert"/)
|
||||
assert.match(source, /部分业务数据加载失败/)
|
||||
assert.match(source, /ordersUnavailable/)
|
||||
assert.match(source, /companyUnavailable/)
|
||||
assert.match(source, /订单数据暂不可用,请重新加载后查看。/)
|
||||
assert.match(source, /企业资料暂不可用,请重新加载后查看。/)
|
||||
assert.match(source, /@click="loadDashboardData"/)
|
||||
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
|
||||
})
|
||||
|
||||
test('matches the prototype business overview and routes each card to its real business page', () => {
|
||||
assert.match(source, /选择业务,快速下单/)
|
||||
assert.match(source, /海外采购/)
|
||||
assert.match(source, /美国本土卡/)
|
||||
assert.match(source, /美本土散板/)
|
||||
assert.match(source, /route: '\/cross-border\/procurement'/)
|
||||
assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
|
||||
assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
|
||||
assert.match(source, /auth\.hasPermission\(business\.permission\)/)
|
||||
assert.match(source, /order\.status === 'DELIVERED'/)
|
||||
assert.match(source, /currentMonthOrderCount/)
|
||||
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
|
||||
})
|
||||
@@ -1,640 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CircleQuestionMark,
|
||||
Headphones,
|
||||
Lightbulb,
|
||||
PanelsTopLeft,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Truck,
|
||||
} from '@lucide/vue'
|
||||
import { useFirstLegOrdersStore } from '@/modules/first-leg/public'
|
||||
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
||||
import { computed } from 'vue'
|
||||
import { ArrowRight, PanelsTopLeft, RefreshCcw, Ship, ShoppingCart, Tags, Truck } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { FirstLegOrdersView } from '@/modules/first-leg/public'
|
||||
|
||||
const router = useRouter()
|
||||
const orders = useFirstLegOrdersStore()
|
||||
const tenant = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const qualificationReminderOpen = ref(false)
|
||||
type DashboardDataSource = 'company' | 'members' | 'orders'
|
||||
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
|
||||
const dataSourceLabels: Record<DashboardDataSource, string> = {
|
||||
company: '企业资料',
|
||||
members: '企业成员',
|
||||
orders: '头程订单',
|
||||
}
|
||||
|
||||
const companyUnavailable = computed(() => Boolean(loadErrors.value.company))
|
||||
const ordersUnavailable = computed(() => Boolean(loadErrors.value.orders))
|
||||
const loadErrorItems = computed(() =>
|
||||
(Object.keys(loadErrors.value) as DashboardDataSource[]).map((key) => ({
|
||||
key,
|
||||
label: dataSourceLabels[key],
|
||||
message: loadErrors.value[key] ?? '请求失败',
|
||||
})),
|
||||
)
|
||||
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
|
||||
const accountName = computed(() => {
|
||||
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
|
||||
return auth.user?.tenantName || auth.user?.name || '企业客户'
|
||||
})
|
||||
const accountInitial = computed(() => accountName.value.trim().slice(0, 1).toUpperCase() || 'C')
|
||||
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'DELIVERED').length)
|
||||
const currentMonthOrderCount = computed(() => {
|
||||
const now = new Date()
|
||||
return orders.orders.filter((order) => {
|
||||
const createdAt = new Date(order.createdAt)
|
||||
return !Number.isNaN(createdAt.getTime())
|
||||
&& createdAt.getFullYear() === now.getFullYear()
|
||||
&& createdAt.getMonth() === now.getMonth()
|
||||
}).length
|
||||
})
|
||||
const overviewStats = computed(() => [
|
||||
{ label: '进行中订单', value: dashboardOrderValue(orders.activeCount) },
|
||||
{ label: '已完成订单', value: dashboardOrderValue(completedOrderCount.value) },
|
||||
{ label: '本月下单', value: dashboardOrderValue(currentMonthOrderCount.value) },
|
||||
])
|
||||
const businessCards = [
|
||||
{
|
||||
title: '海外采购',
|
||||
description: '采购代购 · 验货质检 · 集中发货',
|
||||
icon: ShoppingCart,
|
||||
tone: 'blue',
|
||||
route: '/cross-border/procurement',
|
||||
permission: 'merchant:cross-border:procurement:list',
|
||||
},
|
||||
{
|
||||
title: '美国本土卡',
|
||||
description: '港口提柜 · 仓到仓 · 仓到门',
|
||||
icon: Truck,
|
||||
tone: 'green',
|
||||
route: '/cross-border/domestic-trucking',
|
||||
permission: 'merchant:cross-border:domestic-trucking:list',
|
||||
},
|
||||
{
|
||||
title: '美本土散板',
|
||||
description: '拼车拼载 · 打盘服务 · 灵活派送',
|
||||
icon: PanelsTopLeft,
|
||||
tone: 'teal',
|
||||
route: '/cross-border/pallet-shipping',
|
||||
permission: 'merchant:cross-border:pallet-shipping:list',
|
||||
},
|
||||
] as const
|
||||
const primaryActions = computed(() => [
|
||||
{ title: '头程下单', description: '国内提货 · 海运订舱 · 清关派送', icon: Ship, route: '/orders/first-leg/create', permission: 'order:first-leg:create', tone: 'blue' },
|
||||
{ title: '退货换标', description: '退件接收 · 标签更换 · 重新入仓', icon: RefreshCcw, route: '/overseas-warehouse/return-relabel/create', permission: 'merchant:overseas-warehouse:return-order:create', tone: 'purple' },
|
||||
{ title: '美国本土卡', description: '港口提柜 · 仓到仓 · 仓到门', icon: Truck, route: '/cross-border/domestic-trucking/create', permission: 'merchant:cross-border:domestic-trucking:create', tone: 'green' },
|
||||
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: PanelsTopLeft, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan' },
|
||||
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange' },
|
||||
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose' },
|
||||
].filter((action) => auth.hasPermission(action.permission)))
|
||||
|
||||
function dashboardOrderValue(value: number) {
|
||||
if (!canViewOrders.value || ordersUnavailable.value) return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
loading.value = true
|
||||
loadErrors.value = {}
|
||||
const requests: Array<{ source: DashboardDataSource; request: Promise<unknown> }> = []
|
||||
if (auth.hasPermission('company:profile:view')) requests.push({ source: 'company', request: tenant.loadCompanyContext() })
|
||||
if (auth.hasPermission('company:member:list')) requests.push({ source: 'members', request: tenant.loadMembers() })
|
||||
if (auth.hasPermission('order:first-leg:list')) requests.push({ source: 'orders', request: orders.load() })
|
||||
try {
|
||||
const results = await Promise.allSettled(requests.map(({ request }) => request))
|
||||
const nextErrors: Partial<Record<DashboardDataSource, string>> = {}
|
||||
results.forEach((result, index) => {
|
||||
const request = requests[index]
|
||||
if (request && result.status === 'rejected') nextErrors[request.source] = errorMessage(result.reason)
|
||||
})
|
||||
loadErrors.value = nextErrors
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showQualificationReminderOnce() {
|
||||
if (companyUnavailable.value || !auth.hasPermission('company:qualification:upload')) return
|
||||
if (!['PROFILE_REQUIRED', 'REJECTED'].includes(tenant.company.auditStatus)) return
|
||||
const reminderKey = `client-qualification-reminder:${auth.user?.id ?? 'current'}`
|
||||
if (sessionStorage.getItem(reminderKey)) return
|
||||
sessionStorage.setItem(reminderKey, 'shown')
|
||||
qualificationReminderOpen.value = true
|
||||
}
|
||||
|
||||
async function openQualificationUpload() {
|
||||
qualificationReminderOpen.value = false
|
||||
await router.push({ path: '/company/profile', query: { qualification: 'upload' } })
|
||||
}
|
||||
|
||||
function openBusinessOrder(business: typeof businessCards[number]) {
|
||||
if (auth.hasPermission(business.permission)) {
|
||||
void router.push(business.route)
|
||||
return
|
||||
}
|
||||
ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
|
||||
}
|
||||
|
||||
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
|
||||
const messages = {
|
||||
service: '客服联系方式正在配置中,请联系您的专属客户经理。',
|
||||
faq: '常见问题页面即将上线。',
|
||||
quote: '提交订单后,客户经理将根据货物信息为您提供报价。',
|
||||
}
|
||||
ElMessage.info(messages[type])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
showQualificationReminderOnce()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="client-dashboard" :aria-busy="loading">
|
||||
<el-dialog v-model="qualificationReminderOpen" title="完善企业认证资料" width="min(520px, 92vw)" append-to-body>
|
||||
<p class="qualification-reminder-copy">请先上传营业执照和统一社会信用代码并提交审核;法人身份证及其他资质可选填。审核通过后即可提交头程订单。</p>
|
||||
<template #footer><el-button @click="qualificationReminderOpen = false">稍后处理</el-button><el-button type="primary" @click="openQualificationUpload">立即上传认证资料</el-button></template>
|
||||
</el-dialog>
|
||||
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
|
||||
<AlertTriangle :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>部分业务数据加载失败</strong>
|
||||
<ul>
|
||||
<li v-for="item in loadErrorItems" :key="item.key">
|
||||
<span>{{ item.label }}:</span>{{ item.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
|
||||
</div>
|
||||
<div class="client-dashboard">
|
||||
<header class="business-intro">
|
||||
<h1>选择业务,快速下单</h1>
|
||||
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<main class="dashboard-business" aria-labelledby="dashboard-heading">
|
||||
<header class="dashboard-intro">
|
||||
<h2 id="dashboard-heading">选择业务,快速下单</h2>
|
||||
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
|
||||
</header>
|
||||
<section class="quick-actions" aria-label="业务选择">
|
||||
<RouterLink v-for="(action, index) in primaryActions" :key="action.route" :to="action.route" class="quick-action" :class="`quick-action--${action.tone}`" :style="{ '--entry-index': index }">
|
||||
<span class="action-icon"><component :is="action.icon" :size="23" :stroke-width="2.1" aria-hidden="true" /></span>
|
||||
<span class="action-copy"><strong>{{ action.title }}</strong><small>{{ action.description }}</small></span>
|
||||
<span class="action-enter">开始下单<ArrowRight :size="15" aria-hidden="true" /></span>
|
||||
</RouterLink>
|
||||
<p v-if="!primaryActions.length" class="no-actions">当前账号暂无常用业务权限,请通过已授权菜单办理业务,或联系企业管理员。</p>
|
||||
</section>
|
||||
|
||||
<section class="business-card-grid" aria-label="可选业务">
|
||||
<button
|
||||
v-for="business in businessCards"
|
||||
:key="business.title"
|
||||
type="button"
|
||||
class="business-card"
|
||||
:class="`business-card--${business.tone}`"
|
||||
@click="openBusinessOrder(business)"
|
||||
>
|
||||
<span class="business-card__icon" aria-hidden="true">
|
||||
<component :is="business.icon" :size="29" :stroke-width="2.4" />
|
||||
</span>
|
||||
<span class="business-card__copy">
|
||||
<strong>{{ business.title }}</strong>
|
||||
<small>{{ business.description }}</small>
|
||||
</span>
|
||||
<span class="business-card__action">
|
||||
{{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
|
||||
<ArrowRight :size="18" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="dashboard-aside" aria-label="账户与业务信息">
|
||||
<section class="aside-card account-card">
|
||||
<h3>账户信息</h3>
|
||||
<div class="account-summary">
|
||||
<span class="account-avatar">{{ accountInitial }}</span>
|
||||
<span class="account-copy">
|
||||
<strong>{{ accountName }}</strong>
|
||||
<small>企业客户</small>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="companyUnavailable" class="account-load-error" role="status">企业资料暂不可用,请重新加载后查看。</p>
|
||||
</section>
|
||||
|
||||
<section class="aside-card">
|
||||
<h3>数据概览</h3>
|
||||
<dl class="overview-stats">
|
||||
<div v-for="stat in overviewStats" :key="stat.label">
|
||||
<dt>{{ stat.label }}</dt>
|
||||
<dd>{{ stat.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</p>
|
||||
</section>
|
||||
|
||||
<section class="aside-card">
|
||||
<h3>帮助与支持</h3>
|
||||
<div class="support-links">
|
||||
<button type="button" @click="showSupportMessage('service')"><Headphones :size="19" /><span>联系客服</span></button>
|
||||
<button type="button" @click="showSupportMessage('faq')"><CircleQuestionMark :size="19" /><span>常见问题</span></button>
|
||||
<button type="button" @click="showSupportMessage('quote')"><Tags :size="19" /><span>获取报价</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-tip">
|
||||
<Lightbulb :size="19" aria-hidden="true" />
|
||||
<p>提交订单后,专属客户经理将在 24 小时内与您联系,确认运输方案与报价。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<FirstLegOrdersView v-if="auth.hasPermission('order:first-leg:list')" :show-create-action="false" class="dashboard-order-list" />
|
||||
<section v-else class="orders-permission-empty">
|
||||
<strong>暂无头程订单查看权限</strong>
|
||||
<span>需要查看企业订单时,请联系企业管理员开通。</span>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.client-dashboard {
|
||||
--dashboard-blue: #1565c0;
|
||||
--dashboard-green: #3f9f47;
|
||||
--dashboard-teal: #078a99;
|
||||
display: block;
|
||||
min-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
.dashboard-load-error {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
|
||||
|
||||
.dashboard-load-error ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-load-error li {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-load-error li span {
|
||||
color: #9a5a09;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 286px;
|
||||
gap: clamp(24px, 3vw, 42px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-business {
|
||||
min-width: 0;
|
||||
padding: 14px 0 24px;
|
||||
}
|
||||
|
||||
.dashboard-intro {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.dashboard-intro h2 {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: clamp(30px, 3.2vw, 46px);
|
||||
line-height: 1.18;
|
||||
letter-spacing: -.025em;
|
||||
}
|
||||
|
||||
.dashboard-intro p {
|
||||
max-width: 720px;
|
||||
margin: 16px 0 0;
|
||||
color: #697586;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.business-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
--card-accent: var(--dashboard-blue);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 286px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 30px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 36px rgba(23, 50, 77, .08);
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
.business-card--green { --card-accent: var(--dashboard-green); }
|
||||
.business-card--teal { --card-accent: var(--dashboard-teal); }
|
||||
|
||||
.business-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 46px rgba(23, 50, 77, .14);
|
||||
}
|
||||
|
||||
.business-card:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--card-accent) 36%, white);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.business-card__icon {
|
||||
display: grid;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: var(--card-accent);
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--card-accent) 24%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.business-card__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.business-card__copy strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.business-card__copy small {
|
||||
color: #697586;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.business-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 28px;
|
||||
color: var(--card-accent);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.business-card__action svg {
|
||||
transition: transform .2s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
.business-card:hover .business-card__action svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aside-card {
|
||||
padding: 20px;
|
||||
border: 1px solid #e9eef5;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aside-card h3 {
|
||||
margin: 0 0 18px;
|
||||
color: #8b9bb2;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #1976d2, #00a993);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.account-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.account-copy strong {
|
||||
overflow: hidden;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-copy small {
|
||||
color: #66758a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-load-error {
|
||||
margin: 14px 0 0;
|
||||
color: #8a5a22;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overview-stats div {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px dashed #dfe6ef;
|
||||
}
|
||||
|
||||
.overview-stats div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-stats dt {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.overview-stats dd {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.dashboard-section-error {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 0 0;
|
||||
border-top: 1px solid #e9eef5;
|
||||
color: #8a5a22;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.support-links {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.support-links button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-links button svg {
|
||||
flex: 0 0 19px;
|
||||
color: #00a993;
|
||||
}
|
||||
|
||||
.support-links button:hover {
|
||||
background: #edf2f7;
|
||||
color: var(--dashboard-blue);
|
||||
}
|
||||
|
||||
.support-links button:focus-visible {
|
||||
outline: 2px solid var(--dashboard-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 169, 147, .24);
|
||||
border-radius: 14px;
|
||||
background: #eef9f8;
|
||||
color: #53657a;
|
||||
}
|
||||
|
||||
.dashboard-tip svg {
|
||||
margin-top: 2px;
|
||||
color: #00a993;
|
||||
}
|
||||
|
||||
.dashboard-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.business-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
min-height: 232px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.client-dashboard {
|
||||
min-height: calc(100vh - 118px);
|
||||
}
|
||||
|
||||
.dashboard-business {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.dashboard-intro {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-intro h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.dashboard-intro p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.business-card__copy {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.business-card,
|
||||
.business-card__action svg {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.client-dashboard { display: flex; min-width: 0; container-name: dashboard-content; container-type: inline-size; flex-direction: column; gap: 14px; padding: 0 8px 18px; color: var(--navy); animation: dashboard-enter .3s ease-out both; }
|
||||
.business-intro { padding-top: 0; }
|
||||
.business-intro h1 { margin: 0; color: #172033; font-size: 27px; font-weight: 750; letter-spacing: -.5px; line-height: 1.3; }
|
||||
.business-intro p { margin: 5px 0 0; color: #7a8495; font-size: 13px; line-height: 1.55; }
|
||||
.quick-actions { display: grid; grid-template-columns: repeat(6, minmax(188px, 1fr)); gap: 10px; overflow-x: auto; padding: 0 0 6px; scroll-snap-type: x proximity; scrollbar-color: #bdc9dc transparent; scrollbar-width: thin; }
|
||||
.quick-actions::-webkit-scrollbar { height: 7px; }
|
||||
.quick-actions::-webkit-scrollbar-thumb { border-radius: 999px; background: #bdc9dc; }
|
||||
.quick-actions::-webkit-scrollbar-track { background: transparent; }
|
||||
.quick-action { scroll-snap-align: start; }
|
||||
.quick-action { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); position: relative; display: flex; min-width: 0; min-height: 154px; overflow: hidden; align-items: center; flex-direction: column; padding: 15px 9px 13px; border: 1px solid rgba(148, 163, 184, .22); border-radius: 14px; background: linear-gradient(155deg, #fff 8%, rgba(248, 250, 252, .96) 100%); color: inherit; text-align: center; text-decoration: none; box-shadow: 0 8px 22px rgba(15, 23, 42, .055); animation: action-enter .4s cubic-bezier(.22, 1, .36, 1) both; animation-delay: calc(var(--entry-index) * 55ms); transition: border-color .35s ease, box-shadow .35s ease, transform .35s cubic-bezier(.22, 1, .36, 1); }
|
||||
.quick-action::before { position: absolute; width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle, var(--action-soft), transparent 70%); content: ''; right: -62px; top: -66px; transition: transform .4s ease; }
|
||||
.quick-action::after { position: absolute; right: 20%; bottom: 0; left: 20%; height: 3px; border-radius: 99px 99px 0 0; background: linear-gradient(90deg, var(--action-a), var(--action-b)); content: ''; opacity: .55; transform: scaleX(.35); transition: all .35s ease; }
|
||||
.quick-action--blue { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); }
|
||||
.quick-action--purple { --action-a: #8b5cf6; --action-b: #c026d3; --action-soft: rgba(139, 92, 246, .18); }
|
||||
.quick-action--green { --action-a: #059669; --action-b: #0f766e; --action-soft: rgba(5, 150, 105, .18); }
|
||||
.quick-action--cyan { --action-a: #0891b2; --action-b: #0e7490; --action-soft: rgba(8, 145, 178, .18); }
|
||||
.quick-action--orange { --action-a: #f59e0b; --action-b: #dc6b12; --action-soft: rgba(245, 158, 11, .2); }
|
||||
.quick-action--rose { --action-a: #e11d48; --action-b: #be185d; --action-soft: rgba(225, 29, 72, .17); }
|
||||
.quick-action:hover { border-color: color-mix(in srgb, var(--action-a) 35%, transparent); box-shadow: 0 16px 34px rgba(15, 23, 42, .12); transform: translateY(-5px); }
|
||||
.quick-action:hover::before { transform: scale(1.25); }
|
||||
.quick-action:hover::after { right: 12%; left: 12%; opacity: 1; transform: scaleX(1); }
|
||||
.action-icon { position: relative; display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; margin-bottom: 11px; border-radius: 14px; background: linear-gradient(135deg, var(--action-a), var(--action-b)); color: #fff; box-shadow: 0 8px 17px color-mix(in srgb, var(--action-a) 24%, transparent); transition: transform .35s ease; }
|
||||
.action-icon::after { position: absolute; border: 1px solid color-mix(in srgb, var(--action-a) 20%, transparent); border-radius: 18px; content: ''; inset: -4px; }
|
||||
.quick-action:hover .action-icon { transform: translateY(-3px) scale(1.06) rotate(-3deg); }
|
||||
.action-copy { position: relative; display: flex; min-width: 0; width: 100%; align-items: center; flex-direction: column; }
|
||||
.action-copy strong { color: #172033; font-size: 15px; font-weight: 750; line-height: 1.35; white-space: nowrap; }
|
||||
.action-copy small { overflow: hidden; width: 100%; margin-top: 4px; color: #718096; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.action-enter { position: relative; display: inline-flex; align-items: center; gap: 5px; margin-top: auto; padding-top: 7px; color: var(--action-a); font-size: 10px; font-weight: 700; }
|
||||
.action-enter svg { transition: transform .25s ease; }
|
||||
.quick-action:hover .action-enter svg { transform: translateX(4px); }
|
||||
.no-actions { margin: 0; padding: 20px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #667085; font-size: 14px; }
|
||||
.dashboard-order-list { min-width: 0; }
|
||||
.orders-permission-empty { display: flex; min-height: 180px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #7b8494; }
|
||||
.orders-permission-empty strong { color: #475467; font-size: 15px; }
|
||||
.orders-permission-empty span { font-size: 12px; }
|
||||
.client-dashboard a:focus-visible { border-radius: 6px; outline: 2px solid var(--primary); outline-offset: 4px; }
|
||||
@keyframes dashboard-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes action-enter { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@container dashboard-content (max-width: 1180px) { .quick-actions { grid-template-columns: repeat(6, minmax(178px, 1fr)); } }
|
||||
@media (max-width: 720px) { .client-dashboard { padding: 0; gap: 16px; } .business-intro h1 { font-size: 25px; } .quick-actions { grid-template-columns: repeat(2, minmax(150px, 1fr)); } }
|
||||
@media (max-width: 420px) { .quick-actions { grid-template-columns: repeat(6, minmax(168px, 1fr)); } }
|
||||
@media (prefers-reduced-motion: reduce) { .client-dashboard, .quick-action { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user