feat: 企微通讯录选人替换手填 userid(项目负责人/总群成员/群主合作方)
- wecom-client 新增 listInternalContacts:department/list + 逐部门 user/simplelist(cursor 翻页、去重、部门名映射) - 新 action wecom_list_internal_contacts(仅返回应用可见范围内成员) - admin 新增 WecomUserPickerModal:单选点行填入/多选勾选合并,姓名+userid 搜索,5 分钟共享缓存 - 三处接线:项目负责人(单选)、项目总群成员(多选合并去重)、新建群主合作方(单选) - 拉取失败(60020 等)提示手填兜底,手填路径不变
This commit is contained in:
@@ -1596,6 +1596,7 @@ function PartnersPage({
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
|
const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
|
const [userPickerOpen, setUserPickerOpen] = useState(false);
|
||||||
|
|
||||||
const filteredPartners = useMemo(
|
const filteredPartners = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -2144,16 +2145,26 @@ function PartnersPage({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>企业微信 userid *</span>
|
<span>企业微信 userid *</span>
|
||||||
|
<div className="field-with-action">
|
||||||
<input
|
<input
|
||||||
value={createDraft.wecomUserid}
|
value={createDraft.wecomUserid}
|
||||||
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
|
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
|
||||||
placeholder="例如:zhangsan"
|
placeholder="例如:zhangsan"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => setUserPickerOpen(true)}
|
||||||
|
disabled={createSaving}
|
||||||
|
>
|
||||||
|
从企微选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="project-chat-hint">
|
<p className="project-chat-hint">
|
||||||
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
|
可从企业微信通讯录选择,或手动填写 userid(在企业微信管理后台「通讯录」成员详情中查看)。
|
||||||
用于在任务中心的项目分组中把任务群映射到负责人。
|
群主合作方是内部员工,用于在任务中心的项目分组中把任务群映射到负责人。
|
||||||
</p>
|
</p>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
|
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
|
||||||
@@ -2164,6 +2175,22 @@ function PartnersPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{userPickerOpen && (
|
||||||
|
<WecomUserPickerModal
|
||||||
|
mode="single"
|
||||||
|
selected={
|
||||||
|
createDraft.wecomUserid.trim() ? [createDraft.wecomUserid.trim()] : []
|
||||||
|
}
|
||||||
|
onClose={() => setUserPickerOpen(false)}
|
||||||
|
onConfirm={(userids) => {
|
||||||
|
setCreateDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
wecomUserid: userids[0] ?? "",
|
||||||
|
}));
|
||||||
|
setUserPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pickerPartner && (
|
{pickerPartner && (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
|
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
|
||||||
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
|
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
@@ -2883,6 +2910,223 @@ function ProjectSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InternalWecomContact = {
|
||||||
|
userid: string;
|
||||||
|
name: string;
|
||||||
|
departmentNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let internalWecomContactsCache: {
|
||||||
|
contacts: InternalWecomContact[];
|
||||||
|
fetchedAt: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
const INTERNAL_WECOM_CONTACTS_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function WecomUserPickerModal({
|
||||||
|
mode,
|
||||||
|
selected,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
mode: "single" | "multi";
|
||||||
|
selected: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (userids: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [contacts, setContacts] = useState<InternalWecomContact[]>(
|
||||||
|
internalWecomContactsCache?.contacts ?? [],
|
||||||
|
);
|
||||||
|
const [fetchedAt, setFetchedAt] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [checked, setChecked] = useState<string[]>(selected.filter(Boolean));
|
||||||
|
|
||||||
|
const loadContacts = useCallback(async (force = false) => {
|
||||||
|
const snapshot = internalWecomContactsCache;
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
snapshot &&
|
||||||
|
Date.now() - snapshot.fetchedAt <= INTERNAL_WECOM_CONTACTS_TTL_MS
|
||||||
|
) {
|
||||||
|
setContacts(snapshot.contacts);
|
||||||
|
setFetchedAt(snapshot.fetchedAt);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/action", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "wecom_list_internal_contacts" }),
|
||||||
|
});
|
||||||
|
const parsed = await readApiResponse<{
|
||||||
|
contacts?: InternalWecomContact[];
|
||||||
|
error?: string;
|
||||||
|
}>(response, "获取企业微信通讯录失败");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(parsed.error || "获取企业微信通讯录失败");
|
||||||
|
}
|
||||||
|
const list = Array.isArray(parsed.contacts) ? parsed.contacts : [];
|
||||||
|
internalWecomContactsCache = { contacts: list, fetchedAt: Date.now() };
|
||||||
|
setContacts(list);
|
||||||
|
setFetchedAt(internalWecomContactsCache.fetchedAt);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "获取企业微信通讯录失败",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void loadContacts();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [loadContacts]);
|
||||||
|
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
const filteredContacts =
|
||||||
|
keyword.length === 0
|
||||||
|
? contacts
|
||||||
|
: contacts.filter((contact) =>
|
||||||
|
`${contact.name} ${contact.userid}`.toLowerCase().includes(keyword),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleChecked = (userid: string) => {
|
||||||
|
setChecked((previous) =>
|
||||||
|
previous.includes(userid)
|
||||||
|
? previous.filter((item) => item !== userid)
|
||||||
|
: [...previous, userid],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
从企业微信通讯录(自建应用可见范围)中挑选,
|
||||||
|
{mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="import-guide-strip">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="wecom-picker-search"
|
||||||
|
placeholder="按姓名 / userid 搜索"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => void loadContacts(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "拉取中…" : "刷新列表"}
|
||||||
|
</button>
|
||||||
|
{fetchedAt > 0 && !loading && (
|
||||||
|
<span className="wecom-picker-cache-hint">
|
||||||
|
缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="form-error">
|
||||||
|
{error} 可关闭此弹窗,直接在输入框手动填写 userid。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="wecom-picker-list">
|
||||||
|
{loading && contacts.length === 0 ? (
|
||||||
|
<div className="wecom-picker-empty">正在拉取企业微信通讯录…</div>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<div className="wecom-picker-empty">
|
||||||
|
{error
|
||||||
|
? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试"
|
||||||
|
: "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"}
|
||||||
|
</div>
|
||||||
|
) : filteredContacts.length === 0 ? (
|
||||||
|
<div className="wecom-picker-empty">没有匹配的成员</div>
|
||||||
|
) : (
|
||||||
|
filteredContacts.map((contact) => {
|
||||||
|
const isSelected =
|
||||||
|
mode === "multi"
|
||||||
|
? checked.includes(contact.userid)
|
||||||
|
: selected.includes(contact.userid);
|
||||||
|
const rowBody = (
|
||||||
|
<>
|
||||||
|
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
|
||||||
|
{(contact.name || contact.userid).slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="wecom-picker-meta">
|
||||||
|
<span className="wecom-picker-name">
|
||||||
|
{contact.name || contact.userid}
|
||||||
|
</span>
|
||||||
|
<span className="wecom-picker-sub">
|
||||||
|
{contact.departmentNames.join(" / ") || "—"}
|
||||||
|
</span>
|
||||||
|
<code className="wecom-picker-id">{contact.userid}</code>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return mode === "multi" ? (
|
||||||
|
<label key={contact.userid} className="wecom-picker-row">
|
||||||
|
{rowBody}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleChecked(contact.userid)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={contact.userid}
|
||||||
|
type="button"
|
||||||
|
className="wecom-picker-row"
|
||||||
|
onClick={() => onConfirm([contact.userid])}
|
||||||
|
>
|
||||||
|
{rowBody}
|
||||||
|
{isSelected ? (
|
||||||
|
<span className="wecom-picker-current">当前</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mode === "multi" && (
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => onConfirm(checked)}
|
||||||
|
>
|
||||||
|
{checked.length > 0 ? `确定(${checked.length} 人)` : "确定"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectEditorModal({
|
function ProjectEditorModal({
|
||||||
draft,
|
draft,
|
||||||
saving,
|
saving,
|
||||||
@@ -2896,6 +3140,12 @@ function ProjectEditorModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [picker, setPicker] = useState<null | "owner" | "members">(null);
|
||||||
|
const memberLines = () =>
|
||||||
|
draft.members
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||||
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
|
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
@@ -2927,11 +3177,21 @@ function ProjectEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>项目负责人 userid</span>
|
<span>项目负责人 userid</span>
|
||||||
|
<div className="field-with-action">
|
||||||
<input
|
<input
|
||||||
value={draft.owner}
|
value={draft.owner}
|
||||||
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
|
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
|
||||||
placeholder="例如:zhangsan"
|
placeholder="例如:zhangsan"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => setPicker("owner")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
从企微选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
|
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -2942,8 +3202,16 @@ function ProjectEditorModal({
|
|||||||
onChange={(event) => onChange({ ...draft, members: event.target.value })}
|
onChange={(event) => onChange({ ...draft, members: event.target.value })}
|
||||||
placeholder={"zhangsan\nlisi\nwangwu"}
|
placeholder={"zhangsan\nlisi\nwangwu"}
|
||||||
/>
|
/>
|
||||||
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small>
|
<small>推送任务时会逐人发送应用消息;可从企业微信通讯录选择或手动填写。</small>
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => setPicker("members")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
从企微选择总群成员
|
||||||
|
</button>
|
||||||
<label>
|
<label>
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -2960,6 +3228,32 @@ function ProjectEditorModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{picker === "owner" && (
|
||||||
|
<WecomUserPickerModal
|
||||||
|
mode="single"
|
||||||
|
selected={draft.owner ? [draft.owner.trim()] : []}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onConfirm={(userids) => {
|
||||||
|
onChange({ ...draft, owner: userids[0] ?? "" });
|
||||||
|
setPicker(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{picker === "members" && (
|
||||||
|
<WecomUserPickerModal
|
||||||
|
mode="multi"
|
||||||
|
selected={memberLines()}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onConfirm={(userids) => {
|
||||||
|
const merged = [...memberLines()];
|
||||||
|
for (const userid of userids) {
|
||||||
|
if (!merged.includes(userid)) merged.push(userid);
|
||||||
|
}
|
||||||
|
onChange({ ...draft, members: merged.join("\n") });
|
||||||
|
setPicker(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
sendWecomAppMessage,
|
sendWecomAppMessage,
|
||||||
sendWecomRobotMessage,
|
sendWecomRobotMessage,
|
||||||
listExternalContacts,
|
listExternalContacts,
|
||||||
|
listInternalContacts,
|
||||||
WecomClientError,
|
WecomClientError,
|
||||||
type WecomBindings,
|
type WecomBindings,
|
||||||
} from "../../../lib/wecom-client";
|
} from "../../../lib/wecom-client";
|
||||||
@@ -683,6 +684,12 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
const contacts = await listExternalContacts(wecomConfig);
|
const contacts = await listExternalContacts(wecomConfig);
|
||||||
return Response.json({ contacts });
|
return Response.json({ contacts });
|
||||||
|
} else if (body.action === "wecom_list_internal_contacts") {
|
||||||
|
const wecomConfig = resolveWecomConfig(
|
||||||
|
env as unknown as WecomBindings,
|
||||||
|
);
|
||||||
|
const contacts = await listInternalContacts(wecomConfig);
|
||||||
|
return Response.json({ contacts });
|
||||||
} else if (body.action === "wecom_sync_group_chats") {
|
} else if (body.action === "wecom_sync_group_chats") {
|
||||||
const wecomConfig = resolveWecomConfig(
|
const wecomConfig = resolveWecomConfig(
|
||||||
env as unknown as WecomBindings,
|
env as unknown as WecomBindings,
|
||||||
|
|||||||
@@ -5432,3 +5432,18 @@ label small {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-with-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-with-action > input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-with-action > button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -767,3 +767,124 @@ export async function sendAppChatMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getWecomJson(
|
||||||
|
config: WecomConfig,
|
||||||
|
path: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
errorLabel: string,
|
||||||
|
fetchImpl: FetchLike,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!hasAppCredentials(config)) {
|
||||||
|
throw new WecomClientError(
|
||||||
|
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const token = await fetchAccessToken(config, fetchImpl);
|
||||||
|
const query = new URLSearchParams({ access_token: token, ...params });
|
||||||
|
const response = await fetchImpl(`${WECOM_API_ORIGIN}${path}?${query}`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
});
|
||||||
|
const payload = await readEnvelope(response, errorLabel);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
|
||||||
|
}
|
||||||
|
ensureOk(payload, errorLabel);
|
||||||
|
return payload as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WecomInternalContact = {
|
||||||
|
userid: string;
|
||||||
|
name: string;
|
||||||
|
departmentNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERNAL_CONTACT_DEPARTMENT_LIMIT = 100;
|
||||||
|
const INTERNAL_CONTACT_CURSOR_ROUNDS = 10;
|
||||||
|
|
||||||
|
export async function listInternalContacts(
|
||||||
|
config: WecomConfig,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): Promise<WecomInternalContact[]> {
|
||||||
|
const deptPayload = await getWecomJson(
|
||||||
|
config,
|
||||||
|
"/cgi-bin/department/list",
|
||||||
|
{},
|
||||||
|
"获取企业微信部门列表失败",
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
const departments = (
|
||||||
|
Array.isArray(deptPayload.department)
|
||||||
|
? (deptPayload.department as { id?: unknown; name?: unknown }[])
|
||||||
|
: []
|
||||||
|
).slice(0, INTERNAL_CONTACT_DEPARTMENT_LIMIT);
|
||||||
|
const departmentNames = new Map<string, string>();
|
||||||
|
for (const dept of departments) {
|
||||||
|
const id = String(dept?.id ?? "");
|
||||||
|
if (id) departmentNames.set(id, bindingValue(dept?.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = new Map<string, WecomInternalContact>();
|
||||||
|
for (const dept of departments) {
|
||||||
|
const departmentId = String(dept?.id ?? "");
|
||||||
|
if (!departmentId) continue;
|
||||||
|
let cursor = "";
|
||||||
|
for (let round = 0; round < INTERNAL_CONTACT_CURSOR_ROUNDS; round += 1) {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
department_id: departmentId,
|
||||||
|
fetch_child: "0",
|
||||||
|
};
|
||||||
|
if (cursor) params.cursor = cursor;
|
||||||
|
const payload = await getWecomJson(
|
||||||
|
config,
|
||||||
|
"/cgi-bin/user/simplelist",
|
||||||
|
params,
|
||||||
|
"获取企业微信部门成员失败",
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
const userlist = Array.isArray(payload.userlist)
|
||||||
|
? (payload.userlist as {
|
||||||
|
userid?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
department?: unknown[];
|
||||||
|
}[])
|
||||||
|
: [];
|
||||||
|
for (const user of userlist) {
|
||||||
|
const userid = bindingValue(user?.userid);
|
||||||
|
if (!userid) continue;
|
||||||
|
const name = bindingValue(user?.name);
|
||||||
|
const deptIds = (
|
||||||
|
Array.isArray(user?.department)
|
||||||
|
? user.department.map((id) => String(id)).filter(Boolean)
|
||||||
|
: [departmentId]
|
||||||
|
);
|
||||||
|
const existing = contacts.get(userid);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.name && name) existing.name = name;
|
||||||
|
for (const deptId of deptIds) {
|
||||||
|
const deptName = departmentNames.get(deptId);
|
||||||
|
if (deptName && !existing.departmentNames.includes(deptName)) {
|
||||||
|
existing.departmentNames.push(deptName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contacts.set(userid, {
|
||||||
|
userid,
|
||||||
|
name,
|
||||||
|
departmentNames: deptIds
|
||||||
|
.map((id) => departmentNames.get(id))
|
||||||
|
.filter((deptName): deptName is string => Boolean(deptName)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextCursor = bindingValue(payload.next_cursor);
|
||||||
|
if (!nextCursor || nextCursor === cursor) break;
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...contacts.values()].sort((a, b) =>
|
||||||
|
`${a.name}${a.userid}`.localeCompare(`${b.name}${b.userid}`, "zh"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getAppChat,
|
getAppChat,
|
||||||
listCustomerGroupChats,
|
listCustomerGroupChats,
|
||||||
listExternalContacts,
|
listExternalContacts,
|
||||||
|
listInternalContacts,
|
||||||
resolveWecomConfig,
|
resolveWecomConfig,
|
||||||
sendAppChatMessage,
|
sendAppChatMessage,
|
||||||
sendWecomAppMessage,
|
sendWecomAppMessage,
|
||||||
@@ -57,6 +58,56 @@ function fakeWecom(options = {}) {
|
|||||||
invaliduser: invalid.join("|"),
|
invaliduser: invalid.join("|"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/department/list")) {
|
||||||
|
if (options.departmentErrcode) {
|
||||||
|
return Response.json({
|
||||||
|
errcode: options.departmentErrcode,
|
||||||
|
errmsg: "not allow to access from your ip",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
department: options.departments ?? [
|
||||||
|
{ id: 1, name: "总办" },
|
||||||
|
{ id: 2, name: "运营部" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/user/simplelist")) {
|
||||||
|
const departmentId = url.searchParams.get("department_id") ?? "";
|
||||||
|
const cursor = url.searchParams.get("cursor") ?? "";
|
||||||
|
const table = options.departmentUsers ?? {
|
||||||
|
"1": [
|
||||||
|
{ userid: "zhangsan", name: "张三", department: [1, 2] },
|
||||||
|
{ userid: "lisi", name: "李四", department: [1] },
|
||||||
|
],
|
||||||
|
"2": [
|
||||||
|
{ userid: "zhangsan", name: "张三", department: [1, 2] },
|
||||||
|
{ userid: "wangwu", name: "王五", department: [2] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (options.paginateSimplelist && departmentId === "1") {
|
||||||
|
if (!cursor) {
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
userlist: table[departmentId] ?? [],
|
||||||
|
next_cursor: "cursor-2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
userlist: [{ userid: "pagetwo", name: "第二页", department: [1] }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
userlist: table[departmentId] ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
};
|
};
|
||||||
return { calls, fetchImpl };
|
return { calls, fetchImpl };
|
||||||
@@ -947,3 +998,61 @@ test("sendAppChatMessage throws WecomClientError when provider errors", async ()
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listInternalContacts merges departments, dedupes and maps dept names", async () => {
|
||||||
|
const { calls, fetchImpl } = fakeWecom();
|
||||||
|
const contacts = await listInternalContacts(
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
contacts.map((contact) => `${contact.name}:${contact.userid}`),
|
||||||
|
["李四:lisi", "王五:wangwu", "张三:zhangsan"],
|
||||||
|
"deduped and sorted by name (zh)",
|
||||||
|
);
|
||||||
|
const zhangsan = contacts.find((contact) => contact.userid === "zhangsan");
|
||||||
|
assert.deepEqual(zhangsan.departmentNames.sort(), ["总办", "运营部"]);
|
||||||
|
const simplelistCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/user/simplelist"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
simplelistCalls
|
||||||
|
.map((call) => call.url.searchParams.get("department_id"))
|
||||||
|
.sort(),
|
||||||
|
["1", "2"],
|
||||||
|
"one simplelist call per department",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
simplelistCalls.every((call) => call.url.searchParams.get("fetch_child") === "0"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listInternalContacts follows simplelist cursor pages", async () => {
|
||||||
|
const { fetchImpl } = fakeWecom({ paginateSimplelist: true });
|
||||||
|
const contacts = await listInternalContacts(
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
assert.ok(contacts.some((contact) => contact.userid === "pagetwo"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listInternalContacts throws WecomClientError on department errcode", async () => {
|
||||||
|
const { fetchImpl } = fakeWecom({ departmentErrcode: 60020 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => listInternalContacts(resolveWecomConfig(baseBindings), fetchImpl),
|
||||||
|
(error) =>
|
||||||
|
error instanceof WecomClientError && /部门列表/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listInternalContacts rejects without app credentials", async () => {
|
||||||
|
const { fetchImpl } = fakeWecom();
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
listInternalContacts(
|
||||||
|
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
|
||||||
|
fetchImpl,
|
||||||
|
),
|
||||||
|
/未配置/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user