Files
koc-loop/app/wecom-user-picker.tsx

240 lines
7.9 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}