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:
ABAPPLO
2026-09-03 13:52:10 +08:00
parent bd80b2d7d8
commit 5430624a43
5 changed files with 559 additions and 13 deletions

View File

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

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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"),
);
}

View File

@@ -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,
),
/未配置/,
);
});