weifuwu 0.33.5 → 0.33.6
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 +109 -5
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +177 -33
- package/dist/client/jsx-runtime.d.ts +335 -1
- package/dist/client/jsx-runtime.js +177 -33
- package/dist/client/lib/form.d.ts +54 -0
- package/dist/client/router.d.ts +1 -34
- package/dist/client/types.d.ts +4 -0
- package/dist/index.js +10 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ app.mount('#root', AppShell)
|
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
```js
|
|
69
|
-
// build.mjs —
|
|
69
|
+
// build.mjs — 传统构建方式(可选)
|
|
70
|
+
// 推荐:使用 ctx.ui.js() 服务端动态编译,无需独立构建脚本
|
|
70
71
|
esbuild.build({
|
|
71
72
|
entryPoints: ['src/main.tsx'],
|
|
72
73
|
jsx: 'automatic',
|
|
@@ -75,9 +76,10 @@ esbuild.build({
|
|
|
75
76
|
})
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
或用服务端动态编译——一行代码,无需构建步骤、无需 watch 模式:
|
|
79
80
|
```ts
|
|
80
81
|
app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
|
|
82
|
+
app.get('/static/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
### Environment Variables
|
|
@@ -151,6 +153,10 @@ app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
|
|
|
151
153
|
| `LoginForm` | Login/register form component |
|
|
152
154
|
| `Chat` | Real-time messaging component |
|
|
153
155
|
| `domMount()` | Direct DOM mounting |
|
|
156
|
+
| `wrap()` | Third-party library integration |
|
|
157
|
+
| `useForm()` | Form state management (validation, submit, reset) |
|
|
158
|
+
| `createPortal()` | Render outside parent DOM hierarchy |
|
|
159
|
+
| `<ErrorBoundary>` | Catch render errors, show fallback |
|
|
154
160
|
|
|
155
161
|
### Utilities
|
|
156
162
|
|
|
@@ -229,6 +235,29 @@ ctx.route.path // "/chat/123"
|
|
|
229
235
|
ctx.route.params // { id: "123" }
|
|
230
236
|
ctx.route.query // { tab: "settings" }
|
|
231
237
|
|
|
238
|
+
#### Route Loader — 数据预取
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
const routes: RouteDef[] = [
|
|
242
|
+
{
|
|
243
|
+
path: '/post/:id',
|
|
244
|
+
component: PostPage,
|
|
245
|
+
loader: async (ctx) => ({
|
|
246
|
+
post: await ctx.api.get(`/api/posts/${ctx.route.params.id}`),
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
// In component:
|
|
252
|
+
function PostPage(_, ctx) {
|
|
253
|
+
const post = ctx.route.data.post
|
|
254
|
+
if (!post) return <p class="text-gray-400">加载中...</p>
|
|
255
|
+
return <h1 class="text-2xl font-bold">{post.title}</h1>
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
组件先渲染(显示 loading),loader 完成后自动重渲染。
|
|
260
|
+
|
|
232
261
|
### Middleware: api / auth / ws
|
|
233
262
|
|
|
234
263
|
```tsx
|
|
@@ -330,6 +359,79 @@ const PieChart = wrap('div', (el, props: { data: any[] }, ctx) => {
|
|
|
330
359
|
</Dashboard>
|
|
331
360
|
```
|
|
332
361
|
|
|
362
|
+
### useForm() — 表单状态管理
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
import { useForm } from 'weifuwu/client'
|
|
366
|
+
|
|
367
|
+
const form = useForm({
|
|
368
|
+
initial: { email: '', password: '' },
|
|
369
|
+
validate: {
|
|
370
|
+
email: (v) => !v.includes('@') && '请输入有效邮箱',
|
|
371
|
+
password: (v) => v.length < 6 && '至少 6 位',
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// 绑定到 input:{...form.field('name')} 自动设置 value + onInput
|
|
376
|
+
<input {...form.field('email')} placeholder="邮箱" />
|
|
377
|
+
{form.errors.email && <span class="text-red-500">{form.errors.email}</span>}
|
|
378
|
+
|
|
379
|
+
// 提交时自动验证所有字段
|
|
380
|
+
<button onClick={() => form.submit((data) => ctx.login(data.email, data.password))}>
|
|
381
|
+
登录
|
|
382
|
+
</button>
|
|
383
|
+
|
|
384
|
+
// 重置
|
|
385
|
+
<button onClick={form.reset}>重置</button>
|
|
386
|
+
|
|
387
|
+
// 编程设置
|
|
388
|
+
form.setValue('email', 'a@b.com')
|
|
389
|
+
form.setValues({ email: 'a@b.com', password: '123' })
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### createPortal() — 渲染到父容器外
|
|
393
|
+
|
|
394
|
+
适用于 Modal、Dropdown、Tooltip 等需突破 `overflow: hidden` 或 z-index 层级的情况。
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
import { createPortal, Show } from 'weifuwu/client'
|
|
398
|
+
|
|
399
|
+
function Modal({ show, title, children }) {
|
|
400
|
+
return <Show when={show}>
|
|
401
|
+
{createPortal(
|
|
402
|
+
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
|
|
403
|
+
<div class="bg-white rounded-xl p-6 min-w-[400px]">
|
|
404
|
+
<h2 class="text-lg font-bold mb-4">{title}</h2>
|
|
405
|
+
{children}
|
|
406
|
+
</div>
|
|
407
|
+
</div>,
|
|
408
|
+
document.body
|
|
409
|
+
)}
|
|
410
|
+
</Show>
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### ErrorBoundary — 错误边界
|
|
415
|
+
|
|
416
|
+
子组件渲染异常时捕获,显示 fallback 而非白屏。children 必须是 thunk(延迟执行)。
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
import { ErrorBoundary } from 'weifuwu/client'
|
|
420
|
+
|
|
421
|
+
function AppShell(_, ctx) {
|
|
422
|
+
return (
|
|
423
|
+
<div>
|
|
424
|
+
<nav>...</nav>
|
|
425
|
+
<main>
|
|
426
|
+
<ErrorBoundary fallback={(e) => <p>出错了: {e.message}</p>}>
|
|
427
|
+
{() => <RouteView />}
|
|
428
|
+
</ErrorBoundary>
|
|
429
|
+
</main>
|
|
430
|
+
</div>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
333
435
|
### Show / For
|
|
334
436
|
|
|
335
437
|
```tsx
|
|
@@ -858,12 +960,14 @@ src/
|
|
|
858
960
|
├── hub.ts ← WebSocket hub
|
|
859
961
|
├── ui/ ← ctx.ui.html / ctx.ui.js / ctx.ui.css
|
|
860
962
|
├── client/ ← Frontend framework
|
|
861
|
-
│ ├── index.ts ← Entry (exports signal, wrap, createApp, ...)
|
|
963
|
+
│ ├── index.ts ← Entry (exports signal, useForm, wrap, createApp, ...)
|
|
862
964
|
│ ├── signal.ts ← Signal / effect / computed
|
|
863
|
-
│ ├── jsx-runtime.ts ← JSX → DOM / Show / For / wrap /
|
|
965
|
+
│ ├── jsx-runtime.ts ← JSX → DOM / Show / For / wrap / ErrorBoundary / createPortal
|
|
864
966
|
│ ├── app.ts ← createApp / hydrate / middleware chain
|
|
865
|
-
│ ├── router.ts ← Route matching / RouteView
|
|
967
|
+
│ ├── router.ts ← Route matching / RouteView / loader
|
|
866
968
|
│ ├── types.ts ← WfuiContext / RouteDef
|
|
969
|
+
│ ├── lib/
|
|
970
|
+
│ │ └── form.ts ← useForm
|
|
867
971
|
│ ├── middleware/
|
|
868
972
|
│ │ ├── api.ts ← HTTP client
|
|
869
973
|
│ │ ├── auth.ts ← Login / logout / token
|
package/dist/client/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export { signal, computed, effect, isSignal } from './signal.ts';
|
|
17
17
|
export type { Signal } from './signal.ts';
|
|
18
|
-
export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount, wrap } from './jsx-runtime.ts';
|
|
18
|
+
export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount, wrap, createPortal, ErrorBoundary } from './jsx-runtime.ts';
|
|
19
19
|
export type { Component } from './jsx-runtime.ts';
|
|
20
20
|
export type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
|
|
21
21
|
export { createApp } from './app.ts';
|
|
@@ -24,5 +24,6 @@ export { api, ApiClient, ApiError } from './middleware/api.ts';
|
|
|
24
24
|
export { auth } from './middleware/auth.ts';
|
|
25
25
|
export type { UserRecord } from './middleware/auth.ts';
|
|
26
26
|
export { ws } from './middleware/ws.ts';
|
|
27
|
+
export { useForm } from './lib/form.ts';
|
|
27
28
|
export { LoginForm } from './components/LoginForm.ts';
|
|
28
29
|
export { Chat } from './components/Chat.ts';
|
package/dist/client/index.js
CHANGED
|
@@ -184,6 +184,17 @@ function domMount(root, app) {
|
|
|
184
184
|
container.innerHTML = "";
|
|
185
185
|
container.appendChild(app);
|
|
186
186
|
}
|
|
187
|
+
function ErrorBoundary({ fallback, children }, _ctx) {
|
|
188
|
+
try {
|
|
189
|
+
return children();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return fallback(e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function createPortal(node, target) {
|
|
195
|
+
target.appendChild(node);
|
|
196
|
+
return document.createDocumentFragment();
|
|
197
|
+
}
|
|
187
198
|
function toNode(v) {
|
|
188
199
|
if (v instanceof Node) return v;
|
|
189
200
|
if (typeof v === "function") return toNode(v());
|
|
@@ -232,7 +243,8 @@ function createApp() {
|
|
|
232
243
|
params: {},
|
|
233
244
|
query: Object.fromEntries(new URLSearchParams(window.location.search)),
|
|
234
245
|
hash: window.location.hash,
|
|
235
|
-
component: null
|
|
246
|
+
component: null,
|
|
247
|
+
data: {}
|
|
236
248
|
},
|
|
237
249
|
app: {
|
|
238
250
|
navigate(path) {
|
|
@@ -308,56 +320,79 @@ function router(opts) {
|
|
|
308
320
|
}).join("/") + "$";
|
|
309
321
|
return { re: new RegExp(reStr), keys, route };
|
|
310
322
|
});
|
|
311
|
-
function
|
|
312
|
-
for (const
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
323
|
+
function matchRoute(path) {
|
|
324
|
+
for (const m of matchers) {
|
|
325
|
+
const result = path.match(m.re);
|
|
326
|
+
if (!result) continue;
|
|
327
|
+
const params = {};
|
|
328
|
+
m.keys.forEach((k, i) => {
|
|
329
|
+
params[k] = decodeURIComponent(result[i + 1]);
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
matched: { component: m.route.component, params, title: m.route.title, auth: m.route.auth },
|
|
333
|
+
routeDef: m.route
|
|
334
|
+
};
|
|
321
335
|
}
|
|
322
336
|
return null;
|
|
323
337
|
}
|
|
324
338
|
return (ctx) => {
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
function emit(path) {
|
|
340
|
+
window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
|
|
341
|
+
}
|
|
342
|
+
function resolve(path) {
|
|
343
|
+
const raw = mode === "hash" ? path || "/" : path;
|
|
344
|
+
const [cleanPath, qs] = raw.split("?");
|
|
328
345
|
ctx.route.query = Object.fromEntries(new URLSearchParams(qs ?? ""));
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
ctx.route.params = matched.params;
|
|
333
|
-
ctx.route.component = matched.component;
|
|
334
|
-
ctx.route.title = matched.title;
|
|
335
|
-
ctx.route.auth = matched.auth;
|
|
336
|
-
if (matched.title) document.title = matched.title;
|
|
337
|
-
if (matched.auth && !ctx.user) {
|
|
338
|
-
setTimeout(() => ctx.app.navigate("/login"), 0);
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
} else {
|
|
346
|
+
ctx.route.path = cleanPath;
|
|
347
|
+
const r = matchRoute(cleanPath);
|
|
348
|
+
if (!r) {
|
|
342
349
|
ctx.route.component = opts.notFound ?? null;
|
|
350
|
+
ctx.route.data = {};
|
|
351
|
+
return { component: null };
|
|
352
|
+
}
|
|
353
|
+
ctx.route.params = r.matched.params;
|
|
354
|
+
ctx.route.component = r.matched.component;
|
|
355
|
+
ctx.route.title = r.matched.title;
|
|
356
|
+
ctx.route.auth = r.matched.auth;
|
|
357
|
+
if (r.matched.title) document.title = r.matched.title;
|
|
358
|
+
return { component: r.matched.component, routeDef: r.routeDef };
|
|
359
|
+
}
|
|
360
|
+
function navigateAndLoad(path) {
|
|
361
|
+
const { routeDef } = resolve(path);
|
|
362
|
+
if (ctx.route.auth && !ctx.user) {
|
|
363
|
+
setTimeout(() => ctx.app.navigate("/login"), 0);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
emit(ctx.route.path);
|
|
367
|
+
if (routeDef?.loader) {
|
|
368
|
+
routeDef.loader(ctx).then((data) => {
|
|
369
|
+
ctx.route.data = data;
|
|
370
|
+
emit(ctx.route.path);
|
|
371
|
+
}).catch(() => {
|
|
372
|
+
ctx.route.data = {};
|
|
373
|
+
emit(ctx.route.path);
|
|
374
|
+
});
|
|
343
375
|
}
|
|
344
|
-
window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
|
|
345
376
|
}
|
|
346
|
-
const origNavigate = ctx.app.navigate;
|
|
347
377
|
ctx.app.navigate = (path) => {
|
|
348
378
|
if (mode === "hash") {
|
|
349
379
|
window.location.hash = "#" + path;
|
|
350
380
|
} else {
|
|
351
381
|
window.history.pushState({}, "", path);
|
|
352
|
-
|
|
382
|
+
navigateAndLoad(path);
|
|
353
383
|
}
|
|
354
384
|
};
|
|
355
385
|
if (mode === "hash") {
|
|
356
|
-
window.addEventListener("hashchange",
|
|
386
|
+
window.addEventListener("hashchange", () => {
|
|
387
|
+
navigateAndLoad(window.location.hash.slice(1) || "/");
|
|
388
|
+
});
|
|
357
389
|
} else {
|
|
358
|
-
window.addEventListener("popstate",
|
|
390
|
+
window.addEventListener("popstate", () => {
|
|
391
|
+
navigateAndLoad(window.location.pathname + window.location.search);
|
|
392
|
+
});
|
|
359
393
|
}
|
|
360
|
-
|
|
394
|
+
const initialPath = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
|
|
395
|
+
navigateAndLoad(initialPath);
|
|
361
396
|
return ctx;
|
|
362
397
|
};
|
|
363
398
|
}
|
|
@@ -583,6 +618,112 @@ function ws(opts = {}) {
|
|
|
583
618
|
};
|
|
584
619
|
}
|
|
585
620
|
|
|
621
|
+
// src/client/lib/form.ts
|
|
622
|
+
function useForm(opts) {
|
|
623
|
+
const fields = /* @__PURE__ */ new Map();
|
|
624
|
+
const errors = {};
|
|
625
|
+
const touched = {};
|
|
626
|
+
for (const key of Object.keys(opts.initial)) {
|
|
627
|
+
fields.set(key, signal(opts.initial[key]));
|
|
628
|
+
errors[key] = null;
|
|
629
|
+
touched[key] = false;
|
|
630
|
+
}
|
|
631
|
+
const valid = signal(true);
|
|
632
|
+
const values = signal({ ...opts.initial });
|
|
633
|
+
function snapshot() {
|
|
634
|
+
const data = {};
|
|
635
|
+
for (const [key, sig] of fields) {
|
|
636
|
+
data[key] = sig.value;
|
|
637
|
+
}
|
|
638
|
+
values.value = data;
|
|
639
|
+
}
|
|
640
|
+
function validateField(name) {
|
|
641
|
+
if (!opts.validate) return null;
|
|
642
|
+
const rule = opts.validate[name];
|
|
643
|
+
if (!rule) return null;
|
|
644
|
+
const sig = fields.get(name);
|
|
645
|
+
const result = rule(sig.value, values.value);
|
|
646
|
+
const error = result && typeof result === "string" ? result : null;
|
|
647
|
+
errors[name] = error;
|
|
648
|
+
return error;
|
|
649
|
+
}
|
|
650
|
+
function validateAll() {
|
|
651
|
+
if (!opts.validate) return true;
|
|
652
|
+
let allValid = true;
|
|
653
|
+
for (const key of Object.keys(opts.initial)) {
|
|
654
|
+
const err = validateField(key);
|
|
655
|
+
if (err) allValid = false;
|
|
656
|
+
}
|
|
657
|
+
valid.value = allValid;
|
|
658
|
+
return allValid;
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
field(name) {
|
|
662
|
+
let sig = fields.get(name);
|
|
663
|
+
if (!sig) {
|
|
664
|
+
sig = signal(opts.initial[name]);
|
|
665
|
+
fields.set(name, sig);
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
value: sig,
|
|
669
|
+
onInput(e) {
|
|
670
|
+
const target = e.target;
|
|
671
|
+
touched[name] = true;
|
|
672
|
+
if (target.type === "checkbox") {
|
|
673
|
+
sig.value = target.checked;
|
|
674
|
+
} else {
|
|
675
|
+
sig.value = target.value;
|
|
676
|
+
}
|
|
677
|
+
snapshot();
|
|
678
|
+
validateField(name);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
},
|
|
682
|
+
get errors() {
|
|
683
|
+
return errors;
|
|
684
|
+
},
|
|
685
|
+
get touched() {
|
|
686
|
+
return touched;
|
|
687
|
+
},
|
|
688
|
+
valid,
|
|
689
|
+
get values() {
|
|
690
|
+
return values;
|
|
691
|
+
},
|
|
692
|
+
async submit(handler) {
|
|
693
|
+
if (!validateAll()) return;
|
|
694
|
+
const data = {};
|
|
695
|
+
for (const [key, sig] of fields) {
|
|
696
|
+
data[key] = sig.value;
|
|
697
|
+
}
|
|
698
|
+
await handler(data);
|
|
699
|
+
},
|
|
700
|
+
reset() {
|
|
701
|
+
for (const key of Object.keys(opts.initial)) {
|
|
702
|
+
const sig = fields.get(key);
|
|
703
|
+
if (sig) sig.value = opts.initial[key];
|
|
704
|
+
errors[key] = null;
|
|
705
|
+
touched[key] = false;
|
|
706
|
+
}
|
|
707
|
+
valid.value = true;
|
|
708
|
+
snapshot();
|
|
709
|
+
},
|
|
710
|
+
setValue(name, value) {
|
|
711
|
+
const sig = fields.get(name);
|
|
712
|
+
if (sig) sig.value = value;
|
|
713
|
+
snapshot();
|
|
714
|
+
validateField(name);
|
|
715
|
+
},
|
|
716
|
+
setValues(partial) {
|
|
717
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
718
|
+
const sig = fields.get(key);
|
|
719
|
+
if (sig) sig.value = value;
|
|
720
|
+
}
|
|
721
|
+
snapshot();
|
|
722
|
+
validateAll();
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
586
727
|
// src/client/components/LoginForm.ts
|
|
587
728
|
var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
|
|
588
729
|
function LoginForm(_props, ctx) {
|
|
@@ -739,6 +880,7 @@ export {
|
|
|
739
880
|
ApiClient,
|
|
740
881
|
ApiError,
|
|
741
882
|
Chat,
|
|
883
|
+
ErrorBoundary,
|
|
742
884
|
For,
|
|
743
885
|
Fragment,
|
|
744
886
|
LoginForm,
|
|
@@ -748,6 +890,7 @@ export {
|
|
|
748
890
|
auth,
|
|
749
891
|
computed,
|
|
750
892
|
createApp,
|
|
893
|
+
createPortal,
|
|
751
894
|
domMount,
|
|
752
895
|
effect,
|
|
753
896
|
isSignal,
|
|
@@ -756,6 +899,7 @@ export {
|
|
|
756
899
|
jsxs,
|
|
757
900
|
router,
|
|
758
901
|
signal,
|
|
902
|
+
useForm,
|
|
759
903
|
wrap,
|
|
760
904
|
ws
|
|
761
905
|
};
|
|
@@ -13,8 +13,306 @@ import type { WfuiContext } from './types.ts';
|
|
|
13
13
|
declare global {
|
|
14
14
|
namespace JSX {
|
|
15
15
|
interface IntrinsicElements {
|
|
16
|
-
|
|
16
|
+
div: HtmlDiv;
|
|
17
|
+
span: HtmlSpan;
|
|
18
|
+
p: HtmlP;
|
|
19
|
+
h1: HtmlH1;
|
|
20
|
+
h2: HtmlH2;
|
|
21
|
+
h3: HtmlH3;
|
|
22
|
+
h4: HtmlH4;
|
|
23
|
+
h5: HtmlH5;
|
|
24
|
+
h6: HtmlH6;
|
|
25
|
+
header: HtmlHeader;
|
|
26
|
+
footer: HtmlFooter;
|
|
27
|
+
nav: HtmlNav;
|
|
28
|
+
main: HtmlMain;
|
|
29
|
+
section: HtmlSection;
|
|
30
|
+
article: HtmlArticle;
|
|
31
|
+
aside: HtmlAside;
|
|
32
|
+
pre: HtmlPre;
|
|
33
|
+
blockquote: HtmlBlockquote;
|
|
34
|
+
figure: HtmlFigure;
|
|
35
|
+
address: HtmlAddress;
|
|
36
|
+
ul: HtmlUl;
|
|
37
|
+
ol: HtmlOl;
|
|
38
|
+
li: HtmlLi;
|
|
39
|
+
dl: HtmlDl;
|
|
40
|
+
dt: HtmlDt;
|
|
41
|
+
dd: HtmlDd;
|
|
42
|
+
table: HtmlTable;
|
|
43
|
+
thead: HtmlThead;
|
|
44
|
+
tbody: HtmlTbody;
|
|
45
|
+
tr: HtmlTr;
|
|
46
|
+
th: HtmlTh;
|
|
47
|
+
td: HtmlTd;
|
|
48
|
+
form: HtmlForm;
|
|
49
|
+
input: HtmlInput;
|
|
50
|
+
button: HtmlButton;
|
|
51
|
+
select: HtmlSelect;
|
|
52
|
+
option: HtmlOption;
|
|
53
|
+
textarea: HtmlTextarea;
|
|
54
|
+
label: HtmlLabel;
|
|
55
|
+
fieldset: HtmlFieldset;
|
|
56
|
+
legend: HtmlLegend;
|
|
57
|
+
img: HtmlImg;
|
|
58
|
+
video: HtmlVideo;
|
|
59
|
+
audio: HtmlAudio;
|
|
60
|
+
canvas: HtmlCanvas;
|
|
61
|
+
svg: HtmlSvg;
|
|
62
|
+
a: HtmlA;
|
|
63
|
+
link: HtmlLink;
|
|
64
|
+
strong: HtmlStrong;
|
|
65
|
+
em: HtmlEm;
|
|
66
|
+
b: HtmlB;
|
|
67
|
+
i: HtmlI;
|
|
68
|
+
u: HtmlU;
|
|
69
|
+
s: HtmlS;
|
|
70
|
+
mark: HtmlMark;
|
|
71
|
+
code: HtmlCode;
|
|
72
|
+
small: HtmlSmall;
|
|
73
|
+
sub: HtmlSub;
|
|
74
|
+
sup: HtmlSup;
|
|
75
|
+
abbr: HtmlAbbr;
|
|
76
|
+
time: HtmlTime;
|
|
77
|
+
br: HtmlBr;
|
|
78
|
+
hr: HtmlHr;
|
|
79
|
+
wbr: HtmlWbr;
|
|
80
|
+
style: HtmlStyle;
|
|
81
|
+
script: HtmlScript;
|
|
82
|
+
template: HtmlTemplate;
|
|
83
|
+
slot: HtmlSlot;
|
|
84
|
+
details: HtmlDetails;
|
|
85
|
+
summary: HtmlSummary;
|
|
86
|
+
dialog: HtmlDialog;
|
|
87
|
+
iframe: HtmlIframe;
|
|
17
88
|
}
|
|
89
|
+
interface WfuiAttributes<T> {
|
|
90
|
+
class?: string | Signal<string>;
|
|
91
|
+
className?: string | Signal<string>;
|
|
92
|
+
id?: string;
|
|
93
|
+
style?: Record<string, string | undefined>;
|
|
94
|
+
title?: string;
|
|
95
|
+
lang?: string;
|
|
96
|
+
dir?: string;
|
|
97
|
+
hidden?: boolean | Signal<boolean>;
|
|
98
|
+
tabindex?: number;
|
|
99
|
+
accesskey?: string;
|
|
100
|
+
draggable?: boolean;
|
|
101
|
+
contenteditable?: boolean;
|
|
102
|
+
slot?: string;
|
|
103
|
+
spellcheck?: boolean;
|
|
104
|
+
ref?: (el: T) => void;
|
|
105
|
+
onClick?: (e: MouseEvent) => void;
|
|
106
|
+
onDblClick?: (e: MouseEvent) => void;
|
|
107
|
+
onMouseDown?: (e: MouseEvent) => void;
|
|
108
|
+
onMouseUp?: (e: MouseEvent) => void;
|
|
109
|
+
onMouseMove?: (e: MouseEvent) => void;
|
|
110
|
+
onMouseEnter?: (e: MouseEvent) => void;
|
|
111
|
+
onMouseLeave?: (e: MouseEvent) => void;
|
|
112
|
+
onKeyDown?: (e: KeyboardEvent) => void;
|
|
113
|
+
onKeyUp?: (e: KeyboardEvent) => void;
|
|
114
|
+
onKeyPress?: (e: KeyboardEvent) => void;
|
|
115
|
+
onFocus?: (e: FocusEvent) => void;
|
|
116
|
+
onBlur?: (e: FocusEvent) => void;
|
|
117
|
+
onInput?: (e: Event) => void;
|
|
118
|
+
onChange?: (e: Event) => void;
|
|
119
|
+
onSubmit?: (e: Event) => void;
|
|
120
|
+
onScroll?: (e: Event) => void;
|
|
121
|
+
onWheel?: (e: WheelEvent) => void;
|
|
122
|
+
onTouchStart?: (e: TouchEvent) => void;
|
|
123
|
+
onTouchEnd?: (e: TouchEvent) => void;
|
|
124
|
+
onTouchMove?: (e: TouchEvent) => void;
|
|
125
|
+
onLoad?: (e: Event) => void;
|
|
126
|
+
onError?: (e: Event) => void;
|
|
127
|
+
onAnimationEnd?: (e: AnimationEvent) => void;
|
|
128
|
+
onTransitionEnd?: (e: TransitionEvent) => void;
|
|
129
|
+
[data: string]: unknown;
|
|
130
|
+
}
|
|
131
|
+
type HtmlDiv = WfuiAttributes<HTMLDivElement>;
|
|
132
|
+
type HtmlSpan = WfuiAttributes<HTMLSpanElement>;
|
|
133
|
+
type HtmlP = WfuiAttributes<HTMLParagraphElement>;
|
|
134
|
+
type HtmlH1 = WfuiAttributes<HTMLHeadingElement>;
|
|
135
|
+
type HtmlH2 = WfuiAttributes<HTMLHeadingElement>;
|
|
136
|
+
type HtmlH3 = WfuiAttributes<HTMLHeadingElement>;
|
|
137
|
+
type HtmlH4 = WfuiAttributes<HTMLHeadingElement>;
|
|
138
|
+
type HtmlH5 = WfuiAttributes<HTMLHeadingElement>;
|
|
139
|
+
type HtmlH6 = WfuiAttributes<HTMLHeadingElement>;
|
|
140
|
+
type HtmlHeader = WfuiAttributes<HTMLElement>;
|
|
141
|
+
type HtmlFooter = WfuiAttributes<HTMLElement>;
|
|
142
|
+
type HtmlNav = WfuiAttributes<HTMLElement>;
|
|
143
|
+
type HtmlMain = WfuiAttributes<HTMLElement>;
|
|
144
|
+
type HtmlSection = WfuiAttributes<HTMLElement>;
|
|
145
|
+
type HtmlArticle = WfuiAttributes<HTMLElement>;
|
|
146
|
+
type HtmlAside = WfuiAttributes<HTMLElement>;
|
|
147
|
+
type HtmlPre = WfuiAttributes<HTMLPreElement>;
|
|
148
|
+
type HtmlBlockquote = WfuiAttributes<HTMLQuoteElement>;
|
|
149
|
+
type HtmlFigure = WfuiAttributes<HTMLElement>;
|
|
150
|
+
type HtmlAddress = WfuiAttributes<HTMLElement>;
|
|
151
|
+
type HtmlUl = WfuiAttributes<HTMLUListElement>;
|
|
152
|
+
type HtmlOl = WfuiAttributes<HTMLOListElement>;
|
|
153
|
+
type HtmlLi = WfuiAttributes<HTMLLIElement>;
|
|
154
|
+
type HtmlDl = WfuiAttributes<HTMLDListElement>;
|
|
155
|
+
type HtmlDt = WfuiAttributes<HTMLElement>;
|
|
156
|
+
type HtmlDd = WfuiAttributes<HTMLElement>;
|
|
157
|
+
type HtmlTable = WfuiAttributes<HTMLTableElement>;
|
|
158
|
+
type HtmlThead = WfuiAttributes<HTMLTableSectionElement>;
|
|
159
|
+
type HtmlTbody = WfuiAttributes<HTMLTableSectionElement>;
|
|
160
|
+
type HtmlTr = WfuiAttributes<HTMLTableRowElement>;
|
|
161
|
+
type HtmlTh = WfuiAttributes<HTMLTableCellElement>;
|
|
162
|
+
type HtmlTd = WfuiAttributes<HTMLTableCellElement>;
|
|
163
|
+
interface HtmlForm extends WfuiAttributes<HTMLFormElement> {
|
|
164
|
+
action?: string;
|
|
165
|
+
method?: string;
|
|
166
|
+
enctype?: string;
|
|
167
|
+
novalidate?: boolean;
|
|
168
|
+
target?: string;
|
|
169
|
+
}
|
|
170
|
+
interface HtmlInput extends WfuiAttributes<HTMLInputElement> {
|
|
171
|
+
type?: string;
|
|
172
|
+
value?: string | Signal<string>;
|
|
173
|
+
placeholder?: string;
|
|
174
|
+
checked?: boolean | Signal<boolean>;
|
|
175
|
+
disabled?: boolean | Signal<boolean>;
|
|
176
|
+
readonly?: boolean;
|
|
177
|
+
required?: boolean;
|
|
178
|
+
autofocus?: boolean;
|
|
179
|
+
autocomplete?: string;
|
|
180
|
+
name?: string;
|
|
181
|
+
min?: string | number;
|
|
182
|
+
max?: string | number;
|
|
183
|
+
step?: number;
|
|
184
|
+
pattern?: string;
|
|
185
|
+
accept?: string;
|
|
186
|
+
multiple?: boolean;
|
|
187
|
+
src?: string;
|
|
188
|
+
alt?: string;
|
|
189
|
+
}
|
|
190
|
+
interface HtmlButton extends WfuiAttributes<HTMLButtonElement> {
|
|
191
|
+
type?: 'button' | 'submit' | 'reset';
|
|
192
|
+
disabled?: boolean | Signal<boolean>;
|
|
193
|
+
name?: string;
|
|
194
|
+
value?: string;
|
|
195
|
+
}
|
|
196
|
+
interface HtmlSelect extends WfuiAttributes<HTMLSelectElement> {
|
|
197
|
+
value?: string | Signal<string>;
|
|
198
|
+
disabled?: boolean;
|
|
199
|
+
name?: string;
|
|
200
|
+
required?: boolean;
|
|
201
|
+
multiple?: boolean;
|
|
202
|
+
}
|
|
203
|
+
interface HtmlOption extends WfuiAttributes<HTMLOptionElement> {
|
|
204
|
+
value?: string;
|
|
205
|
+
selected?: boolean;
|
|
206
|
+
disabled?: boolean;
|
|
207
|
+
label?: string;
|
|
208
|
+
}
|
|
209
|
+
interface HtmlTextarea extends WfuiAttributes<HTMLTextAreaElement> {
|
|
210
|
+
value?: string | Signal<string>;
|
|
211
|
+
placeholder?: string;
|
|
212
|
+
disabled?: boolean;
|
|
213
|
+
readonly?: boolean;
|
|
214
|
+
required?: boolean;
|
|
215
|
+
rows?: number;
|
|
216
|
+
cols?: number;
|
|
217
|
+
autofocus?: boolean;
|
|
218
|
+
name?: string;
|
|
219
|
+
}
|
|
220
|
+
interface HtmlLabel extends WfuiAttributes<HTMLLabelElement> {
|
|
221
|
+
htmlFor?: string;
|
|
222
|
+
}
|
|
223
|
+
interface HtmlFieldset extends WfuiAttributes<HTMLFieldSetElement> {
|
|
224
|
+
disabled?: boolean;
|
|
225
|
+
name?: string;
|
|
226
|
+
}
|
|
227
|
+
type HtmlLegend = WfuiAttributes<HTMLLegendElement>;
|
|
228
|
+
interface HtmlA extends WfuiAttributes<HTMLAnchorElement> {
|
|
229
|
+
href?: string;
|
|
230
|
+
target?: string;
|
|
231
|
+
rel?: string;
|
|
232
|
+
download?: string;
|
|
233
|
+
}
|
|
234
|
+
interface HtmlImg extends WfuiAttributes<HTMLImageElement> {
|
|
235
|
+
src?: string | Signal<string>;
|
|
236
|
+
alt?: string;
|
|
237
|
+
width?: number | string;
|
|
238
|
+
height?: number | string;
|
|
239
|
+
loading?: 'lazy' | 'eager';
|
|
240
|
+
srcset?: string;
|
|
241
|
+
sizes?: string;
|
|
242
|
+
}
|
|
243
|
+
interface HtmlVideo extends WfuiAttributes<HTMLVideoElement> {
|
|
244
|
+
src?: string;
|
|
245
|
+
controls?: boolean;
|
|
246
|
+
autoplay?: boolean;
|
|
247
|
+
loop?: boolean;
|
|
248
|
+
muted?: boolean;
|
|
249
|
+
poster?: string;
|
|
250
|
+
width?: number | string;
|
|
251
|
+
height?: number | string;
|
|
252
|
+
}
|
|
253
|
+
interface HtmlAudio extends WfuiAttributes<HTMLAudioElement> {
|
|
254
|
+
src?: string;
|
|
255
|
+
controls?: boolean;
|
|
256
|
+
autoplay?: boolean;
|
|
257
|
+
loop?: boolean;
|
|
258
|
+
muted?: boolean;
|
|
259
|
+
}
|
|
260
|
+
type HtmlCanvas = WfuiAttributes<HTMLCanvasElement> & {
|
|
261
|
+
width?: number;
|
|
262
|
+
height?: number;
|
|
263
|
+
};
|
|
264
|
+
type HtmlSvg = WfuiAttributes<SVGSVGElement> & {
|
|
265
|
+
viewBox?: string;
|
|
266
|
+
xmlns?: string;
|
|
267
|
+
fill?: string;
|
|
268
|
+
width?: string | number;
|
|
269
|
+
height?: string | number;
|
|
270
|
+
};
|
|
271
|
+
interface HtmlLink extends WfuiAttributes<HTMLLinkElement> {
|
|
272
|
+
rel?: string;
|
|
273
|
+
href?: string;
|
|
274
|
+
type?: string;
|
|
275
|
+
media?: string;
|
|
276
|
+
}
|
|
277
|
+
interface HtmlStyle extends WfuiAttributes<HTMLStyleElement> {
|
|
278
|
+
scoped?: boolean;
|
|
279
|
+
media?: string;
|
|
280
|
+
}
|
|
281
|
+
interface HtmlScript extends WfuiAttributes<HTMLScriptElement> {
|
|
282
|
+
src?: string;
|
|
283
|
+
type?: string;
|
|
284
|
+
async?: boolean;
|
|
285
|
+
defer?: boolean;
|
|
286
|
+
}
|
|
287
|
+
type HtmlStrong = WfuiAttributes<HTMLElement>;
|
|
288
|
+
type HtmlEm = WfuiAttributes<HTMLElement>;
|
|
289
|
+
type HtmlB = WfuiAttributes<HTMLElement>;
|
|
290
|
+
type HtmlI = WfuiAttributes<HTMLElement>;
|
|
291
|
+
type HtmlU = WfuiAttributes<HTMLElement>;
|
|
292
|
+
type HtmlS = WfuiAttributes<HTMLElement>;
|
|
293
|
+
type HtmlMark = WfuiAttributes<HTMLElement>;
|
|
294
|
+
type HtmlCode = WfuiAttributes<HTMLElement>;
|
|
295
|
+
type HtmlSmall = WfuiAttributes<HTMLElement>;
|
|
296
|
+
type HtmlSub = WfuiAttributes<HTMLElement>;
|
|
297
|
+
type HtmlSup = WfuiAttributes<HTMLElement>;
|
|
298
|
+
type HtmlAbbr = WfuiAttributes<HTMLElement>;
|
|
299
|
+
type HtmlTime = WfuiAttributes<HTMLElement> & {
|
|
300
|
+
datetime?: string;
|
|
301
|
+
};
|
|
302
|
+
type HtmlBr = WfuiAttributes<HTMLBRElement>;
|
|
303
|
+
type HtmlHr = WfuiAttributes<HTMLHRElement>;
|
|
304
|
+
type HtmlWbr = WfuiAttributes<HTMLElement>;
|
|
305
|
+
type HtmlTemplate = WfuiAttributes<HTMLTemplateElement>;
|
|
306
|
+
type HtmlSlot = WfuiAttributes<HTMLSlotElement>;
|
|
307
|
+
type HtmlDetails = WfuiAttributes<HTMLDetailsElement>;
|
|
308
|
+
type HtmlSummary = WfuiAttributes<HTMLElement>;
|
|
309
|
+
type HtmlDialog = WfuiAttributes<HTMLDialogElement>;
|
|
310
|
+
type HtmlIframe = WfuiAttributes<HTMLIFrameElement> & {
|
|
311
|
+
src?: string;
|
|
312
|
+
name?: string;
|
|
313
|
+
width?: string;
|
|
314
|
+
height?: string;
|
|
315
|
+
};
|
|
18
316
|
}
|
|
19
317
|
}
|
|
20
318
|
export declare function setCtx(ctx: WfuiContext | null): void;
|
|
@@ -58,6 +356,42 @@ export declare function Fragment(props: Record<string, unknown> | null, ...child
|
|
|
58
356
|
* 直接挂载 DOM(低层 API,一般用 createApp().mount())
|
|
59
357
|
*/
|
|
60
358
|
export declare function domMount(root: string | Element, app: Node): void;
|
|
359
|
+
/**
|
|
360
|
+
* ErrorBoundary — 捕获子组件渲染时的异常。
|
|
361
|
+
*
|
|
362
|
+
* children 必须是 thunk(函数),延迟执行以便捕获错误。
|
|
363
|
+
*
|
|
364
|
+
* ```tsx
|
|
365
|
+
* <ErrorBoundary fallback={(e) => <p>出错了: {e.message}</p>}>
|
|
366
|
+
* {() => <Dashboard />}
|
|
367
|
+
* </ErrorBoundary>
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
export declare function ErrorBoundary({ fallback, children }: {
|
|
371
|
+
fallback: (error: Error) => Node;
|
|
372
|
+
children: () => Node;
|
|
373
|
+
}, _ctx: WfuiContext): Node;
|
|
374
|
+
/**
|
|
375
|
+
* Portal — 将组件渲染到父容器之外的 DOM 位置。
|
|
376
|
+
*
|
|
377
|
+
* 适用于 Modal、Dropdown、Tooltip 等需要突破 overflow / z-index 的场景。
|
|
378
|
+
*
|
|
379
|
+
* ```tsx
|
|
380
|
+
* import { createPortal } from 'weifuwu/client'
|
|
381
|
+
*
|
|
382
|
+
* function Modal({ show, children }) {
|
|
383
|
+
* return <Show when={show}>
|
|
384
|
+
* {createPortal(
|
|
385
|
+
* <div class="fixed inset-0 bg-black/50 flex items-center justify-center">
|
|
386
|
+
* {children}
|
|
387
|
+
* </div>,
|
|
388
|
+
* document.body
|
|
389
|
+
* )}
|
|
390
|
+
* </Show>
|
|
391
|
+
* }
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
export declare function createPortal(node: Node, target: Element): Node;
|
|
61
395
|
/**
|
|
62
396
|
* 条件渲染 — when 为 Signal 时响应式切换
|
|
63
397
|
*
|
|
@@ -184,6 +184,17 @@ function domMount(root, app) {
|
|
|
184
184
|
container.innerHTML = "";
|
|
185
185
|
container.appendChild(app);
|
|
186
186
|
}
|
|
187
|
+
function ErrorBoundary({ fallback, children }, _ctx) {
|
|
188
|
+
try {
|
|
189
|
+
return children();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return fallback(e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function createPortal(node, target) {
|
|
195
|
+
target.appendChild(node);
|
|
196
|
+
return document.createDocumentFragment();
|
|
197
|
+
}
|
|
187
198
|
function toNode(v) {
|
|
188
199
|
if (v instanceof Node) return v;
|
|
189
200
|
if (typeof v === "function") return toNode(v());
|
|
@@ -232,7 +243,8 @@ function createApp() {
|
|
|
232
243
|
params: {},
|
|
233
244
|
query: Object.fromEntries(new URLSearchParams(window.location.search)),
|
|
234
245
|
hash: window.location.hash,
|
|
235
|
-
component: null
|
|
246
|
+
component: null,
|
|
247
|
+
data: {}
|
|
236
248
|
},
|
|
237
249
|
app: {
|
|
238
250
|
navigate(path) {
|
|
@@ -308,56 +320,79 @@ function router(opts) {
|
|
|
308
320
|
}).join("/") + "$";
|
|
309
321
|
return { re: new RegExp(reStr), keys, route };
|
|
310
322
|
});
|
|
311
|
-
function
|
|
312
|
-
for (const
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
323
|
+
function matchRoute(path) {
|
|
324
|
+
for (const m of matchers) {
|
|
325
|
+
const result = path.match(m.re);
|
|
326
|
+
if (!result) continue;
|
|
327
|
+
const params = {};
|
|
328
|
+
m.keys.forEach((k, i) => {
|
|
329
|
+
params[k] = decodeURIComponent(result[i + 1]);
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
matched: { component: m.route.component, params, title: m.route.title, auth: m.route.auth },
|
|
333
|
+
routeDef: m.route
|
|
334
|
+
};
|
|
321
335
|
}
|
|
322
336
|
return null;
|
|
323
337
|
}
|
|
324
338
|
return (ctx) => {
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
function emit(path) {
|
|
340
|
+
window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
|
|
341
|
+
}
|
|
342
|
+
function resolve(path) {
|
|
343
|
+
const raw = mode === "hash" ? path || "/" : path;
|
|
344
|
+
const [cleanPath, qs] = raw.split("?");
|
|
328
345
|
ctx.route.query = Object.fromEntries(new URLSearchParams(qs ?? ""));
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
ctx.route.params = matched.params;
|
|
333
|
-
ctx.route.component = matched.component;
|
|
334
|
-
ctx.route.title = matched.title;
|
|
335
|
-
ctx.route.auth = matched.auth;
|
|
336
|
-
if (matched.title) document.title = matched.title;
|
|
337
|
-
if (matched.auth && !ctx.user) {
|
|
338
|
-
setTimeout(() => ctx.app.navigate("/login"), 0);
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
} else {
|
|
346
|
+
ctx.route.path = cleanPath;
|
|
347
|
+
const r = matchRoute(cleanPath);
|
|
348
|
+
if (!r) {
|
|
342
349
|
ctx.route.component = opts.notFound ?? null;
|
|
350
|
+
ctx.route.data = {};
|
|
351
|
+
return { component: null };
|
|
352
|
+
}
|
|
353
|
+
ctx.route.params = r.matched.params;
|
|
354
|
+
ctx.route.component = r.matched.component;
|
|
355
|
+
ctx.route.title = r.matched.title;
|
|
356
|
+
ctx.route.auth = r.matched.auth;
|
|
357
|
+
if (r.matched.title) document.title = r.matched.title;
|
|
358
|
+
return { component: r.matched.component, routeDef: r.routeDef };
|
|
359
|
+
}
|
|
360
|
+
function navigateAndLoad(path) {
|
|
361
|
+
const { routeDef } = resolve(path);
|
|
362
|
+
if (ctx.route.auth && !ctx.user) {
|
|
363
|
+
setTimeout(() => ctx.app.navigate("/login"), 0);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
emit(ctx.route.path);
|
|
367
|
+
if (routeDef?.loader) {
|
|
368
|
+
routeDef.loader(ctx).then((data) => {
|
|
369
|
+
ctx.route.data = data;
|
|
370
|
+
emit(ctx.route.path);
|
|
371
|
+
}).catch(() => {
|
|
372
|
+
ctx.route.data = {};
|
|
373
|
+
emit(ctx.route.path);
|
|
374
|
+
});
|
|
343
375
|
}
|
|
344
|
-
window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
|
|
345
376
|
}
|
|
346
|
-
const origNavigate = ctx.app.navigate;
|
|
347
377
|
ctx.app.navigate = (path) => {
|
|
348
378
|
if (mode === "hash") {
|
|
349
379
|
window.location.hash = "#" + path;
|
|
350
380
|
} else {
|
|
351
381
|
window.history.pushState({}, "", path);
|
|
352
|
-
|
|
382
|
+
navigateAndLoad(path);
|
|
353
383
|
}
|
|
354
384
|
};
|
|
355
385
|
if (mode === "hash") {
|
|
356
|
-
window.addEventListener("hashchange",
|
|
386
|
+
window.addEventListener("hashchange", () => {
|
|
387
|
+
navigateAndLoad(window.location.hash.slice(1) || "/");
|
|
388
|
+
});
|
|
357
389
|
} else {
|
|
358
|
-
window.addEventListener("popstate",
|
|
390
|
+
window.addEventListener("popstate", () => {
|
|
391
|
+
navigateAndLoad(window.location.pathname + window.location.search);
|
|
392
|
+
});
|
|
359
393
|
}
|
|
360
|
-
|
|
394
|
+
const initialPath = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
|
|
395
|
+
navigateAndLoad(initialPath);
|
|
361
396
|
return ctx;
|
|
362
397
|
};
|
|
363
398
|
}
|
|
@@ -583,6 +618,112 @@ function ws(opts = {}) {
|
|
|
583
618
|
};
|
|
584
619
|
}
|
|
585
620
|
|
|
621
|
+
// src/client/lib/form.ts
|
|
622
|
+
function useForm(opts) {
|
|
623
|
+
const fields = /* @__PURE__ */ new Map();
|
|
624
|
+
const errors = {};
|
|
625
|
+
const touched = {};
|
|
626
|
+
for (const key of Object.keys(opts.initial)) {
|
|
627
|
+
fields.set(key, signal(opts.initial[key]));
|
|
628
|
+
errors[key] = null;
|
|
629
|
+
touched[key] = false;
|
|
630
|
+
}
|
|
631
|
+
const valid = signal(true);
|
|
632
|
+
const values = signal({ ...opts.initial });
|
|
633
|
+
function snapshot() {
|
|
634
|
+
const data = {};
|
|
635
|
+
for (const [key, sig] of fields) {
|
|
636
|
+
data[key] = sig.value;
|
|
637
|
+
}
|
|
638
|
+
values.value = data;
|
|
639
|
+
}
|
|
640
|
+
function validateField(name) {
|
|
641
|
+
if (!opts.validate) return null;
|
|
642
|
+
const rule = opts.validate[name];
|
|
643
|
+
if (!rule) return null;
|
|
644
|
+
const sig = fields.get(name);
|
|
645
|
+
const result = rule(sig.value, values.value);
|
|
646
|
+
const error = result && typeof result === "string" ? result : null;
|
|
647
|
+
errors[name] = error;
|
|
648
|
+
return error;
|
|
649
|
+
}
|
|
650
|
+
function validateAll() {
|
|
651
|
+
if (!opts.validate) return true;
|
|
652
|
+
let allValid = true;
|
|
653
|
+
for (const key of Object.keys(opts.initial)) {
|
|
654
|
+
const err = validateField(key);
|
|
655
|
+
if (err) allValid = false;
|
|
656
|
+
}
|
|
657
|
+
valid.value = allValid;
|
|
658
|
+
return allValid;
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
field(name) {
|
|
662
|
+
let sig = fields.get(name);
|
|
663
|
+
if (!sig) {
|
|
664
|
+
sig = signal(opts.initial[name]);
|
|
665
|
+
fields.set(name, sig);
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
value: sig,
|
|
669
|
+
onInput(e) {
|
|
670
|
+
const target = e.target;
|
|
671
|
+
touched[name] = true;
|
|
672
|
+
if (target.type === "checkbox") {
|
|
673
|
+
sig.value = target.checked;
|
|
674
|
+
} else {
|
|
675
|
+
sig.value = target.value;
|
|
676
|
+
}
|
|
677
|
+
snapshot();
|
|
678
|
+
validateField(name);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
},
|
|
682
|
+
get errors() {
|
|
683
|
+
return errors;
|
|
684
|
+
},
|
|
685
|
+
get touched() {
|
|
686
|
+
return touched;
|
|
687
|
+
},
|
|
688
|
+
valid,
|
|
689
|
+
get values() {
|
|
690
|
+
return values;
|
|
691
|
+
},
|
|
692
|
+
async submit(handler) {
|
|
693
|
+
if (!validateAll()) return;
|
|
694
|
+
const data = {};
|
|
695
|
+
for (const [key, sig] of fields) {
|
|
696
|
+
data[key] = sig.value;
|
|
697
|
+
}
|
|
698
|
+
await handler(data);
|
|
699
|
+
},
|
|
700
|
+
reset() {
|
|
701
|
+
for (const key of Object.keys(opts.initial)) {
|
|
702
|
+
const sig = fields.get(key);
|
|
703
|
+
if (sig) sig.value = opts.initial[key];
|
|
704
|
+
errors[key] = null;
|
|
705
|
+
touched[key] = false;
|
|
706
|
+
}
|
|
707
|
+
valid.value = true;
|
|
708
|
+
snapshot();
|
|
709
|
+
},
|
|
710
|
+
setValue(name, value) {
|
|
711
|
+
const sig = fields.get(name);
|
|
712
|
+
if (sig) sig.value = value;
|
|
713
|
+
snapshot();
|
|
714
|
+
validateField(name);
|
|
715
|
+
},
|
|
716
|
+
setValues(partial) {
|
|
717
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
718
|
+
const sig = fields.get(key);
|
|
719
|
+
if (sig) sig.value = value;
|
|
720
|
+
}
|
|
721
|
+
snapshot();
|
|
722
|
+
validateAll();
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
586
727
|
// src/client/components/LoginForm.ts
|
|
587
728
|
var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
|
|
588
729
|
function LoginForm(_props, ctx) {
|
|
@@ -739,6 +880,7 @@ export {
|
|
|
739
880
|
ApiClient,
|
|
740
881
|
ApiError,
|
|
741
882
|
Chat,
|
|
883
|
+
ErrorBoundary,
|
|
742
884
|
For,
|
|
743
885
|
Fragment,
|
|
744
886
|
LoginForm,
|
|
@@ -748,6 +890,7 @@ export {
|
|
|
748
890
|
auth,
|
|
749
891
|
computed,
|
|
750
892
|
createApp,
|
|
893
|
+
createPortal,
|
|
751
894
|
domMount,
|
|
752
895
|
effect,
|
|
753
896
|
isSignal,
|
|
@@ -756,6 +899,7 @@ export {
|
|
|
756
899
|
jsxs,
|
|
757
900
|
router,
|
|
758
901
|
signal,
|
|
902
|
+
useForm,
|
|
759
903
|
wrap,
|
|
760
904
|
ws
|
|
761
905
|
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useForm — 表单状态管理
|
|
3
|
+
*
|
|
4
|
+
* 自动绑定字段信号、验证、提交。
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* const form = useForm({
|
|
8
|
+
* initial: { email: '', password: '' },
|
|
9
|
+
* validate: {
|
|
10
|
+
* email: (v) => !v.includes('@') && '请输入有效邮箱',
|
|
11
|
+
* },
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* // 在 JSX 中
|
|
15
|
+
* <input {...form.field('email')} placeholder="邮箱" />
|
|
16
|
+
* {form.errors.email && <span class="text-red-500">{form.errors.email}</span>}
|
|
17
|
+
*
|
|
18
|
+
* <button onClick={() => form.submit((data) => ctx.login(data.email, data.password))}>
|
|
19
|
+
* 登录
|
|
20
|
+
* </button>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { type Signal } from '../signal.ts';
|
|
24
|
+
export type ValidationRule<T> = {
|
|
25
|
+
[K in keyof T]?: (value: T[K], values: T) => string | false | undefined | null;
|
|
26
|
+
};
|
|
27
|
+
export interface UseFormOptions<T extends Record<string, unknown>> {
|
|
28
|
+
initial: T;
|
|
29
|
+
validate?: ValidationRule<T>;
|
|
30
|
+
}
|
|
31
|
+
export interface UseFormResult<T extends Record<string, unknown>> {
|
|
32
|
+
/** 绑定到 input 的属性:value + onInput */
|
|
33
|
+
field: (name: keyof T) => {
|
|
34
|
+
value: Signal<T[keyof T]>;
|
|
35
|
+
onInput: (e: Event) => void;
|
|
36
|
+
};
|
|
37
|
+
/** 字段错误信息(空字符串表示无错误) */
|
|
38
|
+
errors: Record<keyof T, string | null>;
|
|
39
|
+
/** 字段是否已 touched(用户操作过) */
|
|
40
|
+
touched: Record<keyof T, boolean>;
|
|
41
|
+
/** 整个表单是否有效 */
|
|
42
|
+
valid: Signal<boolean>;
|
|
43
|
+
/** 表单数据快照(只读) */
|
|
44
|
+
values: Signal<T>;
|
|
45
|
+
/** 提交 — 先验证,验证通过后调用 handler */
|
|
46
|
+
submit: (handler: (data: T) => void | Promise<void>) => Promise<void>;
|
|
47
|
+
/** 重置到初始值 */
|
|
48
|
+
reset: () => void;
|
|
49
|
+
/** 设置单个字段值 */
|
|
50
|
+
setValue: <K extends keyof T>(name: K, value: T[K]) => void;
|
|
51
|
+
/** 批量设置字段值 */
|
|
52
|
+
setValues: (partial: Partial<T>) => void;
|
|
53
|
+
}
|
|
54
|
+
export declare function useForm<T extends Record<string, unknown>>(opts: UseFormOptions<T>): UseFormResult<T>;
|
package/dist/client/router.d.ts
CHANGED
|
@@ -1,51 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* weifuwu/client router — 路由中间件 + RouteView 组件
|
|
3
|
-
*
|
|
4
|
-
* ```tsx
|
|
5
|
-
* import { createApp, router, RouteView } from 'weifuwu/client'
|
|
6
|
-
*
|
|
7
|
-
* const app = createApp()
|
|
8
|
-
* app.use(router({
|
|
9
|
-
* routes: [
|
|
10
|
-
* { path: '/', component: HomePage },
|
|
11
|
-
* { path: '/chat/:id', component: ChatPage },
|
|
12
|
-
* ],
|
|
13
|
-
* }))
|
|
14
|
-
*
|
|
15
|
-
* function AppShell(_, ctx) {
|
|
16
|
-
* return <div><Header /><RouteView /></div>
|
|
17
|
-
* }
|
|
18
|
-
* app.mount('#root', AppShell)
|
|
19
|
-
* ```
|
|
20
3
|
*/
|
|
21
4
|
import type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
|
|
22
5
|
import type { Component } from './jsx-runtime.ts';
|
|
23
6
|
export interface RouterOptions {
|
|
24
|
-
/** 路由模式,默认 'hash' */
|
|
25
7
|
mode?: 'hash' | 'history';
|
|
26
|
-
/** 路由表 */
|
|
27
8
|
routes: RouteDef[];
|
|
28
|
-
/** 404 组件 */
|
|
29
9
|
notFound?: Component;
|
|
30
10
|
}
|
|
31
11
|
/**
|
|
32
|
-
* 路由中间件 — 注入 ctx.route
|
|
12
|
+
* 路由中间件 — 注入 ctx.route + 改写 ctx.app.navigate
|
|
33
13
|
*/
|
|
34
14
|
export declare function router(opts: RouterOptions): AppMiddleware;
|
|
35
15
|
/**
|
|
36
16
|
* RouteView — 渲染当前路由匹配的组件
|
|
37
|
-
*
|
|
38
|
-
* 放在布局组件中,URL 变化时自动切换内容。
|
|
39
|
-
*
|
|
40
|
-
* ```tsx
|
|
41
|
-
* function AppShell(_, ctx) {
|
|
42
|
-
* return (
|
|
43
|
-
* <div class="app">
|
|
44
|
-
* <Header user={ctx.user} />
|
|
45
|
-
* <main><RouteView /></main>
|
|
46
|
-
* </div>
|
|
47
|
-
* )
|
|
48
|
-
* }
|
|
49
|
-
* ```
|
|
50
17
|
*/
|
|
51
18
|
export declare function RouteView(_props: {}, ctx: WfuiContext): Node;
|
package/dist/client/types.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface WfuiContext {
|
|
|
24
24
|
component: Component | null;
|
|
25
25
|
title?: string;
|
|
26
26
|
auth?: boolean;
|
|
27
|
+
/** 路由 loader 返回的数据 */
|
|
28
|
+
data: Record<string, unknown>;
|
|
27
29
|
};
|
|
28
30
|
app: {
|
|
29
31
|
navigate: (path: string) => void;
|
|
@@ -72,4 +74,6 @@ export interface RouteDef {
|
|
|
72
74
|
component: Component;
|
|
73
75
|
auth?: boolean;
|
|
74
76
|
title?: string;
|
|
77
|
+
/** 页面加载器 — 切换路由时自动调用,结果注入 ctx.route.data */
|
|
78
|
+
loader?: (ctx: WfuiContext) => Promise<Record<string, unknown>>;
|
|
75
79
|
}
|
package/dist/index.js
CHANGED
|
@@ -1457,7 +1457,16 @@ function ui() {
|
|
|
1457
1457
|
headers: { "Content-Type": "text/css; charset=utf-8" }
|
|
1458
1458
|
});
|
|
1459
1459
|
}
|
|
1460
|
-
|
|
1460
|
+
let code = await readFile2(absPath, "utf-8");
|
|
1461
|
+
try {
|
|
1462
|
+
const postcss = await import("postcss");
|
|
1463
|
+
const tw = await import("@tailwindcss/postcss");
|
|
1464
|
+
const plugin = tw.default || tw;
|
|
1465
|
+
const instance = typeof plugin === "function" ? plugin() : plugin;
|
|
1466
|
+
const result = await postcss.default([instance]).process(code, { from: absPath });
|
|
1467
|
+
code = result.css;
|
|
1468
|
+
} catch {
|
|
1469
|
+
}
|
|
1461
1470
|
cssCache.set(absPath, { code, mtime: stat.mtimeMs });
|
|
1462
1471
|
return new Response(code, {
|
|
1463
1472
|
headers: { "Content-Type": "text/css; charset=utf-8" }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weifuwu",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.33.
|
|
4
|
+
"version": "0.33.6",
|
|
5
5
|
"description": "AI SaaS framework — (req, ctx) => Response",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -32,10 +32,13 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@graphql-tools/schema": "^10",
|
|
35
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
35
36
|
"ai": "^7.0.14",
|
|
36
37
|
"graphql": "^16",
|
|
37
38
|
"ioredis": "^5.11.0",
|
|
39
|
+
"postcss": "^8.5.19",
|
|
38
40
|
"postgres": "^3.4.9",
|
|
41
|
+
"tailwindcss": "^4.3.3",
|
|
39
42
|
"ws": "^8"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|