Release/test 合并上生产 #1
Vendored
+4
@@ -26,6 +26,9 @@ declare module 'vue' {
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
@@ -52,6 +55,7 @@ declare module 'vue' {
|
||||
QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
WorkspaceTabs: typeof import('./components/WorkspaceTabs.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { MoreHorizontal, X } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
||||
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import { useWorkspaceTabsStore } from '@/navigation/workspace-tabs/workspaceTabs'
|
||||
|
||||
type CloseCommand = 'current' | 'others' | 'right' | 'all'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const workspace = useWorkspaceTabsStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const tabScroller = ref<HTMLElement>()
|
||||
|
||||
async function activate(fullPath: string) {
|
||||
if (fullPath === workspace.activeTab?.fullPath) return
|
||||
await router.push(fullPath)
|
||||
}
|
||||
|
||||
async function confirmDiscard(keys: readonly string[]) {
|
||||
const dirty = unsaved.findDirty(keys)
|
||||
if (dirty.length === 0) return true
|
||||
const labels = [...new Set(dirty.map((entry) => entry.label))].join('、')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`“${labels}”还有未保存的修改。关闭页签后这些内容将丢失。`,
|
||||
'放弃未保存的修改?',
|
||||
{ type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function closeTabs(keys: readonly string[]) {
|
||||
const existingKeys = keys.filter((key) => workspace.tabs.some((tab) => tab.key === key))
|
||||
if (existingKeys.length === 0 || !await confirmDiscard(existingKeys)) return
|
||||
unsaved.removeRoutes(existingKeys)
|
||||
const result = workspace.close(existingKeys)
|
||||
if (!result.closedActive) return
|
||||
await router.push(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
|
||||
}
|
||||
|
||||
function keysForCommand(command: CloseCommand) {
|
||||
const activeIndex = workspace.tabs.findIndex((tab) => tab.key === workspace.activeKey)
|
||||
if (command === 'current') return workspace.activeKey ? [workspace.activeKey] : []
|
||||
if (command === 'others') return workspace.tabs.filter((tab) => tab.key !== workspace.activeKey).map((tab) => tab.key)
|
||||
if (command === 'right') return activeIndex < 0 ? [] : workspace.tabs.slice(activeIndex + 1).map((tab) => tab.key)
|
||||
return workspace.tabs.map((tab) => tab.key)
|
||||
}
|
||||
|
||||
function handleCommand(command: CloseCommand) {
|
||||
void closeTabs(keysForCommand(command))
|
||||
}
|
||||
|
||||
function handleTabWheel(event: WheelEvent) {
|
||||
const scroller = tabScroller.value
|
||||
if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||
if (delta === 0) return
|
||||
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth
|
||||
const canScroll = delta < 0 ? scroller.scrollLeft > 0 : scroller.scrollLeft < maxScrollLeft
|
||||
if (!canScroll) return
|
||||
event.preventDefault()
|
||||
scroller.scrollLeft = Math.max(0, Math.min(maxScrollLeft, scroller.scrollLeft + delta))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => workspace.activeKey,
|
||||
async () => {
|
||||
await nextTick()
|
||||
tabScroller.value?.querySelector<HTMLElement>('.workspace-tab.is-active')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-tabs" aria-label="已打开页面">
|
||||
<div
|
||||
ref="tabScroller"
|
||||
class="workspace-tabs__scroller"
|
||||
role="tablist"
|
||||
aria-label="工作区页签"
|
||||
@wheel="handleTabWheel"
|
||||
>
|
||||
<div
|
||||
v-for="tab in workspace.tabs"
|
||||
:key="tab.key"
|
||||
class="workspace-tab"
|
||||
:class="{ 'is-active': tab.key === workspace.activeKey, 'is-dirty': unsaved.dirtyRouteKeys.has(tab.key) }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-tab__label"
|
||||
role="tab"
|
||||
:aria-selected="tab.key === workspace.activeKey"
|
||||
:aria-current="tab.key === workspace.activeKey ? 'page' : undefined"
|
||||
:title="tab.title"
|
||||
@click="activate(tab.fullPath)"
|
||||
>
|
||||
<span>{{ tab.title }}</span>
|
||||
<i v-if="unsaved.dirtyRouteKeys.has(tab.key)" aria-label="有未保存修改" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-tab__close"
|
||||
:aria-label="`关闭${tab.title}`"
|
||||
:title="`关闭${tab.title}`"
|
||||
@click="closeTabs([tab.key])"
|
||||
><X :size="14" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
|
||||
<button type="button" class="workspace-tabs__more" aria-label="页签管理" title="页签管理">
|
||||
<MoreHorizontal :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="current" :disabled="!workspace.activeKey">关闭当前</el-dropdown-item>
|
||||
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
|
||||
<el-dropdown-item command="right" :disabled="keysForCommand('right').length === 0">关闭右侧</el-dropdown-item>
|
||||
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</section>
|
||||
</template>
|
||||
Vendored
+2
@@ -10,5 +10,7 @@ declare module 'vue-router' {
|
||||
description?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
workspaceTab?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +60,14 @@ test('source contract: aligns the light top bar beside the prototype-inspired da
|
||||
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
|
||||
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
|
||||
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-bottom: 1px solid rgba\(255, 255, 255, \.09\)/)
|
||||
assert.match(shellStyles, /\.client-main \{[^}]*padding: 88px 20px 40px;/)
|
||||
assert.match(shellStyles, /\.client-main \{[^}]*padding: 134px 20px 40px;/)
|
||||
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
|
||||
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
|
||||
assert.match(globalStyles, /\.client-header-heading p, \.client-user-copy \{ display: none; \}/)
|
||||
})
|
||||
|
||||
test('source contract: closes the mobile menu on route changes and Escape', () => {
|
||||
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, closeMobileMenu\)/)
|
||||
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, \(\) => \{\s*closeMobileMenu\(\)\s*registerCurrentRoute\(\)\s*\}\)/)
|
||||
assert.match(layoutSource, /event\.key === 'Escape'/)
|
||||
assert.match(layoutSource, /onMounted\(\(\) => \{[\s\S]*document\.addEventListener\('keydown', handleKeydown\)[\s\S]*\}\)/)
|
||||
assert.match(layoutSource, /document\.removeEventListener\('keydown', handleKeydown\)/)
|
||||
|
||||
+108
-3
@@ -5,11 +5,22 @@ import { ElMessageBox } from 'element-plus'
|
||||
import { LogOut, Menu, X } from '@lucide/vue'
|
||||
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
||||
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
||||
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import {
|
||||
MAX_CACHED_WORKSPACE_VIEWS,
|
||||
isWorkspaceRoute,
|
||||
useWorkspaceTabsStore,
|
||||
type WorkspaceTab,
|
||||
} from '@/navigation/workspace-tabs/workspaceTabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const workspace = useWorkspaceTabsStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const pageTitle = computed(() => route.meta.title || '业务总览')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const mobileMenuOpen = ref(false)
|
||||
@@ -19,6 +30,25 @@ const sidebarRef = ref<HTMLElement>()
|
||||
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
|
||||
let previousBodyOverflow: string | undefined
|
||||
let desktopMediaQuery: MediaQueryList | undefined
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
function isAllowedTab(tab: WorkspaceTab) {
|
||||
const resolved = router.resolve(tab.fullPath)
|
||||
if (String(resolved.name ?? '') !== tab.routeName || !isWorkspaceRoute(resolved)) return false
|
||||
const permission = typeof resolved.meta.permission === 'string' ? resolved.meta.permission : undefined
|
||||
return auth.hasPermission(permission)
|
||||
}
|
||||
|
||||
function registerCurrentRoute() {
|
||||
workspace.openRoute(route, unsaved.dirtyRouteKeys)
|
||||
}
|
||||
|
||||
const restoredActivePath = workspace.initialize(auth.user?.id ?? '', isAllowedTab)
|
||||
if (route.fullPath === '/' && restoredActivePath && restoredActivePath !== '/') {
|
||||
void router.replace(restoredActivePath)
|
||||
} else {
|
||||
registerCurrentRoute()
|
||||
}
|
||||
|
||||
function openMobileMenu() {
|
||||
mobileMenuOpen.value = true
|
||||
@@ -44,7 +74,51 @@ function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
|
||||
if (event.matches) closeMobileMenu()
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, closeMobileMenu)
|
||||
watch(() => route.fullPath, () => {
|
||||
closeMobileMenu()
|
||||
registerCurrentRoute()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => String(route.name ?? ''),
|
||||
async (routeName, previousRouteName) => {
|
||||
if (previousRouteName && workspace.tabs.some((tab) => tab.key === previousRouteName)) {
|
||||
scrollPositions.set(previousRouteName, window.scrollY)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
window.requestAnimationFrame(() => {
|
||||
window.scrollTo(0, scrollPositions.get(routeName) ?? 0)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => workspace.tabs.map((tab) => tab.key),
|
||||
(tabKeys) => {
|
||||
const retainedKeys = new Set(tabKeys)
|
||||
for (const routeKey of scrollPositions.keys()) {
|
||||
if (!retainedKeys.has(routeKey)) scrollPositions.delete(routeKey)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.permissionCodes.join('|'),
|
||||
() => {
|
||||
const result = workspace.reconcile(isAllowedTab)
|
||||
if (result.closedActive) void router.replace(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (authenticated) return
|
||||
workspace.clear()
|
||||
unsaved.clear()
|
||||
},
|
||||
)
|
||||
|
||||
watch(mobileMenuOpen, async (open, wasOpen) => {
|
||||
if (open) {
|
||||
@@ -66,6 +140,7 @@ watch(mobileMenuOpen, async (open, wasOpen) => {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
|
||||
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
|
||||
handleDesktopBreakpoint(desktopMediaQuery)
|
||||
@@ -73,21 +148,33 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
|
||||
restoreBodyOverflow()
|
||||
})
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!unsaved.hasDirty) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出客户工作台吗?', '退出登录', {
|
||||
await ElMessageBox.confirm(unsaved.hasDirty
|
||||
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
|
||||
: '确定退出客户工作台吗?', '退出登录', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
type: unsaved.hasDirty ? 'warning' : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
|
||||
workspace.clear()
|
||||
unsaved.clear()
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
@@ -158,12 +245,30 @@ async function handleLogout() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<WorkspaceTabs
|
||||
:inert="mobileMenuOpen"
|
||||
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="client-main"
|
||||
:inert="mobileMenuOpen"
|
||||
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||
>
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="viewRoute.meta.keepAlive !== false"
|
||||
:key="workspace.cacheKey(viewRoute.name)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="viewRoute.meta.keepAlive === false"
|
||||
:key="String(viewRoute.name ?? viewRoute.fullPath)"
|
||||
/>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const firstLegModule = {
|
||||
path: 'orders/first-leg',
|
||||
name: 'first-leg-orders',
|
||||
component: () => import('./views/FirstLegOrdersView.vue'),
|
||||
props: { createPage: false },
|
||||
release: 'active',
|
||||
meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' },
|
||||
navigation: { key: 'first-leg-orders', title: '头程下单', icon: FileText },
|
||||
@@ -17,6 +18,7 @@ export const firstLegModule = {
|
||||
path: 'orders/first-leg/create',
|
||||
name: 'first-leg-order-create',
|
||||
component: () => import('./views/FirstLegOrdersView.vue'),
|
||||
props: { createPage: true },
|
||||
release: 'active',
|
||||
meta: { title: '创建头程订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
|
||||
},
|
||||
|
||||
@@ -12,8 +12,10 @@ test('routes the confirmed first-leg path to the implemented order view', () =>
|
||||
|
||||
test('opens order creation as a dedicated protected page while keeping draft editing in a drawer', () => {
|
||||
assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/)
|
||||
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?props: \{ createPage: true \}/)
|
||||
assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/)
|
||||
assert.match(source, /const isCreatePage = computed\(\(\) => route\.name === 'first-leg-order-create'\)/)
|
||||
assert.match(source, /defineProps<\{ createPage\?: boolean \}>/)
|
||||
assert.match(source, /const isCreatePage = computed\(\(\) => props\.createPage\)/)
|
||||
assert.match(source, /<FirstLegOrderEditorShell[^>]*:page="isCreatePage"/)
|
||||
assert.match(editorShellSource, /<section v-if="page" class="first-leg-editor-page">/)
|
||||
assert.match(editorShellSource, /<el-drawer[\s\S]*v-else/)
|
||||
@@ -206,8 +208,9 @@ test('discards stale inbound billing responses when the drawer closes or the use
|
||||
})
|
||||
|
||||
test('protects unsaved drafts and exposes recoverable load failures', () => {
|
||||
assert.match(source, /onBeforeRouteLeave/)
|
||||
assert.match(source, /beforeunload/)
|
||||
assert.match(source, /useUnsavedWorkRegistration\(/)
|
||||
assert.match(source, /routeKey: unsavedRouteKey/)
|
||||
assert.doesNotMatch(source, /onBeforeRouteLeave|beforeunload/)
|
||||
assert.match(source, /confirmDiscardChanges/)
|
||||
assert.match(source, /:before-close="requestCloseEditor"/)
|
||||
assert.match(source, /store\.loadError/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
|
||||
import { AlertTriangle, Box, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
|
||||
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
|
||||
@@ -13,6 +13,7 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin
|
||||
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
|
||||
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
|
||||
import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
@@ -29,9 +30,9 @@ import {
|
||||
const store = useFirstLegOrdersStore()
|
||||
const tenantStore = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isCreatePage = computed(() => route.name === 'first-leg-order-create')
|
||||
const props = withDefaults(defineProps<{ createPage?: boolean }>(), { createPage: false })
|
||||
const isCreatePage = computed(() => props.createPage)
|
||||
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
|
||||
const companyAuditLoaded = ref(false)
|
||||
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
||||
@@ -220,6 +221,14 @@ function emptyDraft(): FirstLegOrderDraft {
|
||||
const form = reactive<FirstLegOrderDraft>(emptyDraft())
|
||||
const serializedDraft = computed(() => JSON.stringify(form))
|
||||
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
|
||||
const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: `first-leg-order-editor:${unsavedRouteKey}`,
|
||||
routeKey: unsavedRouteKey,
|
||||
label: props.createPage ? '创建头程订单' : '头程订单编辑',
|
||||
dirty: editorDirty,
|
||||
})
|
||||
|
||||
const filteredOrders = computed(() => store.orders.filter((order) => {
|
||||
const keywordValue = appliedFilters.value.keyword
|
||||
@@ -284,14 +293,7 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '服务暂时不可用'
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!editorDirty.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
loadPrivateAddresses()
|
||||
void loadPageData()
|
||||
})
|
||||
@@ -308,15 +310,9 @@ watch(detailOpen, (open) => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
resetInboundBillingState()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!editorDirty.value) return true
|
||||
return confirmDiscardChanges()
|
||||
})
|
||||
|
||||
function markEditorClean() {
|
||||
editorBaseline.value = serializedDraft.value
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const systemModule = {
|
||||
name: 'forbidden',
|
||||
component: () => import('./views/ForbiddenView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '无权访问', description: '当前账号没有访问该页面的权限' },
|
||||
meta: { title: '无权访问', description: '当前账号没有访问该页面的权限', workspaceTab: false, keepAlive: false },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -19,11 +19,14 @@ interface ClientRouteBase {
|
||||
path: string
|
||||
name: string
|
||||
component: Component | (() => Promise<unknown>)
|
||||
props?: boolean | Record<string, unknown>
|
||||
release: ClientRouteRelease
|
||||
meta: {
|
||||
title: string
|
||||
description?: string
|
||||
permission?: string
|
||||
workspaceTab?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { computed, onBeforeUnmount, ref, watchEffect, type Ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface UnsavedWorkEntry {
|
||||
key: string
|
||||
routeKey: string
|
||||
label: string
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export const useUnsavedWorkStore = defineStore('client-unsaved-work', () => {
|
||||
const entries = ref<UnsavedWorkEntry[]>([])
|
||||
const owners = new Map<string, symbol>()
|
||||
const dirtyEntries = computed(() => entries.value.filter((entry) => entry.dirty))
|
||||
const hasDirty = computed(() => dirtyEntries.value.length > 0)
|
||||
const dirtyRouteKeys = computed(() => new Set(dirtyEntries.value.map((entry) => entry.routeKey)))
|
||||
|
||||
function claim(key: string, owner: symbol) {
|
||||
owners.set(key, owner)
|
||||
}
|
||||
|
||||
function upsert(entry: UnsavedWorkEntry, owner?: symbol) {
|
||||
if (owner && owners.get(entry.key) !== owner) return
|
||||
const index = entries.value.findIndex((item) => item.key === entry.key)
|
||||
if (index === -1) entries.value.push(entry)
|
||||
else entries.value[index] = entry
|
||||
}
|
||||
|
||||
function remove(key: string, owner?: symbol) {
|
||||
if (owner && owners.get(key) !== owner) return
|
||||
owners.delete(key)
|
||||
entries.value = entries.value.filter((entry) => entry.key !== key)
|
||||
}
|
||||
|
||||
function removeRoutes(routeKeys: readonly string[]) {
|
||||
const removed = new Set(routeKeys)
|
||||
entries.value
|
||||
.filter((entry) => removed.has(entry.routeKey))
|
||||
.forEach((entry) => owners.delete(entry.key))
|
||||
entries.value = entries.value.filter((entry) => !removed.has(entry.routeKey))
|
||||
}
|
||||
|
||||
function findDirty(routeKeys: readonly string[]) {
|
||||
const candidates = new Set(routeKeys)
|
||||
return dirtyEntries.value.filter((entry) => candidates.has(entry.routeKey))
|
||||
}
|
||||
|
||||
function clear() {
|
||||
owners.clear()
|
||||
entries.value = []
|
||||
}
|
||||
|
||||
return { entries, dirtyEntries, hasDirty, dirtyRouteKeys, claim, upsert, remove, removeRoutes, findDirty, clear }
|
||||
})
|
||||
|
||||
export function useUnsavedWorkRegistration(options: {
|
||||
key: string
|
||||
routeKey: string
|
||||
label: string
|
||||
dirty: Readonly<Ref<boolean>>
|
||||
}) {
|
||||
const store = useUnsavedWorkStore()
|
||||
const owner = Symbol(options.key)
|
||||
store.claim(options.key, owner)
|
||||
watchEffect(() => {
|
||||
store.upsert({
|
||||
key: options.key,
|
||||
routeKey: options.routeKey,
|
||||
label: options.label,
|
||||
dirty: options.dirty.value,
|
||||
}, owner)
|
||||
})
|
||||
onBeforeUnmount(() => store.remove(options.key, owner))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
export const MAX_WORKSPACE_TABS = 12
|
||||
export const MAX_CACHED_WORKSPACE_VIEWS = 24
|
||||
|
||||
const excludedRouteNames = new Set(['login', 'register', 'terms', 'privacy', 'forbidden'])
|
||||
|
||||
export interface WorkspaceTab {
|
||||
key: string
|
||||
routeName: string
|
||||
title: string
|
||||
fullPath: string
|
||||
permission: string | null
|
||||
lastVisitedAt: number
|
||||
}
|
||||
|
||||
interface PersistedWorkspace {
|
||||
activeKey: string | null
|
||||
tabs: WorkspaceTab[]
|
||||
}
|
||||
|
||||
function storageKey(ownerId: string) {
|
||||
return `cc:client-workspace-tabs:${ownerId}`
|
||||
}
|
||||
|
||||
function isWorkspaceTab(value: unknown): value is WorkspaceTab {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const tab = value as Partial<WorkspaceTab>
|
||||
return typeof tab.key === 'string'
|
||||
&& typeof tab.routeName === 'string'
|
||||
&& typeof tab.title === 'string'
|
||||
&& typeof tab.fullPath === 'string'
|
||||
&& (typeof tab.permission === 'string' || tab.permission === null)
|
||||
&& typeof tab.lastVisitedAt === 'number'
|
||||
}
|
||||
|
||||
function readWorkspace(ownerId: string): PersistedWorkspace {
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(storageKey(ownerId)) ?? 'null') as Partial<PersistedWorkspace> | null
|
||||
return {
|
||||
activeKey: typeof parsed?.activeKey === 'string' ? parsed.activeKey : null,
|
||||
tabs: Array.isArray(parsed?.tabs) ? parsed.tabs.filter(isWorkspaceTab) : [],
|
||||
}
|
||||
} catch {
|
||||
return { activeKey: null, tabs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceRoute(route: { name?: unknown; meta: { workspaceTab?: boolean } }) {
|
||||
const name = typeof route.name === 'string' ? route.name : ''
|
||||
return Boolean(name && !excludedRouteNames.has(name) && route.meta.workspaceTab !== false)
|
||||
}
|
||||
|
||||
export const useWorkspaceTabsStore = defineStore('client-workspace-tabs', () => {
|
||||
const tabs = ref<WorkspaceTab[]>([])
|
||||
const activeKey = ref<string | null>(null)
|
||||
const ownerId = ref('')
|
||||
const cacheEpochs = ref<Record<string, number>>({})
|
||||
const initialized = ref(false)
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((tab) => tab.key === activeKey.value) ?? null)
|
||||
|
||||
function persist() {
|
||||
if (!ownerId.value) return
|
||||
try {
|
||||
sessionStorage.setItem(storageKey(ownerId.value), JSON.stringify({
|
||||
activeKey: activeKey.value,
|
||||
tabs: tabs.value,
|
||||
} satisfies PersistedWorkspace))
|
||||
} catch {
|
||||
// The workspace remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function initialize(nextOwnerId: string, isAllowed: (tab: WorkspaceTab) => boolean) {
|
||||
if (!nextOwnerId) return null
|
||||
if (initialized.value && ownerId.value === nextOwnerId) return activeTab.value?.fullPath ?? null
|
||||
|
||||
ownerId.value = nextOwnerId
|
||||
initialized.value = true
|
||||
cacheEpochs.value = {}
|
||||
const restored = readWorkspace(nextOwnerId)
|
||||
tabs.value = restored.tabs.filter(isAllowed).slice(-MAX_WORKSPACE_TABS)
|
||||
activeKey.value = tabs.value.some((tab) => tab.key === restored.activeKey)
|
||||
? restored.activeKey
|
||||
: tabs.value.at(-1)?.key ?? null
|
||||
persist()
|
||||
return activeTab.value?.fullPath ?? null
|
||||
}
|
||||
|
||||
function bumpCacheEpoch(key: string) {
|
||||
cacheEpochs.value = { ...cacheEpochs.value, [key]: (cacheEpochs.value[key] ?? 0) + 1 }
|
||||
}
|
||||
|
||||
function removeKeys(keys: ReadonlySet<string>) {
|
||||
for (const key of keys) bumpCacheEpoch(key)
|
||||
tabs.value = tabs.value.filter((tab) => !keys.has(tab.key))
|
||||
}
|
||||
|
||||
function enforceLimit(protectedKeys: ReadonlySet<string>) {
|
||||
while (tabs.value.length > MAX_WORKSPACE_TABS) {
|
||||
const candidate = [...tabs.value]
|
||||
.filter((tab) => tab.key !== activeKey.value && !protectedKeys.has(tab.key))
|
||||
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)[0]
|
||||
if (!candidate) return
|
||||
removeKeys(new Set([candidate.key]))
|
||||
}
|
||||
}
|
||||
|
||||
function openRoute(route: RouteLocationNormalizedLoaded, protectedKeys: ReadonlySet<string> = new Set()) {
|
||||
if (!isWorkspaceRoute(route)) return
|
||||
const routeName = String(route.name)
|
||||
const current = tabs.value.find((tab) => tab.key === routeName)
|
||||
const next: WorkspaceTab = {
|
||||
key: routeName,
|
||||
routeName,
|
||||
title: String(route.meta.title || routeName),
|
||||
fullPath: route.fullPath,
|
||||
permission: typeof route.meta.permission === 'string' ? route.meta.permission : null,
|
||||
lastVisitedAt: Date.now(),
|
||||
}
|
||||
if (current) Object.assign(current, next)
|
||||
else tabs.value.push(next)
|
||||
activeKey.value = routeName
|
||||
enforceLimit(protectedKeys)
|
||||
persist()
|
||||
}
|
||||
|
||||
function close(keys: readonly string[]) {
|
||||
const closing = new Set(keys)
|
||||
const closedActive = activeKey.value !== null && closing.has(activeKey.value)
|
||||
removeKeys(closing)
|
||||
if (closedActive) {
|
||||
activeKey.value = [...tabs.value]
|
||||
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)[0]?.key ?? null
|
||||
}
|
||||
persist()
|
||||
return { closedActive, fallbackPath: activeTab.value?.fullPath ?? null }
|
||||
}
|
||||
|
||||
function reconcile(isAllowed: (tab: WorkspaceTab) => boolean) {
|
||||
const removedKeys = tabs.value.filter((tab) => !isAllowed(tab)).map((tab) => tab.key)
|
||||
return close(removedKeys)
|
||||
}
|
||||
|
||||
function cacheKey(routeName: unknown) {
|
||||
const key = typeof routeName === 'string' ? routeName : 'anonymous'
|
||||
return `${key}:${cacheEpochs.value[key] ?? 0}`
|
||||
}
|
||||
|
||||
function clear(removePersisted = true) {
|
||||
if (removePersisted && ownerId.value) {
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey(ownerId.value))
|
||||
} catch {
|
||||
// Ignore storage failures while clearing an authenticated session.
|
||||
}
|
||||
}
|
||||
tabs.value = []
|
||||
activeKey.value = null
|
||||
cacheEpochs.value = {}
|
||||
ownerId.value = ''
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeKey,
|
||||
activeTab,
|
||||
ownerId,
|
||||
initialized,
|
||||
initialize,
|
||||
openRoute,
|
||||
close,
|
||||
reconcile,
|
||||
cacheKey,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const storeSource = readFileSync(new URL('./workspace-tabs/workspaceTabs.ts', import.meta.url), 'utf8')
|
||||
const unsavedSource = readFileSync(new URL('./workspace-tabs/unsavedWork.ts', import.meta.url), 'utf8')
|
||||
const tabsSource = readFileSync(new URL('../components/WorkspaceTabs.vue', import.meta.url), 'utf8')
|
||||
const layoutSource = readFileSync(new URL('../layouts/AppLayout.vue', import.meta.url), 'utf8')
|
||||
const tabStyles = readFileSync(new URL('../styles/workspace-tabs.css', import.meta.url), 'utf8')
|
||||
|
||||
test('workspace tabs are isolated by account, restored from session storage, and capped without evicting protected work', () => {
|
||||
assert.match(storeSource, /cc:client-workspace-tabs:\$\{ownerId\}/)
|
||||
assert.match(storeSource, /sessionStorage\.getItem/)
|
||||
assert.match(storeSource, /sessionStorage\.setItem/)
|
||||
assert.match(storeSource, /MAX_WORKSPACE_TABS = 12/)
|
||||
assert.match(storeSource, /MAX_CACHED_WORKSPACE_VIEWS = 24/)
|
||||
assert.match(storeSource, /!protectedKeys\.has\(tab\.key\)/)
|
||||
assert.match(storeSource, /sort\(\(left, right\) => left\.lastVisitedAt - right\.lastVisitedAt\)/)
|
||||
})
|
||||
|
||||
test('workspace tabs exclude public and forbidden routes and use one tab per route name', () => {
|
||||
assert.match(storeSource, /excludedRouteNames = new Set\(\['login', 'register', 'terms', 'privacy', 'forbidden'\]\)/)
|
||||
assert.match(storeSource, /const routeName = String\(route\.name\)/)
|
||||
assert.match(storeSource, /key: routeName/)
|
||||
assert.match(storeSource, /tabs\.value\.find\(\(tab\) => tab\.key === routeName\)/)
|
||||
})
|
||||
|
||||
test('closing tabs invalidates their cached instance and returns the most recently visited fallback', () => {
|
||||
assert.match(storeSource, /function bumpCacheEpoch/)
|
||||
assert.match(storeSource, /for \(const key of keys\) bumpCacheEpoch\(key\)/)
|
||||
assert.match(storeSource, /sort\(\(left, right\) => right\.lastVisitedAt - left\.lastVisitedAt\)/)
|
||||
assert.match(storeSource, /return \{ closedActive, fallbackPath:/)
|
||||
})
|
||||
|
||||
test('the shell renders cached route views below an accessible workspace tab strip', () => {
|
||||
assert.match(layoutSource, /<WorkspaceTabs[\s\S]*:inert="mobileMenuOpen"/)
|
||||
assert.match(layoutSource, /<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">/)
|
||||
assert.match(layoutSource, /workspace\.cacheKey\(viewRoute\.name\)/)
|
||||
assert.match(layoutSource, /scrollPositions\.set\(previousRouteName, window\.scrollY\)/)
|
||||
assert.match(layoutSource, /window\.scrollTo\(0, scrollPositions\.get\(routeName\) \?\? 0\)/)
|
||||
assert.match(tabsSource, /role="tablist"/)
|
||||
assert.match(tabsSource, /role="tab"/)
|
||||
assert.match(tabsSource, /:aria-selected=/)
|
||||
assert.match(tabsSource, /关闭当前/)
|
||||
assert.match(tabsSource, /关闭其他/)
|
||||
assert.match(tabsSource, /关闭右侧/)
|
||||
assert.match(tabsSource, /关闭全部/)
|
||||
})
|
||||
|
||||
test('unsaved work blocks destructive closes and browser unload without blocking cached tab switches', () => {
|
||||
assert.match(unsavedSource, /dirtyRouteKeys/)
|
||||
assert.match(tabsSource, /unsaved\.findDirty\(keys\)/)
|
||||
assert.match(tabsSource, /放弃未保存的修改/)
|
||||
assert.match(layoutSource, /window\.addEventListener\('beforeunload', handleBeforeUnload\)/)
|
||||
assert.match(layoutSource, /if \(!unsaved\.hasDirty\) return/)
|
||||
assert.doesNotMatch(tabsSource, /beforeEach|onBeforeRouteLeave/)
|
||||
})
|
||||
|
||||
test('unsaved registrations use instance ownership so stale cached views cannot replace or remove a reopened view', () => {
|
||||
assert.match(unsavedSource, /const owners = new Map<string, symbol>\(\)/)
|
||||
assert.match(unsavedSource, /function claim\(key: string, owner: symbol\)/)
|
||||
assert.match(unsavedSource, /if \(owner && owners\.get\(entry\.key\) !== owner\) return/)
|
||||
assert.match(unsavedSource, /if \(owner && owners\.get\(key\) !== owner\) return/)
|
||||
assert.match(unsavedSource, /store\.claim\(options\.key, owner\)/)
|
||||
assert.match(unsavedSource, /store\.remove\(options\.key, owner\)/)
|
||||
})
|
||||
|
||||
test('the tab strip keeps touch targets and horizontal navigation usable on narrow screens', () => {
|
||||
assert.match(tabStyles, /overflow-x: auto/)
|
||||
assert.match(tabStyles, /scrollbar-width: none/)
|
||||
assert.match(tabStyles, /::-webkit-scrollbar \{ display: none; width: 0; height: 0; \}/)
|
||||
assert.match(tabsSource, /@wheel="handleTabWheel"/)
|
||||
assert.match(tabsSource, /scroller\.scrollLeft = Math\.max/)
|
||||
assert.match(tabStyles, /@media \(max-width: 720px\)/)
|
||||
assert.match(tabStyles, /\.workspace-tab__close \{ width: 40px; height: 40px;/)
|
||||
assert.match(tabStyles, /\.workspace-tabs__more \{ width: 48px; height: 48px;/)
|
||||
assert.match(tabStyles, /prefers-reduced-motion: reduce/)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
@import './foundation.css';
|
||||
@import './shell.css';
|
||||
@import './workspace-tabs.css';
|
||||
|
||||
.auth-page { min-height: 100vh; background: #edf2f4; color: var(--navy); }
|
||||
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid #dfe6e8; background: #fff; color: #59646d; font-size: 13px; }
|
||||
@@ -144,7 +145,7 @@
|
||||
.client-sidebar-scrim { position: fixed; inset: 0; z-index: 20; display: block; padding: 0; border: 0; background: rgba(7, 18, 34, .56); }
|
||||
.client-header-menu-button { background: #eef3f8; color: #24324a; }
|
||||
.client-sidebar-brand button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #fff; place-items: center; }
|
||||
.client-main { width: 100%; max-width: 100%; margin-left: 0; padding: 88px 14px 30px; }
|
||||
.client-main { width: 100%; max-width: 100%; margin-left: 0; padding: 136px 14px 30px; }
|
||||
.client-header-menu-button { display: grid; }
|
||||
.client-header-heading p, .client-user-copy { display: none; }
|
||||
.client-header-heading h1 { font-size: 16px; }
|
||||
|
||||
@@ -56,4 +56,4 @@
|
||||
.client-sidebar-logout { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; gap: 11px; margin: 10px 0 0; padding: 0 12px; border: 0; border-radius: 10px; background: rgba(239, 68, 68, .12); color: #fca5a5; cursor: pointer; font-size: 13px; font-weight: 600; text-align: left; }
|
||||
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
|
||||
.client-sidebar-logout svg { flex: 0 0 18px; }
|
||||
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 88px 20px 40px; }
|
||||
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 134px 20px 40px; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.workspace-tabs { position: fixed; inset: 68px 0 auto 250px; z-index: 19; display: flex; min-width: 0; height: 46px; align-items: stretch; border-bottom: 1px solid var(--border); background: #fff; box-shadow: 0 3px 12px rgba(15, 31, 58, .04); }
|
||||
.workspace-tabs__scroller { display: flex; min-width: 0; flex: 1; align-items: end; gap: 2px; overflow-x: auto; overflow-y: hidden; padding: 6px 8px 0; overscroll-behavior-inline: contain; scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.workspace-tabs__scroller::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
.workspace-tab { display: flex; min-width: 112px; max-width: 220px; height: 39px; flex: 0 0 auto; align-items: center; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; color: #657184; }
|
||||
.workspace-tab:hover { background: #f5f8fc; color: var(--navy); }
|
||||
.workspace-tab.is-active { border-color: var(--border); background: #f5f7fa; color: var(--primary); }
|
||||
.workspace-tab__label { display: flex; min-width: 0; height: 100%; flex: 1; align-items: center; gap: 7px; padding: 0 6px 0 12px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 600; text-align: left; }
|
||||
.workspace-tab__label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-tab__label i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: #d97706; }
|
||||
.workspace-tab__close { display: grid; width: 32px; height: 32px; flex: 0 0 32px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: #8b95a5; cursor: pointer; place-items: center; }
|
||||
.workspace-tab__close:hover { background: #e8edf4; color: #27364d; }
|
||||
.workspace-tab__label:focus-visible, .workspace-tab__close:focus-visible, .workspace-tabs__more:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||
.workspace-tabs__more { display: grid; width: 46px; height: 46px; flex: 0 0 46px; padding: 0; border: 0; border-left: 1px solid var(--border); background: #fff; color: #647187; cursor: pointer; place-items: center; }
|
||||
.workspace-tabs__more:hover { background: #f4f7fb; color: var(--primary); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace-tabs { inset: 68px 0 auto 0; height: 48px; }
|
||||
.workspace-tabs__scroller { padding-left: 8px; }
|
||||
.workspace-tab { min-width: 132px; height: 41px; }
|
||||
.workspace-tab__label { min-height: 40px; }
|
||||
.workspace-tab__close { width: 40px; height: 40px; flex-basis: 40px; }
|
||||
.workspace-tabs__more { width: 48px; height: 48px; flex-basis: 48px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-tabs__scroller { scroll-behavior: auto; }
|
||||
}
|
||||
Reference in New Issue
Block a user