feat: implement shared dispatch error signal
Introduce a shared `dispatchError` signal in the command dispatcher to unify error reporting across different components. This allows `CommandLinkManager` to surface errors through the `JournalInput` UI, ensuring that errors from `:cmd[]` directive clicks are visible to the user.
This commit is contained in:
parent
ffbfa65716
commit
86abf34c10
|
|
@ -1,14 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||||
* clicks on :cmd[] directive spans to dispatch them via the shared
|
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||||
* command dispatcher.
|
* command dispatcher. Errors are surfaced through the shared
|
||||||
|
* dispatchError signal, shown by JournalInput above the textarea.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, onCleanup } from "solid-js";
|
import { Component, onCleanup } from "solid-js";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
import { useJournalStream } from "./stores/journalStream";
|
import { useJournalStream } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
import { dispatchCommand } from "./journal/command-dispatcher";
|
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
|
||||||
|
|
||||||
export const CommandLinkManager: Component = () => {
|
export const CommandLinkManager: Component = () => {
|
||||||
const contentDom = useArticleDom();
|
const contentDom = useArticleDom();
|
||||||
|
|
@ -26,7 +27,7 @@ export const CommandLinkManager: Component = () => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
void dispatchCommand({
|
dispatchCommand({
|
||||||
role: stream.myRole as "gm" | "player" | "observer",
|
role: stream.myRole as "gm" | "player" | "observer",
|
||||||
myName: stream.myName,
|
myName: stream.myName,
|
||||||
command,
|
command,
|
||||||
|
|
@ -34,6 +35,10 @@ export const CommandLinkManager: Component = () => {
|
||||||
statValues: stream.stats,
|
statValues: stream.stats,
|
||||||
statDefs: comp.data.stats,
|
statDefs: comp.data.stats,
|
||||||
statTemplates: comp.data.statTemplates,
|
statTemplates: comp.data.statTemplates,
|
||||||
|
}).then((result) => {
|
||||||
|
if (!result.ok) {
|
||||||
|
setDispatchError(result.error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import type { CompletionItem } from "./command-parser";
|
||||||
import { buildCompletions } from "./command-completions";
|
import { buildCompletions } from "./command-completions";
|
||||||
import { CompletionsDropdown } from "./CompletionsDropdown";
|
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||||
import { ErrorPopup } from "./ErrorPopup";
|
import { ErrorPopup } from "./ErrorPopup";
|
||||||
import { dispatchCommand } from "./command-dispatcher";
|
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
|
||||||
|
|
||||||
// ---- Component ----
|
// ---- Component ----
|
||||||
|
|
||||||
|
|
@ -33,7 +33,6 @@ export const JournalInput: Component = () => {
|
||||||
const isGm = () => stream.myRole === "gm";
|
const isGm = () => stream.myRole === "gm";
|
||||||
|
|
||||||
const [text, setText] = createSignal("");
|
const [text, setText] = createSignal("");
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
|
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
|
||||||
const [sending, setSending] = createSignal(false);
|
const [sending, setSending] = createSignal(false);
|
||||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
const [showCompletions, setShowCompletions] = createSignal(false);
|
||||||
|
|
@ -80,11 +79,11 @@ export const JournalInput: Component = () => {
|
||||||
|
|
||||||
const parsed = parseInput(raw);
|
const parsed = parseInput(raw);
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
setError(parsed.error);
|
setDispatchError(parsed.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setDispatchError(null);
|
||||||
|
|
||||||
// Players: chat + stat commands only
|
// Players: chat + stat commands only
|
||||||
if (isPlayer()) {
|
if (isPlayer()) {
|
||||||
|
|
@ -96,7 +95,7 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type !== "stat") {
|
if (parsed.type !== "stat") {
|
||||||
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
setDispatchError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +103,7 @@ export const JournalInput: Component = () => {
|
||||||
// GM: all commands, Player: stat commands
|
// GM: all commands, Player: stat commands
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
const result = await dispatchCommand({
|
await dispatchCommand({
|
||||||
role: stream.myRole as "gm" | "player" | "observer",
|
role: stream.myRole as "gm" | "player" | "observer",
|
||||||
myName: stream.myName,
|
myName: stream.myName,
|
||||||
command: raw,
|
command: raw,
|
||||||
|
|
@ -114,15 +113,22 @@ export const JournalInput: Component = () => {
|
||||||
statTemplates: comp.data.statTemplates,
|
statTemplates: comp.data.statTemplates,
|
||||||
});
|
});
|
||||||
|
|
||||||
finish(result.ok, result.ok ? undefined : result.error);
|
// dispatchCommand already set the shared error if it failed.
|
||||||
|
// Only clear text on success (no error is shown).
|
||||||
|
if (!dispatchError()) {
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
textareaRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear text + error on success, or set error on failure. */
|
/** Clear text or set shared dispatch error on failure. */
|
||||||
function finish(success: boolean, err?: string) {
|
function finish(success: boolean, err?: string) {
|
||||||
if (success) {
|
if (success) {
|
||||||
setText("");
|
setText("");
|
||||||
|
setDispatchError(null);
|
||||||
} else if (err) {
|
} else if (err) {
|
||||||
setError(err);
|
setDispatchError(err);
|
||||||
}
|
}
|
||||||
setSending(false);
|
setSending(false);
|
||||||
textareaRef?.focus();
|
textareaRef?.focus();
|
||||||
|
|
@ -269,13 +275,13 @@ export const JournalInput: Component = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between px-2 pb-2">
|
<div class="flex items-center justify-between px-2 pb-2">
|
||||||
<Show when={error()}>
|
<Show when={dispatchError()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowErrorPopup((v) => !v)}
|
onClick={() => setShowErrorPopup((v) => !v)}
|
||||||
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
|
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
|
||||||
title={error() ?? undefined}
|
title={dispatchError() ?? undefined}
|
||||||
>
|
>
|
||||||
{error()}
|
{dispatchError()}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
|
@ -295,7 +301,7 @@ export const JournalInput: Component = () => {
|
||||||
|
|
||||||
{/* Error popup */}
|
{/* Error popup */}
|
||||||
<ErrorPopup
|
<ErrorPopup
|
||||||
message={error()}
|
message={dispatchError()}
|
||||||
show={showErrorPopup()}
|
show={showErrorPopup()}
|
||||||
onClose={() => setShowErrorPopup(false)}
|
onClose={() => setShowErrorPopup(false)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sendMessage } from "../stores/journalStream";
|
import { sendMessage } from "../stores/journalStream";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
import { parseInput } from "./command-parser";
|
import { parseInput } from "./command-parser";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
|
|
@ -26,6 +27,14 @@ export type DispatchResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||||
|
* Components that show errors (JournalInput) read from here; callers that
|
||||||
|
* want errors surfaced (CommandLinkManager) write to it.
|
||||||
|
*/
|
||||||
|
export const [dispatchError, setDispatchError] =
|
||||||
|
createSignal<string | null>(null);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main dispatch
|
// Main dispatch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,25 +69,25 @@ export async function dispatchCommand(
|
||||||
// Observers can only send chat
|
// Observers can only send chat
|
||||||
if (ctx.role === "observer") {
|
if (ctx.role === "observer") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
return unwrap(result);
|
return finish(unwrap(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
||||||
const parsed = parseInput(prefixed);
|
const parsed = parseInput(prefixed);
|
||||||
if (parsed.error) return { ok: false, error: parsed.error };
|
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
||||||
|
|
||||||
// Players: chat + stat commands only
|
// Players: chat + stat commands only
|
||||||
if (ctx.role === "player") {
|
if (ctx.role === "player") {
|
||||||
if (parsed.type === "chat") {
|
if (parsed.type === "chat") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
return unwrap(result);
|
return finish(unwrap(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type === "stat") {
|
||||||
return dispatchStat(parsed.payload as Record<string, unknown>, ctx);
|
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" };
|
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM: all commands
|
// GM: all commands
|
||||||
|
|
@ -87,7 +96,7 @@ export async function dispatchCommand(
|
||||||
parsed.payload as { notation: string; label?: string },
|
parsed.payload as { notation: string; label?: string },
|
||||||
);
|
);
|
||||||
const result = sendMessage("roll", p);
|
const result = sendMessage("roll", p);
|
||||||
return unwrap(result);
|
return finish(unwrap(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "spark") {
|
if (parsed.type === "spark") {
|
||||||
|
|
@ -98,21 +107,27 @@ export async function dispatchCommand(
|
||||||
const remix = match?.remix ?? false;
|
const remix = match?.remix ?? false;
|
||||||
const p = await resolveSparkPayload({ key, csvPath, remix });
|
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||||
const result = sendMessage("spark", p);
|
const result = sendMessage("spark", p);
|
||||||
return unwrap(result);
|
return finish(unwrap(result));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return finish({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type === "stat") {
|
||||||
return dispatchStat(parsed.payload as Record<string, unknown>, ctx);
|
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
const result = sendMessage(parsed.type, parsed.payload);
|
||||||
return unwrap(result);
|
return finish(unwrap(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the shared error signal and return the result (pass-through). */
|
||||||
|
function finish(r: DispatchResult): DispatchResult {
|
||||||
|
setDispatchError(r.ok ? null : r.error);
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue