// Broadcast modal — pick groups, compose, schedule, read-status after send const BroadcastModal = ({ open, onClose, groups, onSent, prefillGroup }) => { const [step, setStep] = React.useState(1); const [pickedIds, setPickedIds] = React.useState(new Set()); const [query, setQuery] = React.useState(""); const [text, setText] = React.useState(""); const [scheduled, setScheduled] = React.useState(false); const [scheduleTime, setScheduleTime] = React.useState(""); const [sendingProgress, setSendingProgress] = React.useState(0); const [sent, setSent] = React.useState(false); const [filter, setFilter] = React.useState("all"); const [errors, setErrors] = React.useState({}); const [selectedOwners, setSelectedOwners] = React.useState(new Set()); const [perOwnerLimit, setPerOwnerLimit] = React.useState(""); // "" 表示不限 React.useEffect(() => { if (open) { setStep(1); setText(""); setScheduled(false); setScheduleTime(""); setSendingProgress(0); setSent(false); setErrors({}); setQuery(""); setFilter("all"); setSelectedOwners(new Set()); setPerOwnerLimit(""); setPickedIds(new Set(prefillGroup ? [prefillGroup.id] : [])); } }, [open, prefillGroup]); // 群主清单 + 每人群数(用于群主多选 UI)—— useMemo 必须在条件 return 之前调用 const ownerStats = React.useMemo(() => { const m = new Map(); for (const g of (groups || [])) { const name = (g.owner && g.owner.name) || "(未知)"; m.set(name, (m.get(name) || 0) + 1); } return [...m.entries()].sort((a, b) => b[1] - a[1]); // 按群数降序 }, [groups]); if (!open) return null; const toggle = (id) => { setPickedIds(prev => { const s = new Set(prev); if (s.has(id)) s.delete(id); else s.add(id); return s; }); }; const filteredGroups = groups.filter(g => { if (query && !g.name.includes(query) && !g.company.includes(query)) return false; if (selectedOwners.size > 0 && !selectedOwners.has((g.owner && g.owner.name) || "(未知)")) return false; if (filter === "vip") return g.tags.some(t => ["VIP", "KA客户", "战略客户", "高价值"].includes(t)); if (filter === "renewal") return g.tags.some(t => t === "续约期"); if (filter === "alert") return g.unreplyMinutes > 60; return true; }); // 应用每群主限额:每个 owner 只取前 N 个(按 filteredGroups 当前顺序) const applyPerOwnerLimit = () => { const lim = parseInt(perOwnerLimit, 10); if (!Number.isFinite(lim) || lim <= 0) { // 不限额:直接全选当前过滤结果 setPickedIds(new Set(filteredGroups.map(g => g.id))); return; } const counts = new Map(); const newPicked = new Set(pickedIds); for (const g of filteredGroups) { const o = (g.owner && g.owner.name) || "(未知)"; const n = counts.get(o) || 0; if (n >= lim) continue; counts.set(o, n + 1); newPicked.add(g.id); } setPickedIds(newPicked); }; const pickedGroups = groups.filter(g => pickedIds.has(g.id)); const validate = () => { const e = {}; if (pickedIds.size === 0) e.groups = "请至少选择 1 个群"; if (!text.trim()) e.text = "消息内容不能为空"; else if (text.trim().length < 5) e.text = "消息至少 5 个字符"; if (scheduled && !scheduleTime) e.time = "请选择发送时间"; setErrors(e); return Object.keys(e).length === 0; }; const handleSend = async () => { if (!validate()) return; setStep(3); setSendingProgress(0); setErrors({}); const targets = pickedGroups .filter(g => g.chat_id) // synthetic g.id 不能用于发消息 .map(g => ({ chat_id: g.chat_id, chat_name: g.name })); if (targets.length === 0) { setErrors({ groups: "选中的群没有 chat_id,无法发送" }); return; } let batchId; try { const res = await fetch("/api/bulk-send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_targets: targets, text, title: text.slice(0, 30) }), }); if (!res.ok) { const err = await res.text(); setErrors({ send: `发送启动失败: ${err}` }); return; } const data = await res.json(); batchId = data.batch_id; } catch (err) { setErrors({ send: `网络错误: ${err.message}` }); return; } // 订阅 WebSocket 进度推送 const wsProto = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${wsProto}://${location.host}/ws/bulk-progress/${batchId}`); ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (typeof msg.sent === "number") { setSendingProgress(msg.sent + msg.failed); } if (msg.type === "done" || msg.status === "done") { setSent(true); ws.close(); } } catch { // ignore non-JSON pings } }; ws.onerror = () => setErrors(prev => ({ ...prev, ws: "进度连接断开,结果以 Base 为准" })); }; return (
e.stopPropagation()} style={{ width: "min(820px, 100%)", maxHeight: "90vh", background: "var(--bg-elev)", borderRadius: "var(--r-xl)", boxShadow: "var(--sh-3)", overflow: "hidden", display: "flex", flexDirection: "column", animation: "slideUp 200ms cubic-bezier(.2,.9,.25,1)", }}> {/* header */}
批量发送群消息
{sent ? "已发送完成" : `第 ${step} / 2 步 · ${step === 1 ? "选择目标群" : "撰写消息内容"}`}
{/* step indicator */} {!sent && (
1}/>
2}/>
)} {/* body */}
{step === 1 && ( setSelectedOwners(prev => { const s = new Set(prev); if (s.has(name)) s.delete(name); else s.add(name); return s; })} clearOwners={() => setSelectedOwners(new Set())} perOwnerLimit={perOwnerLimit} setPerOwnerLimit={setPerOwnerLimit} applyPerOwnerLimit={applyPerOwnerLimit} selectAll={() => setPickedIds(new Set(filteredGroups.map(g => g.id)))} clearAll={() => setPickedIds(new Set())} errors={errors} total={groups.length} /> )} {step === 2 && ( )} {step === 3 && ( )}
{/* footer */}
{step === 1 && ( <>已选 {pickedIds.size} 个群,将覆盖约 {pickedGroups.reduce((a,g)=>a+g.memberCount,0)} 人 )} {step === 2 && ( <>字数 300 ? "var(--accent-ink)" : "var(--ink)" }}>{text.length} / 500 )} {step === 3 && !sent && <>发送中 · 请勿关闭窗口…} {sent && <>✓ 已成功发送到 {pickedIds.size} 个群}
{step === 1 && <> 取消 { if (pickedIds.size === 0) { setErrors({ groups: "请至少选择 1 个群" }); return; } setErrors({}); setStep(2); }} iconRight={}> 下一步 } {step === 2 && <> setStep(1)}>上一步 }> {scheduled ? "定时发送" : `立即发送到 ${pickedIds.size} 个群`} } {step === 3 && <> {sent && { onSent && onSent(); onClose(); }}>关闭} }
); }; const StepDot = ({ n, label, active, done }) => (
{done ? : n}
{label}
); const PickGroups = ({ groups, pickedIds, toggle, query, setQuery, filter, setFilter, ownerStats, selectedOwners, toggleOwner, clearOwners, perOwnerLimit, setPerOwnerLimit, applyPerOwnerLimit, selectAll, clearAll, errors, total, }) => { const [showAllOwners, setShowAllOwners] = React.useState(false); const filters = [ { id: "all", label: "全部" }, { id: "vip", label: "VIP / 高价值" }, { id: "renewal", label: "续约期" }, { id: "alert", label: "待回复" }, ]; // 群主选择默认显示前 8 个,"更多"展开全部 const visibleOwners = showAllOwners ? ownerStats : ownerStats.slice(0, 8); // 过滤后按群主分组统计 const ownerCountInFiltered = React.useMemo(() => { const m = new Map(); for (const g of groups) { const o = (g.owner && g.owner.name) || "(未知)"; m.set(o, (m.get(o) || 0) + 1); } return m; }, [groups]); return (
setQuery(e.target.value)} placeholder="搜索群或公司(如:增购)" style={{ flex: 1, background: "transparent", border: "none", fontSize: 13 }}/>
全选过滤后 清空
{filters.map(f => ( ))}
{/* 群主多选 */}
按群主筛选 {selectedOwners.size > 0 && · 已选 {selectedOwners.size} 人}
{selectedOwners.size > 0 && ( )}
{visibleOwners.map(([name, count]) => { const on = selectedOwners.has(name); return ( ); })} {!showAllOwners && ownerStats.length > 8 && ( )}
{/* 每群主限额 */}
每群主最多 setPerOwnerLimit(e.target.value)} style={{ width: 64, padding: "4px 8px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 12, textAlign: "center", background: "var(--bg-elev)", }} /> 按规则勾选 ({groups.length} 候选)
{/* 过滤后每群主多少 */} {groups.length > 0 && groups.length < total && (
过滤后: {[...ownerCountInFiltered.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 6) .map(([n, c]) => ( {n} {c} 个 ))} {ownerCountInFiltered.size > 6 && ...}
)}
{errors.groups && (
⚠ {errors.groups}
)}
{groups.map((g, i) => { const picked = pickedIds.has(g.id); return ( ); })} {groups.length === 0 && (
没有匹配的群
)}
); }; const TEMPLATES = [ { label: "节日问候", text: "各位老朋友:\n中秋将至,Chorus 团队祝您和家人团圆安康。近期上线了全新报表导出功能,欢迎随时体验,有任何问题直接群内@我们就好 🌕" }, { label: "新版本通知", text: "Hi 各位:\n我们在本周二(23 号)发布了新版本,本次更新包含【群聊监控看板】【消息批量发送】两项您反馈过的功能。更新日志见附件,欢迎联系您的客户经理预约培训。" }, { label: "会议邀请", text: "Hi 各位伙伴:\n想和大家对齐下季度合作节奏,拟于周四(25 号)下午 15:00 线上开一个 30 分钟的对接会,合适的请在群里扣 1~~" }, ]; const ComposeMessage = ({ text, setText, scheduled, setScheduled, scheduleTime, setScheduleTime, pickedGroups, errors }) => { return (
{/* selected preview strip */}
发送至
{pickedGroups.slice(0, 6).map((g, i) => (
))}
{pickedGroups.length} 个群 · 覆盖约 {pickedGroups.reduce((a,g)=>a+g.memberCount,0)} 人
{/* templates */}
快捷模板
{TEMPLATES.map(t => ( ))}
{/* textarea */}