@koishi-ce/client 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.
Files changed (121) hide show
  1. package/app/assets/logo.png +0 -0
  2. package/app/home/home.vue +51 -0
  3. package/app/home/index.ts +18 -0
  4. package/app/home/welcome.en-US.yml +9 -0
  5. package/app/home/welcome.vue +111 -0
  6. package/app/home/welcome.zh-CN.yml +9 -0
  7. package/app/index.html +13 -0
  8. package/app/index.scss +64 -0
  9. package/app/index.ts +32 -0
  10. package/app/layout/header.vue +187 -0
  11. package/app/layout/index.ts +15 -0
  12. package/app/layout/layout.vue +194 -0
  13. package/app/layout/menu-item.vue +60 -0
  14. package/app/settings/index.ts +39 -0
  15. package/app/settings/settings.vue +82 -0
  16. package/app/settings/theme.vue +123 -0
  17. package/app/shims.d.ts +1 -0
  18. package/app/status/index.ts +21 -0
  19. package/app/status/loading.vue +23 -0
  20. package/app/status/status.vue +56 -0
  21. package/app/styles/element.scss +217 -0
  22. package/app/styles/hc.scss +35 -0
  23. package/app/styles/index.scss +140 -0
  24. package/app/styles/index.ts +30 -0
  25. package/app/styles/layout.scss +92 -0
  26. package/app/theme/activity/button.vue +97 -0
  27. package/app/theme/activity/index.vue +119 -0
  28. package/app/theme/activity/item.vue +185 -0
  29. package/app/theme/activity/separator.vue +163 -0
  30. package/app/theme/activity/utils.ts +27 -0
  31. package/app/theme/blank.vue +24 -0
  32. package/app/theme/index.ts +37 -0
  33. package/app/theme/index.vue +34 -0
  34. package/app/theme/menu/index.vue +26 -0
  35. package/app/theme/menu/menu-item.vue +58 -0
  36. package/app/theme/menu/menu.vue +45 -0
  37. package/app/theme/status.vue +45 -0
  38. package/app/tsconfig.json +15 -0
  39. package/client/components/chat/image.vue +20 -0
  40. package/client/components/chat/overlay.vue +231 -0
  41. package/client/components/chat/utils.ts +6 -0
  42. package/client/components/common/index.ts +11 -0
  43. package/client/components/common/k-button.vue +127 -0
  44. package/client/components/common/k-hint.vue +62 -0
  45. package/client/components/common/k-tab.vue +50 -0
  46. package/client/components/dynamic.vue +61 -0
  47. package/client/components/icons/activity/default.vue +6 -0
  48. package/client/components/icons/activity/ellipsis.vue +6 -0
  49. package/client/components/icons/activity/home.vue +13 -0
  50. package/client/components/icons/activity/moon.vue +6 -0
  51. package/client/components/icons/activity/settings.vue +6 -0
  52. package/client/components/icons/activity/sun.vue +6 -0
  53. package/client/components/icons/index.ts +133 -0
  54. package/client/components/icons/style.scss +3 -0
  55. package/client/components/icons/svg/arrow-left.vue +6 -0
  56. package/client/components/icons/svg/arrow-right.vue +6 -0
  57. package/client/components/icons/svg/box-open.vue +6 -0
  58. package/client/components/icons/svg/check-full.vue +6 -0
  59. package/client/components/icons/svg/chevron-down.vue +6 -0
  60. package/client/components/icons/svg/chevron-left.vue +6 -0
  61. package/client/components/icons/svg/chevron-right.vue +6 -0
  62. package/client/components/icons/svg/chevron-up.vue +6 -0
  63. package/client/components/icons/svg/clipboard-list.vue +6 -0
  64. package/client/components/icons/svg/edit.vue +6 -0
  65. package/client/components/icons/svg/exclamation-full.vue +6 -0
  66. package/client/components/icons/svg/expand.vue +6 -0
  67. package/client/components/icons/svg/file-archive.vue +6 -0
  68. package/client/components/icons/svg/filter.vue +6 -0
  69. package/client/components/icons/svg/github.vue +6 -0
  70. package/client/components/icons/svg/gitlab.vue +6 -0
  71. package/client/components/icons/svg/info-full.vue +6 -0
  72. package/client/components/icons/svg/koishi.vue +6 -0
  73. package/client/components/icons/svg/link.vue +6 -0
  74. package/client/components/icons/svg/paper-plane.vue +6 -0
  75. package/client/components/icons/svg/question-empty.vue +6 -0
  76. package/client/components/icons/svg/redo.vue +6 -0
  77. package/client/components/icons/svg/search-minus.vue +6 -0
  78. package/client/components/icons/svg/search-plus.vue +6 -0
  79. package/client/components/icons/svg/search.vue +6 -0
  80. package/client/components/icons/svg/star-empty.vue +6 -0
  81. package/client/components/icons/svg/star-full.vue +6 -0
  82. package/client/components/icons/svg/start.vue +6 -0
  83. package/client/components/icons/svg/tag.vue +6 -0
  84. package/client/components/icons/svg/times-full.vue +6 -0
  85. package/client/components/icons/svg/tools.vue +6 -0
  86. package/client/components/icons/svg/undo.vue +6 -0
  87. package/client/components/icons/svg/user.vue +6 -0
  88. package/client/components/index.ts +75 -0
  89. package/client/components/layout/card.vue +69 -0
  90. package/client/components/layout/content.vue +37 -0
  91. package/client/components/layout/empty.vue +41 -0
  92. package/client/components/layout/index.ts +15 -0
  93. package/client/components/layout/tab-group.vue +45 -0
  94. package/client/components/layout/tab-item.vue +35 -0
  95. package/client/components/link.ts +39 -0
  96. package/client/components/perms.vue +73 -0
  97. package/client/components/slot.ts +95 -0
  98. package/client/context.ts +143 -0
  99. package/client/data.ts +178 -0
  100. package/client/index.ts +76 -0
  101. package/client/plugins/action.ts +251 -0
  102. package/client/plugins/i18n.ts +43 -0
  103. package/client/plugins/loader.ts +154 -0
  104. package/client/plugins/router.ts +278 -0
  105. package/client/plugins/setting.ts +217 -0
  106. package/client/plugins/theme.ts +134 -0
  107. package/client/shims.d.ts +55 -0
  108. package/client/tsconfig.json +15 -0
  109. package/client/utils.ts +36 -0
  110. package/global.d.ts +24 -0
  111. package/lib/bin.d.ts +1 -0
  112. package/lib/bin.mjs +112 -0
  113. package/lib/client-CaTn_gVG.mjs +125 -0
  114. package/lib/index.d.ts +20 -0
  115. package/lib/index.mjs +2 -0
  116. package/lib/src-Bxzzi1Rx.mjs +160 -0
  117. package/lib/yaml-UzQbvquH.mjs +23 -0
  118. package/package.json +66 -0
  119. package/src/bin.ts +61 -0
  120. package/src/index.ts +280 -0
  121. package/src/yaml.ts +22 -0
