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} + + + {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 (
event.stopPropagation()}> @@ -2927,11 +3177,21 @@ function ProjectEditorModal({
+