Files
koc-loop/app/task-customer-chat-modal.tsx
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥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 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

219 lines
7.5 KiB
TypeScript
Raw 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 () => {
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>
);
}