- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人 - 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人 - 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行 - 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events) - 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表 - 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群) - 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件 - 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
219 lines
7.5 KiB
TypeScript
219 lines
7.5 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 () => {
|
||
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 }),
|
||
});
|
||
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()}
|
||
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.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>
|
||
);
|
||
}
|