diff --git a/backend/internal/handler/announcement.go b/backend/internal/handler/announcement.go index b143b1d49..acb309711 100644 --- a/backend/internal/handler/announcement.go +++ b/backend/internal/handler/announcement.go @@ -80,6 +80,25 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"announcement": announcement}) }) + r.PATCH("/admin/announcements/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.UpdateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + announcement, err := svc.UpdateAnnouncement(user, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"announcement": announcement}) + }) + r.POST("/admin/announcements/:id/close", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/repository/announcement.go b/backend/internal/repository/announcement.go index ac01d5014..dce6d423b 100644 --- a/backend/internal/repository/announcement.go +++ b/backend/internal/repository/announcement.go @@ -60,6 +60,28 @@ func (r *Repository) CloseAnnouncement(id string, closedAt time.Time) (bool, err return result.RowsAffected == 1, result.Error } +func (r *Repository) UpdateAnnouncement(announcement *model.Announcement) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Announcement{}).Where("id = ?", announcement.ID).Updates(map[string]any{ + "title": announcement.Title, + "content": announcement.Content, + "level": announcement.Level, + "status": announcement.Status, + "published_at": announcement.PublishedAt, + "closed_at": announcement.ClosedAt, + "updated_at": announcement.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + // 重新发布同一公告必须清除旧已读记录,否则用户看不到新的内容提醒。 + return tx.Where("announcement_id = ?", announcement.ID).Delete(&model.UserAnnouncementRead{}).Error + }) +} + func (r *Repository) MarkAnnouncementsRead(userID string, announcementIDs []string, readAt time.Time) error { if len(announcementIDs) == 0 { return nil diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index ffabf1242..7ec78397b 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -17,6 +17,8 @@ type CreateAnnouncementRequest struct { Level model.AnnouncementLevel `json:"level"` } +type UpdateAnnouncementRequest = CreateAnnouncementRequest + type AnnouncementPage struct { Announcements []model.Announcement `json:"announcements"` Total int64 `json:"total"` @@ -45,20 +47,9 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe if err := s.RequireAdmin(actor); err != nil { return nil, err } - title := strings.TrimSpace(req.Title) - content := strings.TrimSpace(req.Content) - if title == "" || content == "" { - return nil, BadAuthRequest("请填写公告标题和正文") - } - if utf8.RuneCountInString(title) > 120 { - return nil, BadAuthRequest("公告标题不能超过 120 个字符") - } - if utf8.RuneCountInString(content) > 4000 { - return nil, BadAuthRequest("公告正文不能超过 4000 个字符") - } - level := req.Level - if !validAnnouncementLevel(level) { - return nil, BadAuthRequest("公告级别无效") + title, content, level, err := normalizeAnnouncementInput(req) + if err != nil { + return nil, err } now := time.Now() announcement := &model.Announcement{ @@ -78,6 +69,38 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe return announcement, nil } +func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnnouncementRequest) (*model.Announcement, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + announcement, err := s.repo.Announcement(strings.TrimSpace(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, BadAuthRequest("公告不存在") + } + return nil, err + } + title, content, level, err := normalizeAnnouncementInput(req) + if err != nil { + return nil, err + } + now := time.Now() + announcement.Title = title + announcement.Content = content + announcement.Level = level + announcement.Status = model.AnnouncementStatusActive + announcement.ClosedAt = nil + announcement.PublishedAt = now + announcement.UpdatedAt = now + if err := s.repo.UpdateAnnouncement(announcement); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, BadAuthRequest("公告状态已变化,请刷新后重试") + } + return nil, err + } + return s.repo.Announcement(announcement.ID) +} + func (s *Service) CloseAnnouncement(actor *model.User, id string) (*model.Announcement, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -136,3 +159,21 @@ func (s *Service) MarkAnnouncementsRead(user *model.User, announcementIDs []stri func validAnnouncementLevel(level model.AnnouncementLevel) bool { return level == model.AnnouncementLevelInfo || level == model.AnnouncementLevelSuccess || level == model.AnnouncementLevelWarning || level == model.AnnouncementLevelCritical } + +func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, model.AnnouncementLevel, error) { + title := strings.TrimSpace(req.Title) + content := strings.TrimSpace(req.Content) + if title == "" || content == "" { + return "", "", "", BadAuthRequest("请填写公告标题和正文") + } + if utf8.RuneCountInString(title) > 120 { + return "", "", "", BadAuthRequest("公告标题不能超过 120 个字符") + } + if utf8.RuneCountInString(content) > 4000 { + return "", "", "", BadAuthRequest("公告正文不能超过 4000 个字符") + } + if !validAnnouncementLevel(req.Level) { + return "", "", "", BadAuthRequest("公告级别无效") + } + return title, content, req.Level, nil +} diff --git a/backend/internal/service/announcement_test.go b/backend/internal/service/announcement_test.go index d9b5532e6..238d891ae 100644 --- a/backend/internal/service/announcement_test.go +++ b/backend/internal/service/announcement_test.go @@ -69,6 +69,50 @@ func TestAnnouncementPublishReadAndCloseLifecycle(t *testing.T) { } } +func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil { + t.Fatal(err) + } + svc := New(repository.New(db), t.TempDir()) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive} + + announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "旧标题", Content: "旧正文", Level: model.AnnouncementLevelInfo}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.MarkAnnouncementsRead(user, []string{announcement.ID}); err != nil { + t.Fatal(err) + } + if _, err := svc.CloseAnnouncement(admin, announcement.ID); err != nil { + t.Fatal(err) + } + + updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning}) + if err != nil { + t.Fatal(err) + } + if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning { + t.Fatalf("updated announcement = %+v", updated) + } + feed, err := svc.UserAnnouncements(user) + if err != nil { + t.Fatal(err) + } + if len(feed.Announcements) != 1 || feed.UnreadCount != 1 || feed.Announcements[0].Content != "新正文" { + t.Fatalf("feed after republish = %+v, want one unread updated announcement", feed) + } +} + func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { svc := &Service{} admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index f99e2d9bd..ffa980008 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -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; diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index 27aaff89e..87a57e8bd 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -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(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" ? ( - void closeAnnouncement(announcement)}> - - - ) : 已结束, + width: 160, + render: (_, announcement) => ( +
+ + {announcement.status === "active" ? ( + void closeAnnouncement(announcement)}> + + + ) : null} +
+ ), }, ]; @@ -183,7 +200,7 @@ export default function AdminAnnouncementsPanel() { /> - void publish()} onCancel={() => setModalOpen(false)} destroyOnHidden> + void publish()} onCancel={() => { setModalOpen(false); setEditingAnnouncement(null); }} destroyOnHidden>
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 652fffdb1..57d86f93a 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -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) => { diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 7f75c5c89..6e0eeac71 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -28,6 +28,8 @@ type UseCanvasKeyboardOptions = { deleteNodes: (ids: Set) => 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]); } diff --git a/web/src/pages/canvas/use-canvas-viewport-controller.ts b/web/src/pages/canvas/use-canvas-viewport-controller.ts index 8104d24e4..c90cdec6b 100644 --- a/web/src/pages/canvas/use-canvas-viewport-controller.ts +++ b/web/src/pages/canvas/use-canvas-viewport-controller.ts @@ -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, }; } diff --git a/web/src/services/api/announcements.ts b/web/src/services/api/announcements.ts index a0c9fce80..e1d1432f6 100644 --- a/web/src/services/api/announcements.ts +++ b/web/src/services/api/announcements.ts @@ -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`)); }