// 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 && (
)}
{/* 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 }) => (
);
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 (
{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 */}
{/* schedule */}
{scheduled && (
setScheduleTime(e.target.value)}
style={{
flex: 1, padding: "6px 10px", fontSize: 13,
background: "var(--bg-sunk)", border: "1px solid var(--line)",
borderRadius: 8, color: "var(--ink)",
}}
/>
)}
{scheduled && errors.time && (
⚠ {errors.time}
)}
);
};
const SendProgress = ({ pickedGroups, progress, text, sent, scheduled, scheduleTime }) => {
const pct = pickedGroups.length > 0 ? progress / pickedGroups.length : 0;
return (
{!sent ? (
正在发送…
{progress} / {pickedGroups.length} 个群已完成
) : (
{scheduled ? "定时任务已创建" : "发送完成"}
{scheduled
? `将于 ${scheduleTime.replace("T", " ")} 发送至 ${pickedGroups.length} 个群`
: `已发送至 ${pickedGroups.length} 个群 · 共 ${pickedGroups.reduce((a,g)=>a+g.memberCount,0)} 人`
}
)}
{/* per-group results list */}
群名
状态
阅读
时间
{pickedGroups.map((g, i) => {
const done = i < progress;
const readRatio = done ? (0.35 + Math.random() * 0.55) : 0;
const readCount = done ? Math.floor(g.memberCount * readRatio) : 0;
return (
{g.name}
{!done ? (
等待中
) : scheduled ? (
已排期
) : (
}>已送达
)}
{done && !scheduled ? (
) : (
—
)}
{done ? "刚刚" : "—"}
);
})}
);
};
// 群发消息 tab 页:列出历史群发任务,提供"新建"入口(点开后打开 BroadcastModal 向导)
const BroadcastView = ({ broadcasts, groupCount, onNew }) => {
const formatPct = (v) => `${(v * 100).toFixed(1)}%`;
const sorted = (broadcasts || []).slice().sort((a, b) => (b.sentAtMs || 0) - (a.sentAtMs || 0));
const [refreshing, setRefreshing] = React.useState(false);
const [refreshMsg, setRefreshMsg] = React.useState("");
const [analysisOpen, setAnalysisOpen] = React.useState(false);
const handleRefresh = async () => {
if (refreshing) return;
setRefreshing(true);
setRefreshMsg("已触发,约 30 秒...");
try {
const r = await fetch("/api/bulk-send/refresh?max_age_days=7", { method: "POST" });
const j = await r.json();
if (!j.ok && j.running) {
setRefreshMsg("已经在跑,等当前完成");
}
// 轮询状态
let attempts = 0;
const poll = setInterval(async () => {
attempts += 1;
try {
const sr = await fetch("/api/bulk-send/refresh/status").then(x => x.json());
if (!sr.running && sr.ended_at) {
clearInterval(poll);
const dur = sr.ended_at - sr.started_at;
setRefreshMsg(`✓ 刷新完成(耗时 ${dur}s),即将重载...`);
setTimeout(() => window.location.reload(), 1200);
} else if (attempts > 60) {
clearInterval(poll);
setRefreshing(false);
setRefreshMsg("超时,请手动刷新页面");
}
} catch (e) { /* keep polling */ }
}, 2000);
} catch (e) {
setRefreshing(false);
setRefreshMsg("失败:" + e.message);
}
};
return (
群发消息
可发送范围 {groupCount} 个群 · 已记录 {sorted.length} 次群发任务
{refreshMsg && {refreshMsg}}
setAnalysisOpen(true)}>
数据分析报告
{refreshing ? "刷新中..." : "刷新统计"}
} onClick={onNew}>
新建群发任务
setAnalysisOpen(false)} />
历史任务
已读率 / 回复率排除内部 tenant;statistics 每天 20:00 自动滚动刷新
{sorted.length === 0 ? (
还没有群发记录。点击右上角"新建群发任务"开始第一条。
) : (
任务标题 / 时间
群数
受众
已读率
回复率
采集时间
{sorted.map((b) => {
const heatColor = (rate) => {
if (rate >= 0.6) return "oklch(0.55 0.13 155)";
if (rate >= 0.3) return "oklch(0.55 0.13 75)";
return "oklch(0.55 0.13 20)";
};
return (
{b.title}
{b.sentAtText || "—"}
{b.successCount}{b.failureCount ? ` / -${b.failureCount}` : ""}
{b.targetAudience}
{formatPct(b.avgReadRate)}
({b.readCount})
{formatPct(b.avgReplyRate)}
({b.replyUniqueSenders}人/{b.replyCount}条)
{b.collectedAtText ? b.collectedAtText.slice(5, 16) : "—"}
);
})}
)}
);
};
// 数据分析报告 modal —— 拉 /api/broadcast/analysis 显示 KPI + 排行 + 沉默群清单
const BroadcastAnalysisModal = ({ open, onClose }) => {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
React.useEffect(() => {
if (!open) return;
setLoading(true);
setErr("");
setData(null);
fetch("/api/broadcast/analysis")
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(e => { setErr(String(e)); setLoading(false); });
}, [open]);
if (!open) return null;
const formatPct = (v) => v == null ? "—" : `${(v * 100).toFixed(1)}%`;
const heatColor = (rate) => {
if (rate >= 0.6) return "oklch(0.55 0.13 155)";
if (rate >= 0.3) return "oklch(0.55 0.13 75)";
return "oklch(0.55 0.13 20)";
};
return (
e.stopPropagation()} style={{
width: "min(960px, 100%)", maxHeight: "90vh",
background: "var(--bg-elev)", borderRadius: "var(--r-xl)",
boxShadow: "var(--sh-3)", overflow: "hidden",
display: "flex", flexDirection: "column",
}}>
{loading &&
分析中...
}
{err &&
失败:{err}
}
{data && (
{/* KPI 块 */}
{/* 任务排行 */}
{data.topTasks.length > 0 && (
)}
{data.bottomTasks.length > 0 && (
)}
{/* 高质量群 */}
{data.highQualityChats.length > 0 && (
)}
{/* 沉默群 */}
{data.silentChats.length > 0 && (
)}
生成时间:{new Date(data.generatedAt * 1000).toLocaleString()}
)}
);
};
const KpiCell = ({ label, value, color }) => (
);
const Section = ({ title, children }) => (
);
const TaskList = ({ tasks, formatPct, heatColor }) => (
{tasks.map((t, i) => (
{t.targetAudience} 人
已读 {formatPct(t.avgReadRate)}
回复 {formatPct(t.avgReplyRate)}
))}
);
const ChatList = ({ chats, formatPct, heatColor, silent }) => (
{chats.map((c, i) => (
{c.chatName || c.chatId}
{c.broadcastCount} 次
已读 {silent ? "0%" : formatPct(c.avgReadRate)}
回复 {formatPct(c.avgReplyRate)}
))}
);
Object.assign(window, { BroadcastModal, BroadcastView, BroadcastAnalysisModal });