Files
koc-loop/app/wecom-user-picker.tsx
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥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 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

240 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}