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 渲染/解绑清空)
This commit is contained in:
239
app/wecom-user-picker.tsx
Normal file
239
app/wecom-user-picker.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user