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:
ABAPPLO
2026-09-04 18:03:46 +08:00
parent 5430624a43
commit dd5b603149
44 changed files with 4655 additions and 2039 deletions

View 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>
);
}