n-design-readonly-plugin 1.0.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/.DS_Store +0 -0
- package/.gitignore +31 -0
- package/README.md +89 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.mjs +243 -0
- package/dist/index.umd.js +1 -0
- package/dist/withReadonly.d.ts +71 -0
- package/n-design-readonly-plugin-1.0.0.tgz +0 -0
- package/package.json +45 -0
- package/playground/App.vue +77 -0
- package/playground/index.html +16 -0
- package/playground/main.ts +12 -0
- package/playground/reset.css +126 -0
- package/src/index.ts +52 -0
- package/src/types/env.d.ts +11 -0
- package/src/withReadonly.tsx +414 -0
- package/tsconfig.json +19 -0
- package/vite.config.playground.ts +17 -0
- package/vite.config.ts +19 -0
package/.DS_Store
ADDED
|
Binary file
|
package/.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
.DS_Store
|
|
12
|
+
dist
|
|
13
|
+
dist-ssr
|
|
14
|
+
coverage
|
|
15
|
+
*.local
|
|
16
|
+
package-lock.json
|
|
17
|
+
report.html
|
|
18
|
+
|
|
19
|
+
/cypress/videos/
|
|
20
|
+
/cypress/screenshots/
|
|
21
|
+
|
|
22
|
+
# Editor directories and files
|
|
23
|
+
.vscode/*
|
|
24
|
+
!.vscode/extensions.json
|
|
25
|
+
!.vscode/settings.json
|
|
26
|
+
.idea
|
|
27
|
+
*.suo
|
|
28
|
+
*.ntvs*
|
|
29
|
+
*.njsproj
|
|
30
|
+
*.sln
|
|
31
|
+
*.sw?
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# n-design-readonly-plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/n-design-readonly-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/n-design-readonly-plugin)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
> 一个 Vue 3 高阶组件(HOC),用于将 `n-designv3` 表单组件一键切换为只读模式。
|
|
8
|
+
|
|
9
|
+
在业务系统中,经常需要在「编辑态」和「详情态」之间切换。本插件通过 `:readonly="true"` 全局控制或组件级控制,自动将 `<NkInput>`、`<NkSelect>` 等组件渲染为不可编辑的静态文本,无需重复编写展示逻辑。
|
|
10
|
+
|
|
11
|
+
## ✨ 特性
|
|
12
|
+
|
|
13
|
+
- ✅ 支持 `n-designv3` 所有表单组件(`Input`, `Select`, `DatePicker` 等)
|
|
14
|
+
- ✅ 全局控制:通过 `<NkForm :readonly="true">` 一键禁用整个表单
|
|
15
|
+
- ✅ 组件级控制:单个组件设置 `:readonly="true"`
|
|
16
|
+
- ✅ 自动提取显示文本(支持 `label` / `value` 映射)
|
|
17
|
+
- ✅ 完整 TypeScript 类型支持
|
|
18
|
+
- ✅ 零运行时依赖(仅依赖 `vue` 和 `n-designv3`)
|
|
19
|
+
|
|
20
|
+
## 📦 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install n-design-readonly-plugin
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 1.全局注册插件
|
|
27
|
+
```ts
|
|
28
|
+
// main.ts
|
|
29
|
+
import { createApp } from 'vue';
|
|
30
|
+
import App from './App.vue';
|
|
31
|
+
|
|
32
|
+
// 引入 n-designv3(必须)
|
|
33
|
+
import NDesignV3 from 'n-designv3';
|
|
34
|
+
import 'n-designv3/dist/style.css';
|
|
35
|
+
|
|
36
|
+
// 引入本插件
|
|
37
|
+
import { NkReadonlyPlugin } from 'nk-readonly-hoc';
|
|
38
|
+
|
|
39
|
+
const app = createApp(App);
|
|
40
|
+
app.use(NDesignV3);
|
|
41
|
+
app.use(NkReadonlyPlugin); // 注册后自动提供 NkInput, NkSelect 等组件
|
|
42
|
+
app.mount('#app');
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 2.在模板中使用
|
|
47
|
+
### 全局只读(推荐)
|
|
48
|
+
```vue
|
|
49
|
+
<template>
|
|
50
|
+
<nk-form :readonly="isReadonly" :model="form">
|
|
51
|
+
<nk-form-item label="用户名">
|
|
52
|
+
<nk-input v-model:value="form.name" />
|
|
53
|
+
</nk-form-item>
|
|
54
|
+
<nk-form-item label="城市">
|
|
55
|
+
<nk-select
|
|
56
|
+
v-model:value="form.city"
|
|
57
|
+
:options="cityOptions"
|
|
58
|
+
/>
|
|
59
|
+
</nk-form-item>
|
|
60
|
+
</nk-form>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<script setup lang="ts">
|
|
64
|
+
import { ref } from 'vue';
|
|
65
|
+
|
|
66
|
+
const isReadonly = ref(true);
|
|
67
|
+
const form = reactive({ name: '张三', city: 'bj' });
|
|
68
|
+
const cityOptions = [
|
|
69
|
+
{ label: '北京', value: 'bj' },
|
|
70
|
+
{ label: '上海', value: 'sh' }
|
|
71
|
+
];
|
|
72
|
+
</script>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 3.组件映射
|
|
76
|
+
| 原始组件 (`n-designv3`) | 只读包装组件 |
|
|
77
|
+
|------------------------|-------------|
|
|
78
|
+
| `<n-input>` | `<nk-input>` |
|
|
79
|
+
| `<n-select>` | `<nk-select>` |
|
|
80
|
+
| `<n-date-picker>` | `<nk-date-picker>` |
|
|
81
|
+
| ... | ... |
|
|
82
|
+
|
|
83
|
+
所有 n-* 表单组件均自动映射为 nk-*,命名规则一致。
|
|
84
|
+
|
|
85
|
+
## 4.工作原理
|
|
86
|
+
插件内部使用 provide/inject 传递 readonly 上下文
|
|
87
|
+
每个 nk-* 组件是 withReadonly(WrappedComponent) 的高阶封装
|
|
88
|
+
当 readonly === true 时,不再渲染原始组件,而是渲染一个带样式的 <span> 显示文本
|
|
89
|
+
文本内容通过智能解析 value + options 自动获取(如 Select 的 label)
|
package/dist/index.d.ts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { defineComponent as E, inject as B, ref as T, computed as w, provide as U, useAttrs as z, h as j } from "vue";
|
|
2
|
+
import { Form as K, Checkbox as O, Radio as N, Select as M, Switch as q, TimePicker as J, DatePicker as Q, Cascader as W, TreeSelect as X, Textarea as Y, InputNumber as Z, Input as _ } from "n-designv3";
|
|
3
|
+
function A(e) {
|
|
4
|
+
if (e == null) return "";
|
|
5
|
+
if (typeof e == "string") return e;
|
|
6
|
+
if (Array.isArray(e)) return e.map(A).join("");
|
|
7
|
+
if (typeof e == "object") {
|
|
8
|
+
if (typeof e.children == "string")
|
|
9
|
+
return e.children;
|
|
10
|
+
if (e?.default && typeof e.default == "function") {
|
|
11
|
+
const n = e.default();
|
|
12
|
+
if (Array.isArray(n))
|
|
13
|
+
return A(n);
|
|
14
|
+
}
|
|
15
|
+
e.children && A(e.children);
|
|
16
|
+
}
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
function P(e, n = {}) {
|
|
20
|
+
const t = /* @__PURE__ */ new Map(), { value: l = "value", label: c = "label" } = n;
|
|
21
|
+
if (!e) return t;
|
|
22
|
+
const s = Array.isArray(e) ? e : [e];
|
|
23
|
+
for (const r of s)
|
|
24
|
+
if (!(!r || typeof r != "object")) {
|
|
25
|
+
if (r.type && (typeof r.type == "object" || typeof r.type == "function")) {
|
|
26
|
+
let u = r.type.name?.toLowerCase() || "";
|
|
27
|
+
if (typeof r.type == "function" ? u = r.type.displayName?.toLowerCase() || "" : typeof r.type == "string" && (u = r.type?.toLowerCase() || ""), /option|radio|checkbox/i.test(u)) {
|
|
28
|
+
const o = r.props || {}, a = o[l] ?? o.value, f = o[c] ?? o.label;
|
|
29
|
+
if (a != null) {
|
|
30
|
+
const x = A(r.children) || f || String(a);
|
|
31
|
+
t.set(a, x);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
r.children && P(r.children, n).forEach((o, a) => {
|
|
36
|
+
t.has(a) || t.set(a, o);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return t;
|
|
40
|
+
}
|
|
41
|
+
function V(e, n, t) {
|
|
42
|
+
const { value: l = "value", label: c = "label", children: s = "children" } = t, r = [];
|
|
43
|
+
let u = e;
|
|
44
|
+
for (const o of n) {
|
|
45
|
+
const a = u.find((f) => f[l] === o);
|
|
46
|
+
if (a)
|
|
47
|
+
r.push(a[c] || a[l] || String(o)), u = a[s] || [];
|
|
48
|
+
else {
|
|
49
|
+
r.push(String(o));
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
function ee(e, n, t) {
|
|
56
|
+
const { value: l = "value", label: c = "title", children: s = "children" } = t, r = [], u = new Set(Array.isArray(n) ? n : [n]);
|
|
57
|
+
function o(a) {
|
|
58
|
+
for (const f of a)
|
|
59
|
+
u.has(f[l]) && r.push(f[c] || f[l]), f[s] && o(f[s]);
|
|
60
|
+
}
|
|
61
|
+
return o(e), r;
|
|
62
|
+
}
|
|
63
|
+
function te({
|
|
64
|
+
modelValue: e,
|
|
65
|
+
options: n,
|
|
66
|
+
treeData: t,
|
|
67
|
+
valueToLabel: l,
|
|
68
|
+
fieldNames: c,
|
|
69
|
+
slots: s,
|
|
70
|
+
isSelect: r,
|
|
71
|
+
isRadioGroup: u,
|
|
72
|
+
isCheckboxGroup: o,
|
|
73
|
+
isCheckbox: a,
|
|
74
|
+
isRadio: f,
|
|
75
|
+
isCascader: x,
|
|
76
|
+
isTreeSelect: m,
|
|
77
|
+
isSwitch: R,
|
|
78
|
+
attrs: d,
|
|
79
|
+
emptyText: b
|
|
80
|
+
}) {
|
|
81
|
+
if (l)
|
|
82
|
+
try {
|
|
83
|
+
return l(e) || b;
|
|
84
|
+
} catch (i) {
|
|
85
|
+
console.warn("[ReadonlyHOC] valueToLabel error", i);
|
|
86
|
+
}
|
|
87
|
+
if (R) {
|
|
88
|
+
const i = d.checkedValue ?? d["checked-value"] ?? !0, y = d.uncheckedValue ?? d["un-checked-value"] ?? !1;
|
|
89
|
+
return e === i ? d.checkedChildren ?? d["checked-children"] ?? "开启" : e === y ? d.uncheckedChildren ?? d["un-checked-children"] ?? "关闭" : String(e || b);
|
|
90
|
+
}
|
|
91
|
+
if (x && Array.isArray(e) && n?.length)
|
|
92
|
+
return V(n, e, c).join(" / ") || b;
|
|
93
|
+
if (m && t?.length)
|
|
94
|
+
return ee(t, e, c).join(", ") || b;
|
|
95
|
+
if (n?.length) {
|
|
96
|
+
const i = c.value || "value", y = c.label || "label", g = (v) => {
|
|
97
|
+
const C = n.find((k) => k[i] === v);
|
|
98
|
+
return C ? C[y] || C[i] : v == null ? "" : String(v);
|
|
99
|
+
};
|
|
100
|
+
return Array.isArray(e) ? e.map(g).join(", ") || b : g(e) || b;
|
|
101
|
+
} else if ((r || u || o) && s?.default)
|
|
102
|
+
try {
|
|
103
|
+
const i = s.default(), y = P(i, c);
|
|
104
|
+
if (y.size > 0) {
|
|
105
|
+
const g = (v) => v == null ? "" : y.get(v) || String(v);
|
|
106
|
+
return Array.isArray(e) ? e.map(g).join(", ") || b : g(e) || b;
|
|
107
|
+
}
|
|
108
|
+
} catch (i) {
|
|
109
|
+
console.warn("[ReadonlyHOC] Failed to parse slot options", i);
|
|
110
|
+
}
|
|
111
|
+
else if (s?.default && (a || f) && typeof e == "boolean")
|
|
112
|
+
try {
|
|
113
|
+
const i = s.default(), y = A(i);
|
|
114
|
+
if (y) return y;
|
|
115
|
+
} catch {
|
|
116
|
+
}
|
|
117
|
+
return e == null || e === "" ? b : Array.isArray(e) ? e.join(", ") : String(e);
|
|
118
|
+
}
|
|
119
|
+
function re(e) {
|
|
120
|
+
const n = e.name || "", t = n.toLowerCase(), l = /select/i.test(t) && !/tree|cascader/i.test(t), c = /radio/i.test(t) && !/group/i.test(t), s = /radio.*group/i.test(t), r = /checkbox/i.test(t) && !/group/i.test(t), u = /checkbox.*group/i.test(t), o = /switch/i.test(t), a = /cascader/i.test(t), f = /tree.*select/i.test(t), m = r || o || c ? "checked" : "value", R = `update:${m}`, d = /form$/i.test(t), b = /form.*item/i.test(t);
|
|
121
|
+
return E({
|
|
122
|
+
name: n,
|
|
123
|
+
inheritAttrs: !1,
|
|
124
|
+
props: {
|
|
125
|
+
modelValue: { type: [String, Number, Boolean, Array, Object], default: void 0 },
|
|
126
|
+
[m]: { type: [String, Number, Boolean, Array, Object], default: void 0 },
|
|
127
|
+
readonly: { type: Boolean, default: void 0 },
|
|
128
|
+
emptyText: { type: String, default: "--" },
|
|
129
|
+
valueToLabel: { type: Function, default: null }
|
|
130
|
+
},
|
|
131
|
+
emits: ["update:modelValue", R],
|
|
132
|
+
setup(i, { emit: y, slots: g, expose: v }) {
|
|
133
|
+
const C = B("nkReadonly", T(!1)), k = w(() => i.readonly ?? C.value), F = w(() => i.readonly ?? C.value);
|
|
134
|
+
d && U("nkReadonly", F);
|
|
135
|
+
const L = w(() => i[m] !== void 0 ? i[m] : i.modelValue), G = (h) => {
|
|
136
|
+
y("update:modelValue", h), y(R, h);
|
|
137
|
+
}, p = z(), I = w(() => {
|
|
138
|
+
if (!k.value) return "";
|
|
139
|
+
const h = p.fieldNames ?? p["field-names"] ?? {}, $ = p.options, D = p.treeData ?? p["tree-data"];
|
|
140
|
+
return te({
|
|
141
|
+
modelValue: L.value,
|
|
142
|
+
options: $,
|
|
143
|
+
treeData: D,
|
|
144
|
+
valueToLabel: i.valueToLabel,
|
|
145
|
+
fieldNames: h,
|
|
146
|
+
slots: g,
|
|
147
|
+
isSelect: l,
|
|
148
|
+
isRadioGroup: s,
|
|
149
|
+
isCheckboxGroup: u,
|
|
150
|
+
isCheckbox: r,
|
|
151
|
+
isRadio: c,
|
|
152
|
+
isCascader: a,
|
|
153
|
+
isTreeSelect: f,
|
|
154
|
+
isSwitch: o,
|
|
155
|
+
attrs: p,
|
|
156
|
+
emptyText: i.emptyText
|
|
157
|
+
});
|
|
158
|
+
}), H = {
|
|
159
|
+
lineHeight: "32px",
|
|
160
|
+
padding: "0 6px",
|
|
161
|
+
display: "inline-block",
|
|
162
|
+
minHeight: "32px",
|
|
163
|
+
border: "none",
|
|
164
|
+
backgroundColor: "none",
|
|
165
|
+
cursor: "default",
|
|
166
|
+
wordBreak: "break-all",
|
|
167
|
+
color: "rgba(0, 0, 0, 0.65)"
|
|
168
|
+
}, S = T();
|
|
169
|
+
return d && v({
|
|
170
|
+
validate: () => k.value ? Promise.resolve({}) : S.value?.validate?.(),
|
|
171
|
+
validateFields: (h) => k.value ? Promise.resolve({}) : S.value?.validateFields?.(h),
|
|
172
|
+
resetFields: (h) => !k.value && S.value?.resetFields?.(h),
|
|
173
|
+
clearValidate: (h) => !k.value && S.value?.clearValidate?.(h),
|
|
174
|
+
scrollToField: (h) => !k.value && S.value?.scrollToField?.(h)
|
|
175
|
+
}), () => b ? j(
|
|
176
|
+
e,
|
|
177
|
+
{
|
|
178
|
+
...p,
|
|
179
|
+
style: { ...p.style || {}, marginBottom: k.value ? "10px" : void 0 }
|
|
180
|
+
},
|
|
181
|
+
g
|
|
182
|
+
) : d ? j(
|
|
183
|
+
e,
|
|
184
|
+
{
|
|
185
|
+
...p,
|
|
186
|
+
rules: F.value ? null : p.rules,
|
|
187
|
+
disabled: F.value ? !0 : p.disabled,
|
|
188
|
+
ref: S
|
|
189
|
+
},
|
|
190
|
+
g
|
|
191
|
+
) : k.value ? j(
|
|
192
|
+
"span",
|
|
193
|
+
{
|
|
194
|
+
class: "nk-readonly-wrapper",
|
|
195
|
+
style: H,
|
|
196
|
+
role: "text",
|
|
197
|
+
tabindex: 0,
|
|
198
|
+
"aria-readonly": "true"
|
|
199
|
+
},
|
|
200
|
+
I.value
|
|
201
|
+
) : j(
|
|
202
|
+
e,
|
|
203
|
+
{
|
|
204
|
+
...p,
|
|
205
|
+
[m]: L.value,
|
|
206
|
+
[`onUpdate:${m}`]: G,
|
|
207
|
+
ref: d ? S : void 0
|
|
208
|
+
},
|
|
209
|
+
g
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const ne = {
|
|
215
|
+
Input: _,
|
|
216
|
+
InputNumber: Z,
|
|
217
|
+
Textarea: Y,
|
|
218
|
+
Select: M,
|
|
219
|
+
SelectOption: M.Option,
|
|
220
|
+
TreeSelect: X,
|
|
221
|
+
Cascader: W,
|
|
222
|
+
DatePicker: Q,
|
|
223
|
+
TimePicker: J,
|
|
224
|
+
Radio: N,
|
|
225
|
+
Checkbox: O,
|
|
226
|
+
RadioGroup: N.Group,
|
|
227
|
+
CheckboxGroup: O.Group,
|
|
228
|
+
Switch: q,
|
|
229
|
+
Form: K,
|
|
230
|
+
FormItem: K.Item
|
|
231
|
+
}, ae = {
|
|
232
|
+
install(e) {
|
|
233
|
+
for (const [n, t] of Object.entries(ne))
|
|
234
|
+
if (t) {
|
|
235
|
+
const l = re(t);
|
|
236
|
+
e.component(`Nk${n}`, l);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
export {
|
|
241
|
+
ae as NkReadonlyPlugin,
|
|
242
|
+
re as withReadonly
|
|
243
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(A,o){typeof exports=="object"&&typeof module<"u"?o(exports,require("vue"),require("n-designv3")):typeof define=="function"&&define.amd?define(["exports","vue","n-designv3"],o):(A=typeof globalThis<"u"?globalThis:A||self,o(A.NDesignReadolyPlugin={},A.Vue,A.nDesignv3))})(this,(function(A,o,c){"use strict";function w(e){if(e==null)return"";if(typeof e=="string")return e;if(Array.isArray(e))return e.map(w).join("");if(typeof e=="object"){if(typeof e.children=="string")return e.children;if(e?.default&&typeof e.default=="function"){const n=e.default();if(Array.isArray(n))return w(n)}e.children&&w(e.children)}return""}function L(e,n={}){const t=new Map,{value:s="value",label:u="label"}=n;if(!e)return t;const d=Array.isArray(e)?e:[e];for(const r of d)if(!(!r||typeof r!="object")){if(r.type&&(typeof r.type=="object"||typeof r.type=="function")){let f=r.type.name?.toLowerCase()||"";if(typeof r.type=="function"?f=r.type.displayName?.toLowerCase()||"":typeof r.type=="string"&&(f=r.type?.toLowerCase()||""),/option|radio|checkbox/i.test(f)){const a=r.props||{},l=a[s]??a.value,y=a[u]??a.label;if(l!=null){const j=w(r.children)||y||String(l);t.set(l,j)}}}r.children&&L(r.children,n).forEach((a,l)=>{t.has(l)||t.set(l,a)})}return t}function O(e,n,t){const{value:s="value",label:u="label",children:d="children"}=t,r=[];let f=e;for(const a of n){const l=f.find(y=>y[s]===a);if(l)r.push(l[u]||l[s]||String(a)),f=l[d]||[];else{r.push(String(a));break}}return r}function K(e,n,t){const{value:s="value",label:u="title",children:d="children"}=t,r=[],f=new Set(Array.isArray(n)?n:[n]);function a(l){for(const y of l)f.has(y[s])&&r.push(y[u]||y[s]),y[d]&&a(y[d])}return a(e),r}function M({modelValue:e,options:n,treeData:t,valueToLabel:s,fieldNames:u,slots:d,isSelect:r,isRadioGroup:f,isCheckboxGroup:a,isCheckbox:l,isRadio:y,isCascader:j,isTreeSelect:R,isSwitch:F,attrs:p,emptyText:m}){if(s)try{return s(e)||m}catch(i){console.warn("[ReadonlyHOC] valueToLabel error",i)}if(F){const i=p.checkedValue??p["checked-value"]??!0,h=p.uncheckedValue??p["un-checked-value"]??!1;return e===i?p.checkedChildren??p["checked-children"]??"开启":e===h?p.uncheckedChildren??p["un-checked-children"]??"关闭":String(e||m)}if(j&&Array.isArray(e)&&n?.length)return O(n,e,u).join(" / ")||m;if(R&&t?.length)return K(t,e,u).join(", ")||m;if(n?.length){const i=u.value||"value",h=u.label||"label",g=S=>{const T=n.find(C=>C[i]===S);return T?T[h]||T[i]:S==null?"":String(S)};return Array.isArray(e)?e.map(g).join(", ")||m:g(e)||m}else if((r||f||a)&&d?.default)try{const i=d.default(),h=L(i,u);if(h.size>0){const g=S=>S==null?"":h.get(S)||String(S);return Array.isArray(e)?e.map(g).join(", ")||m:g(e)||m}}catch(i){console.warn("[ReadonlyHOC] Failed to parse slot options",i)}else if(d?.default&&(l||y)&&typeof e=="boolean")try{const i=d.default(),h=w(i);if(h)return h}catch{}return e==null||e===""?m:Array.isArray(e)?e.join(", "):String(e)}function P(e){const n=e.name||"",t=n.toLowerCase(),s=/select/i.test(t)&&!/tree|cascader/i.test(t),u=/radio/i.test(t)&&!/group/i.test(t),d=/radio.*group/i.test(t),r=/checkbox/i.test(t)&&!/group/i.test(t),f=/checkbox.*group/i.test(t),a=/switch/i.test(t),l=/cascader/i.test(t),y=/tree.*select/i.test(t),R=r||a||u?"checked":"value",F=`update:${R}`,p=/form$/i.test(t),m=/form.*item/i.test(t);return o.defineComponent({name:n,inheritAttrs:!1,props:{modelValue:{type:[String,Number,Boolean,Array,Object],default:void 0},[R]:{type:[String,Number,Boolean,Array,Object],default:void 0},readonly:{type:Boolean,default:void 0},emptyText:{type:String,default:"--"},valueToLabel:{type:Function,default:null}},emits:["update:modelValue",F],setup(i,{emit:h,slots:g,expose:S}){const T=o.inject("nkReadonly",o.ref(!1)),C=o.computed(()=>i.readonly??T.value),v=o.computed(()=>i.readonly??T.value);p&&o.provide("nkReadonly",v);const N=o.computed(()=>i[R]!==void 0?i[R]:i.modelValue),H=k=>{h("update:modelValue",k),h(F,k)},b=o.useAttrs(),$=o.computed(()=>{if(!C.value)return"";const k=b.fieldNames??b["field-names"]??{},q=b.options,B=b.treeData??b["tree-data"];return M({modelValue:N.value,options:q,treeData:B,valueToLabel:i.valueToLabel,fieldNames:k,slots:g,isSelect:s,isRadioGroup:d,isCheckboxGroup:f,isCheckbox:r,isRadio:u,isCascader:l,isTreeSelect:y,isSwitch:a,attrs:b,emptyText:i.emptyText})}),E={lineHeight:"32px",padding:"0 6px",display:"inline-block",minHeight:"32px",border:"none",backgroundColor:"none",cursor:"default",wordBreak:"break-all",color:"rgba(0, 0, 0, 0.65)"},x=o.ref();return p&&S({validate:()=>C.value?Promise.resolve({}):x.value?.validate?.(),validateFields:k=>C.value?Promise.resolve({}):x.value?.validateFields?.(k),resetFields:k=>!C.value&&x.value?.resetFields?.(k),clearValidate:k=>!C.value&&x.value?.clearValidate?.(k),scrollToField:k=>!C.value&&x.value?.scrollToField?.(k)}),()=>m?o.h(e,{...b,style:{...b.style||{},marginBottom:C.value?"10px":void 0}},g):p?o.h(e,{...b,rules:v.value?null:b.rules,disabled:v.value?!0:b.disabled,ref:x},g):C.value?o.h("span",{class:"nk-readonly-wrapper",style:E,role:"text",tabindex:0,"aria-readonly":"true"},$.value):o.h(e,{...b,[R]:N.value,[`onUpdate:${R}`]:H,ref:p?x:void 0},g)}})}const I={Input:c.Input,InputNumber:c.InputNumber,Textarea:c.Textarea,Select:c.Select,SelectOption:c.Select.Option,TreeSelect:c.TreeSelect,Cascader:c.Cascader,DatePicker:c.DatePicker,TimePicker:c.TimePicker,Radio:c.Radio,Checkbox:c.Checkbox,RadioGroup:c.Radio.Group,CheckboxGroup:c.Checkbox.Group,Switch:c.Switch,Form:c.Form,FormItem:c.Form.Item},G={install(e){for(const[n,t]of Object.entries(I))if(t){const s=P(t);e.component(`Nk${n}`,s)}}};A.NkReadonlyPlugin=G,A.withReadonly=P,Object.defineProperty(A,Symbol.toStringTag,{value:"Module"})}));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { PropType, Component, VNode } from 'vue';
|
|
2
|
+
type FormModelValue = string | number | boolean | any[] | null | undefined;
|
|
3
|
+
type ValueToLabelFn = (value: FormModelValue) => string;
|
|
4
|
+
export declare function withReadonly(BaseComponent: Component): import('vue').DefineComponent<{
|
|
5
|
+
[x: string]: {
|
|
6
|
+
type: PropType<FormModelValue>;
|
|
7
|
+
default: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
type: BooleanConstructor;
|
|
10
|
+
default: undefined;
|
|
11
|
+
} | {
|
|
12
|
+
type: StringConstructor;
|
|
13
|
+
default: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: PropType<ValueToLabelFn>;
|
|
16
|
+
default: null;
|
|
17
|
+
};
|
|
18
|
+
modelValue: {
|
|
19
|
+
type: PropType<FormModelValue>;
|
|
20
|
+
default: undefined;
|
|
21
|
+
};
|
|
22
|
+
readonly: {
|
|
23
|
+
type: BooleanConstructor;
|
|
24
|
+
default: undefined;
|
|
25
|
+
};
|
|
26
|
+
emptyText: {
|
|
27
|
+
type: StringConstructor;
|
|
28
|
+
default: string;
|
|
29
|
+
};
|
|
30
|
+
valueToLabel: {
|
|
31
|
+
type: PropType<ValueToLabelFn>;
|
|
32
|
+
default: null;
|
|
33
|
+
};
|
|
34
|
+
}, () => VNode<import('vue').RendererNode, import('vue').RendererElement, {
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
}>, unknown, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, string[], string, import('vue').VNodeProps & import('vue').AllowedComponentProps & import('vue').ComponentCustomProps, Readonly<import('vue').ExtractPropTypes<{
|
|
37
|
+
[x: string]: {
|
|
38
|
+
type: PropType<FormModelValue>;
|
|
39
|
+
default: undefined;
|
|
40
|
+
} | {
|
|
41
|
+
type: BooleanConstructor;
|
|
42
|
+
default: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
type: StringConstructor;
|
|
45
|
+
default: string;
|
|
46
|
+
} | {
|
|
47
|
+
type: PropType<ValueToLabelFn>;
|
|
48
|
+
default: null;
|
|
49
|
+
};
|
|
50
|
+
modelValue: {
|
|
51
|
+
type: PropType<FormModelValue>;
|
|
52
|
+
default: undefined;
|
|
53
|
+
};
|
|
54
|
+
readonly: {
|
|
55
|
+
type: BooleanConstructor;
|
|
56
|
+
default: undefined;
|
|
57
|
+
};
|
|
58
|
+
emptyText: {
|
|
59
|
+
type: StringConstructor;
|
|
60
|
+
default: string;
|
|
61
|
+
};
|
|
62
|
+
valueToLabel: {
|
|
63
|
+
type: PropType<ValueToLabelFn>;
|
|
64
|
+
default: null;
|
|
65
|
+
};
|
|
66
|
+
}>> & {
|
|
67
|
+
[x: `on${Capitalize<string>}`]: ((...args: any[]) => any) | undefined;
|
|
68
|
+
}, {
|
|
69
|
+
[x: string]: FormModelValue | ValueToLabelFn;
|
|
70
|
+
}, {}>;
|
|
71
|
+
export {};
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "n-design-readonly-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "只读模式插件 for n-designv3",
|
|
5
|
+
"main": "./dist/index.cjs.js",
|
|
6
|
+
"module": "./dist/index.es.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.es.js",
|
|
12
|
+
"require": "./dist/index.cjs.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
""
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "vite --config vite.config.playground.ts",
|
|
21
|
+
"build": "vite build",
|
|
22
|
+
"preview": "vite preview"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"vue",
|
|
26
|
+
"n-design-readonly-plugin",
|
|
27
|
+
"vue",
|
|
28
|
+
"readonly",
|
|
29
|
+
"n-designv3",
|
|
30
|
+
"form",
|
|
31
|
+
"hoc"
|
|
32
|
+
],
|
|
33
|
+
"author": "pangsh<psh2416623245@163.com>",
|
|
34
|
+
"license": "ISC",
|
|
35
|
+
"packageManager": "pnpm@10.21.0",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^25.2.0",
|
|
38
|
+
"@vitejs/plugin-vue": "^6.0.3",
|
|
39
|
+
"n-designv3": "1.1.42",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vite": "^7.3.1",
|
|
42
|
+
"vite-plugin-dts": "^4.5.4",
|
|
43
|
+
"vue": "3.3.4"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="form-wrapper">
|
|
3
|
+
<nk-form :readonly="false" :model="form" :rules="rules">
|
|
4
|
+
<nk-form-item label="用户名" name="name">
|
|
5
|
+
<nk-input placeholder="用户名" v-model:value="form.name"></nk-input>
|
|
6
|
+
</nk-form-item>
|
|
7
|
+
<n-row>
|
|
8
|
+
<n-col :span="24">
|
|
9
|
+
<nk-form-item label="下拉options" name="ep6ClusterObjId">
|
|
10
|
+
<nk-select
|
|
11
|
+
allowClear
|
|
12
|
+
placeholder="请选择"
|
|
13
|
+
:options="options"
|
|
14
|
+
:showSearch="false"
|
|
15
|
+
v-model:value="form.ep6ClusterObjId"
|
|
16
|
+
>
|
|
17
|
+
</nk-select>
|
|
18
|
+
</nk-form-item>
|
|
19
|
+
</n-col>
|
|
20
|
+
</n-row>
|
|
21
|
+
<n-row>
|
|
22
|
+
<n-col :span="24">
|
|
23
|
+
<nk-form-item label="下拉options" name="ep6ClusterObjId">
|
|
24
|
+
<n-select
|
|
25
|
+
allowClear
|
|
26
|
+
placeholder="请选择"
|
|
27
|
+
:options="options"
|
|
28
|
+
:showSearch="false"
|
|
29
|
+
v-model:value="form.ep6ClusterObjId"
|
|
30
|
+
>
|
|
31
|
+
</n-select>
|
|
32
|
+
</nk-form-item>
|
|
33
|
+
</n-col>
|
|
34
|
+
</n-row>
|
|
35
|
+
</nk-form>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script lang="ts" setup>
|
|
40
|
+
import { reactive } from "vue";
|
|
41
|
+
|
|
42
|
+
const form = reactive({
|
|
43
|
+
name: "张三",
|
|
44
|
+
ep6ClusterObjId: "1",
|
|
45
|
+
});
|
|
46
|
+
const options = [
|
|
47
|
+
{
|
|
48
|
+
label: "选项1",
|
|
49
|
+
value: "1",
|
|
50
|
+
externalValue: "externalValue1",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: "选项2",
|
|
54
|
+
value: "2",
|
|
55
|
+
externalValue: "externalValue2",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "选项3",
|
|
59
|
+
value: "3",
|
|
60
|
+
externalValue: "externalValue3",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
const rules = {
|
|
64
|
+
name: [{ required: true, message: "请输入用户名" }],
|
|
65
|
+
ep6ClusterObjId: [{ required: true, message: "请选择" }],
|
|
66
|
+
};
|
|
67
|
+
</script>
|
|
68
|
+
<style>
|
|
69
|
+
.form-wrapper {
|
|
70
|
+
width: 100%;
|
|
71
|
+
height: 100vh;
|
|
72
|
+
padding: 20px;
|
|
73
|
+
box-sizing: border-box;
|
|
74
|
+
position: relative;
|
|
75
|
+
background-color: bisque;
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<link rel="icon" href="/favicon.ico" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
|
+
<title>NkReadonlyHOC Playground</title>
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
<script type="module" src="./main.ts"></script>
|
|
14
|
+
</body>
|
|
15
|
+
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// main.ts
|
|
2
|
+
import { createApp } from 'vue';
|
|
3
|
+
import App from './App.vue';
|
|
4
|
+
import { NkReadonlyPlugin } from '../src/index';
|
|
5
|
+
import NDesign from 'n-designv3';
|
|
6
|
+
import 'n-designv3/dist/nancal.variable.min.css';
|
|
7
|
+
import './reset.css'
|
|
8
|
+
|
|
9
|
+
const app = createApp(App);
|
|
10
|
+
app.use(NDesign);
|
|
11
|
+
app.use(NkReadonlyPlugin); // ← 一行注册!
|
|
12
|
+
app.mount('#app');
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body,
|
|
3
|
+
div,
|
|
4
|
+
span,
|
|
5
|
+
applet,
|
|
6
|
+
object,
|
|
7
|
+
iframe,
|
|
8
|
+
p,
|
|
9
|
+
blockquote,
|
|
10
|
+
pre,
|
|
11
|
+
a,
|
|
12
|
+
abbr,
|
|
13
|
+
acronym,
|
|
14
|
+
address,
|
|
15
|
+
big,
|
|
16
|
+
cite,
|
|
17
|
+
code,
|
|
18
|
+
del,
|
|
19
|
+
dfn,
|
|
20
|
+
em,
|
|
21
|
+
img,
|
|
22
|
+
ins,
|
|
23
|
+
kbd,
|
|
24
|
+
q,
|
|
25
|
+
s,
|
|
26
|
+
samp,
|
|
27
|
+
small,
|
|
28
|
+
strike,
|
|
29
|
+
strong,
|
|
30
|
+
sub,
|
|
31
|
+
sup,
|
|
32
|
+
tt,
|
|
33
|
+
var,
|
|
34
|
+
b,
|
|
35
|
+
u,
|
|
36
|
+
i,
|
|
37
|
+
center,
|
|
38
|
+
dl,
|
|
39
|
+
dt,
|
|
40
|
+
dd,
|
|
41
|
+
ol,
|
|
42
|
+
ul,
|
|
43
|
+
li,
|
|
44
|
+
fieldset,
|
|
45
|
+
form,
|
|
46
|
+
label,
|
|
47
|
+
legend,
|
|
48
|
+
table,
|
|
49
|
+
caption,
|
|
50
|
+
tbody,
|
|
51
|
+
tfoot,
|
|
52
|
+
thead,
|
|
53
|
+
tr,
|
|
54
|
+
th,
|
|
55
|
+
td,
|
|
56
|
+
article,
|
|
57
|
+
aside,
|
|
58
|
+
canvas,
|
|
59
|
+
details,
|
|
60
|
+
embed,
|
|
61
|
+
figure,
|
|
62
|
+
figcaption,
|
|
63
|
+
footer,
|
|
64
|
+
header,
|
|
65
|
+
hgroup,
|
|
66
|
+
menu,
|
|
67
|
+
nav,
|
|
68
|
+
output,
|
|
69
|
+
ruby,
|
|
70
|
+
section,
|
|
71
|
+
summary,
|
|
72
|
+
time,
|
|
73
|
+
mark,
|
|
74
|
+
audio,
|
|
75
|
+
video {
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: 0;
|
|
78
|
+
border: 0;
|
|
79
|
+
font-size: 100%;
|
|
80
|
+
font: inherit;
|
|
81
|
+
// vertical-align: baseline;
|
|
82
|
+
}
|
|
83
|
+
/* HTML5 display-role reset for older browsers */
|
|
84
|
+
article,
|
|
85
|
+
aside,
|
|
86
|
+
details,
|
|
87
|
+
figcaption,
|
|
88
|
+
figure,
|
|
89
|
+
footer,
|
|
90
|
+
header,
|
|
91
|
+
hgroup,
|
|
92
|
+
menu,
|
|
93
|
+
nav,
|
|
94
|
+
section {
|
|
95
|
+
display: block;
|
|
96
|
+
}
|
|
97
|
+
body {
|
|
98
|
+
line-height: 1;
|
|
99
|
+
}
|
|
100
|
+
ol,
|
|
101
|
+
ul {
|
|
102
|
+
list-style: none;
|
|
103
|
+
}
|
|
104
|
+
blockquote,
|
|
105
|
+
q {
|
|
106
|
+
quotes: none;
|
|
107
|
+
}
|
|
108
|
+
blockquote:before,
|
|
109
|
+
blockquote:after,
|
|
110
|
+
q:before,
|
|
111
|
+
q:after {
|
|
112
|
+
content: '';
|
|
113
|
+
content: none;
|
|
114
|
+
}
|
|
115
|
+
table {
|
|
116
|
+
border-collapse: collapse;
|
|
117
|
+
border-spacing: 0;
|
|
118
|
+
}
|
|
119
|
+
html,
|
|
120
|
+
body {
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
padding: 0;
|
|
124
|
+
margin: 0;
|
|
125
|
+
font-family: 'PingFang SC-Medium', 'PingFang SC-Regular', 'PingFang SC', 'STHeitiSC-Light', 'Helvetica-Light', 'Arial', 'sans-serif';
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import type { App, Plugin } from 'vue';
|
|
3
|
+
import { withReadonly } from './withReadonly';
|
|
4
|
+
|
|
5
|
+
// 👇 直接从 n-designv3 导入组件(构建时 external,不打包)
|
|
6
|
+
import {
|
|
7
|
+
Input,
|
|
8
|
+
InputNumber,
|
|
9
|
+
Textarea,
|
|
10
|
+
Select,
|
|
11
|
+
TreeSelect,
|
|
12
|
+
Cascader,
|
|
13
|
+
DatePicker,
|
|
14
|
+
TimePicker,
|
|
15
|
+
Radio,
|
|
16
|
+
Checkbox,
|
|
17
|
+
Switch,
|
|
18
|
+
Form,
|
|
19
|
+
} from 'n-designv3';
|
|
20
|
+
|
|
21
|
+
const COMPONENTS = {
|
|
22
|
+
Input,
|
|
23
|
+
InputNumber,
|
|
24
|
+
Textarea,
|
|
25
|
+
Select,
|
|
26
|
+
SelectOption: Select.Option,
|
|
27
|
+
TreeSelect,
|
|
28
|
+
Cascader,
|
|
29
|
+
DatePicker,
|
|
30
|
+
TimePicker,
|
|
31
|
+
Radio,
|
|
32
|
+
Checkbox,
|
|
33
|
+
RadioGroup: Radio.Group,
|
|
34
|
+
CheckboxGroup: Checkbox.Group,
|
|
35
|
+
Switch,
|
|
36
|
+
Form,
|
|
37
|
+
FormItem: Form.Item,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const NkReadonlyPlugin: Plugin = {
|
|
41
|
+
install(app: App) {
|
|
42
|
+
for (const [name, Comp] of Object.entries(COMPONENTS)) {
|
|
43
|
+
if (Comp) {
|
|
44
|
+
const ReadonlyComp = withReadonly(Comp);
|
|
45
|
+
app.component(`Nk${name}`, ReadonlyComp);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 可选:导出 HOC 供用户按需使用
|
|
52
|
+
export { withReadonly };
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// composables/withReadonly.ts
|
|
2
|
+
import { defineComponent, h, computed, inject, provide, ref, watch, useAttrs, type PropType, type Component, type VNode, Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
type FormModelValue = string | number | boolean | any[] | null | undefined;
|
|
5
|
+
type ValueToLabelFn = (value: FormModelValue) => string;
|
|
6
|
+
|
|
7
|
+
// ========================
|
|
8
|
+
// 工具函数:提取静态文本(用于单个 checkbox/radio/select )
|
|
9
|
+
// ========================
|
|
10
|
+
function extractStaticText(vnode: any): string {
|
|
11
|
+
if (vnode == null) return '';
|
|
12
|
+
if (typeof vnode === 'string') return vnode;
|
|
13
|
+
if (Array.isArray(vnode)) return vnode.map(extractStaticText).join('');
|
|
14
|
+
if (typeof vnode === 'object') {
|
|
15
|
+
if (typeof vnode.children === 'string') {
|
|
16
|
+
return vnode.children;
|
|
17
|
+
}
|
|
18
|
+
// 处理动态插槽内容
|
|
19
|
+
if (vnode?.default && typeof vnode.default === 'function') {
|
|
20
|
+
const dynamicContent = vnode.default();
|
|
21
|
+
if (Array.isArray(dynamicContent)) {
|
|
22
|
+
return extractStaticText(dynamicContent);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (vnode.children) {
|
|
26
|
+
extractStaticText(vnode.children);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ========================
|
|
33
|
+
// 从插槽中提取 value → label 映射(支持 n-option / n-radio / n-checkbox / n-select)
|
|
34
|
+
// ========================
|
|
35
|
+
function collectOptionLabelMapFromSlots(vnodes: VNode | VNode[] | undefined, fieldNames: Record<string, string> = {}): Map<any, string> {
|
|
36
|
+
const labelMap = new Map<any, string>();
|
|
37
|
+
const { value: valKey = 'value', label: labelKey = 'label' } = fieldNames;
|
|
38
|
+
|
|
39
|
+
if (!vnodes) return labelMap;
|
|
40
|
+
|
|
41
|
+
const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
|
|
42
|
+
|
|
43
|
+
for (const vnode of nodes) {
|
|
44
|
+
if (!vnode || typeof vnode !== 'object') continue;
|
|
45
|
+
|
|
46
|
+
// 处理组件节点
|
|
47
|
+
if (vnode.type && (typeof vnode.type === 'object' || typeof vnode.type === 'function')) {
|
|
48
|
+
let compName = (vnode.type as any).name?.toLowerCase() || '';
|
|
49
|
+
if (typeof vnode.type === 'function') {
|
|
50
|
+
compName = (vnode.type as any).displayName?.toLowerCase() || '';
|
|
51
|
+
} else if (typeof vnode.type === 'string') {
|
|
52
|
+
compName = (vnode.type as any)?.toLowerCase() || '';
|
|
53
|
+
}
|
|
54
|
+
if (/option|radio|checkbox/i.test(compName)) {
|
|
55
|
+
const props = vnode.props || {};
|
|
56
|
+
const value = props[valKey] ?? props.value;
|
|
57
|
+
const labelText = props[labelKey] ?? props.label;
|
|
58
|
+
if (value != null) {
|
|
59
|
+
const label = extractStaticText(vnode.children) || labelText || String(value);
|
|
60
|
+
labelMap.set(value, label);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 递归子节点
|
|
66
|
+
if (vnode.children) {
|
|
67
|
+
const childMap = collectOptionLabelMapFromSlots(vnode.children as any, fieldNames);
|
|
68
|
+
childMap.forEach((label, val) => {
|
|
69
|
+
if (!labelMap.has(val)) labelMap.set(val, label);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return labelMap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ========================
|
|
78
|
+
// Cascader: 递归查找 label 路径
|
|
79
|
+
// ========================
|
|
80
|
+
function findCascaderLabels(options: any[], value: any[], fieldNames: Record<string, string>): string[] {
|
|
81
|
+
const { value: valKey = 'value', label: labelKey = 'label', children: childrenKey = 'children' } = fieldNames;
|
|
82
|
+
const labels: string[] = [];
|
|
83
|
+
let current = options;
|
|
84
|
+
|
|
85
|
+
for (const v of value) {
|
|
86
|
+
const node = current.find((item: any) => item[valKey] === v);
|
|
87
|
+
if (node) {
|
|
88
|
+
labels.push(node[labelKey] || node[valKey] || String(v));
|
|
89
|
+
current = node[childrenKey] || [];
|
|
90
|
+
} else {
|
|
91
|
+
labels.push(String(v));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return labels;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ========================
|
|
99
|
+
// TreeSelect: 从 treeData 递归查找 label
|
|
100
|
+
// ========================
|
|
101
|
+
function findTreeSelectLabels(treeData: any[], value: any, fieldNames: Record<string, string>): string[] {
|
|
102
|
+
const { value: valKey = 'value', label: labelKey = 'title', children: childrenKey = 'children' } = fieldNames;
|
|
103
|
+
const labels: string[] = [];
|
|
104
|
+
const targetValues = new Set(Array.isArray(value) ? value : [value]);
|
|
105
|
+
|
|
106
|
+
function dfs(nodes: any[]) {
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
if (targetValues.has(node[valKey])) {
|
|
109
|
+
labels.push(node[labelKey] || node[valKey]);
|
|
110
|
+
}
|
|
111
|
+
if (node[childrenKey]) {
|
|
112
|
+
dfs(node[childrenKey]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
dfs(treeData);
|
|
118
|
+
return labels;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ========================
|
|
122
|
+
// 获取只读显示文本
|
|
123
|
+
// ========================
|
|
124
|
+
function getDisplayText({
|
|
125
|
+
modelValue,
|
|
126
|
+
options,
|
|
127
|
+
treeData,
|
|
128
|
+
valueToLabel,
|
|
129
|
+
fieldNames,
|
|
130
|
+
slots,
|
|
131
|
+
isSelect,
|
|
132
|
+
isRadioGroup,
|
|
133
|
+
isCheckboxGroup,
|
|
134
|
+
isCheckbox,
|
|
135
|
+
isRadio,
|
|
136
|
+
isCascader,
|
|
137
|
+
isTreeSelect,
|
|
138
|
+
isSwitch,
|
|
139
|
+
attrs,
|
|
140
|
+
emptyText,
|
|
141
|
+
}: {
|
|
142
|
+
modelValue: FormModelValue;
|
|
143
|
+
options?: any[];
|
|
144
|
+
treeData?: any[];
|
|
145
|
+
valueToLabel?: ValueToLabelFn;
|
|
146
|
+
fieldNames: Record<string, string>;
|
|
147
|
+
slots?: any;
|
|
148
|
+
isSelect: boolean;
|
|
149
|
+
isRadioGroup: boolean;
|
|
150
|
+
isCheckboxGroup: boolean;
|
|
151
|
+
isCheckbox: boolean;
|
|
152
|
+
isRadio: boolean;
|
|
153
|
+
isCascader: boolean;
|
|
154
|
+
isTreeSelect: boolean;
|
|
155
|
+
isSwitch: boolean;
|
|
156
|
+
attrs: Record<string, any>;
|
|
157
|
+
emptyText: string;
|
|
158
|
+
}): string {
|
|
159
|
+
// 1. 自定义映射
|
|
160
|
+
if (valueToLabel) {
|
|
161
|
+
try {
|
|
162
|
+
return valueToLabel(modelValue) || emptyText;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.warn('[ReadonlyHOC] valueToLabel error', e);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2. Switch 特殊处理
|
|
169
|
+
if (isSwitch) {
|
|
170
|
+
const checkedVal = attrs.checkedValue ?? attrs['checked-value'] ?? true;
|
|
171
|
+
const uncheckedVal = attrs.uncheckedValue ?? attrs['un-checked-value'] ?? false;
|
|
172
|
+
if (modelValue === checkedVal) return attrs.checkedChildren ?? attrs['checked-children'] ?? '开启';
|
|
173
|
+
if (modelValue === uncheckedVal) return attrs.uncheckedChildren ?? attrs['un-checked-children'] ?? '关闭';
|
|
174
|
+
return String(modelValue || emptyText);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3. Cascader
|
|
178
|
+
if (isCascader && Array.isArray(modelValue) && options?.length) {
|
|
179
|
+
return findCascaderLabels(options, modelValue, fieldNames).join(' / ') || emptyText;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. TreeSelect
|
|
183
|
+
if (isTreeSelect && treeData?.length) {
|
|
184
|
+
return findTreeSelectLabels(treeData, modelValue, fieldNames).join(', ') || emptyText;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 5. 动态 options(Select / RadioGroup / CheckboxGroup)
|
|
188
|
+
if (options?.length) {
|
|
189
|
+
const valKey = fieldNames.value || 'value';
|
|
190
|
+
const labelKey = fieldNames.label || 'label';
|
|
191
|
+
const getLabel = (val: any) => {
|
|
192
|
+
const opt = options.find(item => item[valKey] === val);
|
|
193
|
+
return opt ? opt[labelKey] || opt[valKey] : val === undefined || val === null ? '' : String(val);
|
|
194
|
+
};
|
|
195
|
+
if (Array.isArray(modelValue)) {
|
|
196
|
+
return modelValue.map(getLabel).join(', ') || emptyText;
|
|
197
|
+
} else {
|
|
198
|
+
return getLabel(modelValue) || emptyText;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 6. 静态插槽 fallback(Select / RadioGroup / CheckboxGroup)
|
|
203
|
+
else if ((isSelect || isRadioGroup || isCheckboxGroup) && slots?.default) {
|
|
204
|
+
try {
|
|
205
|
+
const slotContent = slots.default();
|
|
206
|
+
|
|
207
|
+
const labelMap = collectOptionLabelMapFromSlots(slotContent, fieldNames);
|
|
208
|
+
if (labelMap.size > 0) {
|
|
209
|
+
const getLabel = (val: any) => (val === undefined || val === null ? '' : labelMap.get(val) || String(val));
|
|
210
|
+
if (Array.isArray(modelValue)) {
|
|
211
|
+
return modelValue.map(getLabel).join(', ') || emptyText;
|
|
212
|
+
} else {
|
|
213
|
+
return getLabel(modelValue) || emptyText;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
console.warn('[ReadonlyHOC] Failed to parse slot options', e);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 7. 单个 Checkbox / Radio 静态文本兜底
|
|
222
|
+
else if (slots?.default && (isCheckbox || isRadio) && typeof modelValue === 'boolean') {
|
|
223
|
+
try {
|
|
224
|
+
const content = slots.default();
|
|
225
|
+
const text = extractStaticText(content);
|
|
226
|
+
if (text) return text;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
/* ignore */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 8. 最终兜底
|
|
233
|
+
if (modelValue == null || modelValue === '') return emptyText;
|
|
234
|
+
return Array.isArray(modelValue) ? modelValue.join(', ') : String(modelValue);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ========================
|
|
238
|
+
// 高阶组件
|
|
239
|
+
// ========================
|
|
240
|
+
export function withReadonly(BaseComponent: Component) {
|
|
241
|
+
const baseName = BaseComponent.name || '';
|
|
242
|
+
const componentName = baseName.toLowerCase();
|
|
243
|
+
|
|
244
|
+
const isSelect = /select/i.test(componentName) && !/tree|cascader/i.test(componentName);
|
|
245
|
+
const isRadio = /radio/i.test(componentName) && !/group/i.test(componentName);
|
|
246
|
+
const isRadioGroup = /radio.*group/i.test(componentName);
|
|
247
|
+
const isCheckbox = /checkbox/i.test(componentName) && !/group/i.test(componentName);
|
|
248
|
+
const isCheckboxGroup = /checkbox.*group/i.test(componentName);
|
|
249
|
+
const isSwitch = /switch/i.test(componentName);
|
|
250
|
+
const isCascader = /cascader/i.test(componentName);
|
|
251
|
+
const isTreeSelect = /tree.*select/i.test(componentName);
|
|
252
|
+
|
|
253
|
+
const isCheckType = isCheckbox || isSwitch || isRadio;
|
|
254
|
+
const nativeProp = isCheckType ? 'checked' : 'value';
|
|
255
|
+
const updateEvent = `update:${nativeProp}`;
|
|
256
|
+
|
|
257
|
+
const isForm = /form$/i.test(componentName);
|
|
258
|
+
const isFormItem = /form.*item/i.test(componentName);
|
|
259
|
+
|
|
260
|
+
return defineComponent({
|
|
261
|
+
name: baseName,
|
|
262
|
+
inheritAttrs: false,
|
|
263
|
+
props: {
|
|
264
|
+
modelValue: { type: [String, Number, Boolean, Array, Object] as PropType<FormModelValue>, default: undefined },
|
|
265
|
+
[nativeProp]: { type: [String, Number, Boolean, Array, Object] as PropType<FormModelValue>, default: undefined },
|
|
266
|
+
readonly: { type: Boolean, default: undefined },
|
|
267
|
+
emptyText: { type: String, default: '--' },
|
|
268
|
+
valueToLabel: { type: Function as PropType<ValueToLabelFn>, default: null },
|
|
269
|
+
},
|
|
270
|
+
emits: ['update:modelValue', updateEvent],
|
|
271
|
+
setup(props, { emit, slots, expose }) {
|
|
272
|
+
const globalReadonly = inject<Ref<boolean>>('nkReadonly', ref(false));
|
|
273
|
+
const isReadonly = computed(() => props.readonly ?? globalReadonly.value);
|
|
274
|
+
const formReadonly = computed(() => props.readonly ?? globalReadonly.value);
|
|
275
|
+
|
|
276
|
+
if (isForm) provide('nkReadonly', formReadonly);
|
|
277
|
+
|
|
278
|
+
const finalValue = computed(() => (props[nativeProp] !== undefined ? props[nativeProp] : props.modelValue));
|
|
279
|
+
|
|
280
|
+
const emitUpdate = (val: FormModelValue) => {
|
|
281
|
+
emit('update:modelValue', val);
|
|
282
|
+
emit(updateEvent, val);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// ✅ 使用 useAttrs() 保证响应式
|
|
286
|
+
const attrs = useAttrs();
|
|
287
|
+
|
|
288
|
+
const displayText = computed(() => {
|
|
289
|
+
if (!isReadonly.value) return '';
|
|
290
|
+
|
|
291
|
+
const fieldNames = attrs.fieldNames ?? attrs['field-names'] ?? {};
|
|
292
|
+
const options = attrs.options as any[] | undefined;
|
|
293
|
+
const treeData = (attrs.treeData ?? attrs['tree-data']) as any[] | undefined;
|
|
294
|
+
|
|
295
|
+
return getDisplayText({
|
|
296
|
+
modelValue: finalValue.value as FormModelValue,
|
|
297
|
+
options,
|
|
298
|
+
treeData,
|
|
299
|
+
valueToLabel: props.valueToLabel as ValueToLabelFn,
|
|
300
|
+
fieldNames: fieldNames as Record<string, string>,
|
|
301
|
+
slots,
|
|
302
|
+
isSelect,
|
|
303
|
+
isRadioGroup,
|
|
304
|
+
isCheckboxGroup,
|
|
305
|
+
isCheckbox,
|
|
306
|
+
isRadio,
|
|
307
|
+
isCascader,
|
|
308
|
+
isTreeSelect,
|
|
309
|
+
isSwitch,
|
|
310
|
+
attrs,
|
|
311
|
+
emptyText: props.emptyText as string,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ⚠️ 开发警告
|
|
316
|
+
if (import.meta?.env?.DEV) {
|
|
317
|
+
watch(
|
|
318
|
+
() => isReadonly.value,
|
|
319
|
+
readonly => {
|
|
320
|
+
if (
|
|
321
|
+
readonly &&
|
|
322
|
+
!attrs.options &&
|
|
323
|
+
!attrs.treeData &&
|
|
324
|
+
!props.valueToLabel &&
|
|
325
|
+
slots?.default &&
|
|
326
|
+
(isSelect || isRadioGroup || isCheckboxGroup || isCascader || isTreeSelect)
|
|
327
|
+
) {
|
|
328
|
+
console.warn(
|
|
329
|
+
`[ReadonlyHOC] Detected slot-only usage for ${baseName} in readonly mode. ` +
|
|
330
|
+
`This works for static content, but will fail for async/dynamic data. ` +
|
|
331
|
+
`✅ Recommended: Use \`options\` or \`treeData\` prop instead.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
{ immediate: true }
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const readonlyStyle = {
|
|
340
|
+
lineHeight: '32px',
|
|
341
|
+
padding: '0 6px',
|
|
342
|
+
display: 'inline-block',
|
|
343
|
+
minHeight: '32px',
|
|
344
|
+
border: 'none',
|
|
345
|
+
backgroundColor: 'none',
|
|
346
|
+
cursor: 'default',
|
|
347
|
+
wordBreak: 'break-all',
|
|
348
|
+
color: 'rgba(0, 0, 0, 0.65)',
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const formRef = ref<any>();
|
|
352
|
+
if (isForm) {
|
|
353
|
+
expose({
|
|
354
|
+
validate: () => (isReadonly.value ? Promise.resolve({}) : formRef.value?.validate?.()),
|
|
355
|
+
validateFields: (names?: string[]) => (isReadonly.value ? Promise.resolve({}) : formRef.value?.validateFields?.(names)),
|
|
356
|
+
resetFields: (names?: string[]) => !isReadonly.value && formRef.value?.resetFields?.(names),
|
|
357
|
+
clearValidate: (names?: string[]) => !isReadonly.value && formRef.value?.clearValidate?.(names),
|
|
358
|
+
scrollToField: (name: string) => !isReadonly.value && formRef.value?.scrollToField?.(name),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return () => {
|
|
363
|
+
if (isFormItem) {
|
|
364
|
+
return h(
|
|
365
|
+
BaseComponent,
|
|
366
|
+
{
|
|
367
|
+
...attrs,
|
|
368
|
+
style: { ...(attrs.style || {}), marginBottom: isReadonly.value ? '10px' : undefined },
|
|
369
|
+
},
|
|
370
|
+
slots
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (isForm) {
|
|
375
|
+
return h(
|
|
376
|
+
BaseComponent,
|
|
377
|
+
{
|
|
378
|
+
...attrs,
|
|
379
|
+
rules: formReadonly.value ? null : attrs.rules,
|
|
380
|
+
disabled: formReadonly.value ? true : attrs.disabled,
|
|
381
|
+
ref: formRef,
|
|
382
|
+
},
|
|
383
|
+
slots
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (isReadonly.value) {
|
|
388
|
+
return h(
|
|
389
|
+
'span',
|
|
390
|
+
{
|
|
391
|
+
class: 'nk-readonly-wrapper',
|
|
392
|
+
style: readonlyStyle,
|
|
393
|
+
role: 'text',
|
|
394
|
+
tabindex: 0,
|
|
395
|
+
'aria-readonly': 'true',
|
|
396
|
+
},
|
|
397
|
+
displayText.value
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return h(
|
|
402
|
+
BaseComponent,
|
|
403
|
+
{
|
|
404
|
+
...attrs,
|
|
405
|
+
[nativeProp]: finalValue.value,
|
|
406
|
+
[`onUpdate:${nativeProp}`]: emitUpdate,
|
|
407
|
+
ref: isForm ? formRef : undefined,
|
|
408
|
+
},
|
|
409
|
+
slots
|
|
410
|
+
);
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2020", "DOM"],
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationDir": "./dist",
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"allowSyntheticDefaultImports": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: 'playground',
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: {
|
|
10
|
+
'@': resolve(__dirname, 'src'),
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
server: {
|
|
14
|
+
host: '0.0.0.0',
|
|
15
|
+
port: 5200,
|
|
16
|
+
},
|
|
17
|
+
})
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
import dts from 'vite-plugin-dts';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
build: {
|
|
7
|
+
lib: {
|
|
8
|
+
entry: resolve(__dirname, 'src/index.ts'),
|
|
9
|
+
name: 'NDesignReadolyPlugin',
|
|
10
|
+
fileName: 'index',
|
|
11
|
+
},
|
|
12
|
+
rollupOptions: {
|
|
13
|
+
// external 排除 vue 和 n-designv3
|
|
14
|
+
external: ['vue','n-designv3'],
|
|
15
|
+
output: { globals: { vue: 'Vue' } }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
plugins: [dts({ insertTypesEntry: true })]
|
|
19
|
+
});
|