feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user