@axewo/fin-micro 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +82 -3
  2. package/cli/index.mjs +22 -6
  3. package/dist/core.cjs +1 -1
  4. package/dist/core.mjs +131 -127
  5. package/dist/layout.cjs +1 -0
  6. package/dist/layout.css +1 -0
  7. package/dist/layout.mjs +719 -0
  8. package/dist/types/layout/HostShell.vue.d.ts +528 -0
  9. package/dist/types/layout/ShellHeader.vue.d.ts +46 -0
  10. package/dist/types/layout/ShellMenu.vue.d.ts +12 -0
  11. package/dist/types/layout/ShellTabs.vue.d.ts +23 -0
  12. package/dist/types/layout/StandaloneMenuItem.vue.d.ts +11 -0
  13. package/dist/types/layout/StandaloneShell.vue.d.ts +40 -0
  14. package/dist/types/layout/index.d.ts +5 -0
  15. package/dist/types/layout/types.d.ts +65 -0
  16. package/dist/types/sdk/index.d.ts +2 -0
  17. package/dist/types/watermark/FinWatermark.vue.d.ts +16 -0
  18. package/dist/types/watermark/config.d.ts +36 -0
  19. package/dist/types/watermark/index.d.ts +4 -0
  20. package/dist/watermark.cjs +2 -0
  21. package/dist/watermark.mjs +119 -0
  22. package/package.json +28 -3
  23. package/scaffold/base/index.html +2 -1
  24. package/scaffold/base/pnpm-workspace.yaml +6 -0
  25. package/scaffold/base/src/router/demo.ts +1 -1
  26. package/scaffold/base/src/utils/errorUtils.ts +19 -2
  27. package/scaffold/base/src/utils/watermarkConfig.ts +18 -0
  28. package/scaffold/base/src/views/{DemoView.vue → demo/index.vue} +1 -1
  29. package/scaffold/host/src/layout/HostMenu.vue +10 -2
  30. package/scaffold/host/src/layout/HostTabs.vue +55 -5
  31. package/scaffold/host/src/layout/HostWorkspace.vue +12 -2
  32. package/scaffold/host/src/localPages.ts +2 -2
  33. package/scaffold/host/src/platform.ts +12 -6
  34. package/scaffold/host/src/router/index.ts +3 -3
  35. package/scaffold/host/src/styles/shell.css +16 -1
  36. package/scaffold/host/src/views/{LoginView.vue → login/components/LoginForm.vue} +7 -12
  37. package/scaffold/host/src/views/{LoginRoute.vue → login/index.vue} +5 -5
  38. package/scaffold/host/src/views/{ProfileView.vue → profile/index.vue} +1 -1
  39. package/scaffold/host/src/views/{WorkspaceRoute.vue → workspace/index.vue} +3 -3
  40. package/scaffold/layouts/standard/host/src/layout/HostLayout.vue +116 -0
  41. package/scaffold/layouts/standard/host/src/styles/shell.css +168 -0
  42. package/scaffold/layouts/standard/vue/src/layout/StandaloneLayout.vue +26 -0
  43. package/scaffold/vue/src/App.vue +13 -7
  44. package/scaffold/vue/src/bootstrap.ts +7 -2
  45. package/scaffold/vue/src/layout/Header.vue +15 -0
  46. package/scaffold/vue/src/layout/Sidebar.vue +21 -0
  47. package/scaffold/vue/src/layout/SidebarItem.vue +21 -0
  48. package/scaffold/vue/src/layout/StandaloneLayout.vue +34 -0
  49. package/scaffold/vue/src/router/index.ts +8 -5
  50. package/scaffold/vue/src/router/menu.ts +38 -0
  51. package/scaffold/vue/src/views/demo/configs/table.ts +24 -0
  52. package/scaffold/vue/src/views/demo/hooks/useRecordNavigation.ts +32 -0
  53. package/scaffold/vue/src/views/demo/index.vue +17 -0
  54. package/scaffold/vue/src/views/{DetailView.vue → demo/modules/detail/index.vue} +2 -2
  55. package/scaffold/vue/src/views/demo/types.ts +5 -0
  56. package/scaffold/vue/src/views/ListView.vue +0 -55
  57. /package/scaffold/host/src/views/{HomeView.vue → home/index.vue} +0 -0
  58. /package/scaffold/host/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
  59. /package/scaffold/vue/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
