Files
koc-loop/app/task-customer-chat-modal.tsx
ABAPPLO ab78b187c0 feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表
- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫
- 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式
- 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底
- 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id
- 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
2026-09-23 11:13:17 +08:00

225 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import { readApiResponse } from "./wecom-user-picker";
type CustomerChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
type BindResult = {
customerChatId: string;
customerChatName: string;
joinConfigId: string;
joinQrCode: string;
qrError?: string;
};
export function TaskCustomerChatModal({
task,
onClose,
onBound,
}: {
task: { id: string; name: string; customerChatId: string; customerChatName: string };
onClose: () => void;
onBound: () => void;
}) {
const [chats, setChats] = useState<CustomerChat[]>([]);
const [boundChatId, setBoundChatId] = useState(task.customerChatId);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [selectedChatId, setSelectedChatId] = useState(task.customerChatId);
const [bindResult, setBindResult] = useState<BindResult | null>(null);
const loadChats = useCallback(async (options?: { sync?: boolean }) => {
setLoading(true);
setError("");
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "task_customer_chats",
taskId: task.id,
sync: options?.sync === true,
}),
});
const parsed = await readApiResponse<{
chats?: CustomerChat[];
boundChatId?: string;
error?: string;
}>(response, "获取客户群列表失败");
if (!response.ok) {
throw new Error(parsed.error || "获取客户群列表失败");
}
setChats(Array.isArray(parsed.chats) ? parsed.chats : []);
const bound = String(parsed.boundChatId ?? "");
setBoundChatId(bound);
setSelectedChatId(bound);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "获取客户群列表失败");
} finally {
setLoading(false);
}
}, [task.id]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadChats();
}, 0);
return () => window.clearTimeout(timer);
}, [loadChats]);
const submitBind = async (chatId: string) => {
const chat = chats.find((item) => item.chatId === chatId);
setSaving(true);
setError("");
setNotice("");
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "task_bind_customer_chat",
taskId: task.id,
chatId,
chatName: chat?.name ?? "",
}),
});
const parsed = await readApiResponse<{ task?: BindResult; error?: string }>(
response,
"绑定客户群失败",
);
if (!response.ok) {
throw new Error(parsed.error || "绑定客户群失败");
}
const result = parsed.task ?? null;
setBindResult(result);
setBoundChatId(chatId);
setSelectedChatId(chatId);
setNotice(
chatId
? result?.qrError
? `已绑定,但${result.qrError},可重新保存刷新`
: `已绑定「${result?.customerChatName || chat?.name || chatId}」,认领人扫码即可进群`
: "已解绑,认领页不再展示进群二维码",
);
onBound();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "绑定客户群失败");
} finally {
setSaving(false);
}
};
const unbind = async () => {
if (!window.confirm("解绑后认领页将不再展示进群二维码,确定解绑?")) return;
await submitBind("");
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div
className="modal-card wecom-picker-modal"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<h2>绑定客户群</h2>
<p>
列出全部成员名下的企业微信客户群(含各群主的外部群),绑定后生成进群活码,认领人在领取页扫码进群。
</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="import-guide-strip">
<span className="wecom-picker-cache-hint">
任务:{task.name}
{boundChatId ? ` · 当前绑定:${task.customerChatName || boundChatId}` : " · 未绑定"}
</span>
<button
type="button"
className="ghost-button"
onClick={() => void loadChats({ sync: true })}
disabled={loading || saving}
>
{loading ? "同步中…" : "同步最新群列表"}
</button>
</div>
{error && <p className="form-error">{error}</p>}
{notice && <p className="wecom-picker-cache-hint">{notice}</p>}
{bindResult?.joinQrCode && (
<div className="join-qr-preview">
<img src={bindResult.joinQrCode} alt="进群活码" />
<span>进群活码(已同步展示在认领页)</span>
</div>
)}
<div className="wecom-picker-list">
{loading ? (
<div className="wecom-picker-empty">正在拉取客户群列表…</div>
) : chats.length === 0 ? (
<div className="wecom-picker-empty">
未拉取到客户群——请群主先在企微客户端创建客户群(内外部成员都可拉入,且群主需在应用可见范围内),
再点击「同步最新群列表」。
</div>
) : (
chats.map((chat) => {
const isBound = chat.chatId === boundChatId;
return (
<label key={chat.chatId} className="wecom-picker-row">
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{(chat.name || chat.chatId).slice(0, 1)}
</span>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{chat.name || chat.chatId}</span>
<span className="wecom-picker-sub">
群主 {chat.ownerUserId || "—"} · {chat.memberCount} 人
</span>
<code className="wecom-picker-id">{chat.chatId}</code>
</span>
<input
type="radio"
name="customer-chat"
checked={selectedChatId === chat.chatId}
onChange={() => setSelectedChatId(chat.chatId)}
/>
{isBound ? <span className="wecom-picker-current">已绑定</span> : null}
</label>
);
})
)}
</div>
<div className="modal-actions">
{boundChatId ? (
<button
type="button"
className="ghost-button"
onClick={() => void unbind()}
disabled={saving}
>
解绑当前群
</button>
) : null}
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
关闭
</button>
<button
type="button"
className="primary-button"
onClick={() => void submitBind(selectedChatId)}
disabled={saving || loading || !selectedChatId || selectedChatId === boundChatId}
>
{saving ? "保存中…" : selectedChatId === boundChatId ? "已绑定" : "绑定所选群"}
</button>
</div>
</div>
</div>
);
}