@usethink/cf-admin-fe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/composables/index.d.ts +6 -0
- package/dist/composables/useAdminBatchOperation.d.ts +12 -0
- package/dist/composables/useClipboard.d.ts +4 -0
- package/dist/composables/useConfirmDialog.d.ts +43 -0
- package/dist/composables/useTablePagination.d.ts +19 -0
- package/dist/composables/useTableSelection.d.ts +20 -0
- package/dist/composables/useToast.d.ts +22 -0
- package/dist/index.d.ts +17 -0
- package/package.json +84 -0
- package/src/components/AdminModal.vue +193 -0
- package/src/components/AdminPagination.vue +271 -0
- package/src/components/ConfirmDialog.vue +160 -0
- package/src/components/ToastContainer.vue +120 -0
- package/src/components/index.ts +4 -0
- package/src/composables/index.ts +34 -0
- package/src/composables/useAdminBatchOperation.ts +51 -0
- package/src/composables/useClipboard.ts +37 -0
- package/src/composables/useConfirmDialog.ts +103 -0
- package/src/composables/useTablePagination.ts +81 -0
- package/src/composables/useTableSelection.ts +90 -0
- package/src/composables/useToast.ts +29 -0
- package/src/env.d.ts +30 -0
- package/src/index.ts +47 -0
- package/src/styles/admin-primitives.css +2006 -0
- package/src/styles/index.css +3 -0
- package/src/styles/tokens.css +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @usethink/cf-admin-fe
|
|
2
|
+
|
|
3
|
+
Vue 3 **admin foundation** for the Cloudflare product family (`cf-shop`, `cf-lottery`, future CF admin apps).
|
|
4
|
+
|
|
5
|
+
This package is the **frontend** counterpart to backend `@usethink/cf-core`:
|
|
6
|
+
|
|
7
|
+
| Layer | Package | Contents |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Workers / infra | `@usethink/cf-core` | HTTP, crypto, media, secrets, … |
|
|
10
|
+
| Admin UI kit | **`@usethink/cf-admin-fe`** | Composables, shell components, admin CSS tokens |
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Why `cf-admin-fe` (not `cf-admin`)
|
|
14
|
+
|
|
15
|
+
This package is **frontend-only**. The `-fe` suffix makes that explicit next to backend `@usethink/cf-core`, so operators never confuse UI kit code with Worker/API infrastructure.
|
|
16
|
+
|
|
17
|
+
**Not** a business mid-tier: no orders, lottery draws, RBAC pages, or payment UIs.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# monorepo / sibling template (recommended during development)
|
|
23
|
+
npm install file:../cf-admin-fe
|
|
24
|
+
|
|
25
|
+
# or once published
|
|
26
|
+
npm install @usethink/cf-admin-fe
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Peer: `vue` ^3.4.
|
|
30
|
+
|
|
31
|
+
## Quick start (new admin app)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// main.ts
|
|
35
|
+
import { createApp } from 'vue'
|
|
36
|
+
import App from './App.vue'
|
|
37
|
+
import '@usethink/cf-admin-fe/styles' // tokens + admin primitives
|
|
38
|
+
|
|
39
|
+
createApp(App).mount('#app')
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```vue
|
|
43
|
+
<script setup lang="ts">
|
|
44
|
+
import {
|
|
45
|
+
useTablePagination,
|
|
46
|
+
useTableSelection,
|
|
47
|
+
useConfirmDialog,
|
|
48
|
+
useToast,
|
|
49
|
+
useAdminBatchOperation,
|
|
50
|
+
} from '@usethink/cf-admin-fe'
|
|
51
|
+
import {
|
|
52
|
+
AdminPagination,
|
|
53
|
+
ConfirmDialog,
|
|
54
|
+
ToastContainer,
|
|
55
|
+
AdminModal,
|
|
56
|
+
} from '@usethink/cf-admin-fe/components'
|
|
57
|
+
</script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Products may keep thin re-exports under `@/composables/*` so existing views do not mass-rewrite.
|
|
61
|
+
|
|
62
|
+
## Public surface
|
|
63
|
+
|
|
64
|
+
### Composables (`@usethink/cf-admin-fe` or `/composables`)
|
|
65
|
+
|
|
66
|
+
- `useTablePagination` — page/limit/total/offset
|
|
67
|
+
- `useTableSelection` — multi-select + shift range
|
|
68
|
+
- `useConfirmDialog` — confirm + optional checkboxes
|
|
69
|
+
- `useToast` / `MAX_TOASTS` — global toast queue
|
|
70
|
+
- `useAdminBatchOperation` — sequential batch with progress
|
|
71
|
+
- `writeClipboardText` / `copyText` — clipboard helpers
|
|
72
|
+
|
|
73
|
+
### Components (`@usethink/cf-admin-fe/components`)
|
|
74
|
+
|
|
75
|
+
- `AdminPagination`, `AdminModal`, `ConfirmDialog`, `ToastContainer`
|
|
76
|
+
|
|
77
|
+
Components use `$t(...)` for labels when `vue-i18n` is present; provide the usual keys (`pagination.*`, `confirm.*`, `adminModal.*`) in the host app.
|
|
78
|
+
|
|
79
|
+
### Styles
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import '@usethink/cf-admin-fe/styles'
|
|
83
|
+
// or granular:
|
|
84
|
+
import '@usethink/cf-admin-fe/styles/tokens.css'
|
|
85
|
+
import '@usethink/cf-admin-fe/styles/admin-primitives.css'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What stays in the product
|
|
89
|
+
|
|
90
|
+
- Domain admin **pages** (`views/admin/*`)
|
|
91
|
+
- Auth/session composables, API clients, ConfigField registries
|
|
92
|
+
- Product-only CSS beyond the shared primitives
|
|
93
|
+
|
|
94
|
+
## Develop
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm install
|
|
98
|
+
npm test
|
|
99
|
+
npm run type-check
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useTablePagination, type UseTablePaginationOptions, type UseTablePaginationReturn, } from './useTablePagination';
|
|
2
|
+
export { useTableSelection, type UseTableSelectionOptions, } from './useTableSelection';
|
|
3
|
+
export { useConfirmDialog, type ConfirmOptionDef, type ConfirmAskOptions, type ConfirmResult, } from './useConfirmDialog';
|
|
4
|
+
export { useToast, MAX_TOASTS, type ToastType, type ToastItem, } from './useToast';
|
|
5
|
+
export { useAdminBatchOperation, type AdminBatchOperationResult, } from './useAdminBatchOperation';
|
|
6
|
+
export { writeClipboardText, copyText, } from './useClipboard';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface AdminBatchOperationResult<T> {
|
|
2
|
+
total: number;
|
|
3
|
+
success: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
failedItems: T[];
|
|
6
|
+
}
|
|
7
|
+
export declare function useAdminBatchOperation(): {
|
|
8
|
+
operating: import("vue").Ref<boolean, boolean>;
|
|
9
|
+
completed: import("vue").Ref<number, number>;
|
|
10
|
+
total: import("vue").Ref<number, number>;
|
|
11
|
+
runSequential: <T>(items: T[], action: (item: T) => Promise<void>) => Promise<AdminBatchOperationResult<T> | null>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ConfirmOptionDef = {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
/** 补充说明,显示在 label 下方 */
|
|
5
|
+
hint?: string;
|
|
6
|
+
/** 默认 false */
|
|
7
|
+
defaultChecked?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type ConfirmAskOptions = {
|
|
10
|
+
/** 危险操作样式(红按钮),由调用方通过 ConfirmDialog danger prop 控制亦可 */
|
|
11
|
+
danger?: boolean;
|
|
12
|
+
/** 可选勾选项,默认全部未勾选;打开弹窗时按 defaultChecked 重置 */
|
|
13
|
+
options?: ConfirmOptionDef[];
|
|
14
|
+
};
|
|
15
|
+
export type ConfirmResult = {
|
|
16
|
+
confirmed: boolean;
|
|
17
|
+
/** key → 是否勾选;取消时各值为 defaultChecked(调用方应以 confirmed 为准) */
|
|
18
|
+
options: Record<string, boolean>;
|
|
19
|
+
};
|
|
20
|
+
export declare function useConfirmDialog(): {
|
|
21
|
+
confirmVisible: import("vue").Ref<boolean, boolean>;
|
|
22
|
+
confirmMessage: import("vue").Ref<string, string>;
|
|
23
|
+
confirmOptionDefs: import("vue").Ref<{
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
hint?: string
|
|
27
|
+
/** 默认 false */
|
|
28
|
+
| undefined;
|
|
29
|
+
defaultChecked?: boolean | undefined;
|
|
30
|
+
}[], ConfirmOptionDef[] | {
|
|
31
|
+
key: string;
|
|
32
|
+
label: string;
|
|
33
|
+
hint?: string
|
|
34
|
+
/** 默认 false */
|
|
35
|
+
| undefined;
|
|
36
|
+
defaultChecked?: boolean | undefined;
|
|
37
|
+
}[]>;
|
|
38
|
+
confirmOptionValues: import("vue").Ref<Record<string, boolean>, Record<string, boolean>>;
|
|
39
|
+
askConfirm: (message: string) => Promise<boolean>;
|
|
40
|
+
askConfirmWithOptions: (message: string, options?: ConfirmAskOptions) => Promise<ConfirmResult>;
|
|
41
|
+
onConfirm: () => void;
|
|
42
|
+
setConfirmOption: (key: string, checked: boolean) => void;
|
|
43
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Ref, type ComputedRef } from 'vue';
|
|
2
|
+
export interface UseTablePaginationOptions {
|
|
3
|
+
initialPage?: number;
|
|
4
|
+
initialLimit?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface UseTablePaginationReturn {
|
|
7
|
+
page: Ref<number>;
|
|
8
|
+
limit: Ref<number>;
|
|
9
|
+
total: Ref<number>;
|
|
10
|
+
totalPages: ComputedRef<number>;
|
|
11
|
+
setPage: (page: number) => void;
|
|
12
|
+
setLimit: (limit: number) => void;
|
|
13
|
+
setTotal: (total: number) => boolean;
|
|
14
|
+
nextPage: () => void;
|
|
15
|
+
prevPage: () => void;
|
|
16
|
+
reset: () => void;
|
|
17
|
+
offset: ComputedRef<number>;
|
|
18
|
+
}
|
|
19
|
+
export declare function useTablePagination(options?: UseTablePaginationOptions): UseTablePaginationReturn;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type MaybeRef } from 'vue';
|
|
2
|
+
export interface UseTableSelectionOptions<T> {
|
|
3
|
+
isSelectable?: (row: T) => boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Accepts Ref or ComputedRef of row lists (MaybeRef) so product views can pass
|
|
7
|
+
* filtered computed tables without dual-package Ref assignability friction.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useTableSelection<T>(rows: MaybeRef<T[]>, getKey: (row: T) => string, options?: UseTableSelectionOptions<T>): {
|
|
10
|
+
selectedIds: import("vue").Ref<string[], string[]>;
|
|
11
|
+
selectedSet: import("vue").ComputedRef<Set<string>>;
|
|
12
|
+
selectedCount: import("vue").ComputedRef<number>;
|
|
13
|
+
selectableCount: import("vue").ComputedRef<number>;
|
|
14
|
+
allVisibleSelected: import("vue").ComputedRef<boolean>;
|
|
15
|
+
partiallySelected: import("vue").ComputedRef<boolean>;
|
|
16
|
+
isSelected: (id: string) => boolean;
|
|
17
|
+
setSelected: (id: string, checked: boolean, extendRange?: boolean) => void;
|
|
18
|
+
toggleAllVisible: (checked: boolean) => void;
|
|
19
|
+
clearSelection: () => void;
|
|
20
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ToastType = 'success' | 'error' | 'info';
|
|
2
|
+
export type ToastItem = {
|
|
3
|
+
id: number;
|
|
4
|
+
message: string;
|
|
5
|
+
type: ToastType;
|
|
6
|
+
};
|
|
7
|
+
/** Max stacked toasts; older entries drop when exceeded (lottery hardening). */
|
|
8
|
+
export declare const MAX_TOASTS = 5;
|
|
9
|
+
declare function showToast(message: string, type?: ToastType, duration?: number): void;
|
|
10
|
+
export declare function useToast(): {
|
|
11
|
+
toasts: import("vue").Ref<{
|
|
12
|
+
id: number;
|
|
13
|
+
message: string;
|
|
14
|
+
type: ToastType;
|
|
15
|
+
}[], ToastItem[] | {
|
|
16
|
+
id: number;
|
|
17
|
+
message: string;
|
|
18
|
+
type: ToastType;
|
|
19
|
+
}[]>;
|
|
20
|
+
showToast: typeof showToast;
|
|
21
|
+
};
|
|
22
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @usethink/cf-admin — Vue 3 admin foundation for CF product family.
|
|
3
|
+
*
|
|
4
|
+
* Import composables from root or `@usethink/cf-admin/composables`.
|
|
5
|
+
* Import components from `@usethink/cf-admin/components`.
|
|
6
|
+
* Import styles: `import '@usethink/cf-admin/styles'`.
|
|
7
|
+
*/
|
|
8
|
+
export { useTablePagination, type UseTablePaginationOptions, type UseTablePaginationReturn, } from './composables/useTablePagination.ts';
|
|
9
|
+
export { useTableSelection, type UseTableSelectionOptions, } from './composables/useTableSelection.ts';
|
|
10
|
+
export { useConfirmDialog, type ConfirmOptionDef, type ConfirmAskOptions, type ConfirmResult, } from './composables/useConfirmDialog.ts';
|
|
11
|
+
export { useToast, MAX_TOASTS, type ToastType, type ToastItem, } from './composables/useToast.ts';
|
|
12
|
+
export { useAdminBatchOperation, type AdminBatchOperationResult, } from './composables/useAdminBatchOperation.ts';
|
|
13
|
+
export { writeClipboardText, copyText, } from './composables/useClipboard.ts';
|
|
14
|
+
export { default as AdminPagination } from './components/AdminPagination.vue';
|
|
15
|
+
export { default as AdminModal } from './components/AdminModal.vue';
|
|
16
|
+
export { default as ConfirmDialog } from './components/ConfirmDialog.vue';
|
|
17
|
+
export { default as ToastContainer } from './components/ToastContainer.vue';
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usethink/cf-admin-fe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue 3 frontend admin kit for CF product family \u2014 composables, shell components, design tokens (FE only; not cf-core)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"module": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"import": "./src/index.ts",
|
|
19
|
+
"default": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./composables": {
|
|
22
|
+
"types": "./src/composables/index.ts",
|
|
23
|
+
"import": "./src/composables/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./components": {
|
|
26
|
+
"types": "./src/components/index.ts",
|
|
27
|
+
"import": "./src/components/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"./styles": "./src/styles/index.css",
|
|
30
|
+
"./styles/tokens.css": "./src/styles/tokens.css",
|
|
31
|
+
"./styles/admin-primitives.css": "./src/styles/admin-primitives.css",
|
|
32
|
+
"./package.json": "./package.json",
|
|
33
|
+
"./components/AdminPagination.vue": "./src/components/AdminPagination.vue",
|
|
34
|
+
"./components/AdminModal.vue": "./src/components/AdminModal.vue",
|
|
35
|
+
"./components/ConfirmDialog.vue": "./src/components/ConfirmDialog.vue",
|
|
36
|
+
"./components/ToastContainer.vue": "./src/components/ToastContainer.vue"
|
|
37
|
+
},
|
|
38
|
+
"sideEffects": [
|
|
39
|
+
"**/*.css"
|
|
40
|
+
],
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"vue": "^3.4.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"vue-i18n": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
51
|
+
"typescript": "^5.7.2",
|
|
52
|
+
"vite": "^6.0.0",
|
|
53
|
+
"vitest": "^3.0.0",
|
|
54
|
+
"vue": "^3.5.39",
|
|
55
|
+
"vue-tsc": "^2.1.10"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"type-check": "tsc --noEmit && vue-tsc --noEmit -p tsconfig.json",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"build": "tsc -p tsconfig.build.json"
|
|
62
|
+
},
|
|
63
|
+
"keywords": [
|
|
64
|
+
"admin",
|
|
65
|
+
"cf-admin-fe",
|
|
66
|
+
"cloudflare",
|
|
67
|
+
"composables",
|
|
68
|
+
"frontend",
|
|
69
|
+
"vue3"
|
|
70
|
+
],
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"repository": {
|
|
73
|
+
"type": "git",
|
|
74
|
+
"url": "git+https://github.com/qwdingyu/cf-admin-fe.git"
|
|
75
|
+
},
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/qwdingyu/cf-admin-fe/issues"
|
|
78
|
+
},
|
|
79
|
+
"homepage": "https://github.com/qwdingyu/cf-admin-fe#readme",
|
|
80
|
+
"publishConfig": {
|
|
81
|
+
"access": "public",
|
|
82
|
+
"registry": "https://registry.npmjs.org/"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="modelValue" class="modal-mask" @click.self="handleBackdropClick">
|
|
3
|
+
<div
|
|
4
|
+
ref="modalRef"
|
|
5
|
+
class="modal"
|
|
6
|
+
:style="{ maxWidth }"
|
|
7
|
+
role="dialog"
|
|
8
|
+
aria-modal="true"
|
|
9
|
+
:aria-labelledby="title ? titleId : undefined"
|
|
10
|
+
:aria-label="$t('adminModal.title')"
|
|
11
|
+
tabindex="-1"
|
|
12
|
+
@keydown="handleKeydown"
|
|
13
|
+
>
|
|
14
|
+
<h3 v-if="title" :id="titleId" class="modal-title">{{ title }}</h3>
|
|
15
|
+
<div ref="bodyRef" class="modal-body">
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|
|
18
|
+
<div v-if="$slots.actions || !hideActions" class="modal-actions">
|
|
19
|
+
<slot name="actions">
|
|
20
|
+
<button type="button" class="btn btn-ghost" @click="close">{{ $t('adminModal.close') }}</button>
|
|
21
|
+
</slot>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(defineProps<{
|
|
31
|
+
modelValue: boolean
|
|
32
|
+
title?: string
|
|
33
|
+
maxWidth?: string
|
|
34
|
+
hideActions?: boolean
|
|
35
|
+
closeOnBackdrop?: boolean
|
|
36
|
+
closeOnEscape?: boolean
|
|
37
|
+
}>(), {
|
|
38
|
+
hideActions: false,
|
|
39
|
+
closeOnBackdrop: false,
|
|
40
|
+
closeOnEscape: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{
|
|
44
|
+
'update:modelValue': [value: boolean]
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
const modalRef = ref<HTMLElement | null>(null)
|
|
48
|
+
const bodyRef = ref<HTMLElement | null>(null)
|
|
49
|
+
const titleId = `admin-modal-title-${useId()}`
|
|
50
|
+
let restoreFocus: HTMLElement | null = null
|
|
51
|
+
|
|
52
|
+
function close() {
|
|
53
|
+
emit('update:modelValue', false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleBackdropClick() {
|
|
57
|
+
if (props.closeOnBackdrop) close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getFocusableElements() {
|
|
61
|
+
return Array.from(modalRef.value?.querySelectorAll<HTMLElement>(
|
|
62
|
+
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
|
63
|
+
) || [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
67
|
+
if (event.key === 'Escape') {
|
|
68
|
+
if (!props.closeOnEscape) return
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
close()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (event.key !== 'Tab') return
|
|
74
|
+
|
|
75
|
+
const focusable = getFocusableElements()
|
|
76
|
+
if (focusable.length === 0) {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
modalRef.value?.focus()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const first = focusable[0]
|
|
83
|
+
const last = focusable[focusable.length - 1]
|
|
84
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === modalRef.value)) {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
last.focus()
|
|
87
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
88
|
+
event.preventDefault()
|
|
89
|
+
first.focus()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
watch(() => props.modelValue, async (visible, wasVisible) => {
|
|
94
|
+
if (visible && !wasVisible) {
|
|
95
|
+
restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
96
|
+
await nextTick()
|
|
97
|
+
// 长详情中的第一个按钮通常位于正文底部;若直接聚焦它,浏览器会把滚动区自动拉到底部。
|
|
98
|
+
// 打开时先回到正文顶部并聚焦对话框容器,用户可以从订单基本信息开始阅读,再用 Tab 进入控件。
|
|
99
|
+
if (bodyRef.value) bodyRef.value.scrollTop = 0
|
|
100
|
+
modalRef.value?.focus({ preventScroll: true })
|
|
101
|
+
} else if (!visible && wasVisible) {
|
|
102
|
+
restoreFocus?.focus()
|
|
103
|
+
restoreFocus = null
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
onBeforeUnmount(() => {
|
|
108
|
+
restoreFocus?.focus()
|
|
109
|
+
})
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<style scoped>
|
|
113
|
+
.modal-mask {
|
|
114
|
+
position: fixed;
|
|
115
|
+
inset: 0;
|
|
116
|
+
background: var(--overlay, rgba(0, 0, 0, 0.55));
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
padding: 24px;
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
z-index: 200;
|
|
123
|
+
backdrop-filter: saturate(180%) blur(10px);
|
|
124
|
+
-webkit-backdrop-filter: saturate(180%) blur(10px);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.modal {
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
width: 100%;
|
|
130
|
+
max-height: calc(100dvh - 48px);
|
|
131
|
+
min-height: 0;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
/* 比页面底略抬一层,避免与遮罩糊成一团 */
|
|
135
|
+
background: var(--tg-secondary-bg, #151b28);
|
|
136
|
+
color: var(--tg-text);
|
|
137
|
+
border-radius: var(--r-lg, 12px);
|
|
138
|
+
padding: 22px;
|
|
139
|
+
border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.16));
|
|
140
|
+
box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.45));
|
|
141
|
+
/* 勿用 overflow:hidden:会裁切 :focus-visible 描边(控件四边显示不全) */
|
|
142
|
+
overflow: visible;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.modal-title {
|
|
146
|
+
flex: 0 0 auto;
|
|
147
|
+
margin: 0 0 16px;
|
|
148
|
+
font-size: 18px;
|
|
149
|
+
font-weight: 650;
|
|
150
|
+
letter-spacing: 0.01em;
|
|
151
|
+
color: var(--tg-text);
|
|
152
|
+
line-height: 1.35;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.modal-body {
|
|
156
|
+
min-height: 0;
|
|
157
|
+
flex: 1 1 auto;
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: 14px;
|
|
161
|
+
overflow-x: visible;
|
|
162
|
+
overflow-y: auto;
|
|
163
|
+
overscroll-behavior: contain;
|
|
164
|
+
/* 给 focus 描边(outline-offset: 2px)留出左右上下内边距,避免被滚动裁切 */
|
|
165
|
+
padding: 4px 6px 6px;
|
|
166
|
+
margin: -4px -6px -6px;
|
|
167
|
+
scrollbar-gutter: stable;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* 子级 form 需要吃满高度时(如商品编辑粘性脚部) */
|
|
171
|
+
.modal-body > form {
|
|
172
|
+
min-height: 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.modal-actions {
|
|
176
|
+
flex: 0 0 auto;
|
|
177
|
+
display: flex;
|
|
178
|
+
justify-content: flex-end;
|
|
179
|
+
gap: 10px;
|
|
180
|
+
margin-top: 14px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (max-width: 640px) {
|
|
184
|
+
.modal-mask {
|
|
185
|
+
padding: 12px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.modal {
|
|
189
|
+
max-height: calc(100dvh - 24px);
|
|
190
|
+
padding: 16px;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
</style>
|