feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
- D前端: lib/api/messages.ts(patchMessage/deleteMessage,兼容裸DTO与{message}) + MessageActions.tsx(仅user消息; 单击/右键/450ms长按开Miuix玻璃菜单; 编辑弹窗预填+字数→乐观updateMessageById→patchMessage→失败回滚; 删除确认→deleteMessage→removeMessageById; 乐观气泡禁用) + chat.tsx挂载 + Icon加edit/trash/more
- 日志前端: types(AdminLogLine time:string|number) + adminSystem.adminObservabilityLogs + Observability.tsx 后端分级tail(level筛选chip注明'该级及以上'+等宽行HH:mm:ss.SSS·level着色·source·msg+30s刷新) 下半保留WS回显
- Docs/api-client §6a/§4.2 修正: PATCH /messages/{id} 实际返回 {data:<DTO>} 非 {data:{message}}(前端已双向兼容,避免误导Android)
- 前端 build 绿(167 modules); SSR冒烟11 checks
- 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal)
This commit is contained in:
1 parent
025dd1796b
commit
1ac126fc9e
9 files changed
+611
-12
No files matched your search
@@ -47,7 +47,10 @@ export type IconName =
|
||||
| "terminal" // 🖥 code (terminal_rounded)
|
||||
| "image" // 🖼 attachment/vision (image_rounded)
|
||||
| "phone" // 📞 voice call / hang up (call_rounded)
|
||||
| "menu" // ☰ drawer/open nav (menu_rounded);
|
||||
| "menu" // ☰ drawer/open nav (menu_rounded)
|
||||
| "edit" // ✎ edit a message (edit_rounded)
|
||||
| "trash" // 🗑 delete a message (delete_rounded)
|
||||
| "more" // ⋯ message action menu (more_horiz_rounded);
|
||||
|
||||
const ICON_PATHS: Record<IconName, string> = {
|
||||
chart:
|
||||
@@ -128,6 +131,12 @@ const ICON_PATHS: Record<IconName, string> = {
|
||||
"M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1l-2.3 2.2Z",
|
||||
menu:
|
||||
"M4 6h16v2H4V6Zm0 5h16v2H4v-2Zm0 5h16v2H4v-2Z",
|
||||
edit:
|
||||
"M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25ZM20.7 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83Z",
|
||||
trash:
|
||||
"M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12ZM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4Z",
|
||||
more:
|
||||
"M6 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z",
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { WakePath } from "./status";
|
||||
import { modeBody } from "./messages";
|
||||
import { UserMessageActions } from "./messages/MessageActions";
|
||||
import { renderContent } from "../lib/renderContent";
|
||||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||||
|
||||
@@ -332,7 +333,9 @@ export function SplitChatBubble({
|
||||
// bubble and below the last — never inside a bubble's body.
|
||||
const align = message.role === "user" ? "right" : "left";
|
||||
return (
|
||||
<div className={`flex flex-col gap-2.5 ${message.role === "user" ? "items-end" : ""}`}>
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||||
>
|
||||
<BubbleNotes items={head} align={align} />
|
||||
{segments.map((seg, i) =>
|
||||
message.role === "user" ? (
|
||||
@@ -348,6 +351,7 @@ export function SplitChatBubble({
|
||||
),
|
||||
)}
|
||||
<BubbleNotes items={tail} align={align} />
|
||||
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -362,7 +366,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{message.image ? (
|
||||
<img
|
||||
@@ -375,6 +379,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
</div>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
<SenderLabel name={message.senderName} />
|
||||
<UserMessageActions message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { deleteMessage, patchMessage } from "../../lib/api";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Button } from "../primitives";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
/**
|
||||
* 用户消息的编辑 / 删除入口(design/16 §4.2 D3)。只对 **user 角色** 且已落库
|
||||
* (有 sessionId)的消息开放:assistant 回复不可编辑;尚在本机乐观渲染、后端还没
|
||||
* 分配 id 的气泡也没有可 PATCH 的目标。
|
||||
*
|
||||
* 写路径的真实性:编辑走 `PATCH /messages/{id}`、删除走 `DELETE /messages/{id}`,
|
||||
* 两者成功后后端都会广播 `message_edited` / `message_deleted`(lib/ws.ts 已消费),
|
||||
* 所以本组件只做「先乐观、失败回滚」(编辑)与「成功后就地移除」(删除),
|
||||
* 不伪造成功、不静默吞掉错误——失败一律把后端文案展示出来。
|
||||
*/
|
||||
|
||||
/** Backend cap on a message body (runes), mirrored for the inline counter. */
|
||||
const MAX_CONTENT_RUNES = 4000;
|
||||
/** How long a touch must hold the trigger before the menu opens (ms). */
|
||||
const LONG_PRESS_MS = 450;
|
||||
|
||||
function runeLen(s: string): number {
|
||||
return [...s].length;
|
||||
}
|
||||
|
||||
/** Miuix modal shell: dimmed scrim + a glass card. Clicking the scrim or pressing
|
||||
* Escape cancels; the card itself stops propagation. */
|
||||
function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="liquid-glass relative w-full max-w-md rounded-[var(--mu-radius-lg)] p-5 shadow-[var(--mu-shadow-2)]"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--mu-text-hi)]">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline error line shown inside a dialog when the backend rejects the write. */
|
||||
function DialogError({ text }: { text: string | null }): ReactNode {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<p className="mt-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-err)]/15 px-2.5 py-1.5 text-xs text-[var(--mu-err)]">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit dialog: prefilled textarea → PATCH. Optimistically patches the store so
|
||||
* the bubble updates instantly, then normalizes with the backend DTO; a failure
|
||||
* rolls the old content back and keeps the dialog open for a retry. */
|
||||
function EditMessageDialog({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const updateMessageById = useAppStore((s) => s.updateMessageById);
|
||||
const [draft, setDraft] = useState(message.content);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const len = runeLen(draft);
|
||||
const invalid = draft.trim() === "" || len > MAX_CONTENT_RUNES;
|
||||
|
||||
const save = async () => {
|
||||
if (invalid || busy) return;
|
||||
const previous = message.content;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
updateMessageById(message.id, { content: draft });
|
||||
try {
|
||||
const updated = await patchMessage(message.id, draft);
|
||||
updateMessageById(message.id, { content: updated.content });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
updateMessageById(message.id, { content: previous });
|
||||
setError(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="编辑消息" onClose={busy ? () => {} : onClose}>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={5}
|
||||
autoFocus
|
||||
className="w-full resize-y rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm text-[var(--mu-text-hi)] outline-none ring-0 focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center justify-between text-[11px] text-[var(--mu-text-lo)]">
|
||||
<span>{invalid && draft.trim() === "" ? "内容不能为空" : "仅修改本条消息的内容"}</span>
|
||||
<span className={len > MAX_CONTENT_RUNES ? "text-[var(--mu-err)]" : ""}>
|
||||
{len}/{MAX_CONTENT_RUNES}
|
||||
</span>
|
||||
</div>
|
||||
<DialogError text={error} />
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => void save()} disabled={busy || invalid}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete confirm → DELETE. The row is removed from the store only after the
|
||||
* backend confirms (the WS `message_deleted` broadcast then re-applies it as a
|
||||
* no-op), so a rejected delete never leaves the UI lying. */
|
||||
function DeleteMessageDialog({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const removeMessageById = useAppStore((s) => s.removeMessageById);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const confirm = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteMessage(message.id);
|
||||
removeMessageById(message.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="删除消息" onClose={busy ? () => {} : onClose}>
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-[var(--mu-text-mid)]">
|
||||
删除后本条消息会从所有已连接的客户端移除,且无法恢复。
|
||||
</p>
|
||||
<DialogError text={error} />
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => void confirm()} disabled={busy}>
|
||||
{busy ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of the action menu: icon + label, optional disabled reason. */
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
icon: "edit" | "trash";
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none ${
|
||||
danger
|
||||
? "text-[var(--mu-err)] hover:bg-[var(--mu-err)]/10"
|
||||
: "text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-4)]"
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={14} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "⋯" trigger. Desktop reveals it on row hover (`group-hover`); a coarse
|
||||
* pointer keeps it faintly visible. Opens on click, right-click, or a 450ms
|
||||
* long press — a long press swallows the trailing click so it does not toggle
|
||||
* the menu straight back shut. */
|
||||
function ActionTrigger({
|
||||
open,
|
||||
onToggle,
|
||||
onOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onOpen: () => void;
|
||||
}): ReactNode {
|
||||
const pressTimer = useRef<number | null>(null);
|
||||
const longPressed = useRef(false);
|
||||
|
||||
const clearPress = () => {
|
||||
if (pressTimer.current !== null) {
|
||||
window.clearTimeout(pressTimer.current);
|
||||
pressTimer.current = null;
|
||||
}
|
||||
};
|
||||
useEffect(() => clearPress, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="消息操作"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title="消息操作"
|
||||
onClick={() => {
|
||||
if (longPressed.current) {
|
||||
longPressed.current = false;
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
longPressed.current = false;
|
||||
clearPress();
|
||||
pressTimer.current = window.setTimeout(() => {
|
||||
longPressed.current = true;
|
||||
onOpen();
|
||||
}, LONG_PRESS_MS);
|
||||
}}
|
||||
onPointerUp={clearPress}
|
||||
onPointerLeave={clearPress}
|
||||
onPointerCancel={clearPress}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--mu-surface-4)]/80 text-[var(--mu-text-mid)] opacity-0 backdrop-blur transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] focus-visible:opacity-100 group-hover:opacity-100 max-sm:opacity-80"
|
||||
>
|
||||
<Icon name="more" size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The popup itself: a scrim that dismisses on outside click + the 编辑/删除 rows.
|
||||
* Both rows are disabled until the message has a server id to act on. */
|
||||
function ActionMenu({
|
||||
persisted,
|
||||
onDismiss,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
persisted: boolean;
|
||||
onDismiss: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}): ReactNode {
|
||||
const disabledHint = "消息尚未写入后端,稍后再试";
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={onDismiss} aria-hidden />
|
||||
<div
|
||||
role="menu"
|
||||
className="liquid-glass absolute right-0 top-8 z-20 w-28 overflow-hidden rounded-[var(--mu-radius)] py-1 shadow-[var(--mu-shadow-2)]"
|
||||
>
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
label="编辑"
|
||||
disabled={!persisted}
|
||||
title={persisted ? undefined : disabledHint}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="trash"
|
||||
label="删除"
|
||||
danger
|
||||
disabled={!persisted}
|
||||
title={persisted ? undefined : disabledHint}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover / long-press action entry for one own user message. Must be rendered
|
||||
* inside a `relative group` container (the bubble row), which it anchors to.
|
||||
* Unpersisted optimistic bubbles expose the menu but disable both actions — there
|
||||
* is no server id to PATCH/DELETE yet.
|
||||
*/
|
||||
export function UserMessageActions({ message }: { message: ChatMessage }): ReactNode {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [dialog, setDialog] = useState<"edit" | "delete" | null>(null);
|
||||
const openDialog = (next: "edit" | "delete") => {
|
||||
setMenuOpen(false);
|
||||
setDialog(next);
|
||||
};
|
||||
const closeDialog = () => setDialog(null);
|
||||
|
||||
// Assistant / system messages are never editable from the user surface.
|
||||
if (message.role !== "user") return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-2 right-0 z-20">
|
||||
<ActionTrigger
|
||||
open={menuOpen}
|
||||
onToggle={() => setMenuOpen((v) => !v)}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
/>
|
||||
{menuOpen ? (
|
||||
<ActionMenu
|
||||
persisted={Boolean(message.sessionId)}
|
||||
onDismiss={() => setMenuOpen(false)}
|
||||
onEdit={() => openDialog("edit")}
|
||||
onDelete={() => openDialog("delete")}
|
||||
/>
|
||||
) : null}
|
||||
{dialog === "edit" ? <EditMessageDialog message={message} onClose={closeDialog} /> : null}
|
||||
{dialog === "delete" ? <DeleteMessageDialog message={message} onClose={closeDialog} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user