feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-03 15:23:27 +08:00
1 parent 025dd1796b
commit 1ac126fc9e
9 files changed
+611 -12

No files matched your search

+10 -1
View File
@@ -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 {
+7 -2
View File
@@ -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>
);
}