@@ -0,0 +1,40 @@
1
+ import { ShellBrand, ShellMenuItem } from './types';
2
+ /** 独立外壳仅展示路由适配结果;不创建 router、认证或 bridge,也不自行判断嵌入环境。 */
3
+ type __VLS_Props = {
4
+ title: string;
5
+ menus: readonly ShellMenuItem[];
6
+ activeMenu: string;
7
+ brand?: ShellBrand;
8
+ };
9
+ /** 导航由项目处理并捕获失败;重置选中项,以真实路由而非点击结果为准。 */
10
+ declare function navigate(path: string): Promise<void>;
11
+ declare function __VLS_template(): {
12
+ attrs: Partial<{}>;
13
+ slots: {
14
+ brand?(_: {
15
+ brand: ShellBrand;
16
+ }): any;
17
+ 'header-actions'?(_: {}): any;
18
+ sidebar?(_: {
19
+ menus: readonly ShellMenuItem[];
20
+ navigate: typeof navigate;
21
+ }): any;
22
+ default?(_: {}): any;
23
+ 'workspace-overlay'?(_: {}): any;
24
+ };
25
+ refs: {};
26
+ rootEl: HTMLDivElement;
27
+ };
28
+ type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
29
+ declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
30
+ navigate: (path: string) => any;
31
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
32
+ onNavigate?: ((path: string) => any) | undefined;
33
+ }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
34
+ declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
35
+ export default _default;
36
+ type __VLS_WithTemplateSlots<T, S> = T & {
37
+ new (): {
38
+ $slots: S;
39
+ };
40
+ };
@@ -0,0 +1,5 @@
1
+ export { default as HostShell } from './HostShell.vue';
2
+ export { default as StandaloneShell } from './StandaloneShell.vue';
3
+ export type { ShellBrand, ShellMenuItem, HostShellProps, HostShellActions, HostShellSlots } from './types';
4
+ /** 公共外壳模板协议,用于发布前避免新模板与旧 dist 混装。 */
5
+ export declare const layoutProtocol = 1;
@@ -0,0 +1,65 @@
1
+ import { Component, VNode } from 'vue';
2
+ import { MicroApp, MicroMenu, MicroTab, NavigationOperation } from '../sdk';
3
+ /** 公共外壳品牌配置;只改变展示,不注册应用、路由或认证能力。 */
4
+ export interface ShellBrand {
5
+ /** 产品名称。 */
6
+ title?: string;
7
+ /** 左上角短标识;复杂 Logo 请使用 brand 插槽。 */
8
+ mark?: string;
9
+ /** 面包屑前缀。 */
10
+ workspaceTitle?: string;
11
+ }
12
+ /** 独立应用菜单;由项目本地 router meta 转换,不是宿主权限凭据。 */
13
+ export interface ShellMenuItem {
14
+ /** 已解析的应用内完整路径,仅作为导航事件参数。 */
15
+ path: string;
16
+ title: string;
17
+ icon?: Component;
18
+ children: readonly ShellMenuItem[];
19
+ }
20
+ /** HostShell 输入全部来自项目唯一的核心状态;外壳不创建 host、router 或 session。 */
21
+ export interface HostShellProps {
22
+ apps: readonly MicroApp[];
23
+ tabs: readonly MicroTab[];
24
+ activeKey?: string;
25
+ userName: string;
26
+ pending?: boolean;
27
+ notice?: string;
28
+ brand?: ShellBrand;
29
+ /** 可选受控折叠状态;未传入时仅在当前组件内维护展示状态。 */
30
+ collapsed?: boolean;
31
+ /** 项目固定页签规则;仅控制按钮,真正的操作约束仍必须由核心适配器实施。 */
32
+ isFixedTab?: (tab: MicroTab) => boolean;
33
+ }
34
+ /** 所有插槽动作只发出事件;调用方必须继续通过原有授权和异步离页事务处理。 */
35
+ export interface HostShellActions {
36
+ open: (appId: string, menu: MicroMenu) => void;
37
+ operate: (operation: NavigationOperation) => void;
38
+ logout: () => void;
39
+ profile: () => void;
40
+ toggleCollapsed: () => void;
41
+ }
42
+ /** 替换整个区域表示自行维护该区域;未覆盖区域仍使用库内默认组件。 */
43
+ export interface HostShellSlots {
44
+ default: (scope: {
45
+ active?: MicroTab;
46
+ }) => VNode[];
47
+ brand?: (scope: {
48
+ brand: ShellBrand;
49
+ }) => VNode[];
50
+ 'header-actions'?: (scope: HostShellActions) => VNode[];
51
+ header?: (scope: HostShellActions & {
52
+ active?: MicroTab;
53
+ collapsed: boolean;
54
+ pending: boolean;
55
+ }) => VNode[];
56
+ sidebar?: (scope: HostShellActions & {
57
+ active?: MicroTab;
58
+ collapsed: boolean;
59
+ pending: boolean;
60
+ }) => VNode[];
61
+ tabs?: (scope: HostShellActions & {
62
+ active?: MicroTab;
63
+ pending: boolean;
64
+ }) => VNode[];
65
+ }
@@ -5,6 +5,8 @@ export interface MicroMenu {
5
5
  menuId: string;
6
6
  /** 菜单展示名称,同时作为入口页签的默认标题。 */
7
7
  name: string;
8
+ /** 可选展示图标标识(如 HomeFilled、User、Folder、List);仅布局层解析,不接收组件、HTML 或 URL。 */
9
+ icon?: string;
8
10
  /** 子菜单;分组不允许同时配置 target。 */
9
11
  children?: readonly MicroMenu[];
10
12
  /** 入口导航信息;tabId 仍须在整个宿主唯一。 */
@@ -0,0 +1,16 @@
1
+ import { WatermarkConfig } from './config';
2
+ /**
3
+ * 仅叠加水印,不包装业务 DOM。直接父容器必须是 position:relative 的稳定视口。
4
+ * 防篡改只提高常见删除/样式修改成本,不是安全边界;祖先隐藏或脚本被停用无法保证恢复。
5
+ */
6
+ type __VLS_Props = {
7
+ config?: WatermarkConfig;
8
+ };
9
+ declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
10
+ error: (error: Error) => any;
11
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
12
+ onError?: ((error: Error) => any) | undefined;
13
+ }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
14
+ layer: HTMLDivElement;
15
+ }, HTMLDivElement>;
16
+ export default _default;
@@ -0,0 +1,36 @@
1
+ /** 水印配置;接口接入方应只传业务文字,不传 Token 等敏感凭据。 */
2
+ export interface WatermarkConfig {
3
+ /** 是否绘制;默认开启,禁用会清理观察器和检查定时器。 */
4
+ enabled?: boolean;
5
+ /** 自定义文字或多行文字;最多四行,每行最多 120 字。 */
6
+ text?: string | readonly string[];
7
+ /** Canvas 文字颜色;默认 #64748b。 */
8
+ color?: string;
9
+ /** 文字透明度,范围 0~1。 */
10
+ opacity?: number;
11
+ /** 顺时针旋转角度,范围 -180~180。 */
12
+ rotate?: number;
13
+ /** 字号,单位 px,范围 10~64。 */
14
+ fontSize?: number;
15
+ /** Canvas 字体族;不接受 CSS 声明串。 */
16
+ fontFamily?: string;
17
+ /** 水平、垂直文字间距,单位 px,范围 16~600;越小越密。 */
18
+ gap?: readonly [number, number];
19
+ }
20
+ /** 标准化后的不可变快照,不跨组件共享可变配置。 */
21
+ export interface NormalizedWatermarkConfig {
22
+ enabled: boolean;
23
+ text: readonly string[];
24
+ color: string;
25
+ opacity: number;
26
+ rotate: number;
27
+ fontSize: number;
28
+ fontFamily: string;
29
+ gap: readonly [number, number];
30
+ }
31
+ /**
32
+ * 将前端或接口配置转换为有界快照;非法数字使用默认值,超范围数字截断。
33
+ * @param input 可选的水印配置;运行时 null/非法对象也安全降级。
34
+ * @returns 不可变配置,文字/间距复制后冻结;不操作 DOM,不抛校验异常。
35
+ */
36
+ export declare function normalizeWatermarkConfig(input?: WatermarkConfig | null): NormalizedWatermarkConfig;
@@ -0,0 +1,4 @@
1
+ /** 可选水印子路径;核心入口不加载 Canvas、Vue 展示组件或观察器。 */
2
+ export { default as FinWatermark } from './FinWatermark.vue';
3
+ export { normalizeWatermarkConfig } from './config';
4
+ export type { WatermarkConfig, NormalizedWatermarkConfig } from './config';
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const f=require("vue");function y(s,e,u,m){return typeof s=="number"&&Number.isFinite(s)?Math.min(m,Math.max(u,s)):e}function F(s){var r,i;const e=s&&typeof s=="object"?s:{},m=(typeof e.text=="string"?e.text.split(`
2
+ `):Array.isArray(e.text)?e.text:["内部资料"]).filter(l=>typeof l=="string").slice(0,4).map(l=>l.slice(0,120)),c=typeof e.fontFamily=="string"&&e.fontFamily.length<=160&&!/[;{}\\\n\r]/.test(e.fontFamily)?e.fontFamily:"sans-serif";return Object.freeze({enabled:e.enabled!==!1,text:Object.freeze(m),color:typeof e.color=="string"&&e.color.length<=80?e.color:"#64748b",opacity:y(e.opacity,.12,0,1),rotate:y(e.rotate,-22,-180,180),fontSize:y(e.fontSize,16,10,64),fontFamily:c,gap:Object.freeze([y((r=e.gap)==null?void 0:r[0],100,16,600),y((i=e.gap)==null?void 0:i[1],80,16,600)])})}const D=f.defineComponent({__name:"FinWatermark",props:{config:{}},emits:["error"],setup(s,{emit:e}){const u=s,m=e,c=f.ref();let r=null,i,l,g="",d=!1,M=!1;function A(){i==null||i.disconnect(),i=void 0,l!==void 0&&clearInterval(l),l=void 0}function W(t){var O;const a=F(u.config),o=t.ownerDocument.createElement("canvas"),n=o.getContext("2d");if(!n)throw new Error("当前环境不支持 Canvas 2D,无法绘制水印。");const b=`${a.fontSize}px ${a.fontFamily}`;n.font=b;const p=Math.min(1e3,Math.max(1,...a.text.map(T=>n.measureText(T).width))),x=a.fontSize*1.5,w=Math.max(1,a.text.length)*x,h=a.rotate*Math.PI/180,z=Math.ceil(Math.abs(p*Math.cos(h))+Math.abs(w*Math.sin(h))+a.gap[0]),S=Math.ceil(Math.abs(p*Math.sin(h))+Math.abs(w*Math.cos(h))+a.gap[1]),v=Math.min(2,Math.max(1,((O=t.ownerDocument.defaultView)==null?void 0:O.devicePixelRatio)??1));o.width=Math.ceil(z*v),o.height=Math.ceil(S*v),n.scale(v,v),n.translate(z/2,S/2),n.rotate(h),n.font=b,n.fillStyle="#64748b",n.fillStyle=a.color,n.globalAlpha=a.opacity,n.textAlign="center",n.textBaseline="middle",a.text.forEach((T,j)=>n.fillText(T,0,(j-(a.text.length-1)/2)*x,1e3));const E=o.toDataURL("image/png");if(!E.startsWith("data:image/png"))throw new Error("Canvas 未生成有效水印图像。");return{url:E,width:z,height:S}}function _(t){i==null||i.observe(t,{attributes:!0}),r&&(i==null||i.observe(r,{childList:!0}))}function k(){var a;const t=c.value;if(!(!d||M||!t||!r)){M=!0,i==null||i.disconnect();try{t.parentNode!==r&&r.appendChild(t),t.style.cssText!==g&&(t.style.cssText=g),t.className&&t.removeAttribute("class"),t.hasAttribute("hidden")&&t.removeAttribute("hidden"),t.setAttribute("aria-hidden","true");const o=(a=t.ownerDocument.defaultView)==null?void 0:a.getComputedStyle(t);o&&(o.display==="none"||o.visibility!=="visible"||Number(o.opacity)===0)&&(t.style.cssText=g)}finally{M=!1,d&&_(t)}}}function C(){var o;A();const t=c.value;if(!t||!d)return;t.style.cssText="display:none !important;";const a=F(u.config);if(!(!a.enabled||!a.text.some(Boolean)))try{const n=W(t),b={position:"absolute",inset:"0",width:"100%",height:"100%",margin:"0",padding:"0",border:"0",display:"block",visibility:"visible",opacity:"1","pointer-events":"none","z-index":"2000",transform:"none",filter:"none","clip-path":"none","background-color":"transparent","background-image":`url("${n.url}")`,"background-size":`${n.width}px ${n.height}px`,"background-repeat":"repeat","background-position":"0 0","box-sizing":"border-box"};t.style.cssText="";for(const[x,w]of Object.entries(b))t.style.setProperty(x,w,"important");g=t.style.cssText;const p=(o=t.ownerDocument.defaultView)==null?void 0:o.MutationObserver;p&&(i=new p(k)),k(),l=setInterval(k,1e3)}catch(n){t.style.cssText="display:none !important;",m("error",n instanceof Error?n:new Error("水印绘制失败。")),console.warn("[fin-micro/watermark] 水印绘制失败,本次检查已停止。",n)}}return f.onMounted(()=>{var t;d=!0,r=((t=c.value)==null?void 0:t.parentElement)??null,C()}),f.watch(()=>u.config,C,{deep:!0}),f.onBeforeUnmount(()=>{var t;d=!1,A(),(t=c.value)==null||t.remove(),r=null}),(t,a)=>(f.openBlock(),f.createElementBlock("div",{ref_key:"layer",ref:c,"aria-hidden":"true","data-fin-watermark":""},null,512))}});exports.FinWatermark=D;exports.normalizeWatermarkConfig=F;
@@ -0,0 +1,119 @@
1
+ import { defineComponent as W, ref as j, onMounted as B, watch as $, onBeforeUnmount as N, openBlock as I, createElementBlock as P } from "vue";
2
+ function h(s, e, f, m) {
3
+ return typeof s == "number" && Number.isFinite(s) ? Math.min(m, Math.max(f, s)) : e;
4
+ }
5
+ function C(s) {
6
+ var r, o;
7
+ const e = s && typeof s == "object" ? s : {}, m = (typeof e.text == "string" ? e.text.split(`
8
+ `) : Array.isArray(e.text) ? e.text : ["内部资料"]).filter((l) => typeof l == "string").slice(0, 4).map((l) => l.slice(0, 120)), c = typeof e.fontFamily == "string" && e.fontFamily.length <= 160 && !/[;{}\\\n\r]/.test(e.fontFamily) ? e.fontFamily : "sans-serif";
9
+ return Object.freeze({
10
+ enabled: e.enabled !== !1,
11
+ text: Object.freeze(m),
12
+ color: typeof e.color == "string" && e.color.length <= 80 ? e.color : "#64748b",
13
+ opacity: h(e.opacity, 0.12, 0, 1),
14
+ rotate: h(e.rotate, -22, -180, 180),
15
+ fontSize: h(e.fontSize, 16, 10, 64),
16
+ fontFamily: c,
17
+ gap: Object.freeze([h((r = e.gap) == null ? void 0 : r[0], 100, 16, 600), h((o = e.gap) == null ? void 0 : o[1], 80, 16, 600)])
18
+ });
19
+ }
20
+ const H = /* @__PURE__ */ W({
21
+ __name: "FinWatermark",
22
+ props: {
23
+ config: {}
24
+ },
25
+ emits: ["error"],
26
+ setup(s, { emit: e }) {
27
+ const f = s, m = e, c = j();
28
+ let r = null, o, l, y = "", p = !1, v = !1;
29
+ function T() {
30
+ o == null || o.disconnect(), o = void 0, l !== void 0 && clearInterval(l), l = void 0;
31
+ }
32
+ function _(t) {
33
+ var S;
34
+ const a = C(f.config), i = t.ownerDocument.createElement("canvas"), n = i.getContext("2d");
35
+ if (!n) throw new Error("当前环境不支持 Canvas 2D,无法绘制水印。");
36
+ const g = `${a.fontSize}px ${a.fontFamily}`;
37
+ n.font = g;
38
+ const u = Math.min(1e3, Math.max(1, ...a.text.map((F) => n.measureText(F).width))), x = a.fontSize * 1.5, b = Math.max(1, a.text.length) * x, d = a.rotate * Math.PI / 180, k = Math.ceil(Math.abs(u * Math.cos(d)) + Math.abs(b * Math.sin(d)) + a.gap[0]), z = Math.ceil(Math.abs(u * Math.sin(d)) + Math.abs(b * Math.cos(d)) + a.gap[1]), w = Math.min(2, Math.max(1, ((S = t.ownerDocument.defaultView) == null ? void 0 : S.devicePixelRatio) ?? 1));
39
+ i.width = Math.ceil(k * w), i.height = Math.ceil(z * w), n.scale(w, w), n.translate(k / 2, z / 2), n.rotate(d), n.font = g, n.fillStyle = "#64748b", n.fillStyle = a.color, n.globalAlpha = a.opacity, n.textAlign = "center", n.textBaseline = "middle", a.text.forEach((F, O) => n.fillText(F, 0, (O - (a.text.length - 1) / 2) * x, 1e3));
40
+ const E = i.toDataURL("image/png");
41
+ if (!E.startsWith("data:image/png")) throw new Error("Canvas 未生成有效水印图像。");
42
+ return { url: E, width: k, height: z };
43
+ }
44
+ function D(t) {
45
+ o == null || o.observe(t, { attributes: !0 }), r && (o == null || o.observe(r, { childList: !0 }));
46
+ }
47
+ function M() {
48
+ var a;
49
+ const t = c.value;
50
+ if (!(!p || v || !t || !r)) {
51
+ v = !0, o == null || o.disconnect();
52
+ try {
53
+ t.parentNode !== r && r.appendChild(t), t.style.cssText !== y && (t.style.cssText = y), t.className && t.removeAttribute("class"), t.hasAttribute("hidden") && t.removeAttribute("hidden"), t.setAttribute("aria-hidden", "true");
54
+ const i = (a = t.ownerDocument.defaultView) == null ? void 0 : a.getComputedStyle(t);
55
+ i && (i.display === "none" || i.visibility !== "visible" || Number(i.opacity) === 0) && (t.style.cssText = y);
56
+ } finally {
57
+ v = !1, p && D(t);
58
+ }
59
+ }
60
+ }
61
+ function A() {
62
+ var i;
63
+ T();
64
+ const t = c.value;
65
+ if (!t || !p) return;
66
+ t.style.cssText = "display:none !important;";
67
+ const a = C(f.config);
68
+ if (!(!a.enabled || !a.text.some(Boolean)))
69
+ try {
70
+ const n = _(t), g = {
71
+ position: "absolute",
72
+ inset: "0",
73
+ width: "100%",
74
+ height: "100%",
75
+ margin: "0",
76
+ padding: "0",
77
+ border: "0",
78
+ display: "block",
79
+ visibility: "visible",
80
+ opacity: "1",
81
+ "pointer-events": "none",
82
+ "z-index": "2000",
83
+ transform: "none",
84
+ filter: "none",
85
+ "clip-path": "none",
86
+ "background-color": "transparent",
87
+ "background-image": `url("${n.url}")`,
88
+ "background-size": `${n.width}px ${n.height}px`,
89
+ "background-repeat": "repeat",
90
+ "background-position": "0 0",
91
+ "box-sizing": "border-box"
92
+ };
93
+ t.style.cssText = "";
94
+ for (const [x, b] of Object.entries(g)) t.style.setProperty(x, b, "important");
95
+ y = t.style.cssText;
96
+ const u = (i = t.ownerDocument.defaultView) == null ? void 0 : i.MutationObserver;
97
+ u && (o = new u(M)), M(), l = setInterval(M, 1e3);
98
+ } catch (n) {
99
+ t.style.cssText = "display:none !important;", m("error", n instanceof Error ? n : new Error("水印绘制失败。")), console.warn("[fin-micro/watermark] 水印绘制失败,本次检查已停止。", n);
100
+ }
101
+ }
102
+ return B(() => {
103
+ var t;
104
+ p = !0, r = ((t = c.value) == null ? void 0 : t.parentElement) ?? null, A();
105
+ }), $(() => f.config, A, { deep: !0 }), N(() => {
106
+ var t;
107
+ p = !1, T(), (t = c.value) == null || t.remove(), r = null;
108
+ }), (t, a) => (I(), P("div", {
109
+ ref_key: "layer",
110
+ ref: c,
111
+ "aria-hidden": "true",
112
+ "data-fin-watermark": ""
113
+ }, null, 512));
114
+ }
115
+ });
116
+ export {
117
+ H as FinWatermark,
118
+ C as normalizeWatermarkConfig
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axewo/fin-micro",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.mjs",
@@ -36,6 +36,17 @@
36
36
  "import": "./dist/vue.mjs",
37
37
  "require": "./dist/vue.cjs"
38
38
  },
