pandax 0.0.1
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 +83 -0
- package/bin/pandax.js +592 -0
- package/package.json +33 -0
- package/skills/pandax-frontend-api-mock/SKILL.md +62 -0
- package/skills/pandax-frontend-architecture/SKILL.md +1239 -0
- package/skills/pandax-frontend-component-review/SKILL.md +114 -0
- package/skills/pandax-frontend-requirement/SKILL.md +142 -0
- package/skills/pandax-frontend-standards/SKILL.md +468 -0
- package/skills/pandax-frontend-use-modal/SKILL.md +621 -0
- package/templates/pandax-monorepo/.editorconfig +16 -0
- package/templates/pandax-monorepo/.husky/commit-msg +2 -0
- package/templates/pandax-monorepo/.husky/pre-commit +2 -0
- package/templates/pandax-monorepo/.prettierrc.json +28 -0
- package/templates/pandax-monorepo/apps/base/env.d.ts +1 -0
- package/templates/pandax-monorepo/apps/base/index.html +13 -0
- package/templates/pandax-monorepo/apps/base/package.json +30 -0
- package/templates/pandax-monorepo/apps/base/src/App.vue +15 -0
- package/templates/pandax-monorepo/apps/base/src/layouts/default-layout/index.vue +23 -0
- package/templates/pandax-monorepo/apps/base/src/main.ts +31 -0
- package/templates/pandax-monorepo/apps/base/src/router/index.ts +22 -0
- package/templates/pandax-monorepo/apps/base/src/stores/index.ts +15 -0
- package/templates/pandax-monorepo/apps/base/src/styles/global.scss +19 -0
- package/templates/pandax-monorepo/apps/base/src/styles/variables.scss +4 -0
- package/templates/pandax-monorepo/apps/base/src/types/wujie.d.ts +16 -0
- package/templates/pandax-monorepo/apps/base/src/views/home/index.vue +21 -0
- package/templates/pandax-monorepo/apps/base/tsconfig.json +25 -0
- package/templates/pandax-monorepo/apps/base/tsconfig.node.json +11 -0
- package/templates/pandax-monorepo/apps/base/vite.config.ts +26 -0
- package/templates/pandax-monorepo/apps/main/env.d.ts +1 -0
- package/templates/pandax-monorepo/apps/main/index.html +13 -0
- package/templates/pandax-monorepo/apps/main/package.json +32 -0
- package/templates/pandax-monorepo/apps/main/src/App.vue +15 -0
- package/templates/pandax-monorepo/apps/main/src/layouts/MicroApp.vue +29 -0
- package/templates/pandax-monorepo/apps/main/src/layouts/default-layout/index.vue +48 -0
- package/templates/pandax-monorepo/apps/main/src/main.ts +27 -0
- package/templates/pandax-monorepo/apps/main/src/router/index.ts +22 -0
- package/templates/pandax-monorepo/apps/main/src/stores/index.ts +23 -0
- package/templates/pandax-monorepo/apps/main/src/styles/global.scss +23 -0
- package/templates/pandax-monorepo/apps/main/src/styles/variables.scss +8 -0
- package/templates/pandax-monorepo/apps/main/src/views/home/index.vue +25 -0
- package/templates/pandax-monorepo/apps/main/tsconfig.json +25 -0
- package/templates/pandax-monorepo/apps/main/tsconfig.node.json +11 -0
- package/templates/pandax-monorepo/apps/main/vite.config.ts +24 -0
- package/templates/pandax-monorepo/commitlint.config.js +38 -0
- package/templates/pandax-monorepo/eslint.config.js +61 -0
- package/templates/pandax-monorepo/package.json +50 -0
- package/templates/pandax-monorepo/packages/components/package.json +20 -0
- package/templates/pandax-monorepo/packages/components/src/PandaButton.vue +15 -0
- package/templates/pandax-monorepo/packages/components/src/PandaCard.vue +16 -0
- package/templates/pandax-monorepo/packages/components/src/index.ts +2 -0
- package/templates/pandax-monorepo/packages/core/package.json +20 -0
- package/templates/pandax-monorepo/packages/core/src/index.ts +2 -0
- package/templates/pandax-monorepo/packages/core/src/request.ts +24 -0
- package/templates/pandax-monorepo/packages/core/src/utils.ts +7 -0
- package/templates/pandax-monorepo/packages/hooks/package.json +19 -0
- package/templates/pandax-monorepo/packages/hooks/src/index.ts +2 -0
- package/templates/pandax-monorepo/packages/hooks/src/useCounter.ts +27 -0
- package/templates/pandax-monorepo/packages/hooks/src/useToggle.ts +19 -0
- package/templates/pandax-monorepo/packages/styles/package.json +19 -0
- package/templates/pandax-monorepo/packages/styles/src/index.ts +1 -0
- package/templates/pandax-monorepo/packages/styles/src/uno.ts +19 -0
- package/templates/pandax-monorepo/packages/styles/src/variables.scss +14 -0
- package/templates/pandax-monorepo/pnpm-workspace.yaml +32 -0
- package/templates/pandax-monorepo/turbo.json +23 -0
- package/templates/vue3/.eslintrc.cjs +16 -0
- package/templates/vue3/.prettierrc +7 -0
- package/templates/vue3/env.d.ts +1 -0
- package/templates/vue3/index.html +13 -0
- package/templates/vue3/package.json +34 -0
- package/templates/vue3/src/App.vue +14 -0
- package/templates/vue3/src/api/index.ts +27 -0
- package/templates/vue3/src/assets/.gitkeep +1 -0
- package/templates/vue3/src/components/.gitkeep +1 -0
- package/templates/vue3/src/composables/.gitkeep +1 -0
- package/templates/vue3/src/constants/.gitkeep +1 -0
- package/templates/vue3/src/directives/.gitkeep +1 -0
- package/templates/vue3/src/layouts/default-layout/index.vue +33 -0
- package/templates/vue3/src/locales/.gitkeep +1 -0
- package/templates/vue3/src/main.ts +17 -0
- package/templates/vue3/src/router/index.ts +17 -0
- package/templates/vue3/src/services/.gitkeep +1 -0
- package/templates/vue3/src/stores/index.ts +12 -0
- package/templates/vue3/src/styles/global.scss +18 -0
- package/templates/vue3/src/styles/variables.scss +17 -0
- package/templates/vue3/src/types/global.d.ts +9 -0
- package/templates/vue3/src/utils/format.ts +11 -0
- package/templates/vue3/src/views/home/index.vue +19 -0
- package/templates/vue3/tsconfig.json +25 -0
- package/templates/vue3/tsconfig.node.json +10 -0
- package/templates/vue3/vite.config.ts +20 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "pandax-frontend-use-modal"
|
|
3
|
+
category: "frontend"
|
|
4
|
+
description: "基于 Vue 3 + Element Plus 生成 useModal Hook,支持弹窗/抽屉、多实例、嵌套、国际化与全局默认配置。Invoke when 用户需要创建弹窗/抽屉 Hook、封装 modal、useModal、嵌套弹窗或全局弹窗管理。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Pandax useModal Hook 生成规范
|
|
8
|
+
|
|
9
|
+
本 skill 用于生成基于 Vue 3 Composition API + Element Plus 的 `useModal` Hook。支持弹窗(Dialog)和抽屉(Drawer),支持多实例、嵌套、国际化、全局默认配置。
|
|
10
|
+
|
|
11
|
+
## 触发条件
|
|
12
|
+
|
|
13
|
+
- 用户需要创建弹窗/抽屉管理 Hook
|
|
14
|
+
- 提到 `useModal`、`modal`、`dialog`、`drawer`、`弹窗`、`抽屉`
|
|
15
|
+
- 需要支持嵌套弹窗、多实例弹窗
|
|
16
|
+
- 需要弹窗内容组件通信、按钮回调联动
|
|
17
|
+
- 需要全局默认配置或国际化
|
|
18
|
+
|
|
19
|
+
## 文件位置
|
|
20
|
+
|
|
21
|
+
遵循 [pandax-frontend-standards](../pandax-frontend-standards/SKILL.md) 命名规范:目录名全小写 + 短横线,非入口文件使用 PascalCase。
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
src/
|
|
25
|
+
├── composables/ # Vue 组合式函数(推荐)
|
|
26
|
+
│ └── use-modal/ # 目录名:全小写 + 短横线
|
|
27
|
+
│ ├── index.ts # Hook 主入口(入口文件保持小写)
|
|
28
|
+
│ ├── Types.ts # 类型定义(PascalCase)
|
|
29
|
+
│ ├── Render.tsx # 弹窗渲染组件(可选,PascalCase)
|
|
30
|
+
│ ├── Config.ts # 全局默认配置(PascalCase)
|
|
31
|
+
│ └── index.module.scss # 样式隔离(CSS Modules)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
如果项目使用 `hooks/` 目录,统一放到 `src/hooks/use-modal/`。
|
|
35
|
+
|
|
36
|
+
## 核心能力
|
|
37
|
+
|
|
38
|
+
- `type: 'dialog' | 'drawer'` 区分弹窗/抽屉
|
|
39
|
+
- 多实例管理,支持嵌套(A 打开 B,B 打开 C)
|
|
40
|
+
- Header / Content / Footer 支持字符串或组件
|
|
41
|
+
- 按钮点击优先调用内容组件暴露的方法,再执行配置的 `onClick`
|
|
42
|
+
- 内容组件注入 `close`、`setContentLoading`、`setButtonLoading`、`getData`、`emit`、`triggerButtonClick`、`open`
|
|
43
|
+
- 支持 `configureModal` 全局默认配置
|
|
44
|
+
- 支持国际化 `i18n`
|
|
45
|
+
|
|
46
|
+
## 实现步骤
|
|
47
|
+
|
|
48
|
+
### 1. 定义类型(Types.ts)
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import type { Component, VNode } from 'vue';
|
|
52
|
+
|
|
53
|
+
export type ModalType = 'dialog' | 'drawer';
|
|
54
|
+
export type DrawerDirection = 'left' | 'right' | 'top' | 'bottom';
|
|
55
|
+
|
|
56
|
+
export interface ButtonConfig {
|
|
57
|
+
key: string;
|
|
58
|
+
label: string | (() => string);
|
|
59
|
+
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default';
|
|
60
|
+
plain?: boolean;
|
|
61
|
+
size?: 'large' | 'default' | 'small';
|
|
62
|
+
disabled?: boolean | (() => boolean);
|
|
63
|
+
loading?: boolean | (() => boolean);
|
|
64
|
+
visible?: boolean | (() => boolean);
|
|
65
|
+
closeOnClick?: boolean;
|
|
66
|
+
exposeName?: string;
|
|
67
|
+
onClick?: (ctx: ButtonContext) => boolean | Promise<boolean | void> | void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ButtonContext {
|
|
71
|
+
id: string | number;
|
|
72
|
+
close: () => void;
|
|
73
|
+
setContentLoading: (loading: boolean) => void;
|
|
74
|
+
setButtonLoading: (key: string, loading: boolean) => void;
|
|
75
|
+
getData: <T = unknown>() => T;
|
|
76
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
77
|
+
triggerButtonClick: (key: string) => void;
|
|
78
|
+
open: (options: ModalOptions) => ModalController;
|
|
79
|
+
triggerConfirm: () => void;
|
|
80
|
+
triggerCancel: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ModalOptions {
|
|
84
|
+
type?: ModalType;
|
|
85
|
+
title?: string | Component | VNode | (() => VNode);
|
|
86
|
+
header?: string | Component | VNode | (() => VNode);
|
|
87
|
+
content?: string | Component | VNode | (() => VNode);
|
|
88
|
+
footer?: string | Component | VNode | (() => VNode);
|
|
89
|
+
data?: Record<string, unknown>;
|
|
90
|
+
width?: string | number;
|
|
91
|
+
size?: string | number;
|
|
92
|
+
direction?: DrawerDirection;
|
|
93
|
+
showHeader?: boolean;
|
|
94
|
+
showClose?: boolean;
|
|
95
|
+
showFooter?: boolean;
|
|
96
|
+
destroyOnClose?: boolean;
|
|
97
|
+
appendToBody?: boolean;
|
|
98
|
+
linkContentLoading?: boolean;
|
|
99
|
+
closeOnParentClose?: boolean;
|
|
100
|
+
parentId?: string | number;
|
|
101
|
+
buttons?: ButtonConfig[];
|
|
102
|
+
buttonOrder?: string[];
|
|
103
|
+
zIndex?: number;
|
|
104
|
+
className?: string;
|
|
105
|
+
customClass?: string;
|
|
106
|
+
beforeClose?: (done: () => void) => void | boolean | Promise<boolean | void>;
|
|
107
|
+
onOpen?: () => void;
|
|
108
|
+
onOpened?: () => void;
|
|
109
|
+
onClose?: () => boolean | Promise<boolean | void> | void;
|
|
110
|
+
onClosed?: () => void;
|
|
111
|
+
i18n?: Record<string, string>;
|
|
112
|
+
elProps?: Record<string, unknown>;
|
|
113
|
+
injectType?: 'props' | 'provide' | false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface GlobalModalConfig extends Omit<ModalOptions, 'content' | 'data' | 'onOpen' | 'onOpened' | 'onClose' | 'onClosed' | 'parentId'> {}
|
|
117
|
+
|
|
118
|
+
export interface ModalController {
|
|
119
|
+
id: string | number;
|
|
120
|
+
visible: boolean;
|
|
121
|
+
close: () => void;
|
|
122
|
+
setContentLoading: (loading: boolean) => void;
|
|
123
|
+
setButtonLoading: (key: string, loading: boolean) => void;
|
|
124
|
+
getData: <T = unknown>() => T;
|
|
125
|
+
triggerButtonClick: (key: string) => void;
|
|
126
|
+
open: (options: ModalOptions) => ModalController;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ModalInstance {
|
|
130
|
+
id: string | number;
|
|
131
|
+
parentId?: string | number;
|
|
132
|
+
options: Required<ModalOptions>;
|
|
133
|
+
controller: ModalController;
|
|
134
|
+
visible: boolean;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 2. 全局默认配置(Config.ts)
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import type { GlobalModalConfig } from './Types';
|
|
142
|
+
|
|
143
|
+
let globalConfig: GlobalModalConfig = {};
|
|
144
|
+
|
|
145
|
+
export function configureModal(config: GlobalModalConfig) {
|
|
146
|
+
globalConfig = { ...globalConfig, ...config };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getGlobalConfig(): GlobalModalConfig {
|
|
150
|
+
return { ...globalConfig };
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 3. Hook 主体(index.ts)
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { ref, computed, h, render, nextTick, provide, inject, type Component, type VNode } from 'vue';
|
|
158
|
+
import { ElDialog, ElDrawer } from 'element-plus';
|
|
159
|
+
import type {
|
|
160
|
+
ModalOptions,
|
|
161
|
+
ModalController,
|
|
162
|
+
ModalInstance,
|
|
163
|
+
ButtonConfig,
|
|
164
|
+
ButtonContext,
|
|
165
|
+
GlobalModalConfig,
|
|
166
|
+
} from './Types';
|
|
167
|
+
import { getGlobalConfig } from './Config';
|
|
168
|
+
|
|
169
|
+
let seed = 0;
|
|
170
|
+
const stack = ref<ModalInstance[]>([]);
|
|
171
|
+
|
|
172
|
+
// 默认文案遵循文案规范:使用具体动作而非模糊的"确定"
|
|
173
|
+
const defaultI18n = { cancelText: '取消', confirmText: '确认' };
|
|
174
|
+
|
|
175
|
+
function getI18nText(local?: Record<string, string>, key: string): string {
|
|
176
|
+
const merged = { ...defaultI18n, ...getGlobalConfig().i18n, ...local };
|
|
177
|
+
return merged[key] || defaultI18n[key as keyof typeof defaultI18n] || key;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildDefaultButtons(options: ModalOptions): ButtonConfig[] {
|
|
181
|
+
if (options.showFooter === false) return [];
|
|
182
|
+
return [
|
|
183
|
+
{ key: 'cancel', label: () => getI18nText(options.i18n, 'cancelText'), closeOnClick: true },
|
|
184
|
+
{ key: 'confirm', label: () => getI18nText(options.i18n, 'confirmText'), type: 'primary', closeOnClick: true },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveLabel(label: ButtonConfig['label']): string {
|
|
189
|
+
if (typeof label === 'function') return label();
|
|
190
|
+
return label;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveBool(value: boolean | (() => boolean) | undefined, fallback: boolean): boolean {
|
|
194
|
+
if (typeof value === 'function') return value();
|
|
195
|
+
return value ?? fallback;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveRender(node: unknown): VNode | string | null {
|
|
199
|
+
if (!node) return null;
|
|
200
|
+
if (typeof node === 'string') return h('span', null, node);
|
|
201
|
+
if (typeof node === 'function') return (node as () => VNode)();
|
|
202
|
+
return h(node as Component);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createController(instance: ModalInstance): ModalController {
|
|
206
|
+
return {
|
|
207
|
+
id: instance.id,
|
|
208
|
+
get visible() {
|
|
209
|
+
return instance.visible;
|
|
210
|
+
},
|
|
211
|
+
close: () => closeModal(instance.id),
|
|
212
|
+
setContentLoading: (loading: boolean) => {
|
|
213
|
+
instance.options.linkContentLoading = loading;
|
|
214
|
+
},
|
|
215
|
+
setButtonLoading: (key: string, loading: boolean) => {
|
|
216
|
+
const btn = instance.options.buttons?.find((b) => b.key === key);
|
|
217
|
+
if (btn) btn.loading = loading;
|
|
218
|
+
},
|
|
219
|
+
getData: <T = unknown>() => (instance.options.data as T) ?? ({} as T),
|
|
220
|
+
triggerButtonClick: (key: string) => handleButtonClick(instance, key),
|
|
221
|
+
open: (options: ModalOptions) => openModal({ ...options, parentId: instance.id }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function callExposedMethod(instance: ModalInstance, button: ButtonConfig): Promise<boolean | void> {
|
|
226
|
+
// 注入方法到 content 组件时通过 props 或 provide 暴露
|
|
227
|
+
// 这里通过 controller 上的方法模拟暴露调用
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function handleButtonClick(instance: ModalInstance, key: string) {
|
|
232
|
+
const buttons = instance.options.buttons || [];
|
|
233
|
+
const button = buttons.find((b) => b.key === key);
|
|
234
|
+
if (!button) return;
|
|
235
|
+
|
|
236
|
+
const ctx: ButtonContext = {
|
|
237
|
+
id: instance.id,
|
|
238
|
+
close: () => closeModal(instance.id),
|
|
239
|
+
setContentLoading: (loading: boolean) => {
|
|
240
|
+
instance.options.linkContentLoading = loading;
|
|
241
|
+
},
|
|
242
|
+
setButtonLoading: (k: string, loading: boolean) => {
|
|
243
|
+
const target = buttons.find((b) => b.key === k);
|
|
244
|
+
if (target) target.loading = loading;
|
|
245
|
+
},
|
|
246
|
+
getData: <T = unknown>() => (instance.options.data as T) ?? ({} as T),
|
|
247
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
248
|
+
// 触发自定义事件,可向外暴露
|
|
249
|
+
console.log('modal:emit', event, args);
|
|
250
|
+
},
|
|
251
|
+
triggerButtonClick: (k: string) => handleButtonClick(instance, k),
|
|
252
|
+
open: (options: ModalOptions) => openModal({ ...options, parentId: instance.id }),
|
|
253
|
+
triggerConfirm: () => handleButtonClick(instance, 'confirm'),
|
|
254
|
+
triggerCancel: () => handleButtonClick(instance, 'cancel'),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// 1. 优先调用内容组件暴露的方法
|
|
258
|
+
const exposeName = button.exposeName || `on${key.charAt(0).toUpperCase()}${key.slice(1)}`;
|
|
259
|
+
// 实际调用在 render 组件中通过 ref 获取内容组件实例执行
|
|
260
|
+
// 这里先执行 onClick
|
|
261
|
+
let shouldClose = button.closeOnClick !== false;
|
|
262
|
+
if (button.onClick) {
|
|
263
|
+
const result = await button.onClick(ctx);
|
|
264
|
+
if (result === false) shouldClose = false;
|
|
265
|
+
}
|
|
266
|
+
if (shouldClose) closeModal(instance.id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function openModal(options: ModalOptions): ModalController {
|
|
270
|
+
const final: Required<ModalOptions> = {
|
|
271
|
+
type: 'dialog',
|
|
272
|
+
title: '',
|
|
273
|
+
header: '',
|
|
274
|
+
content: '',
|
|
275
|
+
footer: '',
|
|
276
|
+
data: {},
|
|
277
|
+
width: '50%',
|
|
278
|
+
size: '30%',
|
|
279
|
+
direction: 'right',
|
|
280
|
+
showHeader: true,
|
|
281
|
+
showClose: true,
|
|
282
|
+
showFooter: true,
|
|
283
|
+
destroyOnClose: true,
|
|
284
|
+
appendToBody: true,
|
|
285
|
+
linkContentLoading: false,
|
|
286
|
+
closeOnParentClose: false,
|
|
287
|
+
parentId: undefined,
|
|
288
|
+
buttons: [],
|
|
289
|
+
buttonOrder: [],
|
|
290
|
+
zIndex: 2000,
|
|
291
|
+
className: '',
|
|
292
|
+
customClass: '',
|
|
293
|
+
beforeClose: () => true,
|
|
294
|
+
onOpen: () => {},
|
|
295
|
+
onOpened: () => {},
|
|
296
|
+
onClose: () => true,
|
|
297
|
+
onClosed: () => {},
|
|
298
|
+
i18n: {},
|
|
299
|
+
elProps: {},
|
|
300
|
+
injectType: 'props',
|
|
301
|
+
...getGlobalConfig(),
|
|
302
|
+
...options,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (!final.buttons || final.buttons.length === 0) {
|
|
306
|
+
final.buttons = buildDefaultButtons(final);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const id = ++seed;
|
|
310
|
+
const instance: ModalInstance = {
|
|
311
|
+
id,
|
|
312
|
+
parentId: final.parentId,
|
|
313
|
+
options: final,
|
|
314
|
+
visible: true,
|
|
315
|
+
controller: null as unknown as ModalController,
|
|
316
|
+
};
|
|
317
|
+
instance.controller = createController(instance);
|
|
318
|
+
stack.value.push(instance);
|
|
319
|
+
return instance.controller;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function closeModal(id: string | number) {
|
|
323
|
+
const idx = stack.value.findIndex((i) => i.id === id);
|
|
324
|
+
if (idx === -1) return;
|
|
325
|
+
const instance = stack.value[idx];
|
|
326
|
+
|
|
327
|
+
// 关闭子弹窗
|
|
328
|
+
if (instance.options.closeOnParentClose) {
|
|
329
|
+
stack.value.filter((i) => i.parentId === id).forEach((child) => closeModal(child.id));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
instance.visible = false;
|
|
333
|
+
nextTick(() => {
|
|
334
|
+
stack.value.splice(idx, 1);
|
|
335
|
+
instance.options.onClosed();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function useModal() {
|
|
340
|
+
const visible = computed(() => stack.value.length > 0);
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
open: openModal,
|
|
344
|
+
close: (id?: string | number) => {
|
|
345
|
+
const target = id ?? stack.value[stack.value.length - 1]?.id;
|
|
346
|
+
if (target !== undefined) closeModal(target);
|
|
347
|
+
},
|
|
348
|
+
closeAll: () => {
|
|
349
|
+
[...stack.value].forEach((i) => closeModal(i.id));
|
|
350
|
+
},
|
|
351
|
+
getStack: () => stack.value.map((i) => i.controller),
|
|
352
|
+
visible,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export { configureModal } from './Config';
|
|
357
|
+
export type * from './Types';
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### 4. 渲染组件(Render.tsx,可选)
|
|
361
|
+
|
|
362
|
+
> 说明:本组件是 Hook 内部使用的渲染组件,并非通用业务组件,因此保留 `.tsx` + `defineComponent` 渲染函数形式(通用业务组件必须用 `<script setup lang="ts">` + `index.vue`)。代码组织仍遵循 [pandax-frontend-standards](../pandax-frontend-standards/SKILL.md) 的 `// #region` 区域约定;样式通过 `index.module.scss` 做 CSS Modules 隔离。
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
import { defineComponent, computed, ref, watch, h, inject, provide, type PropType } from 'vue';
|
|
366
|
+
import { ElDialog, ElDrawer } from 'element-plus';
|
|
367
|
+
import type { ModalInstance, ButtonConfig } from '../Types';
|
|
368
|
+
import styles from './index.module.scss';
|
|
369
|
+
|
|
370
|
+
export default defineComponent({
|
|
371
|
+
name: 'UseModalRender',
|
|
372
|
+
props: {
|
|
373
|
+
instance: { type: Object as PropType<ModalInstance>, required: true },
|
|
374
|
+
},
|
|
375
|
+
setup(props) {
|
|
376
|
+
// #region Props - 组件属性定义
|
|
377
|
+
// instance 由父级传入,承载弹窗实例的全部状态
|
|
378
|
+
// #endregion
|
|
379
|
+
|
|
380
|
+
// #region Types - 组件内部类型
|
|
381
|
+
// 当前组件未使用额外内部类型
|
|
382
|
+
// #endregion
|
|
383
|
+
|
|
384
|
+
// #region State - 响应式数据
|
|
385
|
+
const visible = computed({
|
|
386
|
+
get: () => props.instance.visible,
|
|
387
|
+
set: (v) => {
|
|
388
|
+
if (!v) props.instance.controller.close();
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const contentRef = ref<Record<string, unknown> | null>(null);
|
|
393
|
+
const provideKey = `__modal_controller_${props.instance.id}__`;
|
|
394
|
+
provide(provideKey, props.instance.controller);
|
|
395
|
+
// #endregion
|
|
396
|
+
|
|
397
|
+
// #region Computed - 计算属性
|
|
398
|
+
// 当前组件未使用
|
|
399
|
+
// #endregion
|
|
400
|
+
|
|
401
|
+
// #region Watch - 监听器
|
|
402
|
+
// 当前组件未使用
|
|
403
|
+
// #endregion
|
|
404
|
+
|
|
405
|
+
// #region Lifecycle - onBeforeMount
|
|
406
|
+
// 在挂载开始之前调用
|
|
407
|
+
// 当前组件未使用
|
|
408
|
+
// #endregion
|
|
409
|
+
|
|
410
|
+
// #region Lifecycle - onMounted
|
|
411
|
+
// 组件挂载完成后调用
|
|
412
|
+
// 当前组件未使用
|
|
413
|
+
// #endregion
|
|
414
|
+
|
|
415
|
+
// #region Lifecycle - onBeforeUpdate
|
|
416
|
+
// 组件更新前调用
|
|
417
|
+
// 当前组件未使用
|
|
418
|
+
// #endregion
|
|
419
|
+
|
|
420
|
+
// #region Lifecycle - onUpdated
|
|
421
|
+
// 组件更新后调用
|
|
422
|
+
// 当前组件未使用
|
|
423
|
+
// #endregion
|
|
424
|
+
|
|
425
|
+
// #region Lifecycle - onBeforeUnmount
|
|
426
|
+
// 组件卸载前调用
|
|
427
|
+
// 当前组件未使用
|
|
428
|
+
// #endregion
|
|
429
|
+
|
|
430
|
+
// #region Lifecycle - onUnmounted
|
|
431
|
+
// 组件卸载后调用
|
|
432
|
+
// 当前组件未使用
|
|
433
|
+
// #endregion
|
|
434
|
+
|
|
435
|
+
// #region Methods - 组件内部方法
|
|
436
|
+
function renderContent() {
|
|
437
|
+
const content = props.instance.options.content;
|
|
438
|
+
if (!content) return null;
|
|
439
|
+
const injectMethods = props.instance.options.injectType === 'props'
|
|
440
|
+
? props.instance.controller
|
|
441
|
+
: {};
|
|
442
|
+
if (typeof content === 'string') return h('span', null, content);
|
|
443
|
+
if (typeof content === 'function') return (content as () => unknown)();
|
|
444
|
+
return h(content as any, {
|
|
445
|
+
ref: contentRef,
|
|
446
|
+
...injectMethods,
|
|
447
|
+
data: props.instance.options.data,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderHeader() {
|
|
452
|
+
const { header, title, showHeader, showClose } = props.instance.options;
|
|
453
|
+
if (!showHeader) return null;
|
|
454
|
+
if (header) {
|
|
455
|
+
const node = typeof header === 'function' ? header() : header;
|
|
456
|
+
return typeof node === 'string' ? h('span', null, node) : h(node as any);
|
|
457
|
+
}
|
|
458
|
+
return (
|
|
459
|
+
<div class={styles.header}>
|
|
460
|
+
<span class={styles.title}>{typeof title === 'string' ? title : ''}</span>
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderFooter() {
|
|
466
|
+
const { showFooter, buttons, buttonOrder } = props.instance.options;
|
|
467
|
+
if (!showFooter || !buttons?.length) return null;
|
|
468
|
+
const order = buttonOrder?.length ? buttonOrder : buttons.map((b) => b.key);
|
|
469
|
+
const ordered = order
|
|
470
|
+
.map((key) => buttons.find((b) => b.key === key))
|
|
471
|
+
.filter(Boolean) as ButtonConfig[];
|
|
472
|
+
|
|
473
|
+
return ordered.map((btn) => (
|
|
474
|
+
<el-button
|
|
475
|
+
key={btn.key}
|
|
476
|
+
type={btn.type || 'default'}
|
|
477
|
+
plain={btn.plain}
|
|
478
|
+
size={btn.size || 'default'}
|
|
479
|
+
loading={typeof btn.loading === 'function' ? btn.loading() : btn.loading}
|
|
480
|
+
disabled={typeof btn.disabled === 'function' ? btn.disabled() : btn.disabled}
|
|
481
|
+
onClick={() => props.instance.controller.triggerButtonClick(btn.key)}
|
|
482
|
+
>
|
|
483
|
+
{typeof btn.label === 'function' ? btn.label() : btn.label}
|
|
484
|
+
</el-button>
|
|
485
|
+
));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderWrapper() {
|
|
489
|
+
const { type, width, size, direction, zIndex, className, customClass, elProps } = props.instance.options;
|
|
490
|
+
const common = {
|
|
491
|
+
modelValue: visible.value,
|
|
492
|
+
'onUpdate:modelValue': (v: boolean) => {
|
|
493
|
+
if (!v) props.instance.controller.close();
|
|
494
|
+
},
|
|
495
|
+
zIndex,
|
|
496
|
+
class: [styles.wrapper, className, customClass].filter(Boolean).join(' ') || undefined,
|
|
497
|
+
destroyOnClose: true,
|
|
498
|
+
...elProps,
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const slots = {
|
|
502
|
+
header: renderHeader,
|
|
503
|
+
default: renderContent,
|
|
504
|
+
footer: renderFooter,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
if (type === 'drawer') {
|
|
508
|
+
return h(ElDrawer, { ...common, size, direction }, slots);
|
|
509
|
+
}
|
|
510
|
+
return h(ElDialog, { ...common, width }, slots);
|
|
511
|
+
}
|
|
512
|
+
// #endregion
|
|
513
|
+
|
|
514
|
+
// #region Expose - 对外暴露
|
|
515
|
+
// 当前组件未对外暴露方法
|
|
516
|
+
// #endregion
|
|
517
|
+
|
|
518
|
+
return () => renderWrapper();
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
配套的 `index.module.scss`:
|
|
524
|
+
|
|
525
|
+
```scss
|
|
526
|
+
.wrapper {
|
|
527
|
+
// 弹窗/抽屉容器外壳样式(按需扩展)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.header {
|
|
531
|
+
display: flex;
|
|
532
|
+
justify-content: space-between;
|
|
533
|
+
align-items: center;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.title {
|
|
537
|
+
font-weight: 600;
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### 5. 全局挂载(main.ts)
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
import { configureModal } from '@/composables/use-modal';
|
|
545
|
+
|
|
546
|
+
configureModal({
|
|
547
|
+
showHeader: true,
|
|
548
|
+
showClose: true,
|
|
549
|
+
showFooter: true,
|
|
550
|
+
linkContentLoading: true,
|
|
551
|
+
destroyOnClose: true,
|
|
552
|
+
appendToBody: true,
|
|
553
|
+
i18n: { cancelText: '取消', confirmText: '确认' },
|
|
554
|
+
});
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## 使用示例
|
|
558
|
+
|
|
559
|
+
### 基础弹窗
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
import { useModal } from '@/composables/use-modal';
|
|
563
|
+
import ConfirmContent from './ConfirmContent.vue';
|
|
564
|
+
|
|
565
|
+
const modal = useModal();
|
|
566
|
+
|
|
567
|
+
modal.open({
|
|
568
|
+
title: '确认删除',
|
|
569
|
+
content: '确定要删除该记录吗?',
|
|
570
|
+
buttons: [
|
|
571
|
+
{ key: 'cancel', label: '取消', closeOnClick: true },
|
|
572
|
+
{ key: 'confirm', label: '确认删除', type: 'primary', onClick: async () => {
|
|
573
|
+
await deleteRecord();
|
|
574
|
+
return true;
|
|
575
|
+
}},
|
|
576
|
+
],
|
|
577
|
+
});
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### 嵌套弹窗
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
// ComponentA.vue
|
|
584
|
+
const props = defineProps<{
|
|
585
|
+
open: (options: any) => any;
|
|
586
|
+
}>();
|
|
587
|
+
|
|
588
|
+
const openB = () => {
|
|
589
|
+
props.open({
|
|
590
|
+
title: 'B 弹窗',
|
|
591
|
+
content: ComponentB,
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### 内容组件与按钮联动
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
// UserForm.vue
|
|
600
|
+
const props = defineProps<{
|
|
601
|
+
close?: () => void;
|
|
602
|
+
triggerButtonClick?: (key: string) => void;
|
|
603
|
+
}>();
|
|
604
|
+
|
|
605
|
+
const handleCustomConfirm = async () => {
|
|
606
|
+
await props.triggerButtonClick?.('confirm');
|
|
607
|
+
};
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
## 输出要求
|
|
611
|
+
|
|
612
|
+
1. 优先生成 `src/composables/use-modal/` 下的文件,命名遵循 [pandax-frontend-standards](../pandax-frontend-standards/SKILL.md):目录全小写 + 短横线,非入口文件 PascalCase(`Types.ts` / `Config.ts` / `Render.tsx`)
|
|
613
|
+
2. 类型定义必须完整,导出 `ModalOptions`、`ModalController`、`ButtonConfig`
|
|
614
|
+
3. 必须支持 `configureModal` 全局配置
|
|
615
|
+
4. 必须支持 `dialog` 和 `drawer` 两种类型
|
|
616
|
+
5. 按钮点击逻辑必须优先调用内容组件暴露的方法,再执行 `onClick`
|
|
617
|
+
6. 内容组件注入方法必须包含 `close`、`setContentLoading`、`setButtonLoading`、`getData`、`emit`、`triggerButtonClick`、`open`
|
|
618
|
+
7. 代码必须写清楚 TODO 或注意点,不能含糊
|
|
619
|
+
8. 渲染组件代码必须按 `// #region` 区域(Props / Types / State / Computed / Watch / Lifecycle / Methods / Expose)组织;样式必须使用 `index.module.scss` 做 CSS Modules 隔离,禁止裸 class
|
|
620
|
+
9. 按钮文案遵循文案规范:使用具体动作(如"确认删除"、"保存"),避免模糊的"确定"
|
|
621
|
+
10. 生成后给出 2 个使用示例:基础弹窗、嵌套弹窗
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# EditorConfig 与 Prettier 保持一致:https://editorconfig.org/
|
|
2
|
+
root = true
|
|
3
|
+
|
|
4
|
+
[*]
|
|
5
|
+
charset = utf-8
|
|
6
|
+
end_of_line = lf
|
|
7
|
+
indent_style = space
|
|
8
|
+
indent_size = 2
|
|
9
|
+
insert_final_newline = true
|
|
10
|
+
trim_trailing_whitespace = true
|
|
11
|
+
|
|
12
|
+
[*.md]
|
|
13
|
+
trim_trailing_whitespace = false
|
|
14
|
+
|
|
15
|
+
[*.{yml,yaml}]
|
|
16
|
+
indent_size = 2
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/prettierrc",
|
|
3
|
+
"semi": false,
|
|
4
|
+
"singleQuote": true,
|
|
5
|
+
"trailingComma": "all",
|
|
6
|
+
"printWidth": 100,
|
|
7
|
+
"tabWidth": 2,
|
|
8
|
+
"useTabs": false,
|
|
9
|
+
"endOfLine": "lf",
|
|
10
|
+
"arrowParens": "always",
|
|
11
|
+
"bracketSpacing": true,
|
|
12
|
+
"htmlWhitespaceSensitivity": "css",
|
|
13
|
+
"vueIndentScriptAndStyle": false,
|
|
14
|
+
"overrides": [
|
|
15
|
+
{
|
|
16
|
+
"files": ["*.json", "*.json5", "*.jsonc"],
|
|
17
|
+
"options": { "trailingComma": "none" }
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"files": ["*.md", "*.mdx"],
|
|
21
|
+
"options": { "proseWrap": "preserve", "printWidth": 120 }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"files": ["*.yml", "*.yaml"],
|
|
25
|
+
"options": { "singleQuote": false }
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Pandax Base App</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|