@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="例如:视频模型已恢复正常使用" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user