SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,740 @@
|
||||
// MessageBuilder — a visual OneBot message composer so you never hand-write a
|
||||
// segment array. Common segments are first-class (text / @ / image / face /
|
||||
// reply / record / video); advanced ones fold away (file / mface / poke / json
|
||||
// / xml / markdown / forward-node). `node` nests a full builder (merged
|
||||
// forward). Rows reorder by drag. Emits the live OneBot segment array up.
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
FileText,
|
||||
Film,
|
||||
Forward,
|
||||
GripVertical,
|
||||
Hand,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
Plus,
|
||||
Quote,
|
||||
Smile,
|
||||
Sticker,
|
||||
Trash2,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Picker } from '@/components/ui/picker';
|
||||
import { FaceGrid } from '@/components/ui/face-grid';
|
||||
import { FileSource } from '@/components/debug/file-source';
|
||||
import { useGroupMembers } from '@/hooks/use-debug-contacts';
|
||||
|
||||
// ── segment model ──
|
||||
// `_id` is a stable React key tied to a segment's identity (so editing one field
|
||||
// doesn't remount the row and lose focus / inner control state); it is NOT
|
||||
// serialised. SegBody is the discriminated union; Seg adds the id.
|
||||
type SegBody =
|
||||
| { k: 'text'; text: string }
|
||||
| { k: 'at'; qq: string }
|
||||
| { k: 'image'; file: string }
|
||||
| { k: 'face'; id: string }
|
||||
| { k: 'reply'; id: string }
|
||||
| { k: 'record'; file: string }
|
||||
| { k: 'video'; file: string }
|
||||
| { k: 'file'; file: string; name: string }
|
||||
| { k: 'mface'; emoji_id: string; emoji_package_id: string; key: string; summary: string }
|
||||
| { k: 'poke'; type: string }
|
||||
| { k: 'json'; data: string }
|
||||
| { k: 'xml'; data: string }
|
||||
| { k: 'markdown'; content: string }
|
||||
| { k: 'node'; user_id: string; nickname: string; content: Seg[] };
|
||||
export type Seg = SegBody & { _id: string };
|
||||
export type MessageBuilderMode = 'auto' | 'message' | 'forward' | 'node-content';
|
||||
|
||||
let segSeq = 0;
|
||||
const nid = () => `s${segSeq++}`;
|
||||
|
||||
const BLANK: Record<SegBody['k'], () => SegBody> = {
|
||||
text: () => ({ k: 'text', text: '' }),
|
||||
at: () => ({ k: 'at', qq: '' }),
|
||||
image: () => ({ k: 'image', file: '' }),
|
||||
face: () => ({ k: 'face', id: '' }),
|
||||
reply: () => ({ k: 'reply', id: '' }),
|
||||
record: () => ({ k: 'record', file: '' }),
|
||||
video: () => ({ k: 'video', file: '' }),
|
||||
file: () => ({ k: 'file', file: '', name: '' }),
|
||||
mface: () => ({ k: 'mface', emoji_id: '', emoji_package_id: '', key: '', summary: '' }),
|
||||
poke: () => ({ k: 'poke', type: '1' }),
|
||||
json: () => ({ k: 'json', data: '' }),
|
||||
xml: () => ({ k: 'xml', data: '' }),
|
||||
markdown: () => ({ k: 'markdown', content: '' }),
|
||||
node: () => ({ k: 'node', user_id: '', nickname: '', content: [] }),
|
||||
};
|
||||
|
||||
/** A fresh segment with a stable id. */
|
||||
export function newSeg(k: SegBody['k']): Seg {
|
||||
return { ...BLANK[k](), _id: nid() } as Seg;
|
||||
}
|
||||
|
||||
/** Serialise the builder model to a OneBot v11 segment array (drops `_id`). */
|
||||
export function toOneBot(segs: Seg[]): Array<{ type: string; data: Record<string, unknown> }> {
|
||||
return segs.map((s): { type: string; data: Record<string, unknown> } => {
|
||||
switch (s.k) {
|
||||
case 'text':
|
||||
return { type: 'text', data: { text: s.text } };
|
||||
case 'at':
|
||||
return { type: 'at', data: { qq: s.qq } };
|
||||
case 'image':
|
||||
return { type: 'image', data: { file: s.file } };
|
||||
case 'face':
|
||||
return { type: 'face', data: { id: Number(s.id) || 0 } };
|
||||
case 'reply':
|
||||
return { type: 'reply', data: { id: Number(s.id) || 0 } };
|
||||
case 'record':
|
||||
return { type: 'record', data: { file: s.file } };
|
||||
case 'video':
|
||||
return { type: 'video', data: { file: s.file } };
|
||||
case 'file':
|
||||
return { type: 'file', data: { file: s.file, name: s.name } };
|
||||
case 'mface':
|
||||
return {
|
||||
type: 'mface',
|
||||
data: {
|
||||
emoji_id: s.emoji_id,
|
||||
emoji_package_id: s.emoji_package_id,
|
||||
key: s.key,
|
||||
summary: s.summary,
|
||||
},
|
||||
};
|
||||
case 'poke':
|
||||
return { type: 'poke', data: { type: Number(s.type) || 1 } };
|
||||
case 'json':
|
||||
return { type: 'json', data: { data: s.data } };
|
||||
case 'xml':
|
||||
return { type: 'xml', data: { data: s.data } };
|
||||
case 'markdown':
|
||||
return { type: 'markdown', data: { content: s.content } };
|
||||
case 'node':
|
||||
return {
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: Number(s.user_id) || 0,
|
||||
nickname: s.nickname,
|
||||
content: toOneBot(s.content),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const COMMON: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [
|
||||
{ k: 'text', label: '文本', icon: <Type className="h-4 w-4" /> },
|
||||
{ k: 'at', label: '@', icon: <AtSign className="h-4 w-4" /> },
|
||||
{ k: 'image', label: '图片', icon: <ImageIcon className="h-4 w-4" /> },
|
||||
{ k: 'face', label: '表情', icon: <Smile className="h-4 w-4" /> },
|
||||
{ k: 'reply', label: '回复', icon: <Quote className="h-4 w-4" /> },
|
||||
{ k: 'record', label: '语音', icon: <Mic className="h-4 w-4" /> },
|
||||
{ k: 'video', label: '视频', icon: <Film className="h-4 w-4" /> },
|
||||
];
|
||||
const ADVANCED: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [
|
||||
{ k: 'file', label: '文件', icon: <FileText className="h-4 w-4" /> },
|
||||
{ k: 'mface', label: '商城表情', icon: <Sticker className="h-4 w-4" /> },
|
||||
{ k: 'poke', label: '戳一戳', icon: <Hand className="h-4 w-4" /> },
|
||||
{ k: 'json', label: 'JSON 卡片', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'xml', label: 'XML 卡片', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'markdown', label: 'Markdown', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'node', label: '合并转发节点', icon: <Forward className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
/** Segment kinds exposed by the add menu for a builder context. */
|
||||
export function messageBuilderSegmentKinds(
|
||||
mode: MessageBuilderMode,
|
||||
segments: Seg[],
|
||||
): SegBody['k'][] {
|
||||
const all = [...COMMON, ...ADVANCED].map((item) => item.k);
|
||||
|
||||
if (mode === 'auto') {
|
||||
return all;
|
||||
}
|
||||
|
||||
if (mode === 'message') {
|
||||
return all.filter((kind) => kind !== 'node');
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return ['node'];
|
||||
}
|
||||
|
||||
// A forward node may contain either an ordinary message or another list of
|
||||
// nodes, never a mixture. Window shake is also rejected anywhere inside a
|
||||
// forward payload. Lock the menu to the branch selected by the first child.
|
||||
const forwardSafe = all.filter((kind) => kind !== 'poke');
|
||||
|
||||
if (segments.length === 0) {
|
||||
return forwardSafe;
|
||||
}
|
||||
|
||||
if (segments.every((segment) => segment.k === 'node')) {
|
||||
return ['node'];
|
||||
}
|
||||
|
||||
if (segments.every((segment) => segment.k !== 'node')) {
|
||||
return forwardSafe.filter((kind) => kind !== 'node');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const LABELS: Record<SegBody['k'], string> = {
|
||||
text: '文本',
|
||||
at: '@',
|
||||
image: '图片',
|
||||
face: '表情',
|
||||
reply: '回复',
|
||||
record: '语音',
|
||||
video: '视频',
|
||||
file: '文件',
|
||||
mface: '商城表情',
|
||||
poke: '戳一戳',
|
||||
json: 'JSON',
|
||||
xml: 'XML',
|
||||
markdown: 'Markdown',
|
||||
node: '转发节点',
|
||||
};
|
||||
|
||||
export function MessageBuilder({
|
||||
segments,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
depth = 0,
|
||||
mode = 'auto',
|
||||
}: {
|
||||
segments: Seg[];
|
||||
onChange: (segs: Seg[]) => void;
|
||||
uin: string;
|
||||
/** group context for @ member picking (empty for private targets). */
|
||||
groupId: string;
|
||||
depth?: number;
|
||||
mode?: MessageBuilderMode;
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [dragFrom, setDragFrom] = useState<number | null>(null);
|
||||
|
||||
const add = (k: SegBody['k']) => {
|
||||
onChange([...segments, newSeg(k)]);
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const patch = (i: number, next: Seg) => onChange(segments.map((s, j) => (j === i ? next : s)));
|
||||
const remove = (i: number) => onChange(segments.filter((_, j) => j !== i));
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= segments.length || from === to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = segments.slice();
|
||||
const [it] = next.splice(from, 1);
|
||||
next.splice(to, 0, it);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
// Keep the top-level canvas concentric with its rows at any operator-selected
|
||||
// corner radius. At the default 12px radius this is 20px → 12px over the 8px
|
||||
// inset. Nested forward builders deliberately avoid another canvas surface:
|
||||
// cards-inside-cards add visual noise and cannot remain concentric with a
|
||||
// parent row once the operator selects a compact radius.
|
||||
const nested = depth > 0;
|
||||
const canvasRadius = 'calc(var(--radius) + 0.5rem)';
|
||||
const rowRadius =
|
||||
depth === 0
|
||||
? 'var(--radius)'
|
||||
: depth === 1
|
||||
? 'max(0px, calc(var(--radius) - 0.25rem))'
|
||||
: 'max(0px, calc(var(--radius) - 0.5rem))';
|
||||
const allowedKinds = new Set(messageBuilderSegmentKinds(mode, segments));
|
||||
const commonOptions = COMMON.filter((item) => allowedKinds.has(item.k));
|
||||
const advancedOptions = ADVANCED.filter((item) => allowedKinds.has(item.k));
|
||||
const primaryOptions = mode === 'forward' ? advancedOptions : commonOptions;
|
||||
const secondaryOptions = mode === 'forward' ? [] : advancedOptions;
|
||||
|
||||
return (
|
||||
// The root is a bounded compose canvas, so its empty hint reads as an
|
||||
// intentional empty state rather than a stray line. Nested builders inherit
|
||||
// the surrounding node surface instead of drawing another card within it.
|
||||
<div
|
||||
className={
|
||||
nested
|
||||
? 'flex flex-col gap-2'
|
||||
: 'flex flex-col gap-2 border border-border/50 bg-muted/20 p-2'
|
||||
}
|
||||
style={nested ? undefined : { borderRadius: canvasRadius }}
|
||||
>
|
||||
{segments.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
{mode === 'forward'
|
||||
? '空转发 — 点下方添加转发节点'
|
||||
: '空消息 — 点下方「添加段」开始'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
key={s._id}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (dragFrom !== null) {
|
||||
move(dragFrom, i);
|
||||
}
|
||||
|
||||
setDragFrom(null);
|
||||
}}
|
||||
className="border border-border/50 bg-card/60 p-2"
|
||||
style={{ borderRadius: rowRadius }}
|
||||
>
|
||||
{/* Drag is scoped to the grip handle only — making the whole row
|
||||
draggable hijacked text selection inside the segment inputs (you
|
||||
couldn't select part of a pasted URL without dragging the card).
|
||||
The row stays the drop target; its body is fully selectable. */}
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
draggable
|
||||
// stopPropagation keeps a nested (forward-node) builder's drag
|
||||
// from reaching the parent row; onDragEnd lives here because the
|
||||
// grip — not the row — is now the drag source.
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('text/plain', String(i));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDragFrom(i);
|
||||
}}
|
||||
onDragEnd={() => setDragFrom(null)}
|
||||
title="拖动此处排序"
|
||||
aria-label="拖动排序"
|
||||
className="flex shrink-0 cursor-grab rounded-md p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-semibold text-muted-foreground">
|
||||
{LABELS[s.k]}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, i - 1)}
|
||||
disabled={i === 0}
|
||||
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="上移"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, i + 1)}
|
||||
disabled={i === segments.length - 1}
|
||||
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="下移"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentEditor
|
||||
seg={s}
|
||||
onChange={(n) => patch(i, n)}
|
||||
uin={uin}
|
||||
groupId={groupId}
|
||||
depth={depth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-border/70 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 添加段
|
||||
</button>
|
||||
{showAdd && (
|
||||
<div className="absolute z-30 mt-1.5 w-64 rounded-xl border border-border/60 bg-popover p-2 shadow-lg">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{primaryOptions.map((c) => (
|
||||
<button
|
||||
key={c.k}
|
||||
type="button"
|
||||
onClick={() => add(c.k)}
|
||||
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{secondaryOptions.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer list-none px-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3" /> 进阶段
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-1 grid grid-cols-4 gap-1">
|
||||
{secondaryOptions.map((c) => (
|
||||
<button
|
||||
key={c.k}
|
||||
type="button"
|
||||
onClick={() => add(c.k)}
|
||||
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtEditor({
|
||||
seg,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
}: {
|
||||
seg: Extract<Seg, { k: 'at' }>;
|
||||
onChange: (s: Seg) => void;
|
||||
uin: string;
|
||||
groupId: string;
|
||||
}) {
|
||||
const { items, loading, error, refresh } = useGroupMembers(uin, groupId);
|
||||
const options = [{ value: 'all', label: '@全体成员', sub: 'all' }, ...items];
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel="@某人"
|
||||
value={seg.qq}
|
||||
onChange={(v) => onChange({ ...seg, qq: v })}
|
||||
options={options}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
placeholder={groupId ? '选择成员 / @全体' : '输入 QQ 号(私聊无成员)'}
|
||||
validateRaw={(s) => s === 'all' || /^\d{3,}$/.test(s)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentEditor({
|
||||
seg,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
depth,
|
||||
}: {
|
||||
seg: Seg;
|
||||
onChange: (s: Seg) => void;
|
||||
uin: string;
|
||||
groupId: string;
|
||||
depth: number;
|
||||
}) {
|
||||
switch (seg.k) {
|
||||
case 'text':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.text}
|
||||
onChange={(e) => onChange({ ...seg, text: e.target.value })}
|
||||
placeholder="文本内容"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
/>
|
||||
);
|
||||
case 'at':
|
||||
return <AtEditor seg={seg} onChange={onChange} uin={uin} groupId={groupId} />;
|
||||
case 'image':
|
||||
return (
|
||||
<FileSource
|
||||
role="image"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'record':
|
||||
return (
|
||||
<FileSource
|
||||
role="record"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<FileSource
|
||||
role="video"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FileSource
|
||||
role="file"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
<Input
|
||||
value={seg.name}
|
||||
onChange={(e) => onChange({ ...seg, name: e.target.value })}
|
||||
placeholder="文件名(可选)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'face':
|
||||
return <FaceGrid value={seg.id} onChange={(v) => onChange({ ...seg, id: v })} />;
|
||||
case 'reply':
|
||||
return (
|
||||
<Input
|
||||
value={seg.id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, id: e.target.value.replace(/[^\d-]/g, '') })
|
||||
}
|
||||
placeholder="回复的消息 id"
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
case 'poke':
|
||||
return (
|
||||
<Input
|
||||
value={seg.type}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, type: e.target.value.replace(/[^\d]/g, '') })
|
||||
}
|
||||
placeholder="戳一戳类型(默认 1)"
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.data}
|
||||
onChange={(e) => onChange({ ...seg, data: e.target.value })}
|
||||
placeholder='JSON 卡片原文 {"app":…}'
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'xml':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.data}
|
||||
onChange={(e) => onChange({ ...seg, data: e.target.value })}
|
||||
placeholder="XML 卡片原文 <msg>…"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'markdown':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.content}
|
||||
onChange={(e) => onChange({ ...seg, content: e.target.value })}
|
||||
placeholder="Markdown 内容"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'mface':
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Input
|
||||
value={seg.emoji_id}
|
||||
onChange={(e) => onChange({ ...seg, emoji_id: e.target.value })}
|
||||
placeholder="emoji_id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.emoji_package_id}
|
||||
onChange={(e) => onChange({ ...seg, emoji_package_id: e.target.value })}
|
||||
placeholder="emoji_package_id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.key}
|
||||
onChange={(e) => onChange({ ...seg, key: e.target.value })}
|
||||
placeholder="key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.summary}
|
||||
onChange={(e) => onChange({ ...seg, summary: e.target.value })}
|
||||
placeholder="summary(可选)"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'node':
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Input
|
||||
value={seg.user_id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, user_id: e.target.value.replace(/[^\d]/g, '') })
|
||||
}
|
||||
placeholder="发送者 QQ"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.nickname}
|
||||
onChange={(e) => onChange({ ...seg, nickname: e.target.value })}
|
||||
placeholder="发送者昵称"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs text-muted-foreground">
|
||||
节点内容(可再嵌套)
|
||||
</span>
|
||||
{depth < 3 ? (
|
||||
<MessageBuilder
|
||||
segments={seg.content}
|
||||
onChange={(c) => onChange({ ...seg, content: c })}
|
||||
uin={uin}
|
||||
groupId={groupId}
|
||||
depth={depth + 1}
|
||||
mode="node-content"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">嵌套层级过深</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact human-readable preview line of a built message. */
|
||||
export function previewSegments(segs: Seg[]): string {
|
||||
return segs
|
||||
.map((s) => {
|
||||
switch (s.k) {
|
||||
case 'text':
|
||||
return s.text;
|
||||
case 'at':
|
||||
return s.qq === 'all' ? '@全体' : `@${s.qq}`;
|
||||
case 'image':
|
||||
return '[图片]';
|
||||
case 'face':
|
||||
return `[表情${s.id}]`;
|
||||
case 'reply':
|
||||
return '[回复]';
|
||||
case 'record':
|
||||
return '[语音]';
|
||||
case 'video':
|
||||
return '[视频]';
|
||||
case 'file':
|
||||
return `[文件${s.name ? ':' + s.name : ''}]`;
|
||||
case 'mface':
|
||||
return '[商城表情]';
|
||||
case 'poke':
|
||||
return '[戳一戳]';
|
||||
case 'json':
|
||||
return '[JSON卡片]';
|
||||
case 'xml':
|
||||
return '[XML卡片]';
|
||||
case 'markdown':
|
||||
return '[Markdown]';
|
||||
case 'node':
|
||||
return `[转发:${s.nickname || s.user_id}]`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** True if the message contains forward nodes (→ send via the forward action). */
|
||||
export function hasNodes(segs: Seg[]): boolean {
|
||||
return segs.some((s) => s.k === 'node');
|
||||
}
|
||||
|
||||
/** Per-segment completeness check (recurses into node content). Returns the
|
||||
* first problem string, or null. The forward "all nodes" rule is enforced
|
||||
* separately at the top level (only the forward action's `messages` array must
|
||||
* be all-nodes; a node's own content is a normal message). */
|
||||
export function validateSegs(segs: Seg[]): string | null {
|
||||
for (const s of segs) {
|
||||
switch (s.k) {
|
||||
case 'at':
|
||||
if (!s.qq.trim()) {
|
||||
return '@ 段未选择对象';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'image':
|
||||
if (!s.file.trim()) {
|
||||
return '图片段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'record':
|
||||
if (!s.file.trim()) {
|
||||
return '语音段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'video':
|
||||
if (!s.file.trim()) {
|
||||
return '视频段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'file':
|
||||
if (!s.file.trim()) {
|
||||
return '文件段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'reply':
|
||||
if (!s.id.trim()) {
|
||||
return '回复段缺少消息 id';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'node': {
|
||||
if (!s.user_id.trim()) {
|
||||
return '转发节点缺少发送者 QQ';
|
||||
}
|
||||
|
||||
if (s.content.length === 0) {
|
||||
return '转发节点内容为空';
|
||||
}
|
||||
|
||||
const inner = validateSegs(s.content);
|
||||
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user