39
+ "./layout": {
40
+ "types": "./dist/types/layout/index.d.ts",
41
+ "import": "./dist/layout.mjs",
42
+ "require": "./dist/layout.cjs"
43
+ },
44
+ "./layout/style.css": "./dist/layout.css",
45
+ "./watermark": {
46
+ "types": "./dist/types/watermark/index.d.ts",
47
+ "import": "./dist/watermark.mjs",
48
+ "require": "./dist/watermark.cjs"
49
+ },
39
50
  "./runtime": {
40
51
  "types": "./dist/types/runtime/index.d.ts",
41
52
  "import": "./dist/runtime.mjs",
@@ -46,6 +57,7 @@
46
57
  "fin-micro": "./cli/index.mjs"
47
58
  },
48
59
  "files": [
60
+ "dist/**/*.css",
49
61
  "dist/**/*.mjs",
50
62
  "dist/**/*.cjs",
51
63
  "dist/**/*.d.ts",
@@ -58,12 +70,22 @@
58
70
  "publishConfig": {
59
71
  "access": "public"
60
72
  },
61
- "sideEffects": false,
73
+ "sideEffects": [
74
+ "**/*.css"
75
+ ],
62
76
  "peerDependencies": {
77
+ "element-plus": "^2.9.0",
78
+ "@element-plus/icons-vue": "^2.3.0",
63
79
  "vue": "^3.4.0",
64
80
  "vue-router": "^4.5.0"
65
81
  },
66
82
  "peerDependenciesMeta": {
83
+ "element-plus": {
84
+ "optional": true
85
+ },
86
+ "@element-plus/icons-vue": {
87
+ "optional": true
88
+ },
67
89
  "vue": {
68
90
  "optional": true
69
91
  },
@@ -75,6 +97,9 @@
75
97
  "wujie": "2.1.0"
76
98
  },
77
99
  "devDependencies": {
100
+ "@vitejs/plugin-vue": "^5.1.0",
101
+ "element-plus": "^2.9.0",
102
+ "@element-plus/icons-vue": "^2.3.0",
78
103
  "vue-router": "^4.5.0",
79
104
  "vue": "^3.5.0",
80
105
  "vite": "^5.4.0",
@@ -88,7 +113,7 @@
88
113
  "build": "vite build",
89
114
  "test": "node ../../node_modules/vitest/vitest.mjs run",
90
115
  "typecheck": "vue-tsc --noEmit -p tsconfig.json",
91
- "test:cli": "node --test cli/create.test.mjs cli/scaffold.test.mjs",
116
+ "test:cli": "node --test cli/create.test.mjs cli/scaffold.test.mjs cli/layout.test.mjs cli/startup.test.mjs",
92
117
  "check:package": "node scripts/check-package.mjs"
93
118
  }
94
119
  }
