diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 12b449e..49c2010 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -387,13 +387,12 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) { sysContent = targetName + " 的禁言已被解除" } sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent) - // 广播系统消息到房间(sender_id=0,前端按系统消息渲染) + // 广播系统消息到房间(sender_id=0,前端按系统消息渲染); + // Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏 if sysMsg != nil && sysMsg.ID > 0 { h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ Type: realtime.EventChatMessage, - Data: gin.H{ - "message": sysMsg, - }, + Data: sysMsg, }) } h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ @@ -682,7 +681,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne } env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data} h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env) - // 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览 + // 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览: + // 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage + // 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。 if h.Chat == nil { return } diff --git a/backend/service/chat.go b/backend/service/chat.go index 32dea2a..9e24efa 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -131,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er return s.membership(s.db, roomID, userID) } -// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送) +// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示) func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 { out := make(map[uint]int64) if userID == 0 || len(roomIDs) == 0 { @@ -141,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 { s.db.Table("chat_messages AS m"). Select("m.room_id AS room_id, count(*) AS cnt"). Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID). - Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID). + Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false). Group("m.room_id"). Scan(&rows) for _, r := range rows { @@ -767,8 +767,10 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro return s.removeMember(roomID, targetID) } -// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true), -// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。 +// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。 +// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览, +// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。 +// 失败返回 nil + error。 func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) { msg := &model.ChatMessage{ RoomID: roomID, @@ -782,10 +784,7 @@ func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.Cha return err } return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). - Updates(map[string]interface{}{ - "last_message_id": msg.ID, - "updated_at": time.Now(), - }).Error + Update("updated_at", time.Now()).Error }) if err != nil { return nil, err @@ -1020,7 +1019,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo if err != nil { return nil, nil, err } - if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil { + if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil { return nil, nil, err } for _, uid := range mentionIDs { @@ -1109,7 +1108,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over q = q.Where("id < ?", beforeID) } var msgs []model.ChatMessage - if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil { + // Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致) + if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil { return nil, false, err } hasMore := len(msgs) > size @@ -1138,7 +1138,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee return nil, false, err } var msg model.ChatMessage - if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil { + if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, ErrChatMsgNotFound } @@ -1147,6 +1147,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee if msg.RecalledAt != nil { return &msg, false, nil // 幂等 } + // 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回) + if msg.Sender.Role == model.RoleOwner && !oversee { + return nil, false, ErrChatRecallDenied + } if msg.SenderID == operatorID { if !oversee { if _, err := s.membership(s.db, roomID, operatorID); err != nil { @@ -1165,7 +1169,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee } msg.RecalledAt = &now msg.RecalledBy = operatorID - _ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID) + // 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息: + // 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。 + var prevID uint + _ = s.db.Model(&model.ChatMessage{}). + Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false). + Order("id DESC").Limit(1). + Select("id").Row().Scan(&prevID) + _ = s.db.Model(&model.ChatRoom{}). + Where("id = ? AND last_message_id = ?", roomID, msg.ID). + Update("last_message_id", prevID).Error + _ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID) return &msg, true, nil } @@ -1345,13 +1359,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error { Update("last_read_message_id", messageID).Error } -// UnreadSummary 当前用户所有群的未读明细与总数 +// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入) func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) { var rows []UnreadRoom err := s.db.Table("chat_messages AS m"). Select("m.room_id AS room_id, count(*) AS cnt"). Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID). - Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID). + Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false). Group("m.room_id"). Scan(&rows).Error if err != nil { diff --git a/frontend/app/admin/settings/BrandMediaPicker.tsx b/frontend/app/admin/settings/BrandMediaPicker.tsx index d6d582e..eb0f6b5 100644 --- a/frontend/app/admin/settings/BrandMediaPicker.tsx +++ b/frontend/app/admin/settings/BrandMediaPicker.tsx @@ -3,8 +3,8 @@ import { Images, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import Modal from "@/components/Modal"; -import { apiMyMedia, type MediaAttachment } from "@/lib/api"; -import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand"; +import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api"; +import { normalizeBrandURL, type BrandSlot } from "@/lib/brand"; import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage"; const SLOT_LABEL: Record = { @@ -13,6 +13,13 @@ const SLOT_LABEL: Record = { favicon: "Favicon", }; +/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */ +export type BrandPickItem = { + key: string; + url: string; + attachmentId: number | null; +}; + export default function BrandMediaPicker({ open, slot, @@ -22,9 +29,9 @@ export default function BrandMediaPicker({ open: boolean; slot: BrandSlot; onClose: () => void; - onPick: (item: MediaAttachment) => void; + onPick: (item: BrandPickItem) => void; }) { - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -33,16 +40,29 @@ export default function BrandMediaPicker({ let cancelled = false; setLoading(true); setError(""); - void apiMyMedia() - .then((res) => { + // 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示) + Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)]) + .then(([mediaRes, libRes]) => { if (cancelled) return; - if (res.error) { - setError(res.error); + if (mediaRes.error) { + setError(mediaRes.error); setItems([]); return; } - const all = res.attachments || []; - setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url))); + const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({ + key: `att-${a.id}`, + url: a.url, + attachmentId: a.id, + })); + const seen = new Set(out.map((i) => i.url)); + for (const it of libRes?.items || []) { + if (it.category !== "brand") continue; + if (!normalizeBrandURL(slot, it.url)) continue; + if (seen.has(it.url)) continue; + seen.add(it.url); + out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null }); + } + setItems(out); }) .catch(() => { if (!cancelled) setError("加载媒体库失败"); @@ -59,7 +79,7 @@ export default function BrandMediaPicker({ ) : items.length === 0 ? ( -

