diff --git a/.dev.vars.example b/.dev.vars.example index 5c2acf8..b004152 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -11,3 +11,14 @@ AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key # Feishu custom app credentials. Keep the secret out of source control. FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx FEISHU_APP_SECRET=replace-with-feishu-app-secret + +# 企业微信 · 全部选填;不填则临期催办与汇总都跳过,不影响其他功能 +WECOM_CORP_ID= +WECOM_AGENT_ID= +WECOM_SECRET= +WECOM_ROBOT_WEBHOOK= +WECOM_NOTIFY_DUE_DAYS=3 +WECOM_NOTIFY_ENABLED=true + +# 本地测试用:让 /api/bootstrap 自动 seed 测试 partner + distribution;上线设为 false +SEED_DEMO_DATA=false diff --git a/.gitignore b/.gitignore index d6fb771..87f8518 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ # misc .DS_Store *.pem +.codegraph/ +.ipynb_checkpoints/ # debug npm-debug.log* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd23550 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Does + +KOC LOOP is a content distribution & data collection platform for KOC (Key Opinion Consumer) operations teams. It runs on **vinext** (Vite-based Next.js 16 on Cloudflare Workers + Pages) with Cloudflare D1 (SQLite) and R2 storage. + +The platform manages: task creation from Feishu (飞书) spreadsheets, content distribution to KOC partners, publishing to Xiaohongshu (小红书), and automated data collection (likes/comments/collects) via MCP-based metrics scraping. + +## Two Applications + +### 1. Admin App (`app/`) — Main dashboard +- Server-rendered admin UI at root (`/`), protected by ChatGPT Sign-In + admin email check +- Heavy client-side SPA in `app/admin-app.tsx` (a single ~59KB component with all dashboard state/views) +- API routes under `app/api/` for CRUD, Feishu import, data collection, image upload + +### 2. KOC Portal (`koc-portal/`) — External task portal +- Independent Next.js app (separate `package.json`) for external KOC collaborators +- KOCs can claim tasks, view assigned content, and submit publish URLs/screenshots +- Does NOT connect directly to the database — uses the admin app's API + +## Tech Stack + +- **Framework**: Next.js 16 + React 19 + TypeScript +- **Build/Runtime**: vinext 0.0.50 (Vite 8 plugin → Cloudflare Workers/Pages) +- **Database**: Cloudflare D1 (SQLite via Drizzle ORM 0.45) +- **Storage**: Cloudflare R2 for uploads/screenshots +- **Styling**: Tailwind CSS 4 +- **Cron**: Cloudflare Workers cron (daily 02:00 UTC / 10:00 CST) + +## Database Schema (7 tables in `db/schema.ts`) + +| Table | Purpose | +|-------|---------| +| `partners` | KOC partners/groups (name, owner, stats) | +| `tasks` | Campaign tasks (brand, quantity, due date, Feishu source) | +| `contents` | Content items (title, body, images, linked to task) | +| `accounts` | Xiaohongshu accounts scraped from publish links | +| `claims` | Partner claims on task content | +| `delegation_bundles` | Delegation bundles with share tokens | +| `distributions` | Content-to-partner assignments (publish URL, metrics, collection status) | +| `collection_runs` | Scheduled metrics collection run log | + +Schema is maintained both via Drizzle (`db/schema.ts`) and imperative migrations in `lib/mvp-db.ts:ensureSchema()`. The imperative path is the source of truth for production — Drizzle migrations are optional. + +## Key Libraries + +- `lib/mvp-db.ts` — Raw D1 helpers, schema bootstrapping, seed data, `getDashboardData()` +- `lib/collection-service.ts` — Scheduled metrics collection orchestration +- `lib/mcp-collection-client.ts` — MCP-based client for scraping Xiaohongshu public metrics +- `lib/feishu-client.ts` — Feishu API client (spreadsheet/wiki/doc reading) +- `lib/account-enrichment-service.ts` — Backfill account profiles from Xiaohongshu +- `lib/admin-auth.ts` — Admin email/token authentication +- `lib/partner-utils.ts` / `lib/publish-url.ts` — URL extraction helpers +- `lib/date-utils.ts` — Date formatting (Shanghai timezone) + +## Worker (`worker/index.ts`) + +The Cloudflare Worker entry point handles: +1. `fetch` — Image optimization proxy at `/_vinext/image`, delegates everything else to vinext app router +2. `scheduled` — Daily cron: ensures DB schema, runs scheduled collections, backfills account profiles + +> Note: 在私有化部署(Node + MySQL)里实际调度走 `lib/scheduler.ts` 的 `node-cron`,每天 09:00 Asia/Shanghai 跑同一套 `runDailyJob`(采集 + 账号资料补全 + 企业微信催办)。Cloudflare Worker 入口仅用于原线上版本。 + +## API Routes (`app/api/`) + +- `action/route.ts` — Central admin action endpoint: create task from Feishu, dashboard data, collect metrics, batch operations, account backfill, 企业微信测试 / 状态查询 / 绑定外部联系人 / 客户群同步与企业群发(半自动,群主确认后送达) +- `partner/route.ts` — Partner-facing API (claim, delegation, distribution) +- `bootstrap/route.ts` — Seed database with demo data +- `upload/route.ts` — Generic file upload to R2 +- `partner-upload/route.ts` — Partner screenshot upload (CORS-enabled) +- `partner-image/route.ts` — Partner image serving (CORS-enabled) +- `content-image-upload/route.ts` — Content image upload +- `creator-screenshot/route.ts` — Creator screenshot upload +- `resources-import/route.ts` — 批量导入 KOC 账号资源(Excel/CSV) +- `resources-insert/route.ts` — 单条新增 KOC 账号资源(表单) + +## Environment Variables (`.dev.vars`) + +``` +KOC_PORTAL_URL — URL of the koc-portal app +ADMIN_ALLOWED_EMAIL — ChatGPT email allowed for admin access +ADMIN_INTERNAL_TOKEN — Shared secret for API-to-API auth +AI_TOOL_CENTER_MCP_URL — MCP endpoint for XHS data collection +AI_TOOL_CENTER_MCP_KEY — MCP API key +FEISHU_APP_ID — Feishu custom app credentials +FEISHU_APP_SECRET — Feishu app secret +WECOM_CORP_ID — 企业微信企业 ID(自建应用消息推送用,可选) +WECOM_AGENT_ID — 企业微信自建应用 AgentId(可选) +WECOM_SECRET — 企业微信自建应用 Secret(可选) +WECOM_ROBOT_WEBHOOK — 企业微信群机器人 webhook(当日催办汇总,可选) +WECOM_NOTIFY_DUE_DAYS — 临期阈值,默认 3,取值 1–30(可选) +WECOM_NOTIFY_ENABLED — 企业微信通知总开关,默认 true(可选) +``` + +## Commands + +```bash +# Admin app (root of repo) +npm run dev # Start local dev server +npm run build # Build for production +npm test # Build + run integration tests +npm run lint # ESLint check +npm run db:generate # Generate Drizzle SQL migration + +# KOC Portal (koc-portal/) +cd koc-portal && npm run dev -- --port 3000 # Start on port 3000 +cd koc-portal && npm run build +cd koc-portal && npm test + +# Tests use `node --test` (Node built-in test runner), live in tests/ +node --test tests/date-utils.test.mjs # Run a single test +``` + +## Build System + +- `vinext` replaces the Next.js build pipeline with Vite +- `@cloudflare/vite-plugin` provides D1/R2/cron local bindings +- Vite config at root resolves bindings from `.openai/hosting.json` +- Custom `build/sites-vite-plugin.ts` copies `.openai/` and `drizzle/` into `dist/` for deployment +- The `koc-portal/` subdirectory has its own identical build setup (separate `vite.config.ts`, `build/sites-vite-plugin.ts`) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 7eaf551..486fe17 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -15,6 +15,7 @@ import { } from "../lib/date-utils"; import type { AuthUser } from "../lib/user-auth"; import { parseResultScreenshotKeys } from "../lib/result-screenshots"; +import { platformFromProfileUrl } from "../lib/resource-import"; import { sortWithNullsLast, type SortDirection, @@ -28,6 +29,7 @@ type Partner = { owner: string; claimed_total: number; completed_total: number; + wecom_external_user_id?: string | null; }; type Task = { @@ -178,6 +180,7 @@ type NavKey = | "tasks" | "distributions" | "resources" + | "partners" | "recovery" | "users"; @@ -186,6 +189,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "tasks", label: "任务中心", mark: "▤" }, { key: "distributions", label: "任务分发", mark: "↗" }, { key: "resources", label: "KOC资源", mark: "◎" }, + { key: "partners", label: "合作方", mark: "Ⓒ" }, { key: "recovery", label: "数据回收", mark: "◫" }, { key: "users", label: "用户管理", mark: "♙" }, ]; @@ -498,6 +502,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [toast, setToast] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const [taskModalOpen, setTaskModalOpen] = useState(false); + const [groupPushTask, setGroupPushTask] = useState(null); const [metricModal, setMetricModal] = useState(null); const [taskForm, setTaskForm] = useState({ taskType: "content_publish" as "content_publish" | "screenshot_collect", @@ -961,6 +966,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { title: "KOC资源", subtitle: "只沉淀真实发布过的账号,不要求提前登记。", }, + partners: { + title: "合作方", + subtitle: "绑定企业微信外部联系人,临期催办自动推送到人。", + }, recovery: { title: "数据回收", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", @@ -1087,6 +1096,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { portalUrl={data.portal_url} onCreate={() => setTaskModalOpen(true)} onCopyShare={copyTaskShareLink} + onPushToGroups={(task) => setGroupPushTask(task)} /> )} {activeNav === "distributions" && ( @@ -1115,6 +1125,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { onImported={completeResourceImport} /> )} + {activeNav === "partners" && isManager && ( + + )} {activeNav === "users" && isManager && ( )} @@ -1391,12 +1409,815 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { )} + {groupPushTask && ( + setGroupPushTask(null)} + setToast={setToast} + /> + )} + {toast &&
{toast}
} {working &&
}
); } +type WecomStatus = { + robotConfigured: boolean; + appConfigured: boolean; + dueDays: number; +}; + +type WecomContact = { + externalUserId: string; + name: string; + avatar: string; + corpName: string; + ownerUserId: string; +}; + +type WecomGroupChatRow = { + chat_id: string; + name: string; + owner_user_id: string; + member_count: number; +}; + +type WecomGroupPushResult = { + sender: string; + msgid: string; + chatCount: number; + failList: string[]; +}; + +type WecomGroupPushRecord = { + id: string; + taskId: string; + msgid: string; + sender: string; + chatIds: string[]; + failList: string[]; + createdAt: string; +}; + +function PartnersPage({ + partners, + working, + onRefresh, + setToast, +}: { + partners: Partner[]; + working: boolean; + onRefresh: () => Promise; + setToast: (message: string) => void; +}) { + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [draftId, setDraftId] = useState(""); + const [partnerWorkingId, setPartnerWorkingId] = useState(null); + const [pickerPartner, setPickerPartner] = useState(null); + const [pickerContacts, setPickerContacts] = useState([]); + const [pickerFetchedAt, setPickerFetchedAt] = useState(0); + const [pickerLoading, setPickerLoading] = useState(false); + const [pickerError, setPickerError] = useState(""); + const [pickerSearch, setPickerSearch] = useState(""); + const [pickerBindingId, setPickerBindingId] = useState(null); + + const loadStatus = useCallback(async () => { + let response: Response; + try { + response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "wecom_status" }), + }); + } catch { + setToast("读取企业微信配置失败"); + setStatusLoading(false); + return; + } + const result = await readApiResponse( + response, + "读取企业微信配置失败", + ); + if (!response.ok) { + setToast(result.error || "读取企业微信配置失败"); + setStatusLoading(false); + return; + } + setStatus({ + robotConfigured: result.robotConfigured, + appConfigured: result.appConfigured, + dueDays: result.dueDays, + }); + setStatusLoading(false); + }, [setToast]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadStatus(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadStatus]); + + const refreshStatus = async () => { + setStatusLoading(true); + await loadStatus(); + }; + + const startEdit = (partner: Partner) => { + setEditingId(partner.id); + setDraftId(partner.wecom_external_user_id ?? ""); + }; + + const cancelEdit = () => { + setEditingId(null); + setDraftId(""); + }; + + const saveEdit = async (partner: Partner) => { + const value = draftId.trim(); + try { + setPartnerWorkingId(partner.id); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "bind_wecom_external_id", + partnerId: partner.id, + wecomExternalUserId: value, + }), + }); + const result = await readApiResponse<{ + partnerId?: string; + wecomExternalUserId?: string | null; + error?: string; + }>(response, "保存失败"); + if (!response.ok) throw new Error(result.error || "保存失败"); + setEditingId(null); + setDraftId(""); + await onRefresh(); + setToast("企业微信外部联系人已保存"); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "保存失败"); + } finally { + setPartnerWorkingId(null); + } + }; + + const testRobot = async () => { + try { + setPartnerWorkingId("robot"); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "send_test_wecom" }), + }); + const result = await readApiResponse<{ + robot?: "ok" | "skipped"; + app?: "ok" | "skipped" | "failed"; + error?: string; + }>(response, "测试失败"); + if (!response.ok) throw new Error(result.error || "测试失败"); + if (result.robot === "ok") setToast("群机器人测试消息已发送"); + else if (result.robot === "skipped") setToast("群机器人未配置,请先填写 WECOM_ROBOT_WEBHOOK"); + else setToast("群机器人测试失败"); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "测试失败"); + } finally { + setPartnerWorkingId(null); + } + }; + + const triggerDueSoon = async () => { + try { + setPartnerWorkingId("due-soon"); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "trigger_wecom_due_soon" }), + }); + const result = await readApiResponse<{ + dueSoonAttempted?: number; + dueSoonSent?: number; + dueSoonFailed?: number; + dueSoonSkipped?: number; + digestSent?: boolean; + error?: string; + }>(response, "触发催办失败"); + if (!response.ok) throw new Error(result.error || "触发催办失败"); + const sent = result.dueSoonSent ?? 0; + const failed = result.dueSoonFailed ?? 0; + const skipped = result.dueSoonSkipped ?? 0; + const attempted = result.dueSoonAttempted ?? 0; + const digest = result.digestSent ? "群汇总已推送" : "群汇总未推送(无内容或未配置 webhook)"; + setToast( + `催办完成:尝试 ${attempted} 个合作方,应用消息已发 ${sent}、跳过 ${skipped}、失败 ${failed};${digest}`, + ); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "触发催办失败"); + } finally { + setPartnerWorkingId(null); + } + }; + + const testAppMessage = async (partner: Partner) => { + if (!partner.wecom_external_user_id) { + setToast("请先保存企业微信外部联系人 ID"); + return; + } + try { + setPartnerWorkingId(partner.id); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "send_test_wecom", + partnerId: partner.id, + }), + }); + const result = await readApiResponse<{ + robot?: "ok" | "skipped"; + app?: "ok" | "skipped" | "failed"; + error?: string; + }>(response, "测试失败"); + if (!response.ok) throw new Error(result.error || "测试失败"); + if (result.app === "ok") setToast(`已向 ${partner.name} 发送测试消息`); + else if (result.app === "skipped") setToast("应用消息未配置或未绑定外部联系人"); + else setToast(`应用消息发送失败:${partner.name} 的外部联系人 ID 无效`); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "测试失败"); + } finally { + setPartnerWorkingId(null); + } + }; + + const loadContacts = useCallback(async () => { + const cacheFresh = pickerFetchedAt > 0 && Date.now() - pickerFetchedAt < 5 * 60 * 1000; + if (cacheFresh) return; + setPickerLoading(true); + setPickerError(""); + let response: Response; + try { + response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "wecom_list_external_contacts" }), + }); + } catch { + setPickerError("拉取企业微信外部联系人失败"); + setPickerLoading(false); + return; + } + const result = await readApiResponse<{ contacts?: WecomContact[]; error?: string }>( + response, + "拉取企业微信外部联系人失败", + ); + if (!response.ok) { + setPickerError(result.error || "拉取企业微信外部联系人失败"); + setPickerLoading(false); + return; + } + setPickerContacts(result.contacts ?? []); + setPickerFetchedAt(Date.now()); + setPickerLoading(false); + }, [pickerFetchedAt]); + + const openPicker = (partner: Partner) => { + setPickerPartner(partner); + setPickerSearch(""); + setPickerError(""); + void loadContacts(); + }; + + const closePicker = () => { + if (pickerBindingId) return; + setPickerPartner(null); + setPickerSearch(""); + setPickerError(""); + }; + + const filteredContacts = pickerContacts.filter((contact) => { + if (!pickerSearch.trim()) return true; + const q = pickerSearch.trim().toLowerCase(); + return ( + contact.name.toLowerCase().includes(q) || + contact.corpName.toLowerCase().includes(q) || + contact.externalUserId.toLowerCase().includes(q) || + contact.ownerUserId.toLowerCase().includes(q) + ); + }); + + const pickContact = async (contact: WecomContact) => { + if (!pickerPartner) return; + try { + setPickerBindingId(contact.externalUserId); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "bind_wecom_external_id", + partnerId: pickerPartner.id, + wecomExternalUserId: contact.externalUserId, + wecomName: contact.name, + }), + }); + const result = await readApiResponse<{ error?: string }>(response, "绑定失败"); + if (!response.ok) throw new Error(result.error || "绑定失败"); + setToast(`已为 ${pickerPartner.name} 绑定 ${contact.name}`); + setPickerPartner(null); + setPickerSearch(""); + await onRefresh(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "绑定失败"); + } finally { + setPickerBindingId(null); + } + }; + + return ( +
+
+
+
+

企业微信连通性

+

临期催办与应用消息依赖以下配置;不显示 Secret。

+
+ +
+
+
+ 群机器人 + {status ? (status.robotConfigured ? "已配置" : "未配置") : "加载中"} + 用于当日催办汇总 +
+
+ 自建应用消息 + {status ? (status.appConfigured ? "已配置" : "未配置") : "加载中"} + 需要 corpId + agentId + secret +
+
+ 临期阈值 + {status ? `${status.dueDays} 天` : "—"} + WECOM_NOTIFY_DUE_DAYS +
+
+
+ + +

+ 立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。 +

+ {(!status?.robotConfigured || !status?.appConfigured) && ( +

+ 未配置项可在 .dev.vars 或部署环境变量里补齐后重启服务。 +

+ )} +
+
+
+
+
+

合作方

+

每个合作方绑定一个企业微信外部联系人 ID,用于临期催办推送

+
+
+
+
+ 合作方 + 企业微信名 + 负责人 + 外部联系人 ID + 操作 +
+ {partners.length === 0 && ( +
暂无合作方,先在飞书表格或领取流程中产生
+ )} + {partners.map((partner) => { + const editing = editingId === partner.id; + const busy = partnerWorkingId === partner.id; + return ( +
+ {partner.name} + {partner.wecom_name || "—"} + {partner.owner || "—"} + + {editing ? ( + setDraftId(event.target.value)} + disabled={busy} + /> + ) : ( + + {partner.wecom_external_user_id || "未绑定"} + + )} + + + {editing ? ( + <> + + + + ) : ( + <> + + + + + )} + +
+ ); + })} +
+
+ {pickerPartner && ( +
+
event.stopPropagation()}> +
+
+

选择 {pickerPartner.name} 的企业微信外部联系人

+

从企业微信外部联系人列表中挑选一个,绑定后会自动写入 ID 与企业微信名。

+
+ +
+
+ setPickerSearch(event.target.value)} + disabled={pickerLoading || !!pickerBindingId} + /> + + {pickerFetchedAt > 0 && !pickerLoading && ( + + 缓存于 {new Date(pickerFetchedAt).toLocaleTimeString()}(5 分钟内复用) + + )} +
+ {pickerError && ( +

{pickerError}

+ )} +
+ {pickerLoading && pickerContacts.length === 0 ? ( +
正在拉取外部联系人…
+ ) : pickerContacts.length === 0 ? ( +
+ {pickerError ? "拉取失败,请检查应用配置后重试" : "未拉取到任何外部联系人"} +
+ ) : filteredContacts.length === 0 ? ( +
没有匹配的联系人
+ ) : ( + filteredContacts.map((contact) => ( + + )) + )} +
+
+
+ )} +
+ ); +} + +function buildGroupPushDraft(task: Task, portalUrl: string) { + const portal = portalUrl.trim().replace(/\/$/, ""); + const claimUrl = portal && task.share_token ? `${portal}/?task=${task.share_token}` : ""; + const formatLabel = + task.task_type === "screenshot_collect" + ? "截图回收" + : task.content_format === "video" + ? "视频" + : "图文"; + return [ + `【新任务】${task.name}`, + `品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`, + `平台:${task.platform}|形式:${formatLabel}`, + claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员", + ].join("\n"); +} + +function GroupPushModal({ + task, + portalUrl, + onClose, + setToast, +}: { + task: Task; + portalUrl: string; + onClose: () => void; + setToast: (message: string) => void; +}) { + const [groups, setGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [selected, setSelected] = useState([]); + const [draft, setDraft] = useState(() => buildGroupPushDraft(task, portalUrl)); + const [sending, setSending] = useState(false); + const [result, setResult] = useState<{ groupCount: number; failCount: number; results: WecomGroupPushResult[] } | null>(null); + const [history, setHistory] = useState([]); + + const postAction = useCallback(async (payload: Record, fallback: string) => { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const parsed = await readApiResponse(response, fallback); + if (!response.ok) throw new Error(parsed.error || fallback); + return parsed; + }, []); + + const loadHistory = useCallback(async () => { + try { + const parsed = await postAction<{ pushes: WecomGroupPushRecord[] }>( + { action: "wecom_task_group_pushes", taskId: task.id }, + "读取发送记录失败", + ); + setHistory(parsed.pushes); + } catch { + // 历史加载失败不阻断弹窗 + } + }, [postAction, task.id]); + + const refreshGroups = useCallback(async (mode: "auto" | "sync") => { + if (mode === "auto") { + try { + const cached = await postAction<{ groups: WecomGroupChatRow[] }>( + { action: "wecom_group_chats" }, + "读取客户群列表失败", + ); + if (cached.groups.length > 0) { + setGroups(cached.groups); + return; + } + } catch { + // 缓存读取失败时继续尝试同步 + } + } + setSyncing(true); + try { + const parsed = await postAction<{ groups: WecomGroupChatRow[]; syncedCount: number }>( + { action: "wecom_sync_group_chats" }, + "同步客户群失败", + ); + setGroups(parsed.groups); + setSelected((previous) => previous.filter((id) => parsed.groups.some((group) => group.chat_id === id))); + setToast(`已同步 ${parsed.syncedCount} 个客户群`); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "同步客户群失败"); + } finally { + setSyncing(false); + } + }, [postAction, setToast]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void refreshGroups("auto").finally(() => setGroupsLoading(false)); + void loadHistory(); + }, 0); + return () => window.clearTimeout(timer); + }, [refreshGroups, loadHistory]); + + const toggleGroup = (chatId: string) => { + setSelected((previous) => + previous.includes(chatId) + ? previous.filter((id) => id !== chatId) + : [...previous, chatId], + ); + }; + + const allSelected = groups.length > 0 && selected.length === groups.length; + + const submitPush = async () => { + if (selected.length === 0) { + setToast("请选择要发送的客户群"); + return; + } + setSending(true); + try { + const parsed = await postAction<{ + results: WecomGroupPushResult[]; + groupCount: number; + failCount: number; + }>( + { + action: "wecom_push_task_to_groups", + taskId: task.id, + chatIds: selected, + text: draft, + }, + "创建企业群发失败", + ); + setResult({ results: parsed.results, groupCount: parsed.groupCount, failCount: parsed.failCount }); + setToast("群发任务已创建"); + await loadHistory(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "创建企业群发失败"); + } finally { + setSending(false); + } + }; + + return ( +
+
event.stopPropagation()}> +
+
+

企业微信 · 企业群发

+

发布「{task.name}」到客户群

+
+ +
+
+ + {groupsLoading ? "加载群列表…" : `${groups.length} 个客户群 · 已选 ${selected.length} 个`} + +
+ + +
+
+
+ {groupsLoading ? ( +
正在读取客户群…
+ ) : groups.length === 0 ? ( +
暂无客户群,请先配置企业微信应用并点击「同步群列表」
+ ) : ( + groups.map((group) => ( + + )) + )} +
+