@@ -6,7 +6,8 @@
6
6
  <title>__PROJECT_NAME__</title>
7
7
  </head>
8
8
  <body>
9
- <div id="app"></div>
9
+ <!-- 启动前即显示;只占当前应用根区域,嵌入运行不会覆盖基座。Vue 挂载时替换。 -->
10
+ <div id="app"><div data-fin-startup="loading" role="status" aria-live="polite" style="min-height:240px;display:grid;place-content:center;gap:12px;text-align:center;color:#536079;background:#f4f6fa;font:14px/1.6 sans-serif"><strong>正在启动应用…</strong><span>正在加载配置与恢复会话,请稍候</span></div></div>
10
11
  <script type="module" src="/src/main.ts"></script>
11
12
  </body>
12
13
  </html>
@@ -0,0 +1,6 @@
1
+ # pnpm >=10.26(包含12.3.4)使用allowBuilds;旧版pnpm请按其审批机制单独配置。
2
+ # 仅授权当前模板依赖的固定版本,升级依赖后须重新检查安装脚本。
3
+ # esbuild准备平台构建二进制;vue-demi适配当前Vue版本。禁止全量放开依赖脚本。
4
+ allowBuilds:
5
+ 'esbuild@0.21.5': true
6
+ 'vue-demi@0.14.10': true
@@ -2,5 +2,5 @@ import type { RouteRecordRaw } from 'vue-router'
2
2
 
