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 渲染/解绑清空)
This commit is contained in:
218
app/task-customer-chat-modal.tsx
Normal file
218
app/task-customer-chat-modal.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user