fix(*): 公告与画布 - 支持公告重新发布并阻止浏览器误缩放

Fixes #55
This commit is contained in:
ddCat
2026-07-31 15:46:58 +08:00
parent 90a903805f
commit bf339911e4
10 changed files with 206 additions and 28 deletions

View File

@@ -145,12 +145,13 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
const isPinchZoom = event.ctrlKey || event.metaKey;
if (target?.closest(CANVAS_WHEEL_IGNORE_SELECTOR)) {
if (target?.closest(CANVAS_WHEEL_IGNORE_SELECTOR) && !isPinchZoom) {
// 内部区域保留纵向滚动,但横向手势不能泄漏为 macOS 浏览器前进/后退。
if (!isPinchZoom && (event.shiftKey || absX > absY)) event.preventDefault();
if (event.shiftKey || absX > absY) event.preventDefault();
return;
}
// Ctrl/Meta + 滚轮在画布内始终由画布接管,避免浮层区域触发浏览器页面缩放。
event.preventDefault();
interactingRef.current = true;
const current = viewportRef.current;

View File

@@ -9,6 +9,7 @@ import {
closeAdminAnnouncement,
createAdminAnnouncement,
listAdminAnnouncements,
updateAdminAnnouncement,
type AnnouncementLevel,
type AnnouncementStatus,
type SystemAnnouncement,
@@ -46,6 +47,7 @@ export default function AdminAnnouncementsPanel() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingAnnouncement, setEditingAnnouncement] = useState<SystemAnnouncement | null>(null);
const [publishing, setPublishing] = useState(false);
const [closingId, setClosingId] = useState("");
@@ -67,21 +69,31 @@ export default function AdminAnnouncementsPanel() {
}, [reload]);
const openPublishModal = () => {
setEditingAnnouncement(null);
form.setFieldsValue({ title: "", content: "", level: "info" });
setModalOpen(true);
};
const openEditModal = (announcement: SystemAnnouncement) => {
setEditingAnnouncement(announcement);
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level });
setModalOpen(true);
};
const publish = async () => {
const values = await form.validateFields();
setPublishing(true);
try {
await createAdminAnnouncement({ title: values.title.trim(), content: values.content.trim(), level: values.level });
const input = { title: values.title.trim(), content: values.content.trim(), level: values.level };
if (editingAnnouncement) await updateAdminAnnouncement(editingAnnouncement.id, input);
else await createAdminAnnouncement(input);
setModalOpen(false);
setEditingAnnouncement(null);
setPage(1);
await reload();
message.success("公告已发布");
message.success(editingAnnouncement ? "公告已更新并重新发布" : "公告已发布");
} catch (error) {
message.error(error instanceof Error ? error.message : "发布公告失败");
message.error(error instanceof Error ? error.message : editingAnnouncement ? "更新公告失败" : "发布公告失败");
} finally {
setPublishing(false);
}
@@ -144,12 +156,17 @@ export default function AdminAnnouncementsPanel() {
title: "操作",
key: "actions",
fixed: "right",
width: 100,
render: (_, announcement) => announcement.status === "active" ? (
<Popconfirm title="关闭这条公告?" description="关闭后用户公告中心将不再展示,历史记录会保留。" okText="关闭公告" cancelText="取消" onConfirm={() => void closeAnnouncement(announcement)}>
<Button type="text" danger size="small" loading={closingId === announcement.id}>关闭</Button>
</Popconfirm>
) : <span className="text-xs text-foreground/35">已结束</span>,
width: 160,
render: (_, announcement) => (
<div className="flex items-center gap-1">
<Button type="text" size="small" onClick={() => openEditModal(announcement)}>编辑</Button>
{announcement.status === "active" ? (
<Popconfirm title="关闭这条公告?" description="关闭后用户公告中心将不再展示,历史记录会保留。" okText="关闭公告" cancelText="取消" onConfirm={() => void closeAnnouncement(announcement)}>
<Button type="text" danger size="small" loading={closingId === announcement.id}>关闭</Button>
</Popconfirm>
) : null}
</div>
),
},
];
@@ -183,7 +200,7 @@ export default function AdminAnnouncementsPanel() {
/>
</TableSurface>
<Modal title="发布系统公告" open={modalOpen} width={680} centered okText="立即发布" cancelText="取消" confirmLoading={publishing} onOk={() => void publish()} onCancel={() => setModalOpen(false)} destroyOnHidden>
<Modal title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"} open={modalOpen} width={680} centered okText={editingAnnouncement ? "保存并重新发布" : "立即发布"} cancelText="取消" confirmLoading={publishing} onOk={() => void publish()} onCancel={() => { setModalOpen(false); setEditingAnnouncement(null); }} destroyOnHidden>
<Form form={form} layout="vertical" className="pt-3" requiredMark={false}>
<Form.Item name="title" label="公告标题" rules={[{ required: true, whitespace: true, message: "请填写公告标题" }, { max: 120, message: "标题不能超过 120 个字符" }]}>
<Input maxLength={120} showCount placeholder="例如:视频模型已恢复正常使用" />

View File

@@ -412,6 +412,8 @@ function InfiniteCanvasPage() {
resetViewport,
screenToCanvas,
setZoomScale,
zoomCanvasIn,
zoomCanvasOut,
zoomToActualSize,
} = useCanvasViewportController({
containerRef,
@@ -962,6 +964,8 @@ function InfiniteCanvasPage() {
deleteNodes,
deleteConnection,
deselectCanvas,
zoomCanvasIn,
zoomCanvasOut,
});
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {

View File

@@ -28,6 +28,8 @@ type UseCanvasKeyboardOptions = {
deleteNodes: (ids: Set<string>) => void;
deleteConnection: (connectionId: string) => void;
deselectCanvas: () => void;
zoomCanvasIn: () => void;
zoomCanvasOut: () => void;
};
export function useCanvasKeyboard({
@@ -56,12 +58,31 @@ export function useCanvasKeyboard({
deleteNodes,
deleteConnection,
deselectCanvas,
zoomCanvasIn,
zoomCanvasOut,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const key = event.key.toLowerCase();
const isModifierShortcut = event.metaKey || event.ctrlKey;
const isTextEditingTarget = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || Boolean(target?.closest("[contenteditable='true']"));
if (isModifierShortcut && !event.altKey && (key === "+" || key === "=" || event.code === "NumpadAdd")) {
event.preventDefault();
if (!isTextEditingTarget) zoomCanvasIn();
return;
}
if (isModifierShortcut && !event.altKey && (key === "-" || key === "_" || event.code === "NumpadSubtract")) {
event.preventDefault();
if (!isTextEditingTarget) zoomCanvasOut();
return;
}
if (isModifierShortcut && !event.altKey && (key === "0" || event.code === "Numpad0")) {
event.preventDefault();
if (!isTextEditingTarget) zoomToActualSize();
return;
}
if (isModifierShortcut && !event.altKey && key === "s") {
event.preventDefault();
@@ -69,7 +90,7 @@ export function useCanvasKeyboard({
if (!event.repeat) void saveCanvasProject();
return;
}
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
if (isTextEditingTarget || target?.closest("[data-canvas-no-zoom]")) return;
if (event.key === "?" && !isModifierShortcut && !event.altKey) {
event.preventDefault();
setShortcutRequestNonce((value) => value + 1);
@@ -142,5 +163,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, undoCanvas, zoomToActualSize]);
}, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}

View File

@@ -136,6 +136,9 @@ export function useCanvasViewportController({
setContextMenu(null);
}, [cancelViewportTransition, commitViewport, previewViewport, setContextMenu, size, viewportRef]);
const zoomCanvasIn = useCallback(() => setZoomScale(viewportRef.current.k + 0.1), [setZoomScale, viewportRef]);
const zoomCanvasOut = useCallback(() => setZoomScale(viewportRef.current.k - 0.1), [setZoomScale, viewportRef]);
const zoomToActualSize = useCallback(() => {
transitionViewportTo(viewportAtScale(viewportRef.current, size, 1));
}, [size, transitionViewportTo, viewportRef]);
@@ -164,6 +167,8 @@ export function useCanvasViewportController({
resetViewport,
screenToCanvas,
setZoomScale,
zoomCanvasIn,
zoomCanvasOut,
zoomToActualSize,
};
}

View File

@@ -54,6 +54,10 @@ export function createAdminAnnouncement(input: { title: string; content: string;
return request<{ announcement: SystemAnnouncement }>(api.post("/admin/announcements", input));
}
export function updateAdminAnnouncement(id: string, input: { title: string; content: string; level: AnnouncementLevel }) {
return request<{ announcement: SystemAnnouncement }>(api.patch(`/admin/announcements/${encodeURIComponent(id)}`, input));
}
export function closeAdminAnnouncement(id: string) {
return request<{ announcement: SystemAnnouncement }>(api.post(`/admin/announcements/${encodeURIComponent(id)}/close`));
}