3
3
  // 仅定义本地页面,不授予宿主菜单/页签权限;host 默认不自动安装。
4
4
  export const demoRoutes: RouteRecordRaw[] = [
5
- { path: '/demo', name: 'demo', component: () => import('../views/DemoView.vue') },
5
+ { path: '/demo', name: 'demo', component: () => import('../views/demo/index.vue') },
6
6
  ]
@@ -34,14 +34,30 @@ export function devDiagnostic(error: unknown, stage: 'load-bootstrap' | 'mount'
34
34
  return ` [阶段=${stage}; 错误=${kind}]${kind === 'SyntaxError' ? ' 请先执行 npm run check:runtime 检查模块导出及依赖能力' : ''}`
35
35
  }
36
36
 
37
+ /**
38
+ * 在当前应用根节点显示启动占位;不使用全屏定位,不依赖 Vue 或 UI 插件。
39
+ * @returns 无返回值;无 DOM 或根节点时安全忽略,Vue 挂载后自然替换占位。
40
+ */
41
+ export function showStartupLoading(): void {
42
+ if (typeof document === 'undefined') return
43
+ const container = document.getElementById('app')
44
+ if (!container) return
45
+ const text = document.createElement('p')
46
+ text.setAttribute('role', 'status')
47
+ text.setAttribute('aria-live', 'polite')
48
+ text.setAttribute('data-fin-startup', 'loading')
49
+ text.textContent = '正在启动应用,请稍候…'
50
+ container.replaceChildren(text)
51
+ }
52
+
37
53
  /** 启动失败的纯文本可重试边界;不插入错误 HTML,不依赖尚未成功安装的 Vue/UI 插件。 */
