diff --git a/app/admin-app.tsx b/app/admin-app.tsx
index 0c47346..3279220 100644
--- a/app/admin-app.tsx
+++ b/app/admin-app.tsx
@@ -1596,6 +1596,7 @@ function PartnersPage({
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
const [createSaving, setCreateSaving] = useState(false);
+ const [userPickerOpen, setUserPickerOpen] = useState(false);
const filteredPartners = useMemo(
() =>
@@ -2144,16 +2145,26 @@ function PartnersPage({
- userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
- 用于在任务中心的项目分组中把任务群映射到负责人。
+ 可从企业微信通讯录选择,或手动填写 userid(在企业微信管理后台「通讯录」成员详情中查看)。
+ 群主合作方是内部员工,用于在任务中心的项目分组中把任务群映射到负责人。
@@ -2164,6 +2175,22 @@ function PartnersPage({
)}
+ {userPickerOpen && (
+ setUserPickerOpen(false)}
+ onConfirm={(userids) => {
+ setCreateDraft((previous) => ({
+ ...previous,
+ wecomUserid: userids[0] ?? "",
+ }));
+ setUserPickerOpen(false);
+ }}
+ />
+ )}
{pickerPartner && (
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
(
+ internalWecomContactsCache?.contacts ?? [],
+ );
+ const [fetchedAt, setFetchedAt] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [search, setSearch] = useState("");
+ const [checked, setChecked] = useState(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 (
+
+
event.stopPropagation()}
+ >
+
+
+
选择企业微信成员
+
+ 从企业微信通讯录(自建应用可见范围)中挑选,
+ {mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。
+
+
+
+
+
+ setSearch(event.target.value)}
+ disabled={loading}
+ />
+
+ {fetchedAt > 0 && !loading && (
+
+ 缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用)
+
+ )}
+
+ {error && (
+
+ {error} 可关闭此弹窗,直接在输入框手动填写 userid。
+
+ )}
+
+ {loading && contacts.length === 0 ? (
+
正在拉取企业微信通讯录…
+ ) : contacts.length === 0 ? (
+
+ {error
+ ? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试"
+ : "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"}
+
+ ) : filteredContacts.length === 0 ? (
+
没有匹配的成员
+ ) : (
+ filteredContacts.map((contact) => {
+ const isSelected =
+ mode === "multi"
+ ? checked.includes(contact.userid)
+ : selected.includes(contact.userid);
+ const rowBody = (
+ <>
+
+ {(contact.name || contact.userid).slice(0, 1)}
+
+
+
+ {contact.name || contact.userid}
+
+
+ {contact.departmentNames.join(" / ") || "—"}
+
+ {contact.userid}
+
+ >
+ );
+ return mode === "multi" ? (
+
+ ) : (
+
+ );
+ })
+ )}
+
+ {mode === "multi" && (
+
+
+
+
+ )}
+
+
+ );
+}
+
function ProjectEditorModal({
draft,
saving,
@@ -2896,6 +3140,12 @@ function ProjectEditorModal({
onClose: () => void;
onSave: () => void;
}) {
+ const [picker, setPicker] = useState(null);
+ const memberLines = () =>
+ draft.members
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
return (
+ {picker === "owner" && (
+
setPicker(null)}
+ onConfirm={(userids) => {
+ onChange({ ...draft, owner: userids[0] ?? "" });
+ setPicker(null);
+ }}
+ />
+ )}
+ {picker === "members" && (
+ 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);
+ }}
+ />
+ )}
);
}
diff --git a/app/api/action/route.ts b/app/api/action/route.ts
index 175322f..6d358f1 100644
--- a/app/api/action/route.ts
+++ b/app/api/action/route.ts
@@ -43,6 +43,7 @@ import {
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
+ listInternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
@@ -683,6 +684,12 @@ export async function POST(request: Request) {
);
const contacts = await listExternalContacts(wecomConfig);
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") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
diff --git a/app/globals.css b/app/globals.css
index 30af4f8..43e6604 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -5432,3 +5432,18 @@ label small {
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;
+}
diff --git a/lib/wecom-client.ts b/lib/wecom-client.ts
index 5f2b534..930f4b1 100644
--- a/lib/wecom-client.ts
+++ b/lib/wecom-client.ts
@@ -767,3 +767,124 @@ export async function sendAppChatMessage(
);
}
+async function getWecomJson(
+ config: WecomConfig,
+ path: string,
+ params: Record,
+ errorLabel: string,
+ fetchImpl: FetchLike,
+): Promise> {
+ 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;
+}
+
+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 {
+ 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();
+ for (const dept of departments) {
+ const id = String(dept?.id ?? "");
+ if (id) departmentNames.set(id, bindingValue(dept?.name));
+ }
+
+ const contacts = new Map();
+ 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 = {
+ 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"),
+ );
+}
+
diff --git a/tests/wecom-client.test.mjs b/tests/wecom-client.test.mjs
index 93d14af..d3dbef7 100644
--- a/tests/wecom-client.test.mjs
+++ b/tests/wecom-client.test.mjs
@@ -10,6 +10,7 @@ import {
getAppChat,
listCustomerGroupChats,
listExternalContacts,
+ listInternalContacts,
resolveWecomConfig,
sendAppChatMessage,
sendWecomAppMessage,
@@ -57,6 +58,56 @@ function fakeWecom(options = {}) {
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 { 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,
+ ),
+ /未配置/,
+ );
+});