- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫 - 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式 - 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底 - 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id - 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|