38
54
  export function showStartupError(error: unknown, retry: () => Promise<void>, stage: 'load-bootstrap' | 'mount' = 'mount'): void {
39
- if (toSafeError(error).kind === 'cancelled' || typeof document === 'undefined') return
55
+ if (typeof document === 'undefined') return
40
56
  const container = document.getElementById('app')
41
57
  if (!container) return
42
58
  const text = document.createElement('p')
43
59
  text.setAttribute('role', 'alert')
44
- text.textContent = `应用启动失败:${toSafeError(error).message}${devDiagnostic(error, stage)}`
60
+ text.textContent = toSafeError(error).kind === 'cancelled' ? '应用启动已取消,可重试启动' : `应用启动失败:${toSafeError(error).message}${devDiagnostic(error, stage)}`
45
61
  const button = document.createElement('button')
46
62
  button.textContent = '重试启动'
47
63
  button.onclick = () => { void retry().catch(reportError) }
@@ -65,6 +81,7 @@ export function createStartupLifecycle(load: () => Promise<{ mount(): Promise<vo
65
81
  async function mount(): Promise<void> {
66
82
  unmount()
67
83
  const version = generation
84
+ showStartupLoading()
68
85
  let stage: 'load-bootstrap' | 'mount' = 'load-bootstrap'
69
86
  try {
70
87
  const bootstrap = await load()
@@ -0,0 +1,18 @@
1
+ import { reactive } from 'vue'
2
+ import type { WatermarkConfig } from '@axewo/fin-micro/watermark'
3
+
4
+ /**
5
+ * 当前使用前端配置;未来在应用配置加载完成后 Object.assign(watermarkConfig, result)。
6
+ * 这里不发请求、不假设接口协议;接口适配仍归项目 apis/,不得将 Token 写入水印。
7
+ * 嵌入式子应用不渲染独立布局,因此只由基座绘制;各应用实例互不共享此对象。
8
+ */
9
+ export const watermarkConfig = reactive<WatermarkConfig>({
10
+ enabled: true,
11
+ text: ['内部资料', '__PROJECT_NAME__'],
12
+ color: '#64748b',
13
+ opacity: 0.12,
14
+ rotate: -22,
15
+ fontSize: 16,
16
+ fontFamily: 'sans-serif',
17
+ gap: [100, 80],
18
+ })
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { storeToRefs } from 'pinia'
3
3
  import { useI18n } from 'vue-i18n'
4
- import { useDemoStore } from '../stores/demo'
4
+ import { useDemoStore } from '../../stores/demo'
5
5
 
6
6
  // 全局词条来自 installAppLocale,切换仅影响当前 Vue App 的 Provider。
7
7
  const { t, locale } = useI18n({ useScope: 'global' })
@@ -1,15 +1,23 @@
1
1
  <script setup lang="ts">
2
+ import { ElIcon } from 'element-plus'
3
+ import { Document, Folder, HomeFilled, List, Menu, Setting, Tickets, User } from '@element-plus/icons-vue'
2
4
  import type { MicroMenu } from '@axewo/fin-micro'
3
5
  /** 薄递归只渲染后台授权菜单,展开与选中状态交给 Element Plus。 */
4
6
  defineProps<{ appId: string; menus: readonly MicroMenu[] }>()
5
7
  const emit = defineEmits<{ open: [appId: string, menu: MicroMenu] }>()
8
+ // 后台仅提供图标标识,显式映射到本地白名单;不动态加载接口数据指定的组件。
9
+ const icons = new Map(Object.entries({ Document, Folder, HomeFilled, List, Menu, Setting, Tickets, User }))
10
+ /** 未配置或未知图标名时安全回退,不影响导航。 */
11
+ const menuIcon = (menu: MicroMenu) => icons.get(menu.icon ?? '') ?? (menu.children?.length ? Folder : Document)
6
12
  </script>
7
13
  <template>
8
14
  <template v-for="menu in menus" :key="JSON.stringify([appId, menu.menuId])">
9
15
  <el-sub-menu v-if="menu.children?.length" :index="JSON.stringify([appId, menu.menuId])">
10
- <template #title>{{ menu.name }}</template>
16
+ <template #title><ElIcon aria-hidden="true"><component :is="menuIcon(menu)" /></ElIcon><span>{{ menu.name }}</span></template>
11
17
  <HostMenu :app-id="appId" :menus="menu.children" @open="(id, item) => emit('open', id, item)" />
12
18
  </el-sub-menu>
13
- <el-menu-item v-else-if="menu.target" :index="JSON.stringify([appId, menu.menuId])" @click="emit('open', appId, menu)">{{ menu.name }}</el-menu-item>
19
+ <el-menu-item v-else-if="menu.target" :index="JSON.stringify([appId, menu.menuId])" @click="emit('open', appId, menu)">
20
+ <ElIcon aria-hidden="true"><component :is="menuIcon(menu)" /></ElIcon><span>{{ menu.name }}</span>
21
+ </el-menu-item>
14
22
  </template>
15
23
  </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { nextTick, ref, watch } from 'vue'
2
+ import { nextTick, onMounted, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
3
4
  import type { MicroTab, NavigationOperation } from '@axewo/fin-micro'
4
5
  import { isHome } from '../localPages'
5
6
 
@@ -21,22 +22,71 @@ function command(action: 'refresh' | 'close' | 'all' | 'others' | 'left' | 'righ
21
22
  const target = { tabId: tab.tabId, key: tab.key }
22
23
  emit('operate', action === 'refresh' || action === 'close' ? { type: action, target } : { type: 'closeMany', scope: action, anchor: target })
23
24
  }
24
- // 只滚动当前页签,不触碰业务容器及其缓存生命周期。
25
- watch(() => props.activeKey, () => {
26
- void nextTick().then(() => strip.value?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })).catch(error => emit('error', error))
25
+ /** 仅调整页签条的水平位置,不滚动业务容器,也不触发页签激活事务。 */
26
+ const overflowing = ref(false)
27
+ const canScrollLeft = ref(false)
28
+ const canScrollRight = ref(false)
29
+ let observer: ResizeObserver | undefined
30
+ let disposed = false
31
+ function measure(): void {
32
+ const el = strip.value
33
+ if (!el || disposed) return
34
+ // 两枚箭头各占 32px;按不含箭头时的总宽度判定,避免临界尺寸反复闪现。
35
+ overflowing.value = el.scrollWidth > el.clientWidth + (overflowing.value ? 64 : 0) + 1
36
+ canScrollLeft.value = el.scrollLeft > 1
37
+ canScrollRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 1
38
+ }
39
+ function revealActive(): void {
40
+ const el = strip.value
41
+ const tab = el?.querySelector<HTMLElement>('[aria-selected="true"]')?.parentElement
42
+ if (!el || !tab || disposed) return
43
+ const viewport = el.getBoundingClientRect()
44
+ const bounds = tab.getBoundingClientRect()
45
+ if (bounds.left < viewport.left) el.scrollLeft += bounds.left - viewport.left
46
+ else if (bounds.right > viewport.right) el.scrollLeft += Math.min(bounds.right - viewport.right, bounds.left - viewport.left)
47
+ measure()
48
+ }
49
+ /** 箭头每次移动约一屏,保留部分重叠便于定位;边界由滚动事件重新测量。 */
50
+ function scrollTabs(direction: -1 | 1): void {
51
+ if (props.pending) return
52
+ strip.value?.scrollBy({ left: direction * Math.max(80, strip.value.clientWidth * 0.8), behavior: 'smooth' })
53
+ }
54
+ function updateViewport(): void {
55
+ void nextTick().then(() => {
56
+ if (disposed) return
57
+ measure()
58
+ // 箭头出现或消失后,可视宽度会再次变化,等布局提交后再定位活动项。
59
+ return nextTick().then(revealActive)
60
+ }).catch(error => emit('error', error))
61
+ }
62
+ onMounted(() => {
63
+ if (typeof ResizeObserver !== 'undefined') {
64
+ observer = new ResizeObserver(updateViewport)
65
+ if (strip.value) observer.observe(strip.value)
66
+ }
67
+ if (typeof window !== 'undefined') window.addEventListener('resize', updateViewport)
68
+ updateViewport()
69
+ })
70
+ onBeforeUnmount(() => {
71
+ disposed = true
72
+ observer?.disconnect()
73
+ if (typeof window !== 'undefined') window.removeEventListener('resize', updateViewport)
27
74
  })
75
+ watch(() => [props.activeKey, ...props.tabs.map(tab => `${tab.key}:${tab.title}`)], updateViewport)
28
76
  watch(() => props.pending, pending => { if (pending) closePopovers() })
29
77
  defineExpose({ closePopovers })
30
78
  </script>
31
79
  <template>
32
80
  <div class="shell-tabbar" :inert="pending || undefined" @keydown.esc="closePopovers">
33
81
  <button v-if="menu" class="popover-backdrop" aria-label="关闭页签菜单" tabindex="-1" @click="closePopovers" />
34
- <div ref="strip" class="tab-strip" role="tablist" aria-label="已打开页面">
82
+ <button v-if="overflowing" class="tab-scroll" aria-label="向左查看页签" title="向左查看页签" :disabled="pending || !canScrollLeft" @click="scrollTabs(-1)"><ArrowLeft aria-hidden="true" /></button>
83
+ <div ref="strip" class="tab-strip" role="tablist" aria-label="已打开页面" @scroll="measure">
35
84
  <div v-for="tab in tabs" :key="tab.key" class="shell-tab" :class="{ active: tab.key === activeKey }" @contextmenu.prevent="showMenu(tab, $event)">
36
85
  <button role="tab" :aria-selected="tab.key === activeKey" :title="tab.title" @click="!pending && emit('operate', { type: 'activate', target: { tabId: tab.tabId, key: tab.key } })">{{ tab.title }}</button>
37
86
  <button v-if="!isHome(tab)" class="tab-close" :aria-label="`关闭 ${tab.title}`" @click="!pending && emit('operate', { type: 'close', target: { tabId: tab.tabId, key: tab.key } })">×</button>
38
87
  </div>
39
88
  </div>
89
+ <button v-if="overflowing" class="tab-scroll" aria-label="向右查看页签" title="向右查看页签" :disabled="pending || !canScrollRight" @click="scrollTabs(1)"><ArrowRight aria-hidden="true" /></button>
40
90
  <button class="tab-more" aria-label="更多页签操作" :aria-expanded="!!menu" :disabled="!tabs.length || pending" @click="tabs.find(tab => tab.key === activeKey) && showMenu(tabs.find(tab => tab.key === activeKey)!)">···</button>
41
91
  <div v-if="menu" class="shell-popover tab-actions" :class="{ 'context-actions': menu.x !== undefined }" :style="menu.x !== undefined ? { left: `${menu.x}px`, top: `${menu.y}px` } : undefined" role="menu" aria-label="页签操作菜单">
42
92
  <p class="popover-label">{{ menu.tab.title }}</p>
@@ -1,11 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { FinWatermark } from '@axewo/fin-micro/watermark'
4
+ import { watermarkConfig } from '../utils/watermarkConfig'
3
5
  import { MicroAppContainer, type MicroApp, type MicroTab, type MicroRuntime } from '@axewo/fin-micro'
4
6
  import { demoMode } from '../platform'
5
7
  import { LOCAL_APP_ID } from '../localPages'
6
8
  import type { PlatformSession } from '../types/platform'
7
- import HomeView from '../views/HomeView.vue'
8
- import ProfileView from '../views/ProfileView.vue'
9
+ import HomeView from '../views/home/index.vue'
10
+ import ProfileView from '../views/profile/index.vue'
9
11
  const props = defineProps<{ apps: readonly MicroApp[]; tabs: readonly MicroTab[]; active?: MicroTab; runtime: MicroRuntime; epochs: Record<string, number>; pending: boolean; session: PlatformSession }>()
10
12
  const emit = defineEmits<{ home: []; profile: []; logout: [] }>()
11
13
  // 远端按 appId 唯一挂载;本地页面使用相同 core 页签及 key,但不创建沙箱或第二套页签状态。
@@ -13,6 +15,8 @@ const mounted = computed(() => props.apps.filter(app => app.kind !== 'local' &&
13
15
  const local = computed(() => props.active?.appId === LOCAL_APP_ID ? props.active : undefined)
14
16
  </script>
15
17
  <template>
18
+ <!-- 稳定视口与原滚动区分离;不改变 appId/key 或子应用缓存身份。 -->
19
+ <div class="workspace-viewport">
16
20
  <section class="shell-content">
17
21
  <!-- 仅清理或异常空快照时兜底;正常交互始终保留核心主页,不用此入口模拟固定页签。 -->
18
22
  <div v-if="!active && !pending" class="home-view"><section class="home-panel"><h1>工作区暂不可用</h1><p>可返回本地主页重新开始。</p><el-button type="primary" @click="emit('home')">打开主页</el-button></section></div>
@@ -23,4 +27,10 @@ const local = computed(() => props.active?.appId === LOCAL_APP_ID ? props.active
23
27
  <!-- 仅局部覆盖通用容器的最小高度;挂载身份与保活策略不变,低视口不会撑开整个基座。 -->
24
28
  <MicroAppContainer class="shell-app-container" v-for="app in mounted" v-show="active?.appId === app.appId" :key="`${app.appId}:${epochs[app.appId] ?? 0}`" :app-id="app.appId" :runtime="runtime" :retry-disabled="pending" />
25
29
  </section>
30
+ <FinWatermark :config="watermarkConfig" />
31
+ </div>
26
32
  </template>
33
+ <style scoped>
34
+ .workspace-viewport { position: relative; isolation: isolate; display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
35
+ .workspace-viewport > .shell-content { min-width: 0; }
36
+ </style>