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 [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
const [createSaving, setCreateSaving] = useState(false);
const [userPickerOpen, setUserPickerOpen] = useState(false);
const filteredPartners = useMemo(
() =>
@@ -2144,16 +2145,26 @@ function PartnersPage({
</label>
<label>
<span>企业微信 userid *</span>
<input
value={createDraft.wecomUserid}
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
placeholder="例如:zhangsan"
/>
<div className="field-with-action">
<input
value={createDraft.wecomUserid}
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setUserPickerOpen(true)}
disabled={createSaving}
>
从企微选择
</button>
</div>
</label>
</div>
<p className="project-chat-hint">
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
用于在任务中心的项目分组中把任务群映射到负责人。
可从企业微信通讯录选择,或手动填写 userid(在企业微信管理后台「通讯录」成员详情中查看)。
群主合作方是内部员工,用于在任务中心的项目分组中把任务群映射到负责人。
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
@@ -2164,6 +2175,22 @@ function PartnersPage({
</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 && (
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
<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({
draft,
saving,
@@ -2896,6 +3140,12 @@ function ProjectEditorModal({
onClose: () => void;
onSave: () => void;
}) {
const [picker, setPicker] = useState<null | "owner" | "members">(null);
const memberLines = () =>
draft.members
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
@@ -2927,11 +3177,21 @@ function ProjectEditorModal({
</div>
<label>
<span>项目负责人 userid</span>
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<div className="field-with-action">
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("owner")}
disabled={saving}
>
从企微选择
</button>
</div>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
<label>
@@ -2942,8 +3202,16 @@ function ProjectEditorModal({
onChange={(event) => onChange({ ...draft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"}
/>
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small>
<small>推送任务时会逐人发送应用消息;可从企业微信通讯录选择或手动填写。</small>
</label>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("members")}
disabled={saving}
>
从企微选择总群成员
</button>
<label>
<span>备注</span>
<textarea
@@ -2960,6 +3228,32 @@ function ProjectEditorModal({
</button>
</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>
);
}

View File

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

View File

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