没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。

+

媒体库还没有图片,先上传一张吧。

) : (
{items.map((item) => ( + {!isDirect && senderRoomRole === "admin" && ( + + 管理 + + )} + {/* 群聊与私聊都展示等级/徽章标识 */} + + {(m.sender.badges ?? []).slice(0, 1).map((ub) => ( + + ))} {formatTime(m.created_at)} @@ -2434,7 +2537,8 @@ export default function ChatRoomClient({ )} - {ctxMenu && ( + {/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */} + {ctxMenu && !muteTarget && !confirm && !userCard && ( )} - {userCtxMenu && ( + {userCtxMenu && !muteTarget && !confirm && !userCard && ( summary { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.9rem; + cursor: pointer; + user-select: none; + font-weight: 600; + font-size: 0.95em; + color: var(--ink); + list-style: none; +} +.prose-content .j13-md-details > summary::-webkit-details-marker { + display: none; +} +.prose-content .j13-md-details > summary:hover { + background: var(--tint); +} +.prose-content .j13-md-details[open] > summary { + border-bottom: 1px solid var(--line); +} +.prose-content .j13-md-details .j13-md-details-caret { + flex-shrink: 0; + color: var(--ink-3); + transition: transform 0.18s ease; +} +.prose-content .j13-md-details[open] .j13-md-details-caret { + transform: rotate(180deg); +} +.prose-content .j13-md-details-body { + padding: 0.35rem 0.9rem 0.6rem; +} +.prose-content .j13-md-details-body > :first-child { + margin-top: 0.35rem; +} +.prose-content .j13-md-details-body > :last-child { + margin-bottom: 0.35rem; +} + +/* 视频嵌入:16:9 容器 + 懒加载 iframe 铺满 */ +.j13-md-video { + position: relative; + aspect-ratio: 16 / 9; + margin: 1rem 0; + border-radius: var(--r-sm); + overflow: hidden; + background: var(--tint); + border: 1px solid var(--line); +} +.j13-md-video iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +/* ==高亮==:荧光笔。UA 默认黄底黑字在暗色下刺眼,显式覆盖 */ +.prose-content mark { + background: color-mix(in srgb, #fde047 55%, transparent); + color: inherit; + padding: 0.05em 0.25em; + border-radius: 4px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +.dark .prose-content mark { + background: color-mix(in srgb, #facc15 30%, transparent); + color: #fef9c3; +} + +/* [kbd] 键帽:等宽 + 下边加厚模拟按键立体 */ +.prose-content kbd { + display: inline-block; + padding: 0.1em 0.45em; + border: 1px solid var(--line-2); + border-bottom-width: 2px; + border-radius: 6px; + background: var(--panel); + color: var(--ink); + font-family: var(--font-mono); + font-size: 0.82em; + line-height: 1.45; + vertical-align: baseline; +} + +/* 编辑器「文字颜色」弹窗:前景/背景两行,每行预设 + 色轮 + hex 输入 */ +.j13-md-color-popover { + width: 288px; +} +.j13-md-color-row { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 10px; +} +.j13-md-color-row-label { + font-size: 12px; + color: var(--ink-3); + font-weight: 600; +} +.j13-md-color-row-items { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; +} +.j13-md-color-none { + height: 26px; + padding: 0 9px; + border-radius: 8px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink-3); + font-size: 12px; + cursor: pointer; +} +.j13-md-color-none.is-active { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} +.j13-md-color-dot { + width: 26px; + height: 26px; + border-radius: 8px; + border: 1px solid var(--line-2); + cursor: pointer; + padding: 0; +} +.j13-md-color-dot.is-active { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +/* 自定义取色器触发按钮:彩虹渐变底 + 吸管图标 */ +.j13-md-color-wheel { + width: 26px; + height: 26px; + border-radius: 8px; + border: 1px solid var(--line-2); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + color: #fff; + background: conic-gradient( + from 210deg, + #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00 + ); +} +.j13-md-color-wheel svg { + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.65)); +} +.j13-md-color-wheel:hover { + transform: scale(1.08); +} +.j13-md-color-wheel.is-active { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.j13-md-color-hex { + flex: 1 1 90px; + min-width: 90px; + font-family: var(--font-mono); + font-size: 12px; +} +/* 内联 HSV 取色器:在弹层文档流内展开,只撑开不浮层,不遮挡预览 */ +.j13-md-hsv { + margin-top: 8px; +} +.j13-md-hsv-sv { + position: relative; + height: 110px; + border-radius: 8px; + overflow: hidden; + cursor: crosshair; + touch-action: none; + border: 1px solid var(--line); +} +.j13-md-hsv-sv-thumb { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); + transform: translate(-50%, -50%); + pointer-events: none; +} +.j13-md-hsv-hue { + position: relative; + height: 12px; + border-radius: 6px; + margin-top: 8px; + cursor: pointer; + touch-action: none; + border: 1px solid var(--line); + background: linear-gradient( + to right, + #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00 + ); +} +.j13-md-hsv-hue-thumb { + position: absolute; + top: 50%; + width: 10px; + height: 18px; + border-radius: 4px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.35); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transform: translate(-50%, -50%); + pointer-events: none; +} +/* 实时预览置于弹层顶部:取色器在行内展开时始终可见,未选背景时透明棋盘格 */ +.j13-md-color-preview { + display: flex; + justify-content: center; + padding: 7px 10px; + margin-bottom: 10px; + border: 1px dashed var(--line); + border-radius: 8px; + background: + repeating-conic-gradient(var(--tint) 0% 25%, transparent 0% 50%) 0 0 / 14px 14px; +} +.j13-md-color-preview span { + padding: 2px 10px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; +} .prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; } .prose-content h2 { font-size: 1.4rem; diff --git a/frontend/components/BrandCropModal.tsx b/frontend/components/BrandCropModal.tsx new file mode 100644 index 0000000..1f244f6 --- /dev/null +++ b/frontend/components/BrandCropModal.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Cropper, { type Area } from "react-easy-crop"; +import "react-easy-crop/react-easy-crop.css"; +import { + UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Crop, +} from "lucide-react"; +import { + apiMyMedia, apiUploadBrand, + type MediaAttachment, +} from "@/lib/api"; +import { getCroppedBrandWebp } from "@/lib/cropImage"; +import Modal from "@/components/Modal"; +import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand"; + +// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) +const SOURCE_MAX_BYTES = 15 * 1024 * 1024; + +// 品牌图槽位到默认裁剪比例的映射 +const SLOT_ASPECT: Record = { + logo_light: 1, // 方标 1:1 + logo_dark: 1, // 方标 1:1 + favicon: 1, // Favicon 1:1 +}; + +// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应) +const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [ + { id: "sq", label: "1:1", aspect: 1 }, + { id: "2x1", label: "2:1", aspect: 2 }, + { id: "3x1", label: "3:1", aspect: 3 }, + { id: "4x1", label: "4:1", aspect: 4 }, + { id: "16x9", label: "16:9", aspect: 16 / 9 }, +]; + +interface BrandCropModalProps { + open: boolean; + slot: BrandSlot; + onClose: () => void; + /** 裁剪完成后返回新 URL */ + onCropped: (url: string) => void; + /** 外部传入的文件:打开后直接进入裁剪 */ + seedFile?: File | null; + /** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */ + seedUrl?: string; + /** 已消费 seedFile / seedUrl */ + onSeedConsumed?: () => void; +} + +export default function BrandCropModal({ + open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed, +}: BrandCropModalProps) { + const [media, setMedia] = useState([]); + const [imageSrc, setImageSrc] = useState(null); + const [srcIsBlob, setSrcIsBlob] = useState(false); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [pixelCrop, setPixelCrop] = useState(null); + const [aspect, setAspect] = useState(SLOT_ASPECT[slot] || 1); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + // 打开时拉取媒体库 + useEffect(() => { + if (!open) return; + setError(""); + apiMyMedia() + .then((res) => setMedia(res.attachments || [])) + .catch(() => setMedia([])); + }, [open]); + + // 关闭时复位并释放本地 blob URL + useEffect(() => { + if (!open) { + setImageSrc((prev) => { + if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); + return null; + }); + setSrcIsBlob(false); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setPixelCrop(null); + setAspect(SLOT_ASPECT[slot] || 1); + setError(""); + setBusy(false); + setDragOver(false); + } + }, [open, slot]); + + const onCropComplete = useCallback((_: Area, areaPixels: Area) => { + setPixelCrop(areaPixels); + }, []); + + const enterCrop = useCallback((src: string, isBlob: boolean) => { + setError(""); + setImageSrc((prev) => { + if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); + return src; + }); + setSrcIsBlob(isBlob); + setCrop({ x: 0, y: 0 }); + setZoom(1); + }, []); + + /** 校验并进入裁剪;返回错误文案,成功返回空串 */ + const acceptImageFile = useCallback((file: File): string => { + const err = brandFileOk(slot, file); + if (err) return err; + if (file.size > SOURCE_MAX_BYTES) { + return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内"; + } + enterCrop(URL.createObjectURL(file), true); + return ""; + }, [enterCrop, slot]); + + // 外部传入:弹窗打开后立刻进入裁剪 + useEffect(() => { + if (!open) return; + if (seedFile) { + const errMsg = acceptImageFile(seedFile); + if (errMsg) setError(errMsg); + onSeedConsumed?.(); + return; + } + if (seedUrl) { + enterCrop(seedUrl, false); + onSeedConsumed?.(); + } + }, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]); + + /** + * 点击媒体库图片:直接进入裁剪界面 + * 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致 + */ + const handlePick = (item: MediaAttachment) => { + if (busy) return; + enterCrop(item.url, false); + }; + + const onFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const errMsg = acceptImageFile(file); + if (errMsg) setError(errMsg); + }; + + const onDropZoneDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!busy) setDragOver(true); + }; + + const onDropZoneDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }; + + const onDropZoneDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + if (busy) return; + const file = e.dataTransfer.files?.[0]; + if (!file) return; + const errMsg = acceptImageFile(file); + if (errMsg) setError(errMsg); + }; + + const backToLibrary = () => { + if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc); + setImageSrc(null); + setSrcIsBlob(false); + setError(""); + }; + + const confirmUpload = async () => { + if (!imageSrc || !pixelCrop || busy) return; + setBusy(true); + setError(""); + try { + // 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB) + const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect); + const file = new File([blob], "brand.webp", { type: "image/webp" }); + const url = await apiUploadBrand(slot, file); + onCropped(url); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "上传失败,请重试"); + } finally { + setBusy(false); + } + }; + + const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon"; + // 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底 + const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5"; + + return ( + + {error &&
{error}
} + + {!imageSrc ? ( + <> + {/* 选择新图片:点击或拖放 */} + + + + {/* 媒体库 */} + {media.length > 0 && ( +
+

+ 我的图片(点击后进入裁剪) +

+
+ {media.map((item) => ( + + ))} +
+
+ )} + + ) : ( + <> + {/* 比例选择 */} +
+ 比例 + {ASPECT_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 裁剪区 */} +
+ +
+ + {/* 缩放 */} +
+ + setZoom(Number(e.target.value))} + className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer" + style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }} + aria-label="缩放" + /> + +
+ + {/* 输出说明 + 操作 */} +
+ + 输出 WebP,高度 ≤ 2048px + +
+ + +
+
+ + )} +
+ ); +} diff --git a/frontend/components/LibrarySearchGrid.tsx b/frontend/components/LibrarySearchGrid.tsx index 4d98080..206852f 100644 --- a/frontend/components/LibrarySearchGrid.tsx +++ b/frontend/components/LibrarySearchGrid.tsx @@ -36,17 +36,17 @@ function TextCover({ title }: { title: string }) { style={{ "--cover-h": coverHue(name) } as CSSProperties} aria-hidden > - + {name}
); } -function ExtChip({ ext }: { ext: string }) { +function ExtChip({ ext, className = "" }: { ext: string; className?: string }) { return ( - {/* 竖版书形封面:书脊光影 + 悬停浮出简介 */} + {/* 横版封面框(宽大于高):模糊底衬完整显示任意比例图 + 悬停浮出简介 */}
{doc.cover_url ? ( - // eslint-disable-next-line @next/next/no-img-element - {doc.title} + <> + {/* 底层:同图放大模糊填充,竖版/非 3:2 封面不露黑边 */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + {/* 上层:完整封面居中显示 */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {doc.title} + ) : ( )} - {/* 书脊/书口光影,营造实体书体积感 */} + {/* 边缘内阴影,营造卡片体积感 */}
@@ -103,7 +115,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) { "linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)", }} > -

{excerpt}

+

{excerpt}

) : null}
@@ -153,7 +165,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) { } /** - * 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布 + * 书库目录:横版封面卡片网格(手机端单列全宽)+ 书名/作者即时过滤(目录接口一次返回全部已发布 * 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。 */ export default function LibrarySearchGrid({ @@ -230,7 +242,7 @@ export default function LibrarySearchGrid({

换个书名或作者关键词试试

) : ( -
+
{filtered.map((d) => ( ))} diff --git a/frontend/components/MarkdownBody.tsx b/frontend/components/MarkdownBody.tsx index 1db4d78..840a596 100644 --- a/frontend/components/MarkdownBody.tsx +++ b/frontend/components/MarkdownBody.tsx @@ -7,6 +7,8 @@ import type { HideKind } from "@/lib/hideBlocks"; import MdHideBlock from "@/components/markdown/MdHideBlock"; import MdTimeline from "@/components/markdown/MdTimeline"; import MdEntries from "@/components/markdown/MdEntries"; +import MdDetails from "@/components/markdown/MdDetails"; +import MdVideo from "@/components/markdown/MdVideo"; type MarkdownBodyProps = { content: string; @@ -75,6 +77,21 @@ export default async function MarkdownBody({ ); continue; } + if (seg.type === "details") { + const inner = seg.body.trim() + ? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body)) + : null; + nodes.push( + + {inner} + + ); + continue; + } + if (seg.type === "video") { + nodes.push(); + continue; + } const kind = seg.kind as HideKind; if (seg.locked) { nodes.push( diff --git a/frontend/components/MarkdownBodyClient.tsx b/frontend/components/MarkdownBodyClient.tsx index c9d6a5a..84a2df5 100644 --- a/frontend/components/MarkdownBodyClient.tsx +++ b/frontend/components/MarkdownBodyClient.tsx @@ -3,11 +3,14 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents"; +import { remarkInlineShortcodes } from "@/components/markdown/remarkInlineShortcodes"; import { splitContentSegments } from "@/lib/directiveBlocks"; import { expandDyEmojiShortcodes } from "@/lib/dyEmoji"; import type { HideKind } from "@/lib/hideBlocks"; import MdHideBlock from "@/components/markdown/MdHideBlock"; import MdTimeline from "@/components/markdown/MdTimeline"; +import MdDetails from "@/components/markdown/MdDetails"; +import MdVideo from "@/components/markdown/MdVideo"; import { MdEntriesView } from "@/components/markdown/MdEntries"; type MarkdownBodyClientProps = { @@ -49,7 +52,11 @@ export default function MarkdownBodyClient({ if (seg.type === "md") { if (!seg.text.trim()) return null; return ( - + {seg.text} ); @@ -61,7 +68,10 @@ export default function MarkdownBodyClient({ items={seg.items} renderBody={(it) => it.body.trim() ? ( - + {expandDyEmojiShortcodes(it.body)} ) : null @@ -76,13 +86,33 @@ export default function MarkdownBodyClient({ items={seg.items} titleIds={entriesTitleIds?.[i]} renderMd={(t) => ( - + {expandDyEmojiShortcodes(t)} )} /> ); } + if (seg.type === "details") { + return ( + + {seg.body.trim() ? ( + + {expandDyEmojiShortcodes(seg.body)} + + ) : null} + + ); + } + if (seg.type === "video") { + return ; + } const kind = seg.kind as HideKind; if (seg.locked) { return ( @@ -100,7 +130,10 @@ export default function MarkdownBodyClient({ return ( {seg.body.trim() ? ( - + {expandDyEmojiShortcodes(seg.body)} ) : null} diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 9c5b047..b3a1dac 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -34,6 +34,11 @@ import { Ellipsis, Smile, LayoutList, + Highlighter, + Keyboard, + Palette, + MonitorPlay, + Pipette, } from "lucide-react"; import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api"; import { toast } from "@/lib/toast"; @@ -42,7 +47,8 @@ import ImageInsertModal from "@/components/ImageInsertModal"; import TimelineEditor from "@/components/TimelineEditor"; import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks"; import EmojiPicker from "@/components/EmojiPicker"; -import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; +import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks"; +import { isValidColorValue } from "@/lib/inlineShortcodes"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; import { postPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; @@ -96,7 +102,61 @@ type WrapOpts = { linePrefix?: string; }; -type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null; +type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | "color" | "video" | null; + +/** 文字颜色弹窗预设色板(均为合法短代码颜色值) */ +const COLOR_FG_PRESETS = [ + "#e03131", "#e8590c", "#f08c00", "#2f9e44", "#0c8599", + "#1971c2", "#6741d9", "#c2255c", "#1f2328", "#5f6b7a", +]; +const COLOR_BG_PRESETS = [ + "#fff3bf", "#ffe3e3", "#d3f9d8", "#d0ebff", + "#e5dbff", "#ffd8a8", "#ffff8f", "#e9ecef", +]; + +type HSV = { h: number; s: number; v: number }; + +/** #rgb/#rrggbb → HSV;非 hex(如颜色名)返回 null */ +function hexToHsv(hex: string): HSV | null { + const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim()); + if (!m) return null; + let x = m[1]; + if (x.length === 3) x = x.split("").map((c) => c + c).join(""); + const r = parseInt(x.slice(0, 2), 16) / 255; + const g = parseInt(x.slice(2, 4), 16) / 255; + const b = parseInt(x.slice(4, 6), 16) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d !== 0) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + return { h, s: max === 0 ? 0 : d / max, v: max }; +} + +/** HSV → #rrggbb */ +function hsvToHex({ h, s, v }: HSV): string { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const to = (n: number) => + Math.max(0, Math.min(255, Math.round((n + m) * 255))) + .toString(16) + .padStart(2, "0"); + return `#${to(r)}${to(g)}${to(b)}`; +} const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; @@ -190,6 +250,16 @@ export default function MarkdownEditor({ const [hidePoints, setHidePoints] = useState(10); const [hidePassword, setHidePassword] = useState(""); const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); + /** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */ + const [colorFg, setColorFg] = useState("#e03131"); + const [colorBg, setColorBg] = useState(null); + /** 色轮/自定义输入框的当前值(始终 #hex,供 HSV 取色器使用) */ + const [colorFgWheel, setColorFgWheel] = useState("#e03131"); + const [colorBgWheel, setColorBgWheel] = useState("#fff3bf"); + /** 内联 HSV 取色器展开侧;打开期间 colorHsv 为该侧当前色 */ + const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null); + const [colorHsv, setColorHsv] = useState({ h: 0, s: 1, v: 1 }); + const [videoInput, setVideoInput] = useState(""); const [toolNarrow, setToolNarrow] = useState(false); const [uploadError, setUploadError] = useState(""); const [modKey, setModKey] = useState("Ctrl"); @@ -504,6 +574,14 @@ export default function MarkdownEditor({ setLinkAdvanced(false); } if (kind === "code") setCodeQuery(""); + if (kind === "color") { + setColorFg("#e03131"); + setColorBg(null); + setColorFgWheel("#e03131"); + setColorBgWheel("#fff3bf"); + setActiveWheel(null); + } + if (kind === "video") setVideoInput(""); if (kind === "hide") { setHideStep("menu"); setHidePoints(10); @@ -614,6 +692,176 @@ export default function MarkdownEditor({ setPopover(null); }; + /** + * 颜色短代码插入:前景/背景可独立或组合。 + * 两侧都有 → [color=前景 bg=背景]…[/color];仅一侧 → [color] / [bg]。 + */ + const applyColor = () => { + const fg = colorFg?.trim(); + const bg = colorBg?.trim(); + if (!fg && !bg) { + toast("请至少选择前景色或背景色", "error"); + return; + } + if ((fg && !isValidColorValue(fg)) || (bg && !isValidColorValue(bg))) { + toast("颜色仅支持 #RGB/#RRGGBB/#RRGGBBAA 或英文颜色名", "error"); + return; + } + restoreSel(); + const el = taRef.current; + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const selected = value.slice(start, end); + const body = selected || "彩色文字"; + let md: string; + if (fg && bg) md = `[color=${fg} bg=${bg}]${body}[/color]`; + else if (fg) md = `[color=${fg}]${body}[/color]`; + else md = `[bg=${bg}]${body}[/bg]`; + const next = value.slice(0, start) + md + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + const caret = start + md.length; + el.setSelectionRange(caret, caret); + }); + setPopover(null); + }; + + /** 内联 HSV 取色器开关:打开时以该侧当前 hex(或上次色轮值)初始化 */ + const toggleColorWheel = (side: "fg" | "bg") => { + if (activeWheel === side) { + setActiveWheel(null); + return; + } + const wheelHex = side === "fg" ? colorFgWheel : colorBgWheel; + setColorHsv(hexToHsv(wheelHex) ?? { h: 0, s: 1, v: 1 }); + setActiveWheel(side); + }; + + /** HSV 拖动 → 同步该侧 wheel hex 与选中色 */ + const commitHsv = (next: HSV) => { + setColorHsv(next); + const hex = hsvToHex(next); + if (activeWheel === "fg") { + setColorFgWheel(hex); + setColorFg(hex); + } else if (activeWheel === "bg") { + setColorBgWheel(hex); + setColorBg(hex); + } + }; + + /** + * 饱和度/明度面板拖拽:坐标以真实渲染元素 rect 映射(pointer capture 保证移出仍跟手)。 + * 注意:必须在同步阶段取出 currentTarget 存入 el——React 在事件派发结束后会把 + * e.currentTarget 置空,延迟回调里再读会拿到 null 导致拖拽中断。 + */ + const onSvPointer = (e: React.PointerEvent) => { + e.preventDefault(); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + const update = (clientX: number, clientY: number) => { + const rect = el.getBoundingClientRect(); + const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const v = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + setColorHsv((prev) => { + const next = { ...prev, s, v }; + const hex = hsvToHex(next); + if (activeWheel === "fg") { + setColorFgWheel(hex); + setColorFg(hex); + } else if (activeWheel === "bg") { + setColorBgWheel(hex); + setColorBg(hex); + } + return next; + }); + }; + update(e.clientX, e.clientY); + const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY); + const cleanup = () => { + el.removeEventListener("pointermove", onMove); + el.removeEventListener("pointerup", cleanup); + el.removeEventListener("pointercancel", cleanup); + }; + el.addEventListener("pointermove", onMove); + el.addEventListener("pointerup", cleanup); + el.addEventListener("pointercancel", cleanup); + }; + + /** 色相条拖拽(水平);同样在同步阶段缓存元素 */ + const onHuePointer = (e: React.PointerEvent) => { + e.preventDefault(); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + const update = (clientX: number) => { + const rect = el.getBoundingClientRect(); + const h = Math.max(0, Math.min(360, ((clientX - rect.left) / rect.width) * 360)); + commitHsv({ ...colorHsv, h }); + }; + update(e.clientX); + const onMove = (ev: PointerEvent) => update(ev.clientX); + const cleanup = () => { + el.removeEventListener("pointermove", onMove); + el.removeEventListener("pointerup", cleanup); + el.removeEventListener("pointercancel", cleanup); + }; + el.addEventListener("pointermove", onMove); + el.addEventListener("pointerup", cleanup); + el.addEventListener("pointercancel", cleanup); + }; + + /** [bilibili]/[youtube] 整行插入:表单裸输入统一走 parseVideoInput */ + const applyVideo = () => { + const parsed = parseVideoInput(videoInput); + if (!parsed) { + toast("请输入 B站 BV 号/链接 或 YouTube 视频 ID/链接", "error"); + return; + } + restoreSel(); + const el = taRef.current; + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const tag = `[${parsed.provider}]${parsed.id}[/${parsed.provider}]`; + const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; + const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; + const inserted = padBefore + tag + padAfter; + const next = value.slice(0, start) + inserted + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + const caret = start + inserted.length; + el.setSelectionRange(caret, caret); + }); + setPopover(null); + }; + + /** [details 标题]…[/details] 折叠面板:包住选区或占位正文 */ + const insertDetails = () => { + restoreSel(); + const el = taRef.current; + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const selected = value.slice(start, end); + const body = selected || "折叠内容"; + const open = "[details 点击展开]"; + const block = `${open}\n${body}\n[/details]`; + const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; + const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; + const inserted = padBefore + block + padAfter; + const next = value.slice(0, start) + inserted + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + const bodyStart = start + padBefore.length + open.length + 1; + el.setSelectionRange(bodyStart, bodyStart + body.length); + }); + setPopover(null); + }; + const openTimelineEditor = () => { rememberSel(); const el = taRef.current; @@ -923,6 +1171,164 @@ export default function MarkdownEditor({
) : null; + // 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。 + // 用普通函数调用而非 组件:避免内联组件每次渲染重挂载导致输入框失焦。 + const renderColorRow = ( + side: "fg" | "bg", + label: string, + presets: readonly string[], + picked: string | null + ) => { + const setPicked = (v: string | null) => + side === "fg" ? setColorFg(v) : setColorBg(v); + const wheelOpen = activeWheel === side; + return ( +
+ {label} +
+ + {presets.map((c) => ( + + { + const v = e.target.value; + setPicked(v); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + if (side === "fg") setColorFgWheel(v); + else setColorBgWheel(v); + if (wheelOpen) setColorHsv(hexToHsv(v) ?? colorHsv); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyColor(); + } + }} + /> +
+ {wheelOpen && ( +
+
+ +
+
+ +
+
+ )} +
+ ); + }; + + const colorPopover = + popover === "color" ? ( +
+
+ + Aa 示例文字 + +
+ {renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)} + {renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)} +
+ + +
+
+ ) : null; + + // 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化 + const videoPopover = + popover === "video" ? ( +
+ +
+ + +
+
+ ) : null; + const ToolBtn = ({ label, icon, @@ -981,6 +1387,31 @@ export default function MarkdownEditor({ onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} /> )} + {!isComment && !toolNarrow && ( + } + onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })} + /> + )} + {!isComment && !toolNarrow && ( + } + onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })} + /> + )} + {!toolNarrow && ( +
+ } + active={popover === "color"} + onClick={() => openPopover("color")} + /> + {popover === "color" && colorPopover} +
+ )} {!isComment && !toolNarrow && } {!isComment && (
@@ -1217,6 +1648,9 @@ export default function MarkdownEditor({ )} + + + {!isComment && ( )} @@ -1230,12 +1664,20 @@ export default function MarkdownEditor({ )} + {!isComment && ( + + )} + {!isComment && ( + + )} {toolNarrow && ( )}
)} {toolNarrow && codePopover} + {toolNarrow && popover === "color" && colorPopover} + {toolNarrow && popover === "video" && videoPopover} {toolNarrow && popover === "emoji" && !isComment && (
@@ -1259,6 +1701,17 @@ export default function MarkdownEditor({ onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)} /> )} + {!isComment && !toolNarrow && ( +
+ } + active={popover === "video"} + onClick={() => openPopover("video")} + /> + {popover === "video" && videoPopover} +
+ )} {allowHide && !toolNarrow ? (
+ + + {title} + + {children ?
{children}
: null} + + ); +} diff --git a/frontend/components/markdown/MdVideo.tsx b/frontend/components/markdown/MdVideo.tsx new file mode 100644 index 0000000..6a9dce6 --- /dev/null +++ b/frontend/components/markdown/MdVideo.tsx @@ -0,0 +1,35 @@ +type VideoProvider = "bilibili" | "youtube"; + +type MdVideoProps = { + provider: VideoProvider; + /** bilibili 为 BV 号;youtube 为视频 ID(parseVideoLine 已校验) */ + id: string; +}; + +function embedUrl(provider: VideoProvider, id: string): string { + if (provider === "bilibili") { + return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent( + id + )}&autoplay=0&danmaku=0&high_quality=1`; + } + return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`; +} + +/** + * [bilibili]…[/bilibili] / [youtube]…[/youtube] 视频嵌入: + * 16:9 容器 + 懒加载 iframe(bilibili 官方播放器 / youtube 隐私域名),纯静态无 JS。 + */ +export default function MdVideo({ provider, id }: MdVideoProps) { + return ( +
+