@zhin.js/command 1.1.7 → 1.1.8
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 +54 -1
- package/lib/command-index.d.ts +16 -2
- package/lib/command-index.js +74 -27
- package/lib/command-menu.d.ts +31 -0
- package/lib/command-menu.js +81 -0
- package/lib/definition.d.ts +9 -1
- package/lib/definition.js +12 -5
- package/lib/execution-policy.d.ts +18 -0
- package/lib/execution-policy.js +27 -0
- package/lib/execution-runtime.d.ts +50 -0
- package/lib/execution-runtime.js +134 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permission-explanation.d.ts +22 -0
- package/lib/permission-explanation.js +54 -0
- package/package.json +8 -4
- package/src/command-index.ts +92 -33
- package/src/command-menu.ts +112 -0
- package/src/definition.ts +17 -6
- package/src/execution-policy.ts +39 -0
- package/src/execution-runtime.ts +165 -0
- package/src/index.ts +1 -0
- package/src/permission-explanation.ts +58 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Internal composition contract. Not part of the command authoring facade. */
|
|
2
|
+
import { createToken } from '@zhin.js/plugin-runtime';
|
|
3
|
+
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
4
|
+
export const commandExecutionAdmissionToken = createToken('zhin.command.execution-admission');
|
|
5
|
+
/** @internal Root-instance-owned state shared by projections and draining retired generations. */
|
|
6
|
+
export class CommandExecutionBudget {
|
|
7
|
+
#entries = new Map();
|
|
8
|
+
#views = new WeakMap();
|
|
9
|
+
#clock;
|
|
10
|
+
#capacity;
|
|
11
|
+
admission;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.#clock = options.clock ?? (() => performance.now());
|
|
14
|
+
this.#capacity = options.capacity ?? 10_000;
|
|
15
|
+
if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1)
|
|
16
|
+
throw new TypeError('Invalid command budget capacity');
|
|
17
|
+
this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot) });
|
|
18
|
+
}
|
|
19
|
+
openOperation(input) {
|
|
20
|
+
const c = input.conversation;
|
|
21
|
+
const values = [c.endpoint.adapter, c.endpoint.id, c.kind, c.id, c.parent?.kind ?? '', c.parent?.id ?? '', c.threadId ?? '', input.senderId ?? ''];
|
|
22
|
+
const valid = values.slice(0, 4).every(nonempty) && ['private', 'group', 'channel'].includes(c.kind) && nonempty(input.senderId)
|
|
23
|
+
&& (c.parent === undefined || (nonempty(c.parent.kind) && nonempty(c.parent.id)))
|
|
24
|
+
&& (c.threadId === undefined || nonempty(c.threadId)) && values.every(value => value.length <= 1024);
|
|
25
|
+
const operation = {
|
|
26
|
+
snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
|
|
27
|
+
active: true, progressStopped: false, admitted: false, signal: input.retirementSignal,
|
|
28
|
+
reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
|
|
29
|
+
};
|
|
30
|
+
const stop = () => {
|
|
31
|
+
operation.progressStopped = true;
|
|
32
|
+
for (const timer of operation.timers)
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
operation.timers.clear();
|
|
35
|
+
};
|
|
36
|
+
input.retirementSignal.addEventListener('abort', stop, { once: true });
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
bind: (view, owner) => { if (operation.active)
|
|
39
|
+
this.#views.set(view, { operation, owner }); },
|
|
40
|
+
trackSend: (send) => {
|
|
41
|
+
if (!operation.active)
|
|
42
|
+
return;
|
|
43
|
+
const settled = send.then(() => { operation.sends.delete(settled); }, () => { operation.sends.delete(settled); });
|
|
44
|
+
operation.sends.add(settled);
|
|
45
|
+
},
|
|
46
|
+
stopProgress: async () => { stop(); await Promise.all(operation.feedback); },
|
|
47
|
+
finish: async () => {
|
|
48
|
+
stop();
|
|
49
|
+
await Promise.all(operation.feedback);
|
|
50
|
+
// New sends may start while an earlier send is draining. Do not release from an initial snapshot alone.
|
|
51
|
+
if (operation.admitted)
|
|
52
|
+
while (operation.sends.size > 0)
|
|
53
|
+
await Promise.all(operation.sends);
|
|
54
|
+
if (!operation.active)
|
|
55
|
+
return;
|
|
56
|
+
operation.active = false;
|
|
57
|
+
operation.signal.removeEventListener('abort', stop);
|
|
58
|
+
for (const release of operation.releases)
|
|
59
|
+
release();
|
|
60
|
+
operation.releases.length = 0;
|
|
61
|
+
operation.feedback.length = 0;
|
|
62
|
+
operation.accepted.clear();
|
|
63
|
+
operation.sends.clear();
|
|
64
|
+
operation.reply = undefined;
|
|
65
|
+
operation.snapshot = undefined;
|
|
66
|
+
operation.key = undefined;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
#admit(source, capability, owner, policy, snapshot) {
|
|
71
|
+
const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
|
|
72
|
+
const operation = binding?.operation;
|
|
73
|
+
if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted)
|
|
74
|
+
return limited('operation_unavailable');
|
|
75
|
+
if (!operation.key)
|
|
76
|
+
return limited('identity_unavailable');
|
|
77
|
+
const key = JSON.stringify([capability, ...operation.key]);
|
|
78
|
+
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
79
|
+
const now = this.#clock();
|
|
80
|
+
let entry = this.#entries.get(key);
|
|
81
|
+
if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent)
|
|
82
|
+
return limited('busy');
|
|
83
|
+
if (entry && entry.cooldownUntil > now)
|
|
84
|
+
return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
|
|
85
|
+
if (!entry) {
|
|
86
|
+
if (this.#entries.size >= this.#capacity) {
|
|
87
|
+
for (const [oldKey, old] of this.#entries)
|
|
88
|
+
if (old.active === 0 && old.cooldownUntil <= now)
|
|
89
|
+
this.#entries.delete(oldKey);
|
|
90
|
+
if (this.#entries.size >= this.#capacity)
|
|
91
|
+
return limited('capacity');
|
|
92
|
+
}
|
|
93
|
+
entry = { active: 0, cooldownUntil: 0 };
|
|
94
|
+
this.#entries.set(key, entry);
|
|
95
|
+
}
|
|
96
|
+
entry.active++;
|
|
97
|
+
operation.admitted = true;
|
|
98
|
+
entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
|
|
99
|
+
const acceptedEntry = entry;
|
|
100
|
+
operation.releases.push(() => {
|
|
101
|
+
acceptedEntry.active--;
|
|
102
|
+
if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock())
|
|
103
|
+
this.#entries.delete(key);
|
|
104
|
+
});
|
|
105
|
+
if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
operation.timers.delete(timer);
|
|
108
|
+
if (!operation.active || operation.progressStopped || operation.signal.aborted || !operation.reply)
|
|
109
|
+
return;
|
|
110
|
+
// Observe failure once. Never retry uncertain delivery and never expose the exception.
|
|
111
|
+
let send;
|
|
112
|
+
try {
|
|
113
|
+
send = Promise.resolve(operation.reply(owner, '正在处理中,请稍候。')).then(() => { }, () => { });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
send = Promise.resolve();
|
|
117
|
+
}
|
|
118
|
+
operation.feedback.push(send);
|
|
119
|
+
}, policy.progressAfterMs);
|
|
120
|
+
operation.timers.add(timer);
|
|
121
|
+
}
|
|
122
|
+
operation.accepted.add(capability);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function nonempty(value) { return typeof value === 'string' && value.length > 0; }
|
|
127
|
+
function limited(reason) { return Object.freeze({ reason }); }
|
|
128
|
+
/** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
|
|
129
|
+
export function admitCommandExecution(source, capability, owner, policy, snapshot) {
|
|
130
|
+
if (!policy)
|
|
131
|
+
return undefined;
|
|
132
|
+
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
|
|
133
|
+
return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
|
|
134
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export * from './command-index.js';
|
|
7
7
|
/** @public Command authoring contract and `defineCommand`. */
|
|
8
8
|
export * from './definition.js';
|
|
9
|
+
export * from './permission-explanation.js';
|
|
9
10
|
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
10
11
|
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
11
12
|
export { default } from './provider.js';
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export * from './command-index.js';
|
|
7
7
|
/** @public Command authoring contract and `defineCommand`. */
|
|
8
8
|
export * from './definition.js';
|
|
9
|
+
export * from './permission-explanation.js';
|
|
9
10
|
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
|
|
10
11
|
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
11
12
|
export { default } from './provider.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
|
|
2
|
+
import type { CommandDescriptor } from './command-index.js';
|
|
3
|
+
/** The exact subject projection used by the real command permit gate. No factories or checkers run. */
|
|
4
|
+
export declare function toCommandPermissionSubject(source: unknown): PermissionSubject;
|
|
5
|
+
/** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
|
|
6
|
+
export declare function commandAdapterMatches(command: Readonly<{
|
|
7
|
+
adapter?: string;
|
|
8
|
+
}>, source: unknown): boolean;
|
|
9
|
+
export type CommandPermissionHostStatus = 'present' | 'missing' | 'unverified';
|
|
10
|
+
/** Read-only hypothetical conditions. A match is not permission to execute the command. */
|
|
11
|
+
export declare function explainCommandPermissions(command: Pick<CommandDescriptor, 'adapter' | 'permit'>, options: Readonly<{
|
|
12
|
+
source?: unknown;
|
|
13
|
+
rolesKnown?: boolean;
|
|
14
|
+
permissionHost: CommandPermissionHostStatus;
|
|
15
|
+
}>): Readonly<{
|
|
16
|
+
permissionHost: CommandPermissionHostStatus;
|
|
17
|
+
adapter: Readonly<PermissionConditionExplanation>;
|
|
18
|
+
rules: readonly import("@zhin.js/permission").PermitRuleExplanation[];
|
|
19
|
+
summary: Readonly<PermissionConditionExplanation>;
|
|
20
|
+
unconstrained: readonly string[];
|
|
21
|
+
assumptions: readonly string[];
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { operationClientAdapter } from '@zhin.js/feature-kit';
|
|
2
|
+
import { explainPermitRules, toPermissionSubject } from '@zhin.js/permission';
|
|
3
|
+
import { resolveCommandSender, resolveCommandSession } from './definition.js';
|
|
4
|
+
/** The exact subject projection used by the real command permit gate. No factories or checkers run. */
|
|
5
|
+
export function toCommandPermissionSubject(source) {
|
|
6
|
+
const projected = toPermissionSubject(source);
|
|
7
|
+
const session = resolveCommandSession(source);
|
|
8
|
+
return { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
9
|
+
}
|
|
10
|
+
/** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
|
|
11
|
+
export function commandAdapterMatches(command, source) {
|
|
12
|
+
return !command.adapter || operationClientAdapter(source) === command.adapter;
|
|
13
|
+
}
|
|
14
|
+
/** Read-only hypothetical conditions. A match is not permission to execute the command. */
|
|
15
|
+
export function explainCommandPermissions(command, options) {
|
|
16
|
+
const projected = toCommandPermissionSubject(options.source);
|
|
17
|
+
const inputSender = options.source && typeof options.source === 'object'
|
|
18
|
+
? options.source.sender : undefined;
|
|
19
|
+
const structuredRoles = inputSender && typeof inputSender === 'object'
|
|
20
|
+
&& Array.isArray(inputSender.role);
|
|
21
|
+
// A sender-only assumption has no synthetic conversation. Apply the real canonical
|
|
22
|
+
// sender defaults without inventing an Endpoint or treating unknown roles as known.
|
|
23
|
+
const senderOnly = options.source && typeof options.source === 'object' && !('conversation' in options.source) && !structuredRoles
|
|
24
|
+
? resolveCommandSender(options.source, undefined) : undefined;
|
|
25
|
+
const subject = senderOnly ? { ...projected, sender: senderOnly } : projected;
|
|
26
|
+
const defaultedRoles = options.rolesKnown && Array.isArray(inputSender?.roles) && inputSender.roles.length === 0
|
|
27
|
+
&& subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
|
|
28
|
+
const actualAdapter = operationClientAdapter(options.source);
|
|
29
|
+
const adapter = !command.adapter
|
|
30
|
+
? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
|
|
31
|
+
: !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
|
|
32
|
+
: { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
|
|
33
|
+
const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
|
|
34
|
+
const conditions = [...(command.adapter ? [adapter] : []), ...rules];
|
|
35
|
+
let summary;
|
|
36
|
+
if (conditions.some(item => item.outcome === 'mismatch'))
|
|
37
|
+
summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
|
|
38
|
+
else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
|
|
39
|
+
summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
|
|
40
|
+
? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
|
|
41
|
+
}
|
|
42
|
+
else if (rules.length === 0)
|
|
43
|
+
summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
|
|
44
|
+
else if (conditions.some(item => item.outcome === 'not_evaluated'))
|
|
45
|
+
summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
|
|
46
|
+
else
|
|
47
|
+
summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
|
|
48
|
+
return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
|
|
49
|
+
unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
|
|
50
|
+
assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
|
|
51
|
+
...(defaultedRoles
|
|
52
|
+
? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
+
"./internal/execution": {
|
|
10
|
+
"types": "./lib/execution-runtime.d.ts",
|
|
11
|
+
"import": "./lib/execution-runtime.js"
|
|
12
|
+
},
|
|
9
13
|
".": {
|
|
10
14
|
"types": "./lib/index.d.ts",
|
|
11
15
|
"import": "./lib/index.js"
|
|
@@ -17,10 +21,10 @@
|
|
|
17
21
|
],
|
|
18
22
|
"dependencies": {
|
|
19
23
|
"segment-matcher": "^1.0.5",
|
|
20
|
-
"@zhin.js/feature-kit": "1.1.
|
|
21
|
-
"@zhin.js/plugin-runtime": "1.1.14",
|
|
24
|
+
"@zhin.js/feature-kit": "1.1.6",
|
|
22
25
|
"@zhin.js/interaction": "1.1.2",
|
|
23
|
-
"@zhin.js/permission": "1.1.
|
|
26
|
+
"@zhin.js/permission": "1.1.7",
|
|
27
|
+
"@zhin.js/plugin-runtime": "1.1.15"
|
|
24
28
|
},
|
|
25
29
|
"devDependencies": {
|
|
26
30
|
"@types/node": "^26.1.2",
|
package/src/command-index.ts
CHANGED
|
@@ -3,19 +3,25 @@ import {
|
|
|
3
3
|
TypeMatcherRegistry,
|
|
4
4
|
type MessageSegment,
|
|
5
5
|
} from 'segment-matcher';
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import {
|
|
7
|
+
htmlRendererToken,
|
|
8
|
+
type CapabilityId,
|
|
9
|
+
type CapabilitySlot,
|
|
10
|
+
type PluginId,
|
|
11
|
+
type RuntimeSnapshot,
|
|
10
12
|
} from '@zhin.js/plugin-runtime';
|
|
11
13
|
import type { UserInteractionFactory } from '@zhin.js/interaction';
|
|
12
|
-
import { bindOperationInput
|
|
14
|
+
import { bindOperationInput } from '@zhin.js/feature-kit';
|
|
13
15
|
import {
|
|
14
16
|
permissionHostToken,
|
|
15
|
-
toPermissionSubject,
|
|
16
17
|
type PermissionHost,
|
|
17
18
|
} from '@zhin.js/permission';
|
|
19
|
+
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
18
20
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
|
|
21
|
+
import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
|
|
22
|
+
import { admitCommandExecution } from './execution-runtime.js';
|
|
23
|
+
import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
|
|
24
|
+
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
19
25
|
import {
|
|
20
26
|
createCommandContext,
|
|
21
27
|
resolveCommandSession,
|
|
@@ -32,6 +38,8 @@ export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface CommandDescriptor {
|
|
41
|
+
readonly id: CapabilityId;
|
|
42
|
+
readonly owner: PluginId;
|
|
35
43
|
readonly name: string;
|
|
36
44
|
readonly description?: string;
|
|
37
45
|
readonly adapter?: string;
|
|
@@ -41,18 +49,34 @@ export interface CommandDescriptor {
|
|
|
41
49
|
readonly options: NonNullable<CommandDefinition['options']>;
|
|
42
50
|
readonly alias?: readonly string[];
|
|
43
51
|
readonly permit?: readonly string[];
|
|
52
|
+
readonly execution?: CommandDefinition['execution'];
|
|
44
53
|
/** shortcut 触发键列表(不含预填 params)。 */
|
|
45
54
|
readonly shortcut?: readonly string[];
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export interface CommandDispatchResult {
|
|
49
58
|
readonly matched: boolean;
|
|
50
|
-
readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
|
|
59
|
+
readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
|
|
60
|
+
readonly limit?: CommandExecutionLimit;
|
|
51
61
|
readonly issues?: readonly CommandValidationIssue[];
|
|
52
62
|
readonly help?: string;
|
|
53
63
|
readonly command?: string;
|
|
54
64
|
readonly owner?: PluginId;
|
|
55
65
|
readonly value?: unknown;
|
|
66
|
+
readonly menu?: CommandMenuNavigation;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Semantic menu navigation; the IM composition layer supplies platform controls. */
|
|
70
|
+
export interface CommandMenuNavigation {
|
|
71
|
+
readonly keyword: string;
|
|
72
|
+
readonly pluginKey?: string;
|
|
73
|
+
readonly page: number;
|
|
74
|
+
readonly pages: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CommandMenuResult {
|
|
78
|
+
readonly value: string | CommandMenuSegment;
|
|
79
|
+
readonly menu?: CommandMenuNavigation;
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
interface CommandRecord extends CommandDescriptor {
|
|
@@ -143,9 +167,13 @@ export class CommandIndex {
|
|
|
143
167
|
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
144
168
|
: undefined;
|
|
145
169
|
|
|
170
|
+
const execution = normalizeExecutionPolicy(slot.definition.execution);
|
|
146
171
|
const record: CommandRecord = Object.freeze({
|
|
172
|
+
id: slot.id,
|
|
173
|
+
owner: slot.owner,
|
|
147
174
|
name,
|
|
148
175
|
description: slot.definition.description,
|
|
176
|
+
...(execution ? { execution } : {}),
|
|
149
177
|
...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
|
|
150
178
|
source: slot.source,
|
|
151
179
|
usage: commandUsage(name, slot.definition),
|
|
@@ -240,6 +268,8 @@ export class CommandIndex {
|
|
|
240
268
|
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
241
269
|
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
242
270
|
if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
271
|
+
const limit = admitCommandExecution(undefined, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
|
|
272
|
+
if (limit) throw new CommandExecutionLimitedError(limit);
|
|
243
273
|
return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
244
274
|
textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
245
275
|
}
|
|
@@ -252,14 +282,14 @@ export class CommandIndex {
|
|
|
252
282
|
operationSnapshot: RuntimeSnapshot = this.snapshot,
|
|
253
283
|
): Promise<CommandDispatchResult> {
|
|
254
284
|
if (this.#menu) {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
285
|
+
const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
|
|
286
|
+
if (menuResult !== undefined) {
|
|
257
287
|
return Object.freeze({
|
|
258
288
|
matched: true,
|
|
259
289
|
status: 'executed',
|
|
260
290
|
command: this.#menu.keyword,
|
|
261
291
|
owner: operationSnapshot.root,
|
|
262
|
-
|
|
292
|
+
...menuResult,
|
|
263
293
|
});
|
|
264
294
|
}
|
|
265
295
|
}
|
|
@@ -289,6 +319,8 @@ export class CommandIndex {
|
|
|
289
319
|
const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
|
|
290
320
|
if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
|
|
291
321
|
owner: match.command.slot.owner, issues: parsed.issues, help });
|
|
322
|
+
const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
|
|
323
|
+
if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
|
|
292
324
|
const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
293
325
|
textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
|
|
294
326
|
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
|
|
@@ -315,9 +347,7 @@ export class CommandIndex {
|
|
|
315
347
|
if (!source || typeof source !== 'object') return false;
|
|
316
348
|
const host = this.#resolveHost(operationSnapshot);
|
|
317
349
|
if (!host) return false;
|
|
318
|
-
const
|
|
319
|
-
const session = resolveCommandSession(source);
|
|
320
|
-
const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
350
|
+
const subject = toCommandPermissionSubject(source);
|
|
321
351
|
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
|
|
322
352
|
return host.checkAll(permits, subject);
|
|
323
353
|
}
|
|
@@ -404,50 +434,68 @@ export class CommandIndex {
|
|
|
404
434
|
// 内置菜单命令
|
|
405
435
|
// ==========================================================================
|
|
406
436
|
|
|
407
|
-
#dispatchMenu(input: CommandMatchInput, commandPrefix
|
|
437
|
+
#dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
|
|
408
438
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
409
439
|
if (text === undefined) return undefined;
|
|
410
440
|
const menu = this.#menu;
|
|
411
441
|
if (!menu) return undefined;
|
|
412
442
|
const keyword = menu.keyword;
|
|
413
|
-
if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
443
|
+
if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
414
444
|
if (text.startsWith(keyword + ' ')) {
|
|
415
445
|
const key = text.slice(keyword.length + 1).trim();
|
|
416
|
-
|
|
417
|
-
|
|
446
|
+
const args = key.split(/\s+/u);
|
|
447
|
+
const flag = args.indexOf('--page');
|
|
448
|
+
if (flag !== -1) {
|
|
449
|
+
const pageText = args[flag + 1] ?? '';
|
|
450
|
+
if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
|
|
451
|
+
|| !Number.isSafeInteger(Number(pageText))) {
|
|
452
|
+
return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
|
|
453
|
+
}
|
|
454
|
+
return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
|
|
455
|
+
}
|
|
456
|
+
if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
|
|
457
|
+
return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
418
458
|
}
|
|
419
459
|
return undefined;
|
|
420
460
|
}
|
|
421
461
|
|
|
422
|
-
#buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string):
|
|
423
|
-
const root =
|
|
462
|
+
#buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
|
|
463
|
+
const root = snapshot.root;
|
|
424
464
|
const targetId = pluginKey
|
|
425
465
|
? `${root}/${pluginKey.split('.').join('/')}` as PluginId
|
|
426
466
|
: root;
|
|
427
467
|
|
|
428
|
-
const node =
|
|
429
|
-
if (!node) return `未找到插件: ${pluginKey}
|
|
468
|
+
const node = snapshot.tree.get(targetId);
|
|
469
|
+
if (!node) return { value: `未找到插件: ${pluginKey}` };
|
|
430
470
|
|
|
431
471
|
const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
|
|
432
472
|
const children = node.children
|
|
433
|
-
.map((childId) =>
|
|
473
|
+
.map((childId) => snapshot.tree.get(childId))
|
|
434
474
|
.filter((child): child is NonNullable<typeof child> => !!child);
|
|
435
475
|
|
|
476
|
+
const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
|
|
477
|
+
const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
|
|
478
|
+
if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
|
|
479
|
+
const offset = (page - 1) * MENU_PAGE_SIZE;
|
|
480
|
+
const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
|
|
481
|
+
const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
|
|
482
|
+
|
|
436
483
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
437
484
|
const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
|
|
485
|
+
if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
|
|
438
486
|
|
|
439
|
-
if (
|
|
487
|
+
if (pageCommands.length > 0) {
|
|
440
488
|
lines.push('');
|
|
441
|
-
for (const cmd of
|
|
489
|
+
for (const cmd of pageCommands) {
|
|
442
490
|
const desc = cmd.description ? ` - ${cmd.description}` : '';
|
|
443
491
|
lines.push(` ${commandPrefix}${cmd.name}${desc}`);
|
|
444
492
|
}
|
|
445
493
|
}
|
|
446
494
|
|
|
447
|
-
if (
|
|
495
|
+
if (pageChildren.length > 0) {
|
|
448
496
|
lines.push('');
|
|
449
497
|
lines.push('子插件:');
|
|
450
|
-
for (const child of
|
|
498
|
+
for (const child of pageChildren) {
|
|
451
499
|
const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
|
|
452
500
|
const label = child.metadata?.displayName ?? child.instanceKey;
|
|
453
501
|
lines.push(` ${childKey} (${label})`);
|
|
@@ -459,12 +507,27 @@ export class CommandIndex {
|
|
|
459
507
|
lines.push('(暂无指令和子插件)');
|
|
460
508
|
}
|
|
461
509
|
|
|
462
|
-
|
|
510
|
+
const hints: string[] = [];
|
|
511
|
+
if (children.length > 0) hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
|
|
512
|
+
if (page > 1) hints.push(`上一页:${invocation} --page ${page - 1}`);
|
|
513
|
+
if (page < pages) hints.push(`下一页:${invocation} --page ${page + 1}`);
|
|
514
|
+
const hint = hints.length ? hints.join('\n') : undefined;
|
|
515
|
+
if (hint) {
|
|
463
516
|
lines.push('');
|
|
464
|
-
lines.push(
|
|
517
|
+
lines.push(hint);
|
|
465
518
|
}
|
|
466
519
|
|
|
467
|
-
|
|
520
|
+
const text = lines.join('\n');
|
|
521
|
+
const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
|
|
522
|
+
if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
|
|
523
|
+
const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
|
|
524
|
+
return { menu, value: renderCommandMenu(displayKey,
|
|
525
|
+
pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })),
|
|
526
|
+
pageChildren.map(child => ({
|
|
527
|
+
name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
|
|
528
|
+
description: child.metadata?.displayName ?? child.instanceKey,
|
|
529
|
+
})), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
|
|
530
|
+
appearance: readMenuAppearance(config?.menuStyle) }) };
|
|
468
531
|
}
|
|
469
532
|
}
|
|
470
533
|
|
|
@@ -839,10 +902,6 @@ function toDescriptor({
|
|
|
839
902
|
return descriptor;
|
|
840
903
|
}
|
|
841
904
|
|
|
842
|
-
function commandAdapterMatches(record: CommandRecord, source: unknown): boolean {
|
|
843
|
-
return !record.adapter || operationClientAdapter(source) === record.adapter;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
905
|
export class CommandParameterValueError extends TypeError {
|
|
847
906
|
constructor(name: string, type: CommandParameterType, value: string) {
|
|
848
907
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
interface MenuEntry {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface MenuAppearance {
|
|
7
|
+
readonly title?: string;
|
|
8
|
+
readonly subtitle?: string;
|
|
9
|
+
readonly accentColor?: string;
|
|
10
|
+
readonly backgroundImage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommandMenuSegment {
|
|
14
|
+
readonly type: 'html';
|
|
15
|
+
readonly data: {
|
|
16
|
+
readonly html: string;
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly width: number;
|
|
19
|
+
readonly fileName: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MENU_PAGE_SIZE = 30;
|
|
24
|
+
|
|
25
|
+
const escape = (value: string) => value.replace(/[&<>"']/gu, char => ({
|
|
26
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
27
|
+
})[char] ?? char);
|
|
28
|
+
|
|
29
|
+
/** Configuration is text/data, never an interpolated CSS or HTML fragment. */
|
|
30
|
+
export function readMenuAppearance(value: unknown): MenuAppearance {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
32
|
+
const input = value as Record<string, unknown>;
|
|
33
|
+
const result: { title?: string; subtitle?: string; accentColor?: string; backgroundImage?: string } = {};
|
|
34
|
+
if (typeof input.title === 'string') result.title = input.title.slice(0, 100);
|
|
35
|
+
if (typeof input.subtitle === 'string') result.subtitle = input.subtitle.slice(0, 160);
|
|
36
|
+
if (typeof input.accentColor === 'string' && /^#[\da-f]{6}$/iu.test(input.accentColor)) result.accentColor = input.accentColor;
|
|
37
|
+
if (typeof input.backgroundImage === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(input.backgroundImage);
|
|
40
|
+
if (url.protocol === 'https:' || url.protocol === 'http:'
|
|
41
|
+
|| /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/u.test(url.href)) result.backgroundImage = url.href;
|
|
42
|
+
} catch { /* Invalid optional artwork falls back to the bundled illustration. */ }
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Self-contained artwork: no network requests or extra asset/package dependency.
|
|
48
|
+
const landscape = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1400" viewBox="0 0 1080 1400">
|
|
49
|
+
<defs><linearGradient id="sky" x2=".8" y2="1"><stop stop-color="#162635"/><stop offset=".48" stop-color="#344b57"/><stop offset="1" stop-color="#70837c"/></linearGradient><radialGradient id="glow"><stop stop-color="#eddbc0" stop-opacity=".3"/><stop offset="1" stop-color="#d6d7c3" stop-opacity="0"/></radialGradient><linearGradient id="hill" x2="0" y2="1"><stop stop-color="#b2c2b5" stop-opacity=".34"/><stop offset="1" stop-color="#213e45" stop-opacity=".1"/></linearGradient></defs>
|
|
50
|
+
<path fill="url(#sky)" d="M0 0h1080v1400H0z"/><circle cx="820" cy="184" r="290" fill="url(#glow)"/><circle cx="823" cy="167" r="92" fill="#e3dfc9"/><circle cx="802" cy="146" r="10" fill="#cbcdbb" opacity=".3"/><circle cx="854" cy="194" r="19" fill="#cbcdbb" opacity=".3"/>
|
|
51
|
+
<g fill="#e7d5ac" opacity=".7"><circle cx="113" cy="72" r="1.5"/><circle cx="360" cy="104" r="1"/><circle cx="624" cy="55" r="2"/><circle cx="713" cy="265" r="1.2"/><circle cx="982" cy="332" r="2"/><circle cx="463" cy="43" r="1.4"/><circle cx="540" cy="202" r="1.4"/><circle cx="96" cy="410" r="2"/></g>
|
|
52
|
+
<g fill="none" stroke="#abac8a" stroke-width="2" opacity=".6"><path d="M1080 0Q959 77 902 267M1012 45Q916 92 866 106M984 78Q1016 164 1004 229M946 146Q866 156 838 204M917 217Q947 257 945 311"/></g>
|
|
53
|
+
<g fill="#d1bc93" opacity=".7"><ellipse cx="916" cy="84" rx="8" ry="21" transform="rotate(60 916 84)"/><ellipse cx="975" cy="115" rx="8" ry="20" transform="rotate(-25 975 115)"/><ellipse cx="876" cy="172" rx="8" ry="20" transform="rotate(58 876 172)"/><ellipse cx="1006" cy="208" rx="8" ry="20"/><ellipse cx="932" cy="270" rx="8" ry="20" transform="rotate(-25 932 270)"/></g>
|
|
54
|
+
<path d="M0 615 171 375 293 517 441 315 619 564 796 417 1080 651V1400H0Z" fill="url(#hill)"/><path d="m0 867 263-305 194 263 173-167 216 142 234-183v783H0Z" fill="#243e48" opacity=".46"/><path d="M0 1155q260-270 540-160t540-125v530H0Z" fill="#17343d" opacity=".65"/>
|
|
55
|
+
<g fill="none" stroke="#b1c1b2" stroke-opacity=".14"><path d="M0 455q210-85 424-12t656-4M0 474q210-85 424-12t656-4M0 1095q310-83 600-9t480-30M0 1124q310-83 600-9t480-30"/></g></svg>`;
|
|
56
|
+
|
|
57
|
+
const symbols = [
|
|
58
|
+
'<path d="m16 3 4 9 9 4-9 4-4 9-4-9-9-4 9-4Z"/><path d="m16 10 2 6-2 6-2-6Z"/>',
|
|
59
|
+
'<circle cx="16" cy="16" r="11"/><path d="m10 19 6-12 6 12-6-3Z"/>',
|
|
60
|
+
'<path d="m16 3 12 7v12l-12 7-12-7V10Z"/><path d="m4 10 12 7 12-7M16 17v12"/>',
|
|
61
|
+
'<circle cx="16" cy="16" r="5"/><path d="m16 2 3 7 8-3-3 8 7 2-7 3 3 8-8-3-3 7-3-7-8 3 3-8-7-3 7-2-3-8 8 3Z"/>',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function icon(name: string, plugin = false): string {
|
|
65
|
+
const hash = [...name].reduce((value, char) => (value + (char.codePointAt(0) ?? 0)) % symbols.length, 0);
|
|
66
|
+
return `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.15">${plugin ? symbols[2] : symbols[hash]}</svg>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
|
|
70
|
+
export function renderCommandMenu(
|
|
71
|
+
title: string,
|
|
72
|
+
commands: readonly MenuEntry[],
|
|
73
|
+
children: readonly MenuEntry[],
|
|
74
|
+
hint: string | undefined,
|
|
75
|
+
text: string,
|
|
76
|
+
options: { readonly page?: number; readonly pages?: number; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: MenuAppearance } = {},
|
|
77
|
+
): CommandMenuSegment {
|
|
78
|
+
const appearance = options.appearance ?? {};
|
|
79
|
+
const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
|
|
80
|
+
// CSS string escaping additionally prevents an artwork URL from closing <style>.
|
|
81
|
+
const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
|
|
82
|
+
const entries = (items: readonly MenuEntry[], plugin: boolean) => items.map(item =>
|
|
83
|
+
`<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
|
|
84
|
+
? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
|
|
85
|
+
const section = (label: string, items: readonly MenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
|
|
86
|
+
: `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
|
|
87
|
+
const page = options.page ?? 1;
|
|
88
|
+
const pages = options.pages ?? 1;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'html',
|
|
92
|
+
data: {
|
|
93
|
+
width: 1080, fileName: 'command-menu.png', text,
|
|
94
|
+
html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
|
95
|
+
*{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
|
|
96
|
+
main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
|
|
97
|
+
header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
|
|
98
|
+
h1{margin:0 0 10px;font-family:"Songti SC","Noto Serif CJK SC",serif;font-size:55px;font-weight:800;letter-spacing:3px;text-shadow:0 2px 16px #0c202a66;max-width:760px;line-height:1.2}
|
|
99
|
+
.subtitle{font-size:17px;color:#e0e6e0;letter-spacing:2px;max-width:700px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary{margin:18px 0 0;display:flex;align-items:center;gap:16px;font-size:13px;letter-spacing:1px;color:#e0dfcf}.summary i{width:22px;height:1px;background:var(--accent)}
|
|
100
|
+
section{margin:0 0 20px;padding:5px;background:#dbe9e516;border:1px solid #f0e2c722;border-radius:19px;box-shadow:0 8px 25px #08192312}.section-head{padding:14px 18px;display:flex;justify-content:space-between;align-items:center;background:#142a34db;border-radius:13px 13px 0 0}
|
|
101
|
+
h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent)}.section-head span{font-size:12px;letter-spacing:2px;color:#b7c7c5}
|
|
102
|
+
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
|
|
103
|
+
.entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
|
|
104
|
+
.icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
|
|
105
|
+
.name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
106
|
+
footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
|
|
107
|
+
</style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
|
|
108
|
+
${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
|
|
109
|
+
<footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|