240 lines
7.9 KiB
TypeScript
240 lines
7.9 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
|
|||
|
|
export async function readApiResponse<T>(
|
|||
|
|
response: Response,
|
|||
|
|
fallback: string,
|
|||
|
|
) {
|
|||
|
|
const text = await response.text();
|
|||
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|||
|
|
if (!contentType.includes("json")) {
|
|||
|
|
if (response.status === 403) {
|
|||
|
|
throw new Error("请求被安全网关拦截,请刷新页面后重试");
|
|||
|
|
}
|
|||
|
|
throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`);
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(text) as T & { error?: string };
|
|||
|
|
} catch {
|
|||
|
|
throw new Error(`${fallback}(服务返回的数据无法解析)`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export 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;
|
|||
|
|
|
|||
|
|
export 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>
|
|||
|
|
);
|
|||
|
|
}
|