- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人 - 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人 - 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行 - 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events) - 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表 - 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群) - 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件 - 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
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>
|
||
);
|
||
}
|