feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)

This commit is contained in:
ABAPPLO
2026-09-01 17:30:47 +08:00
parent b243562e60
commit d01281fba9
10 changed files with 1103 additions and 12 deletions

View File

@@ -533,6 +533,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [menuOpen, setMenuOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [onePushTask, setOnePushTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({
taskType: "content_publish" as "content_publish" | "screenshot_collect",
@@ -1181,6 +1182,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)}
/>
)}
{activeNav === "distributions" && (
@@ -1518,6 +1520,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
/>
)}
{onePushTask && (
<OneClickPushModal
task={onePushTask}
onClose={() => setOnePushTask(null)}
setToast={setToast}
/>
)}
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
@@ -3224,6 +3234,320 @@ function ProjectPushModal({
);
}
type BoundWecomPartnerRow = {
id: string;
name: string;
wecomExternalUserId: string;
};
type TaskPushLogRecord = {
id: string;
task_id: string;
push_kind: string;
main_group_count: number;
koc_ok_count: number;
detail: string;
created_at: string;
};
type OneClickPushOutcomeView = {
linkUrl: string;
kocSent: number;
kocFailed: number;
kocResults: Array<{ partnerId: string; partnerName: string; ok: boolean; error: string }>;
skippedReasons: string[];
hint?: string;
};
const PUSH_KIND_LABELS: Record<string, string> = {
task_open: "任务推送",
card_ack: "卡片确认",
};
function formatPushLogTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return `${formatDate(date.toISOString().slice(0, 10))} ${date.toTimeString().slice(0, 5)}`;
}
function OneClickPushModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [partners, setPartners] = useState<BoundWecomPartnerRow[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [sendMainGroup, setSendMainGroup] = useState(true);
const [createGroupTemplate, setCreateGroupTemplate] = useState(true);
const [sendKoc, setSendKoc] = useState(true);
const [force, setForce] = useState(false);
const [pushedToday, setPushedToday] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<OneClickPushOutcomeView | null>(null);
const [logs, setLogs] = useState<TaskPushLogRecord[]>([]);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}, []);
const loadLogs = useCallback(async () => {
try {
const parsed = await postAction<{ logs: TaskPushLogRecord[] }>(
{ action: "task_push_logs", taskId: task.id },
"读取推送记录失败",
);
setLogs(parsed.logs);
} catch {
// 推送记录加载失败不阻断弹窗
}
}, [postAction, task.id]);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<{
partners: BoundWecomPartnerRow[];
pushedToday: unknown;
}>({ action: "wecom_bound_partners", taskId: task.id }, "读取合作方失败");
setPartners(parsed.partners);
setSelected(parsed.partners.map((partner) => partner.id));
setPushedToday(Boolean(parsed.pushedToday));
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取合作方失败");
} finally {
setLoading(false);
}
})();
void loadLogs();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, loadLogs, task.id, setToast]);
const filtered = partners.filter((partner) => {
const keyword = search.trim().toLowerCase();
if (!keyword) return true;
return (
partner.name.toLowerCase().includes(keyword) ||
partner.wecomExternalUserId.toLowerCase().includes(keyword)
);
});
const allSelected = partners.length > 0 && selected.length === partners.length;
const togglePartner = (partnerId: string) => {
setSelected((previous) =>
previous.includes(partnerId)
? previous.filter((id) => id !== partnerId)
: [...previous, partnerId],
);
};
const submitPush = async () => {
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
setToast("请至少选择一个推送目标");
return;
}
if (sendKoc && selected.length === 0) {
setToast("请至少勾选一位合作方,或取消勾选「KOC 个人直达」");
return;
}
setSending(true);
try {
const parsed = await postAction<OneClickPushOutcomeView>(
{
action: "task_one_click_push",
taskId: task.id,
sendMainGroup,
sendKoc,
createGroupTemplate,
selectedKocPartnerIds: selected,
force,
},
"一键推送失败",
);
setResult(parsed);
setPushedToday(true);
setToast("推送已执行");
await loadLogs();
} catch (reason) {
const message = reason instanceof Error ? reason.message : "一键推送失败";
if (message.includes("仍要推送")) setPushedToday(true);
setToast(message);
} finally {
setSending(false);
}
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">企业微信 · 一键推送</p>
<h2>推送「{task.name}」</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{pushedToday && (
<label className="project-push-target">
<input
type="checkbox"
checked={force}
onChange={(event) => setForce(event.target.checked)}
disabled={sending}
/>
<span>
<b>今天已推送过,仍要推送</b>
<small>重复推送会再次给合作方发送应用消息,请确认有必要</small>
</span>
</label>
)}
<div className="project-push-targets">
<label className="project-push-target">
<input
type="checkbox"
checked={sendMainGroup}
onChange={(event) => setSendMainGroup(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目总群</b>
<small>应用消息逐人发送(任务未挂项目时自动跳过)</small>
</span>
</label>
<label className="project-push-target">
<input
type="checkbox"
checked={createGroupTemplate}
onChange={(event) => setCreateGroupTemplate(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目任务群</b>
<small>以群主身份创建企业群发,群主确认后送达客户群</small>
</span>
</label>
<label className="project-push-target">
<input
type="checkbox"
checked={sendKoc}
onChange={(event) => setSendKoc(event.target.checked)}
disabled={sending}
/>
<span>
<b>KOC 个人直达</b>
<small>应用消息直发已绑定企微身份的合作方,点开链接免输身份</small>
</span>
</label>
</div>
{sendKoc && (
<div className="koc-picker">
{loading ? (
<small>正在读取已绑定合作方…</small>
) : partners.length === 0 ? (
<small>还没有已绑定企微身份的合作方;合作方在企微内打开领取链接领取后会自动绑定。</small>
) : (
<>
<div className="koc-picker-toolbar">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索合作方名称 / 企微 ID"
disabled={sending}
/>
<label>
<input
type="checkbox"
checked={allSelected}
onChange={(event) =>
setSelected(event.target.checked ? partners.map((partner) => partner.id) : [])
}
disabled={sending}
/>
全选({selected.length}/{partners.length})
</label>
</div>
<div className="koc-picker-list">
{filtered.map((partner) => (
<label key={partner.id} className="koc-picker-item">
<input
type="checkbox"
checked={selected.includes(partner.id)}
onChange={() => togglePartner(partner.id)}
disabled={sending}
/>
<span>{partner.name || partner.id}</span>
<small>{partner.wecomExternalUserId}</small>
</label>
))}
{filtered.length === 0 && <small>没有匹配的合作方</small>}
</div>
</>
)}
</div>
)}
{result && (
<div className="group-push-result">
<p>
KOC 直达:成功 {result.kocSent} 人、失败 {result.kocFailed} 人。
{result.skippedReasons.length > 0 ? `(${result.skippedReasons.join(";")})` : ""}
{result.hint ? ` ${result.hint}。` : ""}
</p>
{result.kocResults.some((item) => !item.ok) && (
<ul>
{result.kocResults.filter((item) => !item.ok).map((item) => (
<li key={item.partnerId} className="group-push-fail">
{item.partnerName}:{item.error || "发送失败"}
</li>
))}
</ul>
)}
</div>
)}
{logs.length > 0 && (
<div className="group-push-result">
<b>推送记录</b>
<ul>
{logs.slice(0, 5).map((log) => (
<li key={log.id}>
{formatPushLogTime(log.created_at)} · {PUSH_KIND_LABELS[log.push_kind] ?? log.push_kind}
{log.push_kind === "task_open"
? `(总群 ${log.main_group_count} 人,KOC 直达 ${log.koc_ok_count} 人)`
: ""}
</li>
))}
</ul>
</div>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
<button
type="button"
className="primary-button"
onClick={() => void submitPush()}
disabled={sending || (pushedToday && !force) || !task.share_token}
>
{sending ? "推送中…" : "一键推送"}
</button>
</div>
</div>
</div>
);
}
function LoadingState() {
return (
<div className="loading-layout" aria-label="正在加载">
@@ -3388,12 +3712,14 @@ function TasksPage({
onCreate,
onCopyShare,
onPushToGroups,
onOneClickPush,
}: {
tasks: Task[];
portalUrl: string;
onCreate: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
}) {
return (
<div className="stack">
@@ -3447,6 +3773,13 @@ function TasksPage({
>
发布到企微群
</button>
<button
className="share-link-button"
onClick={() => onOneClickPush(task)}
disabled={!portalUrl || !task.share_token}
>
一键推送
</button>
{portalUrl && task.share_token && (
<a
className="round-arrow"