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 [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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user