@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
@@ -0,0 +1,278 @@
1
+ /**
2
+ * 路由服务:控制台页面(activity)与插槽视图的注册中心。
3
+ *
4
+ * 封装 vue-router,处理三件事:
5
+ * 1. `ctx.page()` 注册页面——挂路由、进侧栏、管理标题与缓存路径;
6
+ * 2. `ctx.slot()` 向具名插槽(layout / status / global 等)注入视图;
7
+ * 3. 导航守卫——首次导航等待扩展加载,未匹配路径暂存待页面注册后跳转。
8
+ */
9
+ import type { Disposable } from "cordis";
10
+ import { type Dict, omit, remove } from "cosmokit";
11
+ import {
12
+ type Component,
13
+ type MaybeRefOrGetter,
14
+ reactive,
15
+ ref,
16
+ toValue,
17
+ } from "vue";
18
+ import { createRouter, createWebHistory, START_LOCATION } from "vue-router";
19
+ import type { SlotOptions } from "../components";
20
+ import Overlay from "../components/chat/overlay.vue";
21
+ import type { Context } from "../context";
22
+ import { global, type Store, store } from "../data";
23
+ import { insert, Service } from "../utils";
24
+
25
+ declare module "vue-router" {
26
+ interface RouteMeta {
27
+ activity?: Activity;
28
+ }
29
+ }
30
+
31
+ declare module "../context" {
32
+ interface Context {
33
+ $router: RouterService;
34
+ slot(options: SlotOptions): () => void;
35
+ page(options: Activity.Options): () => void;
36
+ }
37
+
38
+ interface Events {
39
+ activity(activity: Activity): boolean;
40
+ }
41
+ }
42
+
43
+ /** activity(控制台页面)的注册选项 */
44
+ export namespace Activity {
45
+ export interface Options {
46
+ id?: string;
47
+ /** 路由路径,支持 vue-router 动态段(如 "/foo/:id") */
48
+ path: string;
49
+ /** 开启严格匹配(默认按路径前缀匹配) */
50
+ strict?: boolean;
51
+ component: Component;
52
+ /** 页面标题(未提供时回退到 id) */
53
+ name: MaybeRefOrGetter<string>;
54
+ /** 页面描述(副标题) */
55
+ desc?: MaybeRefOrGetter<string>;
56
+ /** 侧栏图标名,默认 "activity:default" */
57
+ icon?: MaybeRefOrGetter<string>;
58
+ /** 侧栏排序权重,越小越靠前 */
59
+ order?: number;
60
+ /** 访问所需权限等级(authority) */
61
+ authority?: number;
62
+ /** 在侧栏中的位置:顶部或底部 */
63
+ position?: "top" | "bottom";
64
+ /** 依赖的数据服务键:任一未就绪时页面不可进入 */
65
+ fields?: (keyof Store)[];
66
+ /** @deprecated */
67
+ when?: () => boolean;
68
+ disabled?: () => boolean;
69
+ }
70
+ }
71
+
72
+ // 实例上的 desc/name/icon 是 getter(toValue 解析后的值),与 Options 中
73
+ // MaybeRefOrGetter 形状不同,故从继承中排除后再声明
74
+ export interface Activity
75
+ extends Omit<Activity.Options, "desc" | "name" | "icon"> {
76
+ desc?: string | undefined;
77
+ name?: string | undefined;
78
+ icon?: string | undefined;
79
+ }
80
+
81
+ /** 从路由路径提取 activity id(首个非空路径段),如 "/foo/bar" → "foo" */
82
+ function getActivityId(path: string) {
83
+ return path.split("/").find(Boolean) ?? "";
84
+ }
85
+
86
+ /** 页面被卸载时暂存的目标路由:等待路由就绪后由 handleUpdate 跳转 */
87
+ export const redirectTo = ref<string | null>();
88
+
89
+ /**
90
+ * activity:控制台的一个页面(侧栏一项 + 一条 vue-router 路由)。
91
+ * 由 ctx.page() 创建,dispose 时移除路由并尝试回退到首页。
92
+ */
93
+ export class Activity {
94
+ id: string;
95
+ _disposables: Disposable[] = [];
96
+
97
+ ctx: Context;
98
+ options: Activity.Options;
99
+
100
+ constructor(ctx: Context, options: Activity.Options) {
101
+ this.ctx = ctx;
102
+ this.options = options;
103
+ options.order ??= 0;
104
+ options.position ??= "top";
105
+ Object.assign(this, omit(options, ["icon", "name", "desc", "disabled"]));
106
+ const { path, id = getActivityId(path), component } = options;
107
+ // 路由挂载时把 activity 实例放进 meta,供导航守卫与标题栏取用
108
+ this._disposables.push(
109
+ ctx.$router.router.addRoute({
110
+ path,
111
+ name: id,
112
+ component,
113
+ meta: { activity: this },
114
+ }),
115
+ );
116
+ this.id ??= id;
117
+ this.handleUpdate();
118
+ this.authority ??= 0;
119
+ this.fields ??= [];
120
+ ctx.$router.pages[this.id] = this;
121
+ }
122
+
123
+ /** 若存在待跳转目标且当前路由已能解析,则立即完成该跳转 */
124
+ handleUpdate() {
125
+ if (redirectTo.value) {
126
+ const location = this.ctx.$router.router.resolve(redirectTo.value);
127
+ if (location.matched.length) {
128
+ redirectTo.value = null;
129
+ this.ctx.$router.router.replace(location);
130
+ }
131
+ }
132
+ }
133
+
134
+ /** 页面图标名(默认 "activity:default",MaybeRefOrGetter 解析后的值) */
135
+ get icon() {
136
+ return toValue(this.options.icon ?? "activity:default");
137
+ }
138
+
139
+ /** 页面标题(默认取 id) */
140
+ get name() {
141
+ return toValue(this.options.name ?? this.id);
142
+ }
143
+
144
+ /** 页面描述 */
145
+ get desc() {
146
+ return toValue(this.options.desc);
147
+ }
148
+
149
+ /**
150
+ * 判断页面当前是否应被禁用:
151
+ * activity 事件被拦截(bail)、依赖数据未就绪、when/disabled 判否均视为禁用。
152
+ */
153
+ disabled() {
154
+ if (this.ctx.bail("activity", this)) return true;
155
+ if (!this.fields?.every((key) => store[key])) return true;
156
+ if (this.when && !this.when()) return true;
157
+ if (this.options.disabled?.()) return true;
158
+ return false;
159
+ }
160
+
161
+ dispose() {
162
+ this._disposables.forEach((dispose) => dispose());
163
+ // 若被卸载的恰是当前展示的页面:暂存目标路由,退回缓存的首页
164
+ const current = this.ctx.$router.router.currentRoute.value;
165
+ if (current?.meta?.activity === this) {
166
+ redirectTo.value = current.fullPath;
167
+ this.ctx.$router.router.push(this.ctx.$router.cache["home"] || "/");
168
+ }
169
+ return delete this.ctx.$router.pages[this.id];
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 路由服务:封装 vue-router,管理 activity 页面注册、插槽视图与页面缓存。
175
+ *
176
+ * - `ctx.page()` 注册 activity(页面);
177
+ * - `ctx.slot()` 向具名插槽(layout / status / global 等)注入视图;
178
+ * - `views` 为插槽名 → 视图列表,`pages` 为页面 id → Activity,
179
+ * `cache` 记录各页面最近一次访问的完整路径。
180
+ */
181
+ export default class RouterService extends Service {
182
+ /** 各插槽(按 type 分组)的视图列表 */
183
+ public views = reactive<Dict<SlotOptions[]>>({});
184
+ /** 页面 id → 最近访问的完整路径(用于"返回上次位置") */
185
+ public cache = reactive<Record<PropertyKey, string>>({});
186
+ /** 已注册的全部 activity 页面 */
187
+ public pages = reactive<Dict<Activity>>({});
188
+ public router = createRouter({
189
+ history: createWebHistory(global.uiPath),
190
+ // 复用全局 .active 类作为路由激活态样式
191
+ linkActiveClass: "active",
192
+ routes: [],
193
+ });
194
+
195
+ constructor(ctx: Context) {
196
+ super(ctx, "$router", true);
197
+ ctx.mixin("$router", ["slot", "page"]);
198
+
199
+ const initialTitle = document.title;
200
+ ctx.effect(() =>
201
+ this.router.afterEach((route) => {
202
+ const { name, fullPath } = this.router.currentRoute.value;
203
+ if (name) {
204
+ // 本应用的路由名均为字符串;reactive 收窄了索引签名,不含 symbol
205
+ this.cache[name as string] = fullPath;
206
+ }
207
+ // 进入 activity 页面时更新标签页标题为 "页面名 | 原标题"
208
+ if (route.meta.activity) {
209
+ document.title = `${route.meta.activity.name}`;
210
+ if (initialTitle) document.title += ` | ${initialTitle}`;
211
+ }
212
+ }),
213
+ );
214
+
215
+ this.router.beforeEach(async (to, from) => {
216
+ if (to.matched.length) {
217
+ // 已能匹配:目标是具体页面时清空待跳转标记
218
+ if (to.matched[0]?.path !== "/") {
219
+ redirectTo.value = null;
220
+ }
221
+ return;
222
+ }
223
+
224
+ // 首次导航(from 为起始位置)时等待扩展加载完,
225
+ // 页面路由由扩展注册,未加载前无法匹配
226
+ if (from === START_LOCATION) {
227
+ await ctx.$loader.initTask;
228
+ const resolved = this.router.resolve(to);
229
+ if (resolved.matched.length) return resolved;
230
+ }
231
+
232
+ // 仍无法匹配:暂存目标路径,先落回缓存首页(或 "/"),
233
+ // 待对应页面注册后由 Activity.handleUpdate() 自动跳转
234
+ redirectTo.value = to.fullPath;
235
+ const result = this.cache["home"] || "/";
236
+ if (result === to.fullPath) return;
237
+ return result;
238
+ });
239
+
240
+ // 图片查看器等全局浮层经 global 插槽挂载
241
+ this.slot({
242
+ type: "global",
243
+ component: Overlay,
244
+ });
245
+ }
246
+
247
+ /**
248
+ * 向具名插槽注入视图组件;返回取消注册函数。
249
+ * when 是 disabled 的旧写法(取反语义),仅做兼容转换。
250
+ */
251
+ slot(options: SlotOptions) {
252
+ options.order ??= 0;
253
+ const component = this.ctx.wrapComponent(options.component);
254
+ if (component) options.component = component;
255
+ if (options.when) {
256
+ const { when } = options;
257
+ options.disabled = () => !when();
258
+ }
259
+ return this.ctx.effect(() => {
260
+ const list = (this.views[options.type] ||= []);
261
+ insert(list, options);
262
+ return () => {
263
+ remove(list, options);
264
+ if (!list.length) delete this.views[options.type];
265
+ };
266
+ });
267
+ }
268
+
269
+ /** 注册 activity 页面(自动挂到 vue-router 与侧栏);返回取消注册函数 */
270
+ page(options: Activity.Options) {
271
+ const component = this.ctx.wrapComponent(options.component);
272
+ if (component) options.component = component;
273
+ return this.ctx.effect(() => {
274
+ const activity = new Activity(this.ctx, options);
275
+ return () => activity.dispose();
276
+ });
277
+ }
278
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 设置服务:控制台本地配置(localStorage)与设置面板的管理。
3
+ *
4
+ * 配置分两层:original(用户显式修改的原始字段)与 resolved
5
+ * (经所有插件 schema 补全后的完整配置),二者双向同步(见文末类注释)。
6
+ * 插件通过 `ctx.settings()` 注册设置分区、`ctx.schema()` 注册自定义控件。
7
+ */
8
+ import { type RemovableRef, useLocalStorage } from "@vueuse/core";
9
+ import { type Dict, remove } from "cosmokit";
10
+ import { type Component, computed, markRaw, reactive, ref, watch } from "vue";
11
+ import { Schema, SchemaBase } from "../../../components/client/index.ts";
12
+ import type { Config } from "..";
13
+ import type { Context } from "../context";
14
+ import { insert, type Ordered, Service } from "../utils";
15
+
16
+ declare module "../context" {
17
+ interface Context {
18
+ $setting: SettingService;
19
+ schema(extension: SchemaBase.Extension): () => void;
20
+ settings(options: SettingOptions): () => void;
21
+ }
22
+
23
+ interface Internal {
24
+ settings: Dict<SettingOptions[]>;
25
+ }
26
+ }
27
+
28
+ /** 设置面板条目:按 id 分组展示于"设置"页面各分区 */
29
+ interface SettingOptions extends Ordered {
30
+ /** 所属设置分区 id(空串为通用设置) */
31
+ id: string;
32
+ /** 分区标题 */
33
+ title?: string;
34
+ disabled?: () => boolean;
35
+ /** 该分区的配置 schema(渲染为表单) */
36
+ schema?: Schema;
37
+ /** 自定义渲染组件(替代 schema 表单) */
38
+ component?: Component;
39
+ }
40
+
41
+ /**
42
+ * 带版本号的 localStorage 存储。
43
+ * 版本号变化(或首次写入)时整体重置为初始值,避免旧结构残留。
44
+ * 以 "koishi.console." 为键前缀隔离。
45
+ */
46
+ export let useStorage = <T extends object>(
47
+ key: string,
48
+ version?: number,
49
+ fallback?: () => T,
50
+ ): RemovableRef<T> => {
51
+ const initial = (fallback ? fallback() : {}) as T & {
52
+ __version__?: number | undefined;
53
+ };
54
+ initial.__version__ = version;
55
+ const storage = useLocalStorage(`koishi.console.${key}`, initial);
56
+ if (storage.value.__version__ !== version) {
57
+ storage.value = initial;
58
+ }
59
+ return storage;
60
+ };
61
+
62
+ /** 替换默认的 useStorage 实现(宿主 app 用于接管持久化方式) */
63
+ export function provideStorage(factory: typeof useStorage) {
64
+ useStorage = factory;
65
+ }
66
+
67
+ interface StorageData<T> {
68
+ version: number;
69
+ data: T;
70
+ }
71
+
72
+ /** @deprecated 已废弃,请改用 `useConfig` */
73
+ export function createStorage<T extends object>(
74
+ key: string,
75
+ version: number,
76
+ fallback?: () => T,
77
+ ) {
78
+ const storage = useLocalStorage(
79
+ `koishi.console.${key}`,
80
+ {} as StorageData<T>,
81
+ );
82
+ const initial = fallback ? fallback() : ({} as T);
83
+ if (storage.value.version !== version) {
84
+ storage.value = { version, data: initial };
85
+ } else if (!Array.isArray(storage.value.data)) {
86
+ storage.value.data = { ...initial, ...storage.value.data };
87
+ }
88
+ return reactive<T>(storage.value["data"]);
89
+ }
90
+
91
+ // 默认配置本来就是「部分」配置:各插件向 Config 合并的必填字段
92
+ // (如 status)在此处并不存在,故做一次收窄断言以兼容任意增强
93
+ /** 用户实际保存的原始配置(localStorage,仅含用户改过的字段) */
94
+ export const original = useStorage<Config>(
95
+ "config",
96
+ undefined,
97
+ () =>
98
+ ({
99
+ theme: {
100
+ mode: "auto",
101
+ dark: "default-dark",
102
+ light: "default-light",
103
+ },
104
+ locale: "zh-CN",
105
+ }) as unknown as Config,
106
+ );
107
+
108
+ /** 经全部插件 schema 校验/补全后的完整配置(组件默认读取这一份) */
109
+ export const resolved = ref({} as Config);
110
+
111
+ /**
112
+ * 获取客户端配置。
113
+ * @param useOriginal 为 true 时返回用户原始存储(默认返回补全后的 resolved)
114
+ */
115
+ export const useConfig = (useOriginal = false) =>
116
+ useOriginal ? original : resolved;
117
+
118
+ /**
119
+ * 设置服务:管理控制台的本地配置与设置面板。
120
+ *
121
+ * 核心是 original 与 resolved 的双向同步:
122
+ * 用户改 resolved → simplify 后写回 original(只保留用户改过的字段);
123
+ * original 或 schema 变化 → 重新用 schema 解释得到完整 resolved。
124
+ */
125
+ export default class SettingService extends Service {
126
+ constructor(ctx: Context) {
127
+ super(ctx, "$setting", true);
128
+ ctx.mixin("$setting", {
129
+ settings: "settings",
130
+ extendSchema: "schema",
131
+ });
132
+
133
+ ctx.internal.settings = reactive({});
134
+
135
+ this.settings({
136
+ id: "",
137
+ title: "通用设置",
138
+ order: 1000,
139
+ schema: Schema.object({
140
+ locale: Schema.union(["zh-CN", "en-US"]).description("语言设置。"),
141
+ }).description("通用设置"),
142
+ });
143
+
144
+ // 汇总所有分区 schema 为一个相交对象,作为 resolved 的解释器
145
+ const schema = computed(() => {
146
+ const list: Schema[] = [];
147
+ for (const settings of Object.values(ctx.internal.settings)) {
148
+ for (const options of settings) {
149
+ if (options.schema) {
150
+ list.push(options.schema);
151
+ }
152
+ }
153
+ }
154
+ return Schema.intersect(list);
155
+ });
156
+
157
+ // 监听用户在表单上的修改:化简(只留显式设置的字段)后写回原始存储
158
+ const doWatch = () =>
159
+ watch(
160
+ resolved,
161
+ (value) => {
162
+ console.debug("config", JSON.stringify(value));
163
+ original.value = schema.value.simplify(value);
164
+ },
165
+ { deep: true },
166
+ );
167
+
168
+ let stop = doWatch();
169
+
170
+ // 重放同步:先停掉 resolved 监听,用 schema 重新解释 original,
171
+ // 再重启监听——避免解释过程触发的写回造成回环
172
+ const update = () => {
173
+ stop?.();
174
+ try {
175
+ resolved.value = schema.value(original.value);
176
+ } catch (error) {
177
+ console.error(error);
178
+ }
179
+ stop = doWatch();
180
+ };
181
+
182
+ ctx.effect(() => () => stop?.());
183
+
184
+ // 原始存储或 schema 集合任一变化,都重新生成 resolved
185
+ ctx.effect(() => watch(original, update, { deep: true }));
186
+ ctx.effect(() => watch(schema, update));
187
+ }
188
+
189
+ /**
190
+ * 注册一个 schema 扩展(自定义控件类型/角色),供设置表单渲染使用。
191
+ * 返回取消注册函数。
192
+ */
193
+ extendSchema(extension: SchemaBase.Extension) {
194
+ const component = this.ctx.wrapComponent(extension.component);
195
+ if (component) extension.component = component;
196
+ return this.ctx.effect(() => {
197
+ SchemaBase.extensions.add(extension);
198
+ return () => SchemaBase.extensions.delete(extension);
199
+ });
200
+ }
201
+
202
+ /** 向设置面板追加一个分区条目;返回取消注册函数 */
203
+ settings(options: SettingOptions) {
204
+ markRaw(options);
205
+ options.order ??= 0;
206
+ const component = this.ctx.wrapComponent(options.component);
207
+ if (component) options.component = component;
208
+ return this.ctx.effect(() => {
209
+ const list = (this.ctx.internal.settings[options.id] ||= []);
210
+ insert(list, options);
211
+ return () => {
212
+ remove(list, options);
213
+ if (!list.length) delete this.ctx.internal.settings[options.id];
214
+ };
215
+ });
216
+ }
217
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 主题服务:管理控制台的深浅色模式与主题注册。
3
+ *
4
+ * - 监听系统颜色偏好(auto 模式下自动跟随);
5
+ * - 向 <html> 写入 theme 属性与 dark 类,驱动 CSS 变量切换;
6
+ * - 插件通过 `ctx.theme()` 注册主题,并可附带各插槽位的装饰组件。
7
+ */
8
+ import { usePreferredDark } from "@vueuse/core";
9
+ import type { Dict } from "cosmokit";
10
+ import { type Component, computed, markRaw, reactive, watchEffect } from "vue";
11
+ import { Schema } from "../../../components/client/index.ts";
12
+ import type { Context } from "../context";
13
+ import { Service } from "../utils";
14
+ import { useConfig } from "./setting";
15
+
16
+ declare module "../context" {
17
+ interface Context {
18
+ $theme: ThemeService;
19
+ theme(options: ThemeOptions): () => void;
20
+ }
21
+
22
+ interface Internal {
23
+ themes: Dict<ThemeOptions>;
24
+ }
25
+ }
26
+
27
+ declare module ".." {
28
+ interface Config {
29
+ theme: Config.Theme;
30
+ }
31
+
32
+ export namespace Config {
33
+ export interface Theme {
34
+ mode: "auto" | "dark" | "light";
35
+ dark: string;
36
+ light: string;
37
+ }
38
+ }
39
+ }
40
+
41
+ /** 主题注册选项:name 支持多语言文案,components 按插槽名提供装饰组件 */
42
+ export interface ThemeOptions {
43
+ id: string;
44
+ name: string | Dict<string>;
45
+ components?: Dict<Component>;
46
+ }
47
+
48
+ // 系统级深色偏好(用户操作系统设置)
49
+ const preferDark = usePreferredDark();
50
+
51
+ const config = useConfig();
52
+
53
+ // 实际生效的颜色模式:配置为 auto 时跟随系统偏好
54
+ const colorMode = computed(() => {
55
+ const mode = config.value.theme.mode;
56
+ if (mode !== "auto") return mode;
57
+ return preferDark.value ? "dark" : "light";
58
+ });
59
+
60
+ /** 获取当前生效的颜色模式("dark" | "light") */
61
+ export const useColorMode = () => colorMode;
62
+
63
+ export default class ThemeService extends Service {
64
+ constructor(ctx: Context) {
65
+ super(ctx, "$theme", true);
66
+ ctx.mixin("$theme", ["theme"]);
67
+
68
+ ctx.internal.themes = reactive({});
69
+
70
+ ctx.settings({
71
+ id: "appearance",
72
+ title: "外观设置",
73
+ order: 900,
74
+ schema: Schema.object({
75
+ theme: Schema.object({
76
+ mode: Schema.union([
77
+ Schema.const("auto").description("跟随系统"),
78
+ Schema.const("dark").description("深色"),
79
+ Schema.const("light").description("浅色"),
80
+ ])
81
+ .default("auto")
82
+ .description("主题偏好。"),
83
+ dark: Schema.string()
84
+ .role("theme", { mode: "dark" })
85
+ .default("default-dark")
86
+ .description("深色主题。"),
87
+ light: Schema.string()
88
+ .role("theme", { mode: "light" })
89
+ .default("default-light")
90
+ .description("浅色主题。"),
91
+ }).description("主题设置"),
92
+ }),
93
+ });
94
+
95
+ ctx.effect(() =>
96
+ watchEffect(
97
+ () => {
98
+ if (!config.value.theme) return;
99
+ const root = window.document.querySelector("html");
100
+ if (!root) return;
101
+ // 把当前主题名写到 <html theme="...">,并同步 dark 类;
102
+ // 主题样式与深色变量均由 CSS 依据这两个标记选择
103
+ root.setAttribute("theme", config.value.theme[colorMode.value]);
104
+ if (colorMode.value === "dark") {
105
+ root.classList.add("dark");
106
+ } else {
107
+ root.classList.remove("dark");
108
+ }
109
+ },
110
+ { flush: "post" },
111
+ ),
112
+ );
113
+ }
114
+
115
+ /**
116
+ * 注册一个主题;返回取消注册函数。
117
+ * components 中的每个插槽组件仅在该主题被选中时启用,
118
+ * 用于注入背景装饰、挂件等随主题切换的界面元素。
119
+ */
120
+ theme(options: ThemeOptions) {
121
+ markRaw(options);
122
+ for (const [type, component] of Object.entries(options.components || {})) {
123
+ this.ctx.slot({
124
+ type,
125
+ disabled: () => config.value.theme[colorMode.value] !== options.id,
126
+ component,
127
+ });
128
+ }
129
+ return this.ctx.effect(() => {
130
+ this.ctx.internal.themes[options.id] = options;
131
+ return () => delete this.ctx.internal.themes[options.id];
132
+ });
133
+ }
134
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 浏览器端类型垫片(packages/web/client 自身使用;app 项目通过
3
+ * app/shims.d.ts 引用本文件):
4
+ *
5
+ * 1. "schemastery-vue/client" 虚拟子路径:由根 tsconfig.client.json 的
6
+ * paths 解析到 packages/web/components/client/schemastery-vue-client.ts
7
+ * (真实模块,单一事实源),运行时经构建器别名映射回真实包。
8
+ * 2. "@koishi-ce/plugin-console":本包 node_modules 中没有该插件的链接
9
+ * (依赖方向相反,浏览器端 tsconfig 也没有 paths),且该插件源码含有
10
+ * node 专属导入,无法直接进入浏览器程序,故在此按其公开面手写声明。
11
+ * 字段与 plugins/webui/console/src/node/index.ts 的 ClientConfig 以及
12
+ * packages/node/console 的 DataService / Console.Services 保持一致。
13
+ */
14
+
15
+ declare module "@koishi-ce/plugin-console" {
16
+ import type { Schema } from "@koishi-ce/koishi";
17
+ import type { Dict } from "cosmokit";
18
+
19
+ export interface ClientConfig {
20
+ devMode: boolean;
21
+ uiPath: string;
22
+ endpoint: string;
23
+ static?: boolean;
24
+ heartbeat?: HeartbeatConfig;
25
+ proxyBase?: string;
26
+ }
27
+
28
+ interface HeartbeatConfig {
29
+ interval?: number;
30
+ timeout?: number;
31
+ }
32
+
33
+ export interface Events {
34
+ ping(): string;
35
+ }
36
+
37
+ /** 服务端数据服务的类型骨架(仅类型层面使用,浏览器端无实现) */
38
+ export abstract class DataService<T = unknown> {}
39
+
40
+ /** 扩展入口描述:由服务端 entry 服务推送,loader 据此动态加载扩展 */
41
+ export interface EntryData {
42
+ files: string[];
43
+ paths?: string[];
44
+ data: unknown;
45
+ }
46
+
47
+ export namespace Console {
48
+ /** 服务端可用数据服务的清单,Store 类型据此推导各键的负载数据 */
49
+ export interface Services {
50
+ entry: DataService<Dict<EntryData>>;
51
+ schema: DataService<Dict<Schema>>;
52
+ permissions: DataService<string[]>;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../../../tsconfig.client",
3
+ "include": ["."],
4
+ "compilerOptions": {
5
+ "alwaysStrict": true,
6
+ "noPropertyAccessFromIndexSignature": true,
7
+ "noUnusedLocals": true,
8
+ "noUnusedParameters": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "noImplicitOverride": true,
12
+ "allowUnreachableCode": false,
13
+ "allowUnusedLabels": false
14
+ }
15
+ }