package/client/data.ts ADDED
@@ -0,0 +1,178 @@
1
+ /// <reference path="./shims.d.ts" />
2
+ import type { Promisify, Universal } from "@koishi-ce/koishi";
3
+ import type {
4
+ ClientConfig,
5
+ Console,
6
+ DataService,
7
+ Events,
8
+ } from "@koishi-ce/plugin-console";
9
+ import { markRaw, reactive, ref } from "vue";
10
+ import type { Context } from "./context";
11
+
12
+ /**
13
+ * 全局数据仓库的类型:按服务名映射到该 DataService 推送的负载数据。
14
+ * 服务端每类数据(entry、schema、permissions 等)对应 Store 的一个键。
15
+ */
16
+ export type Store = {
17
+ [K in keyof Console.Services]?: Console.Services[K] extends DataService<
18
+ infer T
19
+ >
20
+ ? T
21
+ : never;
22
+ };
23
+
24
+ // 由构建期注入的客户端配置(见 @koishi-ce/plugin-console 的 define 替换)
25
+ declare const KOISHI_CONFIG: ClientConfig;
26
+ /** 服务端下发的客户端配置(uiPath、heartbeat、proxyBase 等),只读 */
27
+ export const global = KOISHI_CONFIG;
28
+ /** 全局响应式数据仓库:服务端通过 data/patch 事件驱动其更新 */
29
+ export const store = reactive<Store>({});
30
+
31
+ /**
32
+ * 为资源 URL 拼上代理前缀。
33
+ * 控制台在反向代理后部署时,静态资源需经由 proxyBase 转发。
34
+ */
35
+ export function withProxy(url: string) {
36
+ return (global.proxyBase || "") + url;
37
+ }
38
+
39
+ /** 当前与服务端的 WebSocket 连接(未连接时为 null) */
40
+ export const socket = ref<Universal.WebSocket | null>(null);
41
+ /** 按事件名登记的推送监听器(receive 注册) */
42
+ const listeners: Record<string, (data: unknown) => void> = {};
43
+ /** 按报文 id 暂存的 RPC 应答钩子 [resolve, reject](send 写入) */
44
+ const responseHooks: Record<
45
+ string,
46
+ [(value: unknown) => void, (reason?: unknown) => void]
47
+ > = {};
48
+
49
+ /**
50
+ * 向服务端发起一次 RPC 调用。
51
+ *
52
+ * 通过 WebSocket 发送 `{ id, type, args }` 报文,并以随机 id 关联
53
+ * 异步应答(见下方 "response" 监听);超时 60s 后 reject。
54
+ * @param type 事件名(对应服务端 Events 接口的方法名)
55
+ * @param args 调用参数
56
+ */
57
+ export function send<T extends keyof Events>(
58
+ type: T,
59
+ ...args: Parameters<Events[T]>
60
+ ): Promisify<ReturnType<Events[T]>>;
61
+ export function send(type: string, ...args: unknown[]) {
62
+ if (!socket.value) return;
63
+ console.debug("↑%c", "color:brown", type, args);
64
+ const id = Math.random().toString(36).slice(2, 9);
65
+ socket.value.send(JSON.stringify({ id, type, args }));
66
+ return new Promise((resolve, reject) => {
67
+ responseHooks[id] = [resolve, reject];
68
+ setTimeout(() => {
69
+ delete responseHooks[id];
70
+ reject(new Error("timeout"));
71
+ }, 60000);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * 注册服务端主动推送事件的监听器(每类事件仅保留最后一个监听)。
77
+ * @param event 事件名,如 "data" / "patch" / "response" / "entry-data"
78
+ */
79
+ export function receive<T = unknown>(
80
+ event: string,
81
+ listener: (data: T) => void,
82
+ ) {
83
+ // 注册表按 unknown 分发实际报文,监听器的载荷类型由调用方以泛型参数自行声明
84
+ listeners[event] = listener as (data: unknown) => void;
85
+ }
86
+
87
+ /** Store 各键负载数据的联合(服务端推送的合法取值集合) */
88
+ type StoreValue = Store[keyof Store];
89
+
90
+ // 服务端整表推送:直接覆盖 store 中对应键
91
+ // (key 为联合类型时直写 store[key] 会被要求交叉类型,故经 Record 视图写入)
92
+ receive<{ key: keyof Store; value: unknown }>("data", ({ key, value }) => {
93
+ (store as Record<keyof Store, StoreValue>)[key] = value as StoreValue;
94
+ });
95
+
96
+ // 服务端增量推送:数组做追加,对象做浅合并
97
+ receive<{ key: keyof Store; value: unknown }>("patch", ({ key, value }) => {
98
+ const current = store[key];
99
+ if (Array.isArray(current)) {
100
+ (current as unknown[]).push(...(value as unknown[]));
101
+ } else if (current) {
102
+ Object.assign(current, value);
103
+ }
104
+ });
105
+
106
+ // RPC 应答分发:按报文 id 找到 send() 留下的 resolve/reject 并结算
107
+ receive<{ id: string; value?: unknown; error?: unknown }>(
108
+ "response",
109
+ ({ id, value, error }) => {
110
+ const hooks = responseHooks[id];
111
+ if (!hooks) return;
112
+ delete responseHooks[id];
113
+ const [resolve, reject] = hooks;
114
+ if (error) {
115
+ reject(error);
116
+ } else {
117
+ resolve(value);
118
+ }
119
+ },
120
+ );
121
+
122
+ /**
123
+ * 建立 WebSocket 连接并维护其生命周期。
124
+ *
125
+ * - 开启心跳:按配置间隔发送 ping,超时未收到消息则主动断开;
126
+ * - 断线重连:清空 store 后 1s 重试,重连成功则刷新页面以恢复完整状态。
127
+ * @param ctx 根 Context,用于把消息转发为 cordis 事件
128
+ * @param callback 创建 WebSocket 实例的工厂函数
129
+ */
130
+ export function connect(ctx: Context, callback: () => Universal.WebSocket) {
131
+ const value = callback();
132
+
133
+ let sendTimer: number;
134
+ let closeTimer: number;
135
+ // 每收到一条消息就重置两个计时器:interval 到点发 ping,
136
+ // timeout 到点仍无消息则强制断线(触发 reconnect)
137
+ const refresh = () => {
138
+ if (!global.heartbeat) return;
139
+ clearTimeout(sendTimer);
140
+ clearTimeout(closeTimer);
141
+ sendTimer = +setTimeout(() => send("ping"), global.heartbeat.interval);
142
+ closeTimer = +setTimeout(() => value?.close(), global.heartbeat.timeout);
143
+ };
144
+
145
+ const reconnect = () => {
146
+ socket.value = null;
147
+ // 清空本地数据仓库:断线期间的数据已不可信,等待重连后重新拉取
148
+ for (const key in store) {
149
+ (store as Record<string, unknown>)[key] = undefined;
150
+ }
151
+ console.log("[koishi] websocket disconnected, will retry in 1s...");
152
+ setTimeout(() => {
153
+ connect(ctx, callback).then(location.reload, () => {
154
+ console.log("[koishi] websocket disconnected, will retry in 1s...");
155
+ });
156
+ }, 1000);
157
+ };
158
+
159
+ value.addEventListener("message", (ev) => {
160
+ refresh();
161
+ const data = JSON.parse(ev.data);
162
+ console.debug("↓%c", "color:purple", data.type, data.body);
163
+ if (data.type in listeners) {
164
+ listeners[data.type]?.(data.body);
165
+ }
166
+ ctx.emit(data.type, data.body);
167
+ });
168
+
169
+ value.addEventListener("close", reconnect);
170
+
171
+ return new Promise<Universal.WebSocket.Event>((resolve, reject) => {
172
+ value.addEventListener("open", (event) => {
173
+ socket.value = markRaw(value);
174
+ resolve(event);
175
+ });
176
+ value.addEventListener("error", reject);
177
+ });
178
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @koishi_ce/client 包的主入口(Koishi 控制台前端组件库)。
3
+ *
4
+ * 职责:
5
+ * - 创建根 Context(`root`),安装内置组件库并启动六个核心服务
6
+ * (action / i18n / loader / router / setting / theme);
7
+ * - 通过 `export *` 汇总对外暴露组件、数据层与各服务插件的公共 API,
8
+ * 供宿主 app 与各控制台插件统一从 `@koishi-ce/client` 导入;
9
+ * - 声明可被其它插件以 `declare module` 合并增强的 `ActionContext`、
10
+ * `Config` 接口。
11
+ */
12
+ import install from "./components";
13
+ import { Context } from "./context";
14
+
15
+ declare module "@koishi-ce/plugin-console" {
16
+ export interface ClientConfig {
17
+ /** 服务端声明不支持的功能列表(版本不兼容时用于前端降级提示) */
18
+ unsupported?: string[];
19
+ }
20
+ }
21
+
22
+ // Satori 协议类型的两个命名空间别名:旧代码多用 Universal,新代码建议用 Satori
23
+ export * as Satori from "@satorijs/protocol";
24
+ export * as Universal from "@satorijs/protocol";
25
+ // cordis 以 ambient const enum 声明 ScopeStatus,verbatimModuleSyntax 下
26
+ // 禁止对其实施运行时访问/再导出(TS2748);这里按等价值展开为 const 对象,
27
+ // 与 cordis(@cordisjs/core)运行时导出的枚举数值保持一致
28
+ export const ScopeStatus = {
29
+ PENDING: 0,
30
+ LOADING: 1,
31
+ ACTIVE: 2,
32
+ FAILED: 3,
33
+ DISPOSED: 4,
34
+ } as const;
35
+
36
+ export type ScopeStatus = (typeof ScopeStatus)[keyof typeof ScopeStatus];
37
+ export * from "./components";
38
+ export * from "./context";
39
+ export * from "./data";
40
+ export * from "./plugins/action";
41
+ export * from "./plugins/i18n";
42
+ export * from "./plugins/loader";
43
+ export * from "./plugins/router";
44
+ export * from "./plugins/setting";
45
+ export * from "./plugins/theme";
46
+ export { Service } from "./utils";
47
+
48
+ /** 组件库安装函数(在 Vue app 上注册全部内置组件) */
49
+ export default install;
50
+
51
+ // 各插件通过 `declare module "@koishi-ce/client"` 对该接口做合并增强,
52
+ // 因此必须是 interface 而非类型别名(同 context.ts 的 Internal)
53
+ // biome-ignore lint/suspicious/noEmptyInterface: 需要保持 interface 以支持声明合并
54
+ export interface ActionContext {}
55
+
56
+ /** 客户端本地配置(持久化于 localStorage,读写见 plugins/setting.ts) */
57
+ export interface Config {
58
+ /** 界面语言,如 "zh-CN" / "en-US" */
59
+ locale?: string;
60
+ }
61
+
62
+ /** 根 Context:整个控制台前端共享的唯一上下文实例 */
63
+ export const root = new Context();
64
+
65
+ // 在根 Vue 应用上安装内置组件库(组件注册 + schema 扩展注册)
66
+ root.app.use(install);
67
+
68
+ // activity 事件的兜底监听:正常传入的 activity 实例恒为真值,
69
+ // 故默认不禁用任何页面;插件可另行监听 "activity" 事件按需隐藏页面
70
+ root.on("activity", (data) => !data);
71
+
72
+ /** @deprecated 已废弃,请改用 `useRouter()` */
73
+ export const router = root.$router.router;
74
+
75
+ /** @deprecated 已废弃,请改用 `useRouter()` */
76
+ export const activities = root.$router.pages;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * 动作(action)与菜单服务:控制台的快捷键、右键菜单、
3
+ * 作用域变量的统一管理。
4
+ *
5
+ * 插件通过 `ctx.action()` 注册动作、`ctx.menu()` 注册菜单条目、
6
+ * `ctx.define()` 暴露作用域变量;组件通过 `useMenu()` 在元素上
7
+ * 绑定右键菜单。作用域变量支持 "a.b" 点分键,经 Flatten 类型
8
+ * 与运行时代理呈现为嵌套对象。
9
+ */
10
+ import { type Dict, type Intersect, remove } from "cosmokit";
11
+ import {
12
+ type MaybeRefOrGetter,
13
+ markRaw,
14
+ reactive,
15
+ shallowReactive,
16
+ toValue,
17
+ } from "vue";
18
+ import type { ActionContext } from "..";
19
+ import { type Context, useContext } from "../context";
20
+ import { insert, Service } from "../utils";
21
+
22
+ declare module "../context" {
23
+ interface Context {
24
+ $action: ActionService;
25
+ action(id: string, options: ActionOptions): () => void;
26
+ menu(id: string, items: MenuItem[]): () => void;
27
+ define<K extends keyof ActionContext>(
28
+ key: K,
29
+ value: MaybeRefOrGetter<ActionContext[K]>,
30
+ ): () => void;
31
+ }
32
+
33
+ interface Internal {
34
+ scope: Store<ActionContext>;
35
+ menus: Dict<MenuItem[]>;
36
+ actions: Dict<ActionOptions>;
37
+ activeMenus: ActiveMenu[];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 动作(action)注册选项。
43
+ * 动作是可被快捷键或菜单触发的操作,其执行时可读取当前上下文作用域。
44
+ */
45
+ export interface ActionOptions {
46
+ /** 触发快捷键,如 "Control+S"(macOS 上 ctrl 自动映射为 meta) */
47
+ shortcut?: string;
48
+ /** 返回 true 时在菜单中隐藏该动作 */
49
+ hidden?: (scope: Flatten<ActionContext>) => boolean;
50
+ /** 返回 true 时禁用该动作(快捷键与菜单均不触发) */
51
+ disabled?: (scope: Flatten<ActionContext>) => boolean;
52
+ /** 动作本体,传入当前作用域执行 */
53
+ action: (scope: Flatten<ActionContext>) => unknown;
54
+ }
55
+
56
+ /** 旧版菜单项写法(字段与 ActionOptions 部分重叠),仅为兼容保留 */
57
+ export type LegacyMenuItem = Partial<ActionOptions> & Omit<MenuItem, "id">;
58
+
59
+ /** 菜单项:label / type / icon 均支持写成随作用域变化的 getter */
60
+ export interface MenuItem {
61
+ id: string;
62
+ label?: MaybeGetter<string>;
63
+ type?: MaybeGetter<string>;
64
+ icon?: MaybeGetter<string>;
65
+ order?: number;
66
+ }
67
+
68
+ /** 值或(以作用域为参数的)取值函数 */
69
+ export type MaybeGetter<T> = T | ((scope: Flatten<ActionContext>) => T);
70
+
71
+ /** 作用域存储:扁平键到「值或 ref 或 getter」的映射 */
72
+ type Store<S extends {}> = { [K in keyof S]?: MaybeRefOrGetter<S[K]> };
73
+
74
+ /**
75
+ * 把 "a.b.c" 形式的扁平键名归并为 { a: { b: { c } } } 的嵌套结构,
76
+ * 供动作/菜单以对象路径的方式消费作用域。
77
+ */
78
+ type Flatten<S extends {}> = Intersect<
79
+ {
80
+ [K in keyof S]: K extends `${infer L}.${infer R}`
81
+ ? { [P in L]: Flatten<{ [P in R]: S[K] }> }
82
+ : { [P in K]: S[K] };
83
+ }[keyof S]
84
+ >;
85
+
86
+ /** 当前激活的右键菜单:id 加上弹出位置(四边坐标) */
87
+ export interface ActiveMenu {
88
+ id: string;
89
+ relative: {
90
+ left: number;
91
+ top: number;
92
+ right: number;
93
+ bottom: number;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 创建右键菜单触发器:返回值直接绑到元素的 contextmenu 事件上。
99
+ * 触发时把附加值写入作用域,并在鼠标位置弹出自定义菜单(由 global 插槽渲染)。
100
+ * @param id 菜单标识,menu() 注册的条目将展示在该菜单中
101
+ */
102
+ export function useMenu<K extends keyof ActionContext>(id: K) {
103
+ const ctx = useContext();
104
+ return (event: MouseEvent, value: MaybeRefOrGetter<ActionContext[K]>) => {
105
+ ctx.define(id, value);
106
+ event.preventDefault();
107
+ const { clientX, clientY } = event;
108
+ ctx.internal.activeMenus.splice(0, Infinity, {
109
+ id,
110
+ relative: {
111
+ left: clientX,
112
+ top: clientY,
113
+ right: clientX,
114
+ bottom: clientY,
115
+ },
116
+ });
117
+ };
118
+ }
119
+
120
+ /**
121
+ * 动作服务:管理全局快捷键、菜单与作用域变量。
122
+ *
123
+ * - `ctx.action()` 注册动作(可含快捷键,由全局 keydown 统一分发);
124
+ * - `ctx.menu()` 向具名菜单追加条目;
125
+ * - `ctx.define()` 声明作用域变量,供动作/菜单项在执行时读取。
126
+ */
127
+ export default class ActionService extends Service {
128
+ constructor(ctx: Context) {
129
+ super(ctx, "$action", true);
130
+ ctx.mixin("$action", ["action", "menu", "define"]);
131
+
132
+ ctx.internal.scope = shallowReactive({} as Store<ActionContext>);
133
+ ctx.internal.menus = reactive({});
134
+ ctx.internal.actions = reactive({});
135
+ ctx.internal.activeMenus = reactive([]);
136
+
137
+ // 全局快捷键:解析每个动作的 shortcut 描述串,
138
+ // 与键盘事件逐项比对(ctrl 在 macOS 平台映射为 meta 键)
139
+ ctx.addEventListener("keydown", (event) => {
140
+ const scope = this.createScope();
141
+ for (const action of Object.values(ctx.internal.actions)) {
142
+ if (!action.shortcut) continue;
143
+ const keys = action.shortcut
144
+ .split("+")
145
+ .map((key) => key.toLowerCase().trim());
146
+ let ctrlKey = false,
147
+ shiftKey = false,
148
+ metaKey = false,
149
+ code: string | undefined;
150
+ for (const key of keys) {
151
+ switch (key) {
152
+ case "shift":
153
+ shiftKey = true;
154
+ continue;
155
+ case "ctrl":
156
+ // macOS 没有 ctrl+字母 的快捷键惯例,改用 Cmd(meta)
157
+ if (navigator.platform.toLowerCase().includes("mac")) {
158
+ metaKey = true;
159
+ } else {
160
+ ctrlKey = true;
161
+ }
162
+ continue;
163
+ default:
164
+ code = key;
165
+ }
166
+ }
167
+ if (ctrlKey !== event.ctrlKey) continue;
168
+ if (shiftKey !== event.shiftKey) continue;
169
+ if (metaKey !== event.metaKey) continue;
170
+ if (code !== event.key.toLowerCase()) continue;
171
+ if (action.disabled?.(scope)) continue;
172
+ event.preventDefault();
173
+ action.action(scope);
174
+ }
175
+ });
176
+ }
177
+
178
+ /** 注册动作;返回取消注册函数 */
179
+ action(id: string, options: ActionOptions) {
180
+ markRaw(options);
181
+ return this.ctx.effect(() => {
182
+ this.ctx.internal.actions[id] = options;
183
+ return () => delete this.ctx.internal.actions[id];
184
+ });
185
+ }
186
+
187
+ /** 向具名菜单追加条目(按 order 插入有序列表);返回取消注册函数 */
188
+ menu(id: string, items: MenuItem[]) {
189
+ return this.ctx.effect(() => {
190
+ const list = (this.ctx.internal.menus[id] ||= []);
191
+ items.forEach((item) => insert(list, item));
192
+ return () => {
193
+ items.forEach((item) => remove(list, item));
194
+ if (!list.length) delete this.ctx.internal.menus[id];
195
+ };
196
+ });
197
+ }
198
+
199
+ /** 声明一个作用域变量(组件卸载/插件停止时自动移除);返回取消函数 */
200
+ define<K extends keyof ActionContext>(
201
+ key: K,
202
+ value: MaybeRefOrGetter<ActionContext[K]>,
203
+ ) {
204
+ return this.ctx.effect(() => {
205
+ // 泛型键直写映射属性会被要求交叉类型,经字符串索引视图写入;
206
+ // 载荷以 unknown 收纳,具体类型由 ActionContext 的声明合并保证
207
+ const scope = this.ctx.internal.scope as Record<
208
+ string,
209
+ MaybeRefOrGetter<unknown>
210
+ >;
211
+ scope[key as string] = value;
212
+ return () => delete this.ctx.internal.scope[key];
213
+ });
214
+ }
215
+
216
+ /**
217
+ * 基于当前已注册的作用域变量构造快照对象(嵌套代理形式),
218
+ * 可用 override 临时覆盖若干键值。
219
+ */
220
+ createScope(override = {}) {
221
+ const scope = { ...this.ctx.internal.scope, ...override };
222
+ return createScope(scope);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 把扁平键存储包装成嵌套对象的代理:
228
+ * 读取 "a" 时若存在 "a.b" 等子键,则递归返回子级作用域代理;
229
+ * 命中普通键则经 toValue 解析 ref/getter 后返回实际值。
230
+ */
231
+ function createScope(
232
+ scope: Store<ActionContext>,
233
+ prefix = "",
234
+ ): Flatten<ActionContext> {
235
+ return new Proxy({} as Record<string, unknown>, {
236
+ get: (target, key) => {
237
+ if (typeof key === "symbol")
238
+ return (target as Record<symbol, unknown>)[key];
239
+ key = prefix + key;
240
+ const source = scope as Record<string, MaybeRefOrGetter<unknown>>;
241
+ if (key in scope) return toValue(source[key]);
242
+ // 键本身不存在,但存在以其为前缀的子键:返回子级作用域代理
243
+ const _prefix = `${key}.`;
244
+ if (Object.keys(scope).some((k) => k.startsWith(_prefix))) {
245
+ return createScope(scope, `${key}.`);
246
+ }
247
+ // 完全未命中:显式返回 undefined(noImplicitReturns)
248
+ return undefined;
249
+ },
250
+ }) as Flatten<ActionContext>;
251
+ }
@@ -0,0 +1,43 @@
1
+ import { watchEffect } from "vue";
2
+ import { createI18n } from "vue-i18n";
3
+ import type { Context } from "../context";
4
+ import { Service } from "../utils";
5
+ import { useConfig } from "./setting";
6
+
7
+ declare module "../context" {
8
+ interface Context {
9
+ $i18n: I18nService;
10
+ }
11
+ }
12
+
13
+ // 客户端本地配置(未与服务端同步前的原始值)
14
+ const config = useConfig();
15
+
16
+ /**
17
+ * 国际化服务:持有 vue-i18n 实例(默认回退语言 zh-CN),
18
+ * 并监听本地配置中的 locale 变化实时切换界面语言。
19
+ * 各扩展自身的语言包由 loader 加载时注入该实例。
20
+ */
21
+ export default class I18nService extends Service {
22
+ public i18n = createI18n({
23
+ legacy: false,
24
+ fallbackLocale: "zh-CN",
25
+ });
26
+
27
+ constructor(ctx: Context) {
28
+ super(ctx, "$i18n", true);
29
+
30
+ // flush: "post" 确保在语言切换引起重渲染前完成 locale 写入
31
+ ctx.effect(() =>
32
+ watchEffect(
33
+ () => {
34
+ const locale = config.value.locale;
35
+ if (locale) {
36
+ this.i18n.global.locale.value = locale;
37
+ }
38
+ },
39
+ { flush: "post" },
40
+ ),
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,154 @@
1
+ import type { EntryData } from "@koishi-ce/plugin-console";
2
+ import type { EffectScope, Plugin } from "cordis";
3
+ import type { Dict } from "cosmokit";
4
+ import { type Ref, ref, shallowReactive, watch } from "vue";
5
+ import type { Context } from "../context";
6
+ import { receive, store } from "../data";
7
+ import { Service } from "../utils";
8
+
9
+ declare module "../context" {
10
+ interface Context {
11
+ $loader: LoaderService;
12
+ extension?: LoadResult;
13
+ }
14
+ }
15
+
16
+ /** 取消函数 */
17
+ export type Disposable = () => void;
18
+ /** 扩展入口函数:以插件形式安装到控制台上下文 */
19
+ export type Extension = (ctx: Context) => void;
20
+
21
+ /** 标记一个函数为扩展入口(仅类型层面,便于构建工具识别) */
22
+ export function defineExtension(callback: Extension) {
23
+ return callback;
24
+ }
25
+
26
+ /** 兼容 rollup 与 vite 两种产物:优先取 default 导出 */
27
+ export function unwrapExports(module: unknown): Plugin {
28
+ const record = module as { default?: unknown } | null | undefined;
29
+ return (record?.default || module) as Plugin;
30
+ }
31
+
32
+ /** 按文件后缀分发的加载器:样式表插入 <link>,其余按动态模块导入 */
33
+ const loaders: Dict<(ctx: Context, url: string) => Promise<void>> = {
34
+ async [`.css`](ctx, url) {
35
+ const link = document.createElement("link");
36
+ link.rel = "stylesheet";
37
+ link.href = url;
38
+ // 等待样式真正加载完成后再插入文档,避免无样式闪烁;
39
+ // link 的挂载/卸载纳入 ctx.effect,随扩展作用域销毁而移除
40
+ await new Promise((resolve, reject) => {
41
+ link.onload = resolve;
42
+ link.onerror = reject;
43
+ ctx.effect(() => {
44
+ document.head.appendChild(link);
45
+ return () => document.head.removeChild(link);
46
+ });
47
+ });
48
+ },
49
+ async [``](ctx, url) {
50
+ const exports = await import(/* @vite-ignore */ url);
51
+ // 扩展入口均为函数式插件;断言到 Function 形状以走 config 可选的重载
52
+ ctx.plugin(unwrapExports(exports) as Plugin.Function, ctx.extension?.data);
53
+ },
54
+ };
55
+
56
+ /** 单个扩展的加载结果:独立作用域 + 数据 ref + 完成标记 */
57
+ export interface LoadResult {
58
+ scope: EffectScope;
59
+ paths?: string[] | undefined;
60
+ done: Ref<boolean>;
61
+ data: Ref;
62
+ }
63
+
64
+ /**
65
+ * 扩展加载服务:根据服务端推送的 entry 数据动态加载各控制台扩展
66
+ * (样式表与 JS 模块),并为每个扩展创建隔离的作用域。
67
+ *
68
+ * 服务端入口集合变化时做增量同步:新增的加载、消失的卸载;
69
+ * 后端实例 id 变化(服务重启)则整页刷新。
70
+ */
71
+ export default class LoaderService extends Service {
72
+ /** 最近一次见到的后端实例 id(用于检测服务端重启) */
73
+ private backendId: string | undefined;
74
+
75
+ /** 当前已加载扩展的 id → LoadResult 映射 */
76
+ public extensions: Dict<LoadResult> = shallowReactive({} as Dict<LoadResult>);
77
+
78
+ constructor(ctx: Context) {
79
+ super(ctx, "$loader", true);
80
+
81
+ // 服务端推送扩展入口的随附数据(entry-data):
82
+ // 同步到 store.entry 并刷新已加载扩展的 data ref
83
+ receive<{ id: string; data: unknown }>("entry-data", ({ id, data }) => {
84
+ const entry = store.entry?.[id];
85
+ if (!entry) return;
86
+ entry.data = data;
87
+ const extension = this.extensions[id];
88
+ if (extension) extension.data.value = data;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * 初始加载任务:监听 store.entry(deep),完成首次全量加载时 resolve。
94
+ * context.ts 的 ready 阶段会 await 本任务,再挂载 i18n / router。
95
+ */
96
+ initTask = new Promise<void>((resolve) => {
97
+ watch(
98
+ () => store.entry,
99
+ async (newValue, oldValue) => {
100
+ // _id 标识后端实例:变化说明服务端已重启,页面状态不再可信
101
+ const { _id, ...rest } = (newValue || {}) as Dict<EntryData> & {
102
+ _id?: string | undefined;
103
+ };
104
+ if (this.backendId && _id && this.backendId !== _id) {
105
+ window.location.reload();
106
+ return;
107
+ }
108
+ this.backendId = _id;
109
+
110
+ // 增量卸载:入口列表中已消失的扩展,销毁其作用域
111
+ for (const key in this.extensions) {
112
+ if (rest[key]) continue;
113
+ this.extensions[key]?.scope.dispose();
114
+ delete this.extensions[key];
115
+ }
116
+
117
+ await Promise.all(
118
+ Object.entries(rest).map(([key, { files, paths, data }]) => {
119
+ if (this.extensions[key]) return;
120
+ // 每个扩展在隔离的 "extension" 作用域中运行,
121
+ // 并通过 ctx.extension 拿到自己的加载结果
122
+ const scope = this.ctx.isolate("extension").plugin(() => {});
123
+ scope.ctx.extension = this.extensions[key] = {
124
+ done: ref(false),
125
+ scope,
126
+ paths,
127
+ data: ref(data),
128
+ };
129
+ // 依次加载入口声明的全部文件,按后缀分发给对应 loader
130
+ const task = Promise.all(
131
+ files.map((url) => {
132
+ for (const ext in loaders) {
133
+ if (!url.endsWith(ext)) continue;
134
+ return loaders[ext]?.(scope.ctx, url);
135
+ }
136
+ // 无匹配后缀的入口文件:显式跳过(noImplicitReturns)
137
+ return undefined;
138
+ }),
139
+ );
140
+ // 加载完成即标记 done(扩展加载失败不阻塞界面可用性)
141
+ void task.finally(() => {
142
+ const extension = this.extensions[key];
143
+ if (extension) extension.done.value = true;
144
+ });
145
+ }),
146
+ );
147
+
148
+ // 首次加载完成(oldValue 为空)时放行 initTask
149
+ if (!oldValue) resolve();
150
+ },
151
+ { deep: true },
152
+ );
153
+ });
154
+ }