This commit is contained in:
2026-10-20 23:34:11 +08:00
parent f155976f52
commit 13e4aa56ec
321 changed files with 6756 additions and 2110 deletions
+3 -1
View File
@@ -29,10 +29,12 @@
.admin-density .el-empty__description { margin-top: 0; }
.admin-density .el-empty__description p { font-size: var(--admin-font-body, 13px); }
.admin-density-dialog .el-form-item__label { line-height: 22px; }
.admin-density-dialog .el-form--inline .el-form-item__label { align-items: center; }
.admin-density-dialog .el-descriptions__label.el-descriptions__cell { font-size: var(--admin-font-caption, 12px); background: var(--admin-label-bg); }
.admin-density-dialog .el-descriptions__content.el-descriptions__cell { font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; }
.admin-density-dialog.el-dialog.is-fullscreen { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
.admin-density-dialog.el-dialog.admin-density-workspace:not(.is-fullscreen) { height: 92vh; max-height: 92vh; margin-top: 4vh; }
.admin-density-dialog.el-dialog.admin-density-workspace:not(.is-fullscreen) { height: 92vh; max-height: 92vh; margin: 4vh auto; }
.admin-density-dialog.el-dialog.admin-density-workspace > .el-dialog__body { flex: 1 1 auto; }
.admin-density-dialog .el-dialog__headerbtn { top: 0; }
.admin-density-dialog .app-dialog__native-fullscreen-control { top: 6px; }
.admin-density-confirm.el-message-box { --el-font-size-base: var(--admin-font-body); border-radius: var(--admin-radius, 4px); padding: var(--admin-space-5, 12px) var(--admin-space-6, 16px); }
+142 -32
View File
@@ -97,7 +97,7 @@ const notificationConsumers = [
'notifications/components/TemplateEditorDialog.vue',
]
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/components/InventoryTurnoverView.vue']
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
const consumers = [
...selfOperatedConsumers,
@@ -156,14 +156,53 @@ const densityPages = [...new Set([
])]
type TemplateNode = NonNullable<NonNullable<ReturnType<typeof parse>['descriptor']['template']>['ast']>['children'][number]
type ElementNode = Extract<TemplateNode, { type: 1 }>
const classesOf = (node: ElementNode, preload = true, dense = preload, workbench = 'DOMESTIC'): string[] => node.props.flatMap(prop => {
const classesOf = (node: ElementNode, preload = true, dense = preload, region = 'DOMESTIC', workbench = false, embedded = false): string[] => node.props.flatMap(prop => {
if (prop.type === 6 && prop.name === 'class') return prop.value?.content.split(/\s+/) ?? []
if (prop.type !== 7 || prop.name !== 'bind' || prop.arg?.type !== 4 || prop.arg.content !== 'class' || prop.exp?.type !== 4 || !prop.exp.content.includes('density')) return []
// Evaluate only the authored density class binding with the shared component's two modes.
const value = new Function('mode', 'isPreload', 'dense', 'isDomestic', 'isOverseas', `return (${prop.exp.content})`)(preload ? 'preload' : 'confirmed', preload, dense, workbench === 'DOMESTIC', workbench === 'OVERSEAS') as Record<string, boolean>
// Keep visual opt-in and embedding independent from the shared component's density/region modes.
const value = new Function('mode', 'isPreload', 'dense', 'isDomestic', 'isOverseas', 'workbench', 'embedded', `return (${prop.exp.content})`)(preload ? 'preload' : 'confirmed', preload, dense, region === 'DOMESTIC', region === 'OVERSEAS', workbench, embedded) as Record<string, boolean>
const flatten = (value: unknown): string[] => typeof value === 'string' ? value.split(/\s+/) : Array.isArray(value) ? value.flatMap(flatten) : value && typeof value === 'object' ? Object.entries(value).filter(([, enabled]) => enabled).flatMap(([names]) => names.split(/\s+/)) : []
return flatten(value)
})
const elementsOf = (source: string, tag?: string): ElementNode[] => {
const parsed = parse(source)
expect(parsed.errors).toEqual([])
const result: ElementNode[] = []
const visit = (nodes: TemplateNode[]) => {
for (const node of nodes) {
if (node.type !== 1) continue
if (!tag || node.tag === tag) result.push(node)
visit(node.children)
}
}
visit(parsed.descriptor.template!.ast!.children)
return result
}
const expectWorkbenchDialogs = (source: string) => {
const dialogs = elementsOf(source, 'AppDialog')
expect(dialogs.length).toBeGreaterThan(0)
for (const dialog of dialogs) {
expect(classesOf(dialog), dialog.loc.source.split('>')[0]).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
}
return dialogs
}
const confirmationClasses = (source: string): string[][] => {
const script = source.includes('<script') ? parse(source).descriptor.scriptSetup!.content : source
const ast = ts.createSourceFile('confirmation.ts', script, ts.ScriptTarget.Latest, true)
const result: string[][] = []
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.expression.getText(ast) === 'ElMessageBox') {
const options = node.arguments[2]
expect(options && ts.isObjectLiteralExpression(options)).toBe(true)
const customClass = options && ts.isObjectLiteralExpression(options) ? options.properties.find(prop => ts.isPropertyAssignment(prop) && prop.name.getText(ast) === 'customClass') : undefined
expect(customClass && ts.isPropertyAssignment(customClass) && ts.isStringLiteral(customClass.initializer)).toBe(true)
if (customClass && ts.isPropertyAssignment(customClass) && ts.isStringLiteral(customClass.initializer)) result.push(customClass.initializer.text.split(/\s+/))
}
ts.forEachChild(node, visit)
}
visit(ast)
return result
}
const styles = [
...parse(read('shared/components/BatchImportDialog.vue')).descriptor.styles.map(style => ({ file: 'BatchImportDialog.vue', css: style.content })),
...consumers.flatMap(file => parse(read(`modules/${file}`)).descriptor.styles.map(style => ({ file, css: style.content }))),
@@ -178,6 +217,24 @@ const cssRoot = (css: string) => {
}
describe('approved Admin density ownership', () => {
it('keeps sailing cutoff controls compact and delegates editor borders to the shared opt-in', () => {
const view = parse(read('modules/sailing-calendar/pages/SailingCalendarView.vue')).descriptor
const rules = new Map<string, Record<string, string>>()
for (const style of view.styles) cssRoot(style.content).walkRules(rule => {
const values: Record<string, string> = {}
rule.walkDecls(decl => { values[decl.prop] = decl.value })
rules.set(rule.selector, values)
})
expect(rules.get('.sailing-entry-label')).toEqual({ width: '116px' })
expect(rules.get('.cutoff-presets')).toEqual({ flex: '0 0 auto' })
expect(rules.get('.sailing-date-editor')).toMatchObject({ width: '280px', 'max-width': '100%' })
expect(rules.get('.cutoff-rule-row, .cutoff-time-row')).toMatchObject({ 'flex-wrap': 'wrap' })
expect(view.template!.content).toContain('density-table-editor density-table-editor--outlined cutoff-days-editor')
expect(view.template!.content).toContain('density-table-editor--outlined cutoff-time-editor')
expect(view.template!.content).toContain('role="status" aria-live="polite"')
expect(view.styles.map(style => style.content).join('\n')).not.toContain('--el-input-border-color')
})
it('outlines only the three opted-in customer billing editors without overriding native focus or disabled styles', () => {
const rules = new Map<string, Record<string, string>>()
cssRoot(shared).walkRules(rule => {
@@ -362,7 +419,8 @@ describe('approved Admin density ownership', () => {
it('opts transfer approval into the shared centered confirmation appearance', () => {
const dialog = read('modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
const css = read('styles/adminDensity.css')
expect(dialog).toContain('class="admin-density-dialog density-confirm-dialog"')
const [frame] = expectWorkbenchDialogs(dialog)
expect(classesOf(frame!)).toContain('density-confirm-dialog')
expect(dialog).toContain('align-center')
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog:not(.is-fullscreen) { margin: auto; }')
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }')
@@ -399,7 +457,8 @@ describe('approved Admin density ownership', () => {
expect(dialogs).toHaveLength(3)
for (const dialog of dialogs) { expect(dialog).toContain('admin-density-dialog'); expect(dialog).toContain('fullscreenable') }
expect(view.template!.content).toContain('density-table density-table--editable')
expect(view.template!.content).toContain('label-position="left" label-width="110px"')
expect(view.template!.content).toContain('label-position="left" label-width="150px"')
expect(view.template!.content).toContain('<el-date-picker v-model="plannedAt" type="datetime"')
expect(view.styles.map(style => style.content).join('\n')).not.toContain('arrival-field')
expect(create.template!.content).toContain('aria-label="预约保存回执"')
expect(create.template!.content).not.toContain('<FbaAppointmentFields')
@@ -421,7 +480,8 @@ describe('approved Admin density ownership', () => {
expect(page.template!.content).toMatch(/class="[^"]*density-status-tabs[^"]*" role="tablist"/)
expect(page.styles.map((style) => style.content).join('\n')).not.toMatch(/\.(status-tabs?|supplier-tabs|container-scope-tabs)\s*[{:.]/)
}
expect(read('modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue')).toContain('class="density-element-tabs density-status-tabs"')
const returnTabs = elementsOf(read('modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue'), 'el-tabs')
expect(returnTabs.some(node => ['density-element-tabs', 'density-status-tabs'].every(name => classesOf(node).includes(name)))).toBe(true)
const selectors: string[] = []
cssRoot(shared).walkRules((rule) => { selectors.push(...rule.selectors) })
expect(selectors).toContain('.admin-density .density-status-tabs > button.is-active::after')
@@ -436,15 +496,21 @@ describe('approved Admin density ownership', () => {
it('keeps dashboard visualizations outside detail-field table styling', () => {
const dashboard = read('modules/dashboard/pages/DashboardView.vue')
expect(dashboard).toContain('class="dashboard-metrics"')
expect(dashboard).toContain('class="dashboard-metric-mark"')
const metrics = elementsOf(dashboard, 'div').filter(node => classesOf(node).includes('dashboard-metrics'))
expect(metrics).toHaveLength(1)
const cards = elementsOf(dashboard, 'article').filter(node => classesOf(node).includes('dashboard-metric'))
expect(cards).toHaveLength(1)
expect(classesOf(cards[0]!)).toEqual(expect.arrayContaining(['workbench-metric', 'workbench-tone']))
const icon = elementsOf(dashboard, 'span').find(node => classesOf(node).includes('workbench-icon'))
expect(icon).toBeDefined()
expect(icon!.children.some(node => node.type === 1 && node.tag === 'component')).toBe(true)
expect(dashboard).not.toContain('density-fields')
})
it('registers the self-operated dashboard with an explicit density root and chart layout', () => {
const source = read('modules/self-operated/pages/OverviewView.vue')
expect(source).toContain("@/styles/adminDensity.css")
expect(source).toContain('<AdminPage class="admin-density dashboard-page">')
expect(source).toContain('<AdminPage class="admin-density admin-workbench dashboard-page">')
expect(source).toContain('class="dashboard-kpis"')
expect(source).toContain('class="dashboard-main-charts"')
expect(source).not.toContain('density-fields')
@@ -521,7 +587,8 @@ describe('approved Admin density ownership', () => {
const source = read('modules/warehouses/pages/WarehousesView.vue')
expect(source).toContain('class="density-form density-dialog-sections"')
expect(source).toContain('density-table density-table--editable warehouse-editor-table')
expect(source).toContain('class="warehouse-import-dialog admin-density-dialog"')
const importer = expectWorkbenchDialogs(source).find(node => classesOf(node).includes('warehouse-import-dialog'))
expect(importer).toBeDefined()
expect(source).toContain('class="density-table warehouse-import-summary"')
expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).not.toContain('<FormSection')
@@ -591,7 +658,7 @@ describe('approved Admin density ownership', () => {
it('keeps download task detail on shared facts, error and footer primitives', () => {
const source = read('modules/downloads/pages/DownloadCenterView.vue')
const dialog = source.slice(source.indexOf('<AppDialog'), source.indexOf('</AppDialog>'))
expect(dialog).toContain('class="admin-density-dialog"')
expectWorkbenchDialogs(source)
expect(dialog).toContain('fullscreenable')
expect(dialog).toContain('data-tone="summary"')
expect(dialog).toContain('class="task-detail-grid density-fields"')
@@ -606,7 +673,7 @@ describe('approved Admin density ownership', () => {
const list = read('modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
const detail = read('modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue')
const dialog = list.slice(list.indexOf('<AppDialog'), list.indexOf('</AppDialog>'))
expect(dialog).toContain('class="admin-density-dialog"')
expectWorkbenchDialogs(list)
expect(dialog).toContain('width="min(560px, calc(100vw - 28px))"')
expect(dialog).toContain(':confirm-loading="uploading"')
expect(dialog).toContain('class="density-table density-table--editable"')
@@ -621,7 +688,9 @@ describe('approved Admin density ownership', () => {
const editor = read('modules/overseas-master-data/components/FleetQuoteEditor.vue')
const importer = read('modules/overseas-master-data/components/FleetQuoteImport.vue')
for (const source of [editor, importer]) {
expect(source).toContain('class="admin-density-dialog" fullscreenable :confirm-loading="saving"')
const [dialog] = expectWorkbenchDialogs(source)
expect(dialog!.props.some(prop => prop.type === 6 && prop.name === 'fullscreenable')).toBe(true)
expect(dialog!.props.some(prop => prop.type === 7 && prop.name === 'bind' && prop.arg?.type === 4 && prop.arg.content === 'confirm-loading' && prop.exp?.type === 4 && prop.exp.content === 'saving')).toBe(true)
expect(source).toContain('density-table--editable')
expect(source).toContain('data-tone="work"')
expect(source).toContain('data-tone="source"')
@@ -632,19 +701,20 @@ describe('approved Admin density ownership', () => {
}
expect(editor).toContain('v-if="readonly"')
expect(editor).toContain('class="density-fields fleet-quote-facts"')
expect(read('modules/overseas-master-data/pages/FleetQuotesView.vue')).toContain("customClass: 'admin-density-confirm'")
const confirmations = confirmationClasses(read('modules/overseas-master-data/pages/FleetQuotesView.vue'))
expect(confirmations.length).toBeGreaterThan(0)
for (const classes of confirmations) expect(classes).toContain('admin-density-confirm')
})
it('keeps pricing dialogs on shared tables and sections without local form paint', () => {
const rate = read('modules/overseas-master-data/pages/ExchangeRatesView.vue')
const quote = read('modules/overseas-master-data/pages/OverseasQuotesView.vue')
for (const source of [rate, quote]) {
const dialogs = parse(source).descriptor.template!.content.match(/<AppDialog[\s\S]*?<\/AppDialog>/g)!
const dialogs = expectWorkbenchDialogs(source)
expect(dialogs.length).toBeGreaterThan(0)
for (const dialog of dialogs) {
expect(dialog).toContain('class="admin-density-dialog"')
expect(dialog).not.toContain('label-position="top"')
expect(dialog).not.toContain('#header')
expect(dialog.loc.source).not.toContain('label-position="top"')
expect(dialog.loc.source).not.toContain('#header')
}
expect(source).toContain('density-table--editable')
expect(source).not.toContain('line-grid')
@@ -660,10 +730,13 @@ describe('approved Admin density ownership', () => {
it('keeps the supplier license dialog styled at its own teleported root', () => {
const source = read('modules/suppliers/components/SupplierLicenseDialog.vue')
expect(source).toContain('<AppDialog class="admin-density-dialog"')
expectWorkbenchDialogs(source)
expect(source).toContain('width="min(560px, calc(100vw - 24px))"')
expect(source).toContain('aria-label="营业执照信息"')
expect(source).toContain('class="density-dialog-section" data-tone="source"')
const sections = elementsOf(source, 'section')
expect(sections).toHaveLength(1)
expect(classesOf(sections[0]!)).toEqual(expect.arrayContaining(['density-dialog-section', 'workbench-evidence']))
expect(sections[0]!.props.some(prop => prop.type === 6 && prop.name === 'data-tone' && prop.value?.content === 'source')).toBe(true)
expect(source).toContain('class="density-table license-info-table"')
expect(source).not.toContain('license-card')
expect(source).not.toContain('box-shadow')
@@ -731,10 +804,16 @@ describe('approved Admin density ownership', () => {
expect(source).toContain('dense?: boolean')
const ast = parse(source).descriptor.template!.ast!
const root = ast.children.find(node => node.type === 1) as ElementNode
expect(classesOf(root, false, true).some(name => name.startsWith('admin-density')), file).toBe(true)
expect(classesOf(root, false, false).some(name => name.startsWith('admin-density')), file).toBe(false)
for (const workbench of [false, true]) {
expect(classesOf(root, false, true, 'DOMESTIC', workbench).some(name => name.startsWith('admin-density')), file).toBe(true)
expect(classesOf(root, false, false, 'DOMESTIC', workbench).some(name => name.startsWith('admin-density')), file).toBe(false)
if (file.endsWith('OverseasDetailShell.vue')) {
for (const dense of [false, true]) expect(classesOf(root, false, dense, 'DOMESTIC', workbench).includes('admin-workbench')).toBe(workbench)
}
}
}
expect(read('modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue')).toContain(':dense="dense"')
expect(read('modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue')).toContain(':workbench="workbench"')
expect(read('modules/overseas-warehouse/pages/InventoryTransactionsView.vue')).toContain('<InboundCodeRelationshipDialog dense')
expect(read('modules/inbounds/pages/InboundOrdersView.vue')).toContain('<InboundCodeRelationshipDialog dense')
for (const file of ['CartonDetailView', 'ProductDetailView']) expect(read('modules/overseas-warehouse/pages/' + file + '.vue')).not.toContain(' dense')
@@ -761,8 +840,14 @@ describe('approved Admin density ownership', () => {
for (const file of ['loading-lists/components/LoadingListDetailDrawer.vue', 'port-schedules/components/CarrierScheduleQueryPanel.vue']) {
const ast = parse(read(`modules/${file}`)).descriptor.template!.ast!
const root = ast.children.find(node => node.type === 1) as ElementNode
expect(classesOf(root, false, true)).toContain('admin-density')
expect(classesOf(root, false, false)).not.toContain('admin-density')
for (const embedded of [false, true]) {
expect(classesOf(root, false, true, 'DOMESTIC', false, embedded)).toContain('admin-density')
expect(classesOf(root, false, false, 'DOMESTIC', false, embedded)).not.toContain('admin-density')
if (file.includes('LoadingListDetail')) {
expect(classesOf(root, false, true, 'DOMESTIC', false, embedded).includes('workbench-panel')).toBe(!embedded)
expect(classesOf(root, false, true, 'DOMESTIC', false, embedded).includes('is-compact')).toBe(!embedded)
}
}
if (file.includes('CarrierSchedule')) {
const dialog = root.children.find(node => node.type === 1 && node.tag === 'AppDialog') as ElementNode
expect(classesOf(dialog, false, true)).toContain('admin-density-dialog')
@@ -883,11 +968,12 @@ describe('approved Admin density ownership', () => {
it('keeps the collection monitor in the Admin theme without legacy list controls', () => {
const page = read('modules/self-operated/pages/CollectionView.vue')
const monitor = read('modules/self-operated/components/CollectionAccountMonitor.vue')
expect(page).toContain('class="admin-density"')
expect(page).toContain('class="admin-density admin-workbench"')
expect(page).toContain('@/styles/adminDensity.css')
expect(page).toContain('@/styles/adminWorkbench.css')
expect(page).toContain('<CollectionAccountMonitor')
expect(page).not.toContain('<DataTable')
expect(monitor).toContain('background: var(--el-bg-color)')
expect(monitor).toContain('workbench-panel')
expect(monitor).not.toMatch(/--el-[\w-]+\s*:/)
})
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
@@ -940,6 +1026,8 @@ describe('approved Admin density ownership', () => {
})
expect(rules.get('.admin-density .el-table__body tr.el-table__row--striped td.el-table__cell')?.background).toBe('var(--admin-row-stripe)')
expect(rules.get('.admin-density-dialog .el-descriptions__label.el-descriptions__cell')?.background).toBe('var(--admin-label-bg)')
expect(rules.get('.admin-density-dialog .el-form--inline .el-form-item__label')?.['align-items']).toBe('center')
expect(rules.get('.admin-density-dialog.el-dialog.admin-density-workspace > .el-dialog__body')?.flex).toBe('1 1 auto')
for (const tone of ['warning', 'purple', 'primary', 'success', 'danger']) {
expect(rules.get(`:is(.admin-density, .admin-density-dialog) .density-action[data-tone="${tone}"]`)?.['--action-hover-bg']).toBe(`var(--admin-action-${tone}-hover)`)
}
@@ -1055,6 +1143,7 @@ describe('approved Admin density ownership', () => {
expect(read('modules/loading-lists/components/LoadingListsView.vue')).toContain('class="business-page loading-lists-page admin-density"')
expect(read('modules/loading-lists/components/PalletLabelPrintDialog.vue')).toContain("'admin-density-dialog': dense")
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("'admin-density-dialog': dense")
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("dense ? 'min(1000px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))'")
for (const file of ['loading-lists/components/LoadingListDetailDrawer.vue', 'loading-lists/components/PalletLabelPrintDialog.vue', 'container-loading/components/ContainerSimulationCreate.vue']) {
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
const visit = (nodes: typeof template.children) => {
@@ -1123,7 +1212,12 @@ describe('approved Admin density ownership', () => {
expect(source).toContain('density-table density-table--editable dispatch-create-table')
expect(source).toContain('<DispatchResourceFields continuous')
expect(source).toContain('label-position="left" label-width="130px"')
for (const tone of ['summary', 'source', 'work']) expect(source).toContain(`data-tone="${tone}"`)
expect(source).toContain('class="density-dialog-section dispatch-setup"')
expect(source).toContain('class="density-dialog-section dispatch-draft-toolbar"')
expect(source).toContain('class="density-form dispatch-setup-fields"')
expect(source).not.toContain('data-tone="work"')
expect(source).not.toContain('data-tone="summary"')
expect(source).not.toContain('data-tone="source"')
expect(source).toContain('class="cargo-workspace"')
expect(source).toContain('<LineClampText')
expect(source).toContain('class="dispatch-source-group"')
@@ -1233,7 +1327,7 @@ describe('approved Admin density ownership', () => {
it('keeps discard readonly sections and confirmation compact while retaining photo geometry', () => {
const content = read('modules/overseas-warehouse/pages/CargoDiscardsView.vue')
const dialog = content.slice(content.indexOf('<AppDialog'))
expect(dialog).toContain('class="admin-density-dialog"')
expectWorkbenchDialogs(content)
expect(dialog).toContain('fullscreenable')
expect(dialog).toContain('920px')
expect(dialog).toContain('data-tone="summary"')
@@ -1242,7 +1336,9 @@ describe('approved Admin density ownership', () => {
expect(dialog).toContain('preview-teleported')
expect(dialog).toContain('<LineClampText')
expect(dialog).not.toContain('<el-timeline')
expect(content).toContain("customClass: 'admin-density-confirm'")
const confirmations = confirmationClasses(content)
expect(confirmations).toHaveLength(1)
expect(confirmations[0]).toEqual(expect.arrayContaining(['admin-density-confirm', 'admin-workbench', 'is-dialog']))
expect(content).toContain('height: 150px')
const styles = parse(content).descriptor.styles.map(style => style.content).join('\n')
expect(styles).not.toMatch(/background:|border-left:|font-size:|border-radius:/)
@@ -1250,14 +1346,16 @@ describe('approved Admin density ownership', () => {
it('keeps overseas location editing and native confirmations on the shared dialog contract', () => {
const content = read('modules/overseas-warehouse/pages/LocationsView.vue')
const dialog = content.slice(content.indexOf('<AppDialog'))
expect(dialog).toContain('class="admin-density-dialog"')
expectWorkbenchDialogs(content)
expect(dialog).toContain('fullscreenable')
expect(dialog).toContain('density-table density-table--editable')
expect(dialog).toContain('label-position="left" label-width="110px" :disabled="formLocked"')
expect(dialog).toContain('data-tone="work"')
expect(dialog).not.toContain('label-position="top"')
expect(content).not.toContain('editor-grid')
expect(content.match(/customClass: 'admin-density-confirm'/g)).toHaveLength(2)
const confirmations = confirmationClasses(content)
expect(confirmations).toHaveLength(2)
for (const classes of confirmations) expect(classes).toEqual(expect.arrayContaining(['admin-density-confirm', 'admin-workbench', 'is-dialog']))
expect(parse(content).descriptor.styles).toHaveLength(0)
})
it('keeps overseas pallet editing on one shared continuous table with native fullscreen', () => {
@@ -1426,4 +1524,16 @@ describe('approved Admin density ownership', () => {
expect(customs).toContain('class="confirmation-history-note"')
})
it('keeps container detail spacing compact and uses shared table styling for dense fulfillment records', () => {
const detail = read('modules/container-loading/pages/ContainerDetailView.vue')
const carrier = read('modules/port-schedules/components/CarrierScheduleQueryPanel.vue')
expect(detail).toContain('<AdminPage spacing="compact" class="container-detail-page admin-density admin-workbench">')
expect(detail).toContain('class="density-element-tabs" @tab-change="rememberWorkbenchTab"')
expect(detail).toContain('<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel')
expect(carrier).toContain('class="fulfillment-table density-table"')
expect(carrier).toContain('v-if="dense && fulfillmentEvents.length"')
expect(carrier).toContain('v-else-if="fulfillmentEvents.length" class="fulfillment-timeline"')
expect(carrier).not.toMatch(/\.fulfillment-table\s+[^{}]*\{[^{}]*(?:font-size|background|padding|border):/)
})
})
+775
View File
@@ -0,0 +1,775 @@
@import './adminWorkbenchTokens.css';
.admin-workbench {
--el-color-primary: var(--workbench-primary);
--el-color-primary-light-3: var(--workbench-primary-hover);
--el-color-primary-light-5: var(--workbench-primary-disabled);
--el-color-primary-light-7: var(--workbench-accent-border);
--el-color-primary-light-8: var(--workbench-accent-border);
--el-color-primary-light-9: var(--workbench-accent-soft);
--el-color-primary-dark-2: var(--workbench-primary-active);
}
/* Keep business grids in the page; these primitives only provide appearance. */
.admin-workbench.admin-page {
min-width: 0;
padding: var(--workbench-page-padding);
border: 0;
border-radius: 0;
background: var(--workbench-surface);
color: var(--workbench-text);
box-shadow: none;
}
.admin-workbench.admin-page.is-embedded {
padding: 0;
}
/* Override AdminPage's scoped normal spacing only for this explicit opt-in. */
.admin-workbench.admin-page.admin-density.is-spacing-normal {
gap: var(--workbench-page-space);
}
.admin-workbench .workbench-heading {
display: grid;
min-width: 0;
gap: 6px;
}
.admin-workbench .workbench-title {
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-title-size);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta {
display: inline-flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta > i {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
}
.admin-workbench .workbench-section {
display: grid;
min-width: 0;
gap: var(--workbench-section-gap);
}
.admin-workbench .workbench-section-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.admin-workbench .workbench-section-title {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-section-title-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-section-title::before {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-badge {
max-width: 100%;
padding: 3px 10px;
border-radius: 999px;
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-badge.is-toned {
border: 1px solid var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-metric,
.admin-workbench .workbench-panel {
min-width: 0;
padding: var(--workbench-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
.admin-workbench .workbench-metric {
min-height: var(--workbench-metric-min-height);
box-sizing: border-box;
background: linear-gradient(145deg, var(--workbench-surface) 58%, var(--workbench-surface-tint));
}
.admin-workbench .workbench-panel.is-flush {
padding: 0;
}
.admin-workbench .workbench-panel.is-compact {
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-heading {
gap: 8px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-label {
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value {
margin: 10px 0 6px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value > strong {
font-size: var(--workbench-compact-number-size);
}
.admin-workbench .workbench-icon.is-compact {
width: 32px;
height: 32px;
border-radius: var(--workbench-tab-radius);
}
.admin-workbench .workbench-icon.is-compact > svg {
width: 18px;
height: 18px;
}
.admin-workbench .workbench-metric-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.admin-workbench .workbench-icon {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-icon > svg {
width: 24px;
height: 24px;
}
.admin-workbench .workbench-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--workbench-icon-radius);
background: var(--workbench-primary);
color: var(--workbench-surface);
}
.admin-workbench .workbench-metric-label {
min-width: 0;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
margin: 20px 0 12px;
}
.admin-workbench .workbench-metric-value > strong {
min-width: 0;
color: var(--workbench-number);
font-size: var(--workbench-number-size);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value > span {
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
}
.admin-workbench .workbench-note,
.admin-workbench .workbench-status {
display: block;
margin: 4px 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-panel.workbench-tone {
border-color: var(--workbench-accent-border);
}
.admin-workbench .workbench-chart {
display: flex;
min-width: 0;
height: 100%;
flex-direction: column;
}
.admin-workbench .workbench-chart-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 12px;
}
.admin-workbench .workbench-chart-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-chart-title::before {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-chart-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.admin-workbench .workbench-chart-actions .el-button {
--el-button-text-color: var(--workbench-primary);
--el-button-hover-text-color: var(--workbench-primary-active);
--el-button-disabled-text-color: var(--workbench-primary-disabled);
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-chart-actions .el-button + .el-button {
margin-left: 0;
}
.admin-workbench .workbench-chart-canvas {
min-width: 0;
flex: 0 0 auto;
}
.admin-workbench .workbench-chart-empty {
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: var(--workbench-card-space);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
text-align: center;
}
.admin-workbench .workbench-chart-empty.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-chart-empty-icon {
display: grid;
width: 56px;
height: 56px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface);
color: var(--workbench-muted);
}
.admin-workbench .workbench-chart-empty p {
max-width: 28em;
margin: 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.7;
overflow-wrap: anywhere;
}
/* DataTable retains ownership of toolbar size, controls and query layout. */
.admin-workbench .query-toolbar.workbench-query {
border-color: var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
/* Integrated filters share the table surface; never nest another query card. */
.admin-workbench .data-table--integrated > .data-table__filters .query-toolbar.workbench-query {
border-radius: 0;
background: transparent;
box-shadow: none;
}
.admin-workbench .workbench-toolbar,
.admin-workbench .workbench-table {
min-width: 0;
}
/* DataTable forwards attrs to its inner table, so opt in through this wrapper. */
.admin-workbench .workbench-toolbar > .data-table,
.admin-workbench .workbench-table > .data-table {
--list-card-accent: var(--workbench-primary);
--list-card-border: var(--workbench-border);
--list-card-muted-bg: var(--workbench-surface-soft);
--list-card-radius: var(--workbench-radius);
min-width: 0;
border-color: var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
/* Integrated DataTable already owns its border; default tables need a frame. */
.admin-workbench .workbench-toolbar > .data-table:not(.data-table--integrated),
.admin-workbench .workbench-table > .data-table:not(.data-table--integrated) {
border: 1px solid var(--workbench-border);
}
.admin-workbench .workbench-toolbar > .data-table > .data-table__toolbar,
.admin-workbench .workbench-table > .data-table > .data-table__toolbar {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
/* The main page is one continuous surface; only inner cards retain their frame. */
.admin-workbench.workbench-panel.is-page-surface,
.admin-workbench .workbench-panel.is-page-surface,
.admin-workbench .workbench-table.is-page-surface > .data-table,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table {
border: 0;
border-radius: 0;
box-shadow: none;
}
/* Main lists share one compact header. Nested tables and dialog density stay local. */
@media (min-width: 721px) {
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__toolbar,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__toolbar {
--el-component-size: var(--admin-control-height, 32px);
min-height: var(--workbench-list-toolbar-height);
box-sizing: border-box;
padding: var(--workbench-list-toolbar-padding) var(--workbench-list-inset);
gap: 8px 12px;
flex-wrap: wrap;
}
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start {
flex: 1 1 280px;
}
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions {
max-width: 100%;
flex-wrap: wrap;
}
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__toolbar .el-button,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__toolbar .el-button {
min-height: var(--admin-control-height, 32px);
height: var(--admin-control-height, 32px);
}
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__filters .query-toolbar,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__filters .query-toolbar {
--el-component-size: var(--admin-control-height, 32px);
padding: var(--workbench-list-query-padding) var(--workbench-list-inset);
gap: 8px 12px;
}
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__filters .query-toolbar__filters,
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__filters .query-toolbar__filters {
gap: 8px 12px;
}
/* The toolbar supplies the inset; moved tabs must not add a second frame. */
.admin-workbench .data-table__toolbar-start .workbench-list-tabs {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-status-tabs > button,
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.workbench-tabs > button,
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-element-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item {
min-height: var(--admin-control-height, 32px);
height: var(--admin-control-height, 32px);
}
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-element-tabs > .el-tabs__header,
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-element-tabs > .el-tabs__header .el-tabs__nav-wrap {
margin: 0;
}
}
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-status-tabs > button:is(:hover, .is-active),
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-element-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item:is(:hover, .is-active) {
color: var(--workbench-primary);
}
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-status-tabs > button.is-active::after,
.admin-workbench .data-table__toolbar-start .workbench-list-tabs.density-element-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item.is-active::after {
background: var(--workbench-primary);
}
.admin-workbench .workbench-table > .data-table > .el-table,
.admin-workbench .workbench-table > .el-table {
--el-table-border-color: var(--workbench-border);
--el-table-header-bg-color: var(--workbench-surface-soft);
--el-table-header-text-color: var(--workbench-muted);
--el-table-row-hover-bg-color: var(--workbench-accent-soft);
--el-table-current-row-bg-color: var(--workbench-accent-soft);
--el-table-text-color: var(--workbench-text);
color: var(--workbench-text);
}
.admin-workbench .workbench-table .el-table th.el-table__cell {
color: var(--workbench-muted);
}
.admin-workbench .el-button.density-action:not([data-tone]),
.admin-workbench .el-button.density-action[data-tone='primary'] {
--action-color: var(--workbench-primary);
--action-border: var(--workbench-accent-border);
--action-hover-bg: var(--workbench-accent-soft);
}
.admin-workbench .workbench-table .data-table .data-table__page-button[aria-current='page']:not(:disabled),
.admin-workbench .workbench-table .data-table__business-actions .el-button--primary:not(:disabled) {
box-shadow: var(--workbench-shadow);
}
/* Keep selection, focus and disabled states consistent for existing native tabs. */
.admin-workbench .workbench-tabs {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0;
}
.admin-workbench .workbench-tabs > button {
display: inline-flex;
min-height: var(--admin-tab-height);
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--workbench-tab-radius);
background: transparent;
color: var(--workbench-muted);
font: inherit;
font-weight: 500;
line-height: 1.5;
text-align: center;
cursor: pointer;
}
.admin-workbench .workbench-tabs > button:hover:not(:disabled):not([aria-pressed='true']):not(.is-active) {
background: var(--workbench-surface-soft);
color: var(--workbench-primary);
}
.admin-workbench .workbench-tabs > button[aria-pressed='true'],
.admin-workbench .workbench-tabs > button.is-active {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-primary);
font-weight: 600;
}
.admin-workbench .workbench-tabs > button:focus-visible,
.admin-workbench .workbench-input:focus-visible {
outline: 2px solid var(--workbench-primary);
outline-offset: 2px;
}
.admin-workbench .workbench-tabs > button:disabled {
color: var(--workbench-primary-disabled);
cursor: not-allowed;
}
.admin-workbench .workbench-input {
min-height: var(--admin-control-height);
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-tab-radius);
background: var(--workbench-surface);
color: var(--workbench-text);
font: inherit;
}
.admin-workbench .workbench-input:disabled {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
cursor: not-allowed;
}
/* Teleported dialogs opt in on their own root; retain the density frame geometry. */
.admin-workbench.is-dialog.el-dialog:not(.is-fullscreen) {
border-radius: var(--workbench-radius);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__header,
.admin-workbench.is-dialog.el-dialog .el-dialog__footer {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__title {
color: var(--workbench-text);
}
/* A teleported drawer carries its own opt-in. Density owns its frame geometry. */
.admin-workbench.is-drawer.el-drawer {
background: var(--workbench-surface);
color: var(--workbench-text);
}
.admin-workbench.is-drawer.el-drawer .el-drawer__header,
.admin-workbench.is-drawer.el-drawer .el-drawer__footer {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
.admin-workbench.is-drawer.el-drawer .el-drawer__title {
color: var(--workbench-text);
}
/* Existing density classes retain layout and typography; adapt neutral paint only. */
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-layout,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-header,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-overview,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields > div,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-dialog-section:not([data-tone]) {
background: var(--workbench-surface);
}
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-layout > .detail-page-layout__header,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-overview,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields > div,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-descriptions .el-descriptions__cell,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .form-section.density-form-section,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-dialog-section:not([data-tone]) {
border-color: var(--workbench-border);
}
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-title,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-overview dd,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields dd,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-descriptions .el-descriptions__content.el-descriptions__cell,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-form-section .form-section__heading h2 {
color: var(--workbench-text);
}
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-overview dt,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-detail-overview small,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-form-section .form-section__heading p,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-form .el-form-item__label {
color: var(--workbench-muted);
}
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-fields dt,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-descriptions .el-descriptions__label.el-descriptions__cell {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
}
/* Neutral form tokens leave required marks, validation, disabled and semantic colors intact. */
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer).density-form,
.admin-workbench:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .density-form {
--el-border-color: var(--workbench-border);
--el-fill-color-blank: var(--workbench-surface);
--el-text-color-regular: var(--workbench-text);
}
.admin-workbench .workbench-evidence table {
width: 100%;
border-collapse: collapse;
font-size: var(--admin-font-body);
}
.admin-workbench .workbench-evidence th,
.admin-workbench .workbench-evidence td {
border: 1px solid var(--workbench-border);
text-align: left;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-evidence table:not(.density-table) :is(th, td) {
padding: var(--admin-space-1) var(--admin-space-3);
line-height: 1.6;
}
.admin-workbench .workbench-evidence th {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-weight: 600;
}
.admin-workbench .workbench-notice {
margin: 0;
padding: 10px 14px;
border: 1px solid var(--workbench-accent-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-text);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
/* State labels remain the source of meaning; color only reinforces them. */
.admin-workbench .workbench-step {
min-width: 0;
padding: var(--workbench-compact-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
overflow-wrap: anywhere;
}
.admin-workbench .workbench-step-index {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: 50%;
background: var(--workbench-surface);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
font-variant-numeric: tabular-nums;
}
.admin-workbench .workbench-step[data-state='active'],
.admin-workbench .workbench-step[data-state='done'] {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-step[data-state='active'] .workbench-step-index,
.admin-workbench .workbench-step[data-state='done'] .workbench-step-index {
border-color: var(--workbench-accent);
background: var(--workbench-accent);
color: var(--workbench-surface);
}
.admin-workbench .el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
--el-button-bg-color: var(--workbench-primary);
--el-button-border-color: var(--workbench-primary);
--el-button-hover-bg-color: var(--workbench-primary-hover);
--el-button-hover-border-color: var(--workbench-primary-hover);
--el-button-active-bg-color: var(--workbench-primary-active);
--el-button-active-border-color: var(--workbench-primary-active);
--el-button-disabled-bg-color: var(--workbench-primary-disabled);
--el-button-disabled-border-color: var(--workbench-primary-disabled);
}
.admin-workbench .workbench-skeleton {
display: block;
min-width: 0;
height: var(--workbench-metric-min-height);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface-soft);
animation: admin-workbench-loading 1.6s ease-in-out infinite alternate;
}
@keyframes admin-workbench-loading {
from { opacity: 0.55; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.admin-workbench .workbench-skeleton {
animation: none;
}
}
+318
View File
@@ -0,0 +1,318 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { compileStyle, parse } from 'vue/compiler-sfc'
import { adminWorkbenchChartTheme } from './adminWorkbenchChartTheme'
const read = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8')
const tokens = read('styles/adminWorkbenchTokens.css')
const styles = read('styles/adminWorkbench.css')
const page = read('modules/self-operated/pages/OverviewView.vue')
const listPages = [
'modules/self-operated/components/AnalyticsView.vue',
'modules/self-operated/components/InventoryTurnoverView.vue',
]
const consumers = [
...listPages,
'modules/self-operated/pages/InventoryView.vue',
'modules/self-operated/components/SkuTrend.vue',
'modules/self-operated/components/ChartDetailsDialog.vue',
'modules/self-operated/pages/CollectionView.vue',
'modules/self-operated/components/CollectionAccountMonitor.vue',
'modules/self-operated/components/CollectionProgressGauge.vue',
'modules/self-operated/components/CollectionTaskProgress.vue',
]
const root = (source: string) => {
const result = compileStyle({ source, filename: 'workbench.css', id: 'workbench-contract' })
expect(result.errors).toEqual([])
return result.rawResult!.root
}
const declarationsFor = (element: Element) => {
const declarations = new Map<string, string>()
root(styles).walkRules(rule => {
if (rule.selectors.some(selector => element.matches(selector))) {
rule.walkDecls(declaration => { declarations.set(declaration.prop, declaration.value) })
}
})
return declarations
}
const fixture = (markup: string) => {
const container = document.createElement('div')
container.innerHTML = markup
return (selector: string) => {
const element = container.querySelector(selector)
expect(element).not.toBeNull()
return element!
}
}
describe('Admin workbench opt-in styles', () => {
it('keeps canvas chart colors aligned with the public CSS palette', () => {
const declarations = new Map<string, string>()
root(tokens).walkRules(rule => {
if (rule.selector !== '.admin-workbench') return
rule.walkDecls(declaration => { declarations.set(declaration.prop, declaration.value) })
})
for (const name of ['primary', 'text', 'muted', 'border', 'surface'] as const) {
expect(adminWorkbenchChartTheme.colors[name]).toBe(declarations.get(`--workbench-${name}`))
}
expect(adminWorkbenchChartTheme.colors.primarySoft).toBe(declarations.get('--workbench-accent-soft'))
})
it('keeps lazy-loaded styles scoped to opted-in pages and teleported roots', () => {
for (const source of [tokens, styles]) {
root(source).walkRules(rule => {
if (rule.parent?.type === 'atrule' && rule.parent.name.endsWith('keyframes')) return
for (const selector of rule.selectors) {
expect(selector).toMatch(/^\.admin-workbench(?:\s|\.|:|\[|$)/)
expect(selector).not.toMatch(/:global|:root/)
if (selector.includes('.el-dialog')) expect(selector).toMatch(/^\.admin-workbench\.is-dialog\.el-dialog/)
if (selector.includes('.el-drawer')) expect(selector).toMatch(/^\.admin-workbench\.is-drawer\.el-drawer/)
}
})
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--admin-/)
expect(declaration.important).toBeFalsy()
})
}
})
it('resolves every workbench variable from the public tokens instead of page defaults', () => {
const declared = new Set<string>()
root(tokens).walkDecls(declaration => {
if (declaration.prop.startsWith('--workbench-')) declared.add(declaration.prop)
})
const pageStyles = [page, ...consumers.map(read)].flatMap(source => parse(source).descriptor.styles.map(style => style.content)).join('\n')
for (const source of [styles, pageStyles]) {
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--workbench-/)
})
for (const match of source.matchAll(/var\((--workbench-[\w-]+)/g)) {
expect(declared.has(match[1]!)).toBe(true)
}
}
})
it('opts list cards and teleported dialogs in on their actual rendering containers', () => {
for (const file of listPages) {
const source = read(file)
expect(source).toContain("import '@/styles/adminWorkbench.css'")
const template = parse(source).descriptor.template!.ast!
type TemplateElement = Extract<(typeof template.children)[number], { type: 1 }>
const classesOf = (node?: TemplateElement) => node?.props.flatMap(prop => prop.type === 6 && prop.name === 'class' ? prop.value?.content.split(/\s+/) ?? [] : []) ?? []
let tables = 0
const visit = (nodes: typeof template.children, ancestors: TemplateElement[]) => {
for (const node of nodes) {
if (node.type !== 1) continue
if (node.tag === 'AdminPage') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density', 'admin-workbench']))
if (node.tag === 'DataTable') {
expect(classesOf(ancestors.at(-1))).toContain('workbench-table')
expect(classesOf(node)).not.toContain('workbench-table')
tables++
}
if (node.tag === 'AppDialog') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
visit(node.children, [...ancestors, node])
}
}
visit(template.children, [])
expect(tables).toBe(1)
}
})
it('paints standalone query cards without adding a nested surface inside integrated filters', () => {
const get = fixture(`
<main class="admin-density admin-workbench">
<section id="standalone" class="query-toolbar workbench-query"></section>
<div class="data-table data-table--integrated">
<div class="data-table__filters"><section id="integrated" class="query-toolbar workbench-query"></section></div>
</div>
</main>
<section id="outside" class="query-toolbar workbench-query"></section>
`)
const standalone = declarationsFor(get('#standalone'))
const integrated = declarationsFor(get('#integrated'))
expect(standalone.get('border-color')).toBe('var(--workbench-border)')
expect(standalone.get('border-radius')).toBe('var(--workbench-radius)')
expect(standalone.get('box-shadow')).toBe('var(--workbench-shadow)')
expect(integrated.get('background')).toBe('transparent')
expect(integrated.get('border-radius')).toBe('0')
expect(integrated.get('box-shadow')).toBe('none')
expect(declarationsFor(get('#outside')).size).toBe(0)
})
it('adds a default table frame only on the real outer container', () => {
const get = fixture(`
<main class="admin-workbench">
<div class="workbench-table"><div id="default" class="data-table"><div class="el-table"></div></div></div>
<div class="workbench-table"><div id="integrated" class="data-table data-table--integrated"></div></div>
<div class="data-table"><div id="inner" class="el-table workbench-table"></div></div>
</main>
`)
expect(declarationsFor(get('#default')).get('border')).toBe('1px solid var(--workbench-border)')
expect(declarationsFor(get('#integrated')).get('border')).toBeUndefined()
expect(declarationsFor(get('#inner')).get('border')).toBeUndefined()
})
it('requires an explicit drawer root while leaving its size and scrolling to the component', () => {
const get = fixture(`
<section id="drawer" class="el-drawer app-drawer admin-density-drawer admin-workbench is-drawer">
<header class="el-drawer__header"><span class="el-drawer__title">详情</span></header>
<div class="el-drawer__body"></div><footer class="el-drawer__footer"></footer>
</section>
<section id="outside" class="el-drawer app-drawer admin-density-drawer"><header class="el-drawer__header"></header></section>
`)
expect(declarationsFor(get('#drawer')).get('background')).toBe('var(--workbench-surface)')
expect(declarationsFor(get('#drawer .el-drawer__header')).get('border-color')).toBe('var(--workbench-border)')
expect(declarationsFor(get('#drawer .el-drawer__title')).get('color')).toBe('var(--workbench-text)')
expect(declarationsFor(get('#outside .el-drawer__header')).size).toBe(0)
})
it('removes only the page frame while retaining the inner cards and table grid', () => {
const get = fixture(`
<main class="admin-page admin-workbench">
<section id="page" class="workbench-panel is-page-surface">
<article id="card" class="workbench-panel"></article>
<div class="workbench-table is-page-surface"><div id="table" class="data-table data-table--integrated"><div id="grid" class="el-table"></div></div></div>
</section>
</main>
`)
for (const selector of ['#page', '#table']) {
const appearance = declarationsFor(get(selector))
expect(appearance.get('border')).toBe('0')
expect(appearance.get('border-radius')).toBe('0')
expect(appearance.get('box-shadow')).toBe('none')
}
expect(declarationsFor(get('#card')).get('border')).toBe('1px solid var(--workbench-border)')
expect(declarationsFor(get('#grid')).get('--el-table-border-color')).toBe('var(--workbench-border)')
})
it('uses one page inset when a routed workbench is explicitly embedded', () => {
const get = fixture(`
<main id="page" class="admin-page admin-workbench">
<section id="embedded" class="admin-page admin-workbench is-embedded"></section>
<section id="card" class="workbench-panel"></section>
</main>
<main id="standalone" class="admin-page admin-workbench"></main>
`)
expect(declarationsFor(get('#embedded')).get('padding')).toBe('0')
for (const selector of ['#page', '#standalone']) {
expect(declarationsFor(get(selector)).get('padding')).toBe('var(--workbench-page-padding)')
}
expect(declarationsFor(get('#card')).get('padding')).toBe('var(--workbench-card-space)')
})
it('compacts only main list headers and aligns their filters without fixing content height', () => {
const table = (id: string) => `
<div class="data-table data-table--integrated">
<div id="${id}-header" class="data-table__toolbar">
<div class="data-table__toolbar-start"><nav id="${id}-tabs" class="density-status-tabs workbench-list-tabs"><button>状态</button></nav></div>
<div id="${id}-actions" class="data-table__toolbar-actions"><button class="el-button">新增</button></div>
</div>
<div class="data-table__filters"><div id="${id}-query" class="query-toolbar"><div class="query-toolbar__filters"></div></div></div>
<div id="${id}-body" class="el-table"></div>
</div>`
const get = fixture(`
<main class="admin-page admin-density admin-workbench">
<div class="workbench-table is-page-surface">${table('main')}</div>
<div class="workbench-table">${table('nested')}</div>
</main>
<div class="workbench-table is-page-surface">${table('outside')}</div>
`)
const header = declarationsFor(get('#main-header'))
const query = declarationsFor(get('#main-query'))
expect(header.get('min-height')).toBe('var(--workbench-list-toolbar-height)')
expect(header.get('padding')).toBe('var(--workbench-list-toolbar-padding) var(--workbench-list-inset)')
expect(query.get('padding')).toBe('var(--workbench-list-query-padding) var(--workbench-list-inset)')
expect(header.get('flex-wrap')).toBe('wrap')
expect(header.has('height')).toBe(false)
expect(declarationsFor(get('#main-actions')).get('max-width')).toBe('100%')
expect(declarationsFor(get('#main-tabs')).get('padding')).toBe('0')
expect(declarationsFor(get('#main-tabs')).get('background')).toBe('transparent')
for (const selector of ['#nested-header', '#outside-header', '#nested-query', '#outside-query', '#main-body']) {
expect(declarationsFor(get(selector)).has('padding')).toBe(false)
expect(declarationsFor(get(selector)).has('height')).toBe(false)
}
})
it('aligns wrapped native tables and dialog actions without repainting semantic actions', () => {
const get = fixture(`
<section class="admin-workbench is-dialog admin-density-dialog">
<div class="workbench-table"><div id="native" class="el-table"></div></div>
<div class="workbench-table"><div class="data-table"><div id="shared" class="el-table"></div></div></div>
<button id="primary" class="el-button density-action" data-tone="primary"></button>
<button id="danger" class="el-button density-action" data-tone="danger"></button>
</section>
<div class="workbench-table"><div id="outside" class="el-table"></div></div>
`)
expect(declarationsFor(get('#native'))).toEqual(declarationsFor(get('#shared')))
expect(declarationsFor(get('#native')).get('--el-table-header-bg-color')).toBe('var(--workbench-surface-soft)')
expect(declarationsFor(get('#primary')).get('--action-color')).toBe('var(--workbench-primary)')
expect(declarationsFor(get('#danger')).get('--action-color')).toBeUndefined()
expect(declarationsFor(get('#outside')).size).toBe(0)
})
it('adapts neutral density content without repainting semantic sections or validation states', () => {
const get = fixture(`
<main class="admin-density admin-workbench">
<dl class="density-fields"><div><dt id="label">名称</dt><dd id="value">内容</dd></div></dl>
<section id="neutral" class="density-dialog-section"></section>
<section id="business" class="density-dialog-section" data-tone="source"></section>
<form id="form" class="density-form">
<div class="el-form-item is-error"><label class="el-form-item__label">必填</label><div id="error" class="el-form-item__error">错误</div></div>
</form>
</main>
<main class="admin-density"><dl class="density-fields"><div><dt id="outside">名称</dt></div></dl></main>
`)
expect(declarationsFor(get('#label')).get('background')).toBe('var(--workbench-surface-soft)')
expect(declarationsFor(get('#value')).get('color')).toBe('var(--workbench-text)')
expect(declarationsFor(get('#neutral')).get('border-color')).toBe('var(--workbench-border)')
expect(declarationsFor(get('#business')).size).toBe(0)
expect(declarationsFor(get('#error')).size).toBe(0)
expect(declarationsFor(get('#outside')).size).toBe(0)
expect([...declarationsFor(get('#form')).keys()]).toEqual(['--el-border-color', '--el-fill-color-blank', '--el-text-color-regular'])
})
it('keeps new surfaces optical and leaves business dimensions and density untouched', () => {
const neutralPaint = new Set(['color', 'background', 'border-color', 'border-radius', 'box-shadow', '--el-border-color', '--el-fill-color-blank', '--el-text-color-regular'])
root(styles).walkRules(rule => {
if (rule.selector.includes('.workbench-query') || rule.selector.includes('.is-drawer')
|| /density-(?:detail|fields|descriptions|form|dialog-section)/.test(rule.selector)) {
rule.walkDecls(declaration => { expect(neutralPaint.has(declaration.prop)).toBe(true) })
}
if (rule.selector.includes('.workbench-brand-mark')) {
rule.walkDecls(declaration => { expect(['display', 'align-items', 'justify-content', 'border-radius', 'background', 'color']).toContain(declaration.prop) })
}
})
const get = fixture('<main class="admin-workbench"><span id="brand" class="workbench-brand-mark"></span></main>')
const brand = declarationsFor(get('#brand'))
expect(brand.get('display')).toBe('inline-flex')
expect(brand.get('background')).toBe('var(--workbench-primary)')
expect(brand.get('color')).toBe('var(--workbench-surface)')
})
it('preserves metric values and confines header density changes to explicit list surfaces', () => {
root(styles).walkRules(rule => {
if (rule.selector.includes('.workbench-metric')) {
rule.walkDecls(declaration => {
expect(['text-overflow', 'line-clamp', '-webkit-line-clamp']).not.toContain(declaration.prop)
if (declaration.prop === 'overflow') expect(declaration.value).not.toBe('hidden')
expect(declaration.prop).not.toBe('height')
})
}
if (rule.selector.includes('.data-table__toolbar')) {
rule.walkDecls(declaration => {
if (rule.selector.includes('.is-page-surface')) {
expect(rule.parent?.type === 'atrule' && rule.parent.params).toBe('(min-width: 721px)')
} else if (!rule.selector.includes('.workbench-list-tabs')) {
expect(declaration.prop).not.toMatch(/^(?:padding|height|min-height|display|gap)/)
}
})
}
if (rule.selector.includes('.is-dialog') || rule.selector.includes('.is-drawer')) {
rule.walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^(?:width|height|min-height|max-height|padding|margin|overflow)/)
})
}
})
})
})
+43
View File
@@ -0,0 +1,43 @@
const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
const colors = {
primary: '#3784fa',
primarySoft: '#eff6ff',
primaryFaint: 'rgba(55,132,250,0.03)',
primaryLight: '#5b9bfb',
teal: '#169b9b',
tealLight: '#46b5bc',
green: '#18a46f',
purple: '#8b5cf6',
amber: '#bf850e',
rose: '#e85e70',
muted: '#64748b',
text: '#1f2937',
border: '#e5eaf2',
axis: '#d8e2ef',
unknown: '#a2adbf',
surface: '#fff',
}
/** Explicitly consumed by workbench charts; never registers a global ECharts theme. */
export const adminWorkbenchChartTheme = {
fontFamily,
colors,
tooltip: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
padding: 12,
extraCssText: 'box-shadow: 0 8px 24px rgba(31, 55, 90, 0.1);',
textStyle: { color: colors.text, fontSize: 12, fontFamily },
},
axisLabel: { color: colors.muted, fontSize: 12 },
axisName: { color: colors.muted, fontSize: 12 },
splitLine: {
show: true,
lineStyle: { type: 'dashed' as const, color: colors.border, opacity: 0.8 },
},
}
export type AdminWorkbenchChartTheme = typeof adminWorkbenchChartTheme
+74
View File
@@ -0,0 +1,74 @@
/* Blue-and-white workbench preview. Pages opt in without changing admin density. */
.admin-workbench {
--workbench-primary: #3784fa;
--workbench-primary-hover: #5b9bfb;
--workbench-primary-active: #276dd5;
--workbench-primary-disabled: #a9c9fc;
--workbench-number: #1890ff;
--workbench-text: #1f2937;
--workbench-muted: #64748b;
--workbench-surface: #fff;
--workbench-surface-soft: #f5f7fb;
--workbench-surface-tint: #f8faff;
--workbench-border: #e5eaf2;
--workbench-shadow: 0 4px 18px rgb(31 55 90 / 4%);
--workbench-radius: 16px;
--workbench-icon-radius: 12px;
--workbench-page-padding: 12px;
--workbench-list-inset: 12px;
--workbench-list-toolbar-height: 48px;
--workbench-list-toolbar-padding: 6px;
--workbench-list-query-padding: 8px;
--workbench-page-space: 24px;
--workbench-section-gap: 16px;
--workbench-grid-gap: 18px;
--workbench-card-space: 20px;
--workbench-metric-min-height: 186px;
--workbench-title-size: 24px;
--workbench-section-title-size: 18px;
--workbench-label-size: 15px;
--workbench-number-size: 34px;
--workbench-compact-number-size: 26px;
--workbench-compact-card-space: 14px;
--workbench-tab-radius: 8px;
--workbench-note-size: 13px;
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='blue'] {
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='teal'] {
--workbench-accent: #169b9b;
--workbench-accent-soft: #effcfa;
--workbench-accent-border: #c7eeea;
}
.admin-workbench .workbench-tone[data-tone='green'] {
--workbench-accent: #18a46f;
--workbench-accent-soft: #ecfdf5;
--workbench-accent-border: #c7efdd;
}
.admin-workbench .workbench-tone[data-tone='purple'] {
--workbench-accent: #8b5cf6;
--workbench-accent-soft: #f5f3ff;
--workbench-accent-border: #e6ddfc;
}
.admin-workbench .workbench-tone[data-tone='amber'] {
--workbench-accent: #bf850e;
--workbench-accent-soft: #fffbeb;
--workbench-accent-border: #f5e2ad;
}
.admin-workbench .workbench-tone[data-tone='rose'] {
--workbench-accent: #e85e70;
--workbench-accent-soft: #fff1f3;
--workbench-accent-border: #f8d7df;
}
+1 -20
View File
@@ -1,7 +1,7 @@
@import './foundation.css';
@import './shell.css';
.forbidden-page { display: flex; min-height: min(560px, calc(100vh - 136px)); align-items: center; justify-content: center; flex-direction: column; padding: clamp(28px, 7vw, 64px) clamp(18px, 6vw, 48px); border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; text-align: center; }
.forbidden-page { display: flex; min-height: min(560px, calc(100vh - 136px)); align-items: center; justify-content: center; flex-direction: column; padding: clamp(28px, 7vw, 64px) clamp(18px, 6vw, 48px); border: 1px solid var(--workbench-border); border-radius: var(--workbench-radius); background: var(--workbench-surface); color: var(--workbench-text); box-shadow: var(--workbench-shadow); text-align: center; }
.forbidden-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 18px; border-radius: 8px; background: #fef3f2; color: #b42318; }
.forbidden-page .section-kicker { margin: 0 0 8px; }
.forbidden-page h2 { max-width: 560px; margin: 0 0 10px; font-size: 20px; line-height: 1.35; }
@@ -9,23 +9,7 @@
.forbidden-page .forbidden-source { margin-bottom: 20px; color: #475467; font-size: 12px; }
.forbidden-page .el-button { min-height: 44px; }
.login-page { display: grid; min-height: 100vh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #fff; }
.login-intro { display: flex; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--cc-navy); color: #fff; }
.login-brand { display: flex; align-items: center; min-height: 64px; }
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
.eyebrow, .section-kicker { color: #3975e9; font-size: 11px; font-weight: 750; letter-spacing: 0; }
.login-intro h1 { max-width: 680px; margin: 16px 0 22px; font-size: 52px; line-height: 1.16; letter-spacing: 0; }
.intro-copy { max-width: 560px; color: #b9c6d8; font-size: 16px; line-height: 1.8; }
.security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; }
.login-panel { display: grid; place-items: center; padding: 48px; }
.login-card { width: min(410px, 100%); }
.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 12px; background: #eaf1ff; color: var(--cc-primary); }
.login-card h2 { margin: 22px 0 8px; font-size: 28px; }
.login-card > p { margin: 0 0 30px; color: var(--cc-muted); }
.login-submit { width: 100%; }
.login-submit span { display: flex; align-items: center; gap: 8px; }
.login-error { margin: 14px 0 0; color: #b42318; font-size: 13px; line-height: 1.5; }
.login-error > p { margin: 0; }
.api-error-alert-title { min-width: 0; }
.dashboard-stack { display: flex; flex-direction: column; gap: 20px; }
@@ -205,8 +189,6 @@
.filter-panel { align-items: stretch; flex-direction: column; }
.filter-actions { justify-content: flex-end; }
.form-grid.three-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.login-page { grid-template-columns: 1fr; }
.login-intro { display: none; }
}
@media (max-width: 720px) {
@@ -255,7 +237,6 @@
.detail-identity .status-badge { grid-column: 2; justify-self: start; }
.section-heading-row { flex-direction: column; }
.legend-list { display: none; }
.login-panel { padding: 30px 20px; }
}
@media (max-width: 420px) {
+47 -22
View File
@@ -1,7 +1,7 @@
.app-shell { min-width: 0; min-height: 100vh; }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; border-right: 1px solid rgba(255,255,255,.3); background: rgba(255,255,255,.92); color: var(--cc-text-secondary); box-shadow: 4px 0 32px rgba(0,0,0,.08); backdrop-filter: blur(20px); }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; border-right: 1px solid var(--workbench-border); background: var(--workbench-surface); color: var(--workbench-text); }
.is-collapsed .app-sidebar { width: 76px; }
.brand-block { display: flex; height: 64px; flex: 0 0 64px; align-items: center; padding: 0 18px; background: var(--cc-topbar); }
.brand-block { display: flex; height: 64px; flex: 0 0 64px; align-items: center; padding: 0 18px; background: var(--workbench-primary); }
.brand-logo { display: block; object-fit: contain; object-position: left center; }
.brand-logo-full { width: 184px; height: 50px; }
.brand-logo-mark { display: none; width: 40px; height: 40px; }
@@ -9,7 +9,9 @@
.is-collapsed .brand-logo-mark { display: block; }
.side-nav {
flex: 1;
min-width: 0;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 12px 8px;
scrollbar-color: rgba(21, 94, 239, .22) transparent;
@@ -29,39 +31,62 @@
border-right: 0 !important;
background: transparent !important;
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--cc-text-secondary);
--el-menu-active-color: var(--cc-menu-active);
--el-menu-hover-bg-color: var(--cc-primary-soft);
--el-menu-text-color: var(--workbench-text);
--el-menu-active-color: var(--workbench-primary);
--el-menu-hover-bg-color: var(--workbench-accent-soft);
}
.side-nav-menu.el-menu--collapse { width: 56px; }
.side-nav-menu.el-menu--collapse { width: 100%; }
.side-nav-menu .el-menu-item,
.side-nav-menu .el-sub-menu__title { height: 44px; margin: 2px 0; border-radius: 0 var(--cc-radius-lg) var(--cc-radius-lg) 0; line-height: 44px; transition: color .16s ease, background .16s ease; }
.side-nav-menu .el-menu-item:hover,
.side-nav-menu .el-sub-menu__title:hover { color: var(--cc-menu-active); background: linear-gradient(135deg, rgba(102,126,234,.10), rgba(240,147,251,.08)); }
.side-nav-menu .el-menu-item.is-active { background: linear-gradient(135deg, rgba(102,126,234,.18), rgba(240,147,251,.12)); color: var(--cc-menu-active); font-weight: 700; box-shadow: inset 3px 0 var(--cc-menu-active); }
.side-nav-menu .el-sub-menu .el-menu { padding: 4px 0; background: var(--cc-surface-muted) !important; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; height: 40px; margin: 0 12px; padding-left: 54px !important; border-radius: var(--cc-radius-md); color: var(--cc-muted); line-height: 40px; }
.side-nav-menu .el-sub-menu .el-menu-item:hover { background: rgba(102,126,234,.08); color: var(--cc-menu-active); }
.side-nav-menu .el-sub-menu .el-menu-item.is-active { background: rgba(102,126,234,.12); box-shadow: none; color: var(--cc-menu-active); font-weight: 700; }
.side-nav-menu .el-sub-menu__title:hover { color: var(--workbench-primary); background: var(--workbench-accent-soft); }
.side-nav-menu .el-menu-item.is-active { background: var(--workbench-accent-soft); color: var(--workbench-primary); font-weight: 700; box-shadow: inset 3px 0 var(--workbench-primary); }
.side-nav-menu .el-sub-menu .el-menu { padding: 4px 0; background: var(--workbench-surface-soft) !important; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; height: 40px; margin: 0 12px; padding-left: 54px !important; border-radius: var(--cc-radius-md); color: var(--workbench-muted); line-height: 40px; }
.side-nav-menu .el-sub-menu .el-menu-item:hover { background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.side-nav-menu .el-sub-menu .el-menu-item.is-active { background: var(--workbench-accent-soft); box-shadow: none; color: var(--workbench-primary); font-weight: 700; }
.side-nav-menu .el-menu-item:focus-visible,
.side-nav-menu .el-sub-menu__title:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
.side-nav-menu .el-menu-item svg,
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
/* Use the width left after the vertical scrollbar, including Element's leaf tooltip trigger. */
.side-nav-menu.el-menu--collapse > .el-menu-item,
.side-nav-menu.el-menu--collapse > .el-sub-menu > .el-sub-menu__title,
.side-nav-menu.el-menu--collapse > .el-menu-item > .el-menu-tooltip__trigger { justify-content: center; padding: 0; }
.side-nav-menu.el-menu--collapse > .el-menu-item .menu-icon,
.side-nav-menu.el-menu--collapse > .el-sub-menu > .el-sub-menu__title .menu-icon { margin-right: 0; }
.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; }
.is-collapsed .app-main { margin-left: 76px; }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 64px; align-items: center; gap: 16px; padding: 8px 20px; background: var(--cc-topbar); color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,.15), 0 2px 8px rgba(0,0,0,.1); backdrop-filter: blur(20px); }
.shell-menu-button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; padding: 0; border: 0; border-radius: var(--cc-radius-md); background: transparent; color: #fff; cursor: pointer; transition: background .16s ease; }
.shell-menu-button:hover { background: rgba(255,255,255,.12); }
.shell-menu-button:focus-visible { outline-color: rgba(255,255,255,.8); }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 64px; align-items: center; gap: 16px; padding: 8px 20px; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface); color: var(--workbench-text); }
.shell-menu-button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; padding: 0; border: 0; border-radius: var(--cc-radius-md); background: transparent; color: var(--workbench-muted); cursor: pointer; transition: background .16s ease; }
.shell-menu-button:hover { background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.shell-menu-button:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
.page-heading { min-width: 0; flex: 1; }
.page-heading h1 { margin: 0; overflow: hidden; font-size: var(--cc-list-page-title-font-size); letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: rgba(255,255,255,.72); font-size: var(--cc-list-page-description-font-size); text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: var(--workbench-muted); font-size: var(--cc-list-page-description-font-size); text-overflow: ellipsis; white-space: nowrap; }
.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #fff; color: var(--cc-primary); font-weight: 700; box-shadow: var(--cc-shadow-sm); }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: var(--workbench-accent-soft); color: var(--workbench-primary); font-weight: 700; }
.user-copy { display: flex; flex-direction: column; }
.user-copy strong { font-size: 13px; }
.user-copy span { color: var(--cc-muted); font-size: 11px; }
.header-user .el-button { width: 44px; height: 44px; color: #fff; }
.header-user .el-button:hover { background: rgba(255,255,255,.15); color: #fff; }
.page-container { padding: var(--cc-space-5) var(--cc-space-5) var(--cc-space-7); }
.user-copy span { color: var(--workbench-muted); font-size: 11px; }
.header-user .el-button { width: 44px; height: 44px; color: var(--workbench-muted); }
.header-user .el-button:hover { background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.page-container { padding: var(--cc-space-3) var(--cc-space-3) var(--cc-space-4); }
/* Collapsed menus and header dropdowns are teleported outside the themed shell regions. */
.admin-workbench.shell-nav-popper {
--el-menu-bg-color: var(--workbench-surface);
--el-menu-text-color: var(--workbench-text);
--el-menu-active-color: var(--workbench-primary);
--el-menu-hover-bg-color: var(--workbench-accent-soft);
border-color: var(--workbench-border);
box-shadow: var(--workbench-shadow);
}
.admin-workbench.shell-nav-popper .el-menu--popup { background: var(--workbench-surface); box-shadow: none; }
.admin-workbench.shell-nav-popper .el-menu-item.is-active { background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.admin-workbench.shell-nav-popper .el-menu-item:focus-visible,
.admin-workbench.shell-nav-popper .el-sub-menu__title:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
.admin-workbench.shell-header-popper { border-color: var(--workbench-border); box-shadow: var(--workbench-shadow); }
/* Static permission preview */
.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; }