From 4c1ff2d5b8ec5ec7b4a2d393589ac3243da3506c Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Tue, 1 Sep 2026 17:46:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=8D=E7=9B=98=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E4=B8=8E=20CSV=20=E5=AF=BC=E5=87=BA=EF=BC=88?= =?UTF-8?q?manager=20=E4=B8=93=E5=B1=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin-app.tsx | 275 ++++++++++++++++++++++++++++ app/api/action/route.ts | 4 + app/api/task-report-export/route.ts | 29 +++ lib/mvp-db.ts | 73 ++++++++ lib/task-report-csv.ts | 94 ++++++++++ tests/task-report.test.mjs | 164 +++++++++++++++++ 6 files changed, 639 insertions(+) create mode 100644 app/api/task-report-export/route.ts create mode 100644 lib/task-report-csv.ts create mode 100644 tests/task-report.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 8e1ec6a..62f5164 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -208,6 +208,7 @@ type NavKey = | "resources" | "partners" | "recovery" + | "reports" | "users"; const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ @@ -218,6 +219,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "resources", label: "KOC资源", mark: "◎" }, { key: "partners", label: "合作方", mark: "Ⓒ" }, { key: "recovery", label: "数据回收", mark: "◫" }, + { key: "reports", label: "复盘报表", mark: "▣" }, { key: "users", label: "用户管理", mark: "♙" }, ]; @@ -560,6 +562,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { if (item.key === "resources" && currentUser.role === "user") return false; if (item.key === "users" && !isManager) return false; if (item.key === "projects" && !isManager) return false; + if (item.key === "reports" && !isManager) return false; return true; }); @@ -668,6 +671,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { if (key === "resources" && currentUser.role === "user") return; if (key === "users" && !isManager) return; if (key === "projects" && !isManager) return; + if (key === "reports" && !isManager) return; setActiveNav(key); setMenuOpen(false); }; @@ -1051,6 +1055,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { title: "数据回收", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", }, + reports: { + title: "复盘报表", + subtitle: "按任务汇总领取、发布与互动数据,支持一键导出 CSV。", + }, users: { title: "用户管理", subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。", @@ -1222,6 +1230,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { {activeNav === "users" && isManager && ( )} + {activeNav === "reports" && isManager && ( + + )} {activeNav === "recovery" && ( = { card_ack: "卡片确认", }; +type TaskReportRow = { + id: string; + name: string; + task_type: string; + brand: string; + platform: string; + status: string; + due_at: string; + created_at: string; + quantity: number; + project_name: string | null; + claimed_count: number; + published_count: number; + completed_count: number; + exposure_total: number | null; + views_total: number | null; + d2_likes: number | null; + d2_comments: number | null; + d2_collects: number | null; + d5_likes: number | null; + d5_comments: number | null; + d5_collects: number | null; + d7_likes: number | null; + d7_comments: number | null; + d7_collects: number | null; + latest_likes: number | null; + latest_comments: number | null; + latest_collects: number | null; + latest_shares: number | null; +}; + +const REPORT_STATUS_FILTERS = [ + { key: "all", label: "全部状态" }, + { key: "active", label: "进行中" }, + { key: "completed", label: "已完结" }, +]; + function formatPushLogTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; @@ -3591,6 +3639,233 @@ function OneClickPushModal({ ); } +function ReportsPage({ setToast }: { setToast: (message: string) => void }) { + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [keyword, setKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [exporting, setExporting] = useState(false); + + const loadReports = useCallback(async () => { + try { + setLoading(true); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "task_report_list" }), + }); + const result = await readApiResponse< + { reports?: TaskReportRow[] } & { error?: string } + >(response, "读取复盘报表失败"); + if (!response.ok) throw new Error(result.error || "读取复盘报表失败"); + setReports(result.reports ?? []); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "读取复盘报表失败"); + } finally { + setLoading(false); + } + }, [setToast]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadReports(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadReports]); + + const filtered = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return reports.filter((row) => { + if (statusFilter !== "all" && row.status !== statusFilter) return false; + if (typeFilter !== "all" && row.task_type !== typeFilter) return false; + if (!query) return true; + return [row.name, row.brand, row.project_name ?? "", row.platform] + .join(" ") + .toLowerCase() + .includes(query); + }); + }, [reports, keyword, statusFilter, typeFilter]); + + const exportCsv = async () => { + try { + setExporting(true); + const response = await fetch("/api/task-report-export", { + cache: "no-store", + }); + if (!response.ok) { + const result = await readApiResponse<{ error?: string }>( + response, + "导出失败", + ); + throw new Error(result.error || "导出失败"); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = `任务复盘报表-${todayInputValue().replaceAll("-", "")}.csv`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + setToast("CSV 已导出,可直接用 Excel 打开"); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "导出失败"); + } finally { + setExporting(false); + } + }; + + const totals = useMemo( + () => + filtered.reduce( + (acc, row) => ({ + claimed: acc.claimed + row.claimed_count, + published: acc.published + row.published_count, + completed: acc.completed + row.completed_count, + exposure: acc.exposure + (row.exposure_total ?? 0), + views: acc.views + (row.views_total ?? 0), + }), + { claimed: 0, published: 0, completed: 0, exposure: 0, views: 0 }, + ), + [filtered], + ); + + return ( +
+
+
+

任务复盘

+

+ 共 {filtered.length} 个任务 · 领取 {totals.claimed} · 发布 {totals.published} · + 完成 {totals.completed} · 曝光 {formatNumber(totals.exposure || null)} · 阅读{" "} + {formatNumber(totals.views || null)} +

+
+
+
+ {REPORT_STATUS_FILTERS.map((item) => ( + + ))} +
+
+ {[ + { key: "all", label: "全部类型" }, + { key: "content_publish", label: "内容发布" }, + { key: "screenshot_collect", label: "截图采集" }, + ].map((item) => ( + + ))} +
+ + +
+
+
+
+ + setKeyword(event.target.value)} + placeholder="搜索任务名称 / 品牌 / 项目 / 平台" + type="search" + value={keyword} + /> +
+
+
+ + + + + + + + + + + + + + + + + + + + + + {filtered.map((row) => ( + + + + + + + + + + + + + + + + + + ))} + +
任务项目截止日状态计划领取发布完成完成率曝光阅读最新点赞最新评论最新收藏最新转发
+
+ + {row.task_type === "screenshot_collect" ? "搜" : "文"} + +
+ {row.name} + + {row.brand || "未填品牌"} · {row.platform} + +
+
+
{row.project_name || —}{formatDate(row.due_at)} + + {row.status === "active" ? "进行中" : "已完结"} + + {row.quantity}{row.claimed_count}{row.published_count}{row.completed_count} + {row.claimed_count + ? `${Math.round((row.completed_count / row.claimed_count) * 100)}%` + : "—"} + {formatNumber(row.exposure_total)}{formatNumber(row.views_total)}{formatNumber(row.latest_likes)}{formatNumber(row.latest_comments)}{formatNumber(row.latest_collects)}{formatNumber(row.latest_shares)}
+ {!loading && filtered.length === 0 && ( +
暂无符合条件的任务
+ )} + {loading &&
正在读取复盘数据…
} +
+
+ ); +} + function LoadingState() { return (
diff --git a/app/api/action/route.ts b/app/api/action/route.ts index ee893fb..7962be1 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -10,6 +10,7 @@ import { ensureSchema, getDashboardData, getRawDb, + getTaskReportData, uid, } from "../../../lib/mvp-db"; import { @@ -801,6 +802,9 @@ export async function POST(request: Request) { } else if (body.action === "task_push_logs") { const logs = await listTaskPushLogs(db, String(body.taskId ?? "").trim()); return Response.json({ logs }); + } else if (body.action === "task_report_list") { + if (!isManagerRequest(request)) return adminForbidden(); + return Response.json({ reports: await getTaskReportData(db) }); } else if (body.action === "refresh_account_fields") { const status = startAccountFieldRefresh( db, diff --git a/app/api/task-report-export/route.ts b/app/api/task-report-export/route.ts new file mode 100644 index 0000000..5279efb --- /dev/null +++ b/app/api/task-report-export/route.ts @@ -0,0 +1,29 @@ +import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; +import { buildTaskReportCsv } from "../../../lib/task-report-csv"; +import { + ensureSchema, + getRawDb, + getTaskReportData, +} from "../../../lib/mvp-db"; + +export async function GET(request: Request) { + try { + if (!(await isAdminRequest(request))) return adminForbidden(); + await ensureSchema(); + const rows = await getTaskReportData(getRawDb()); + const fileName = `任务复盘报表.csv`; + return new Response(buildTaskReportCsv(rows), { + headers: { + "Cache-Control": "private, no-store", + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="koc-task-report.csv"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "导出失败" }, + { status: 500 }, + ); + } +} diff --git a/lib/mvp-db.ts b/lib/mvp-db.ts index 01e2692..bc5d98e 100644 --- a/lib/mvp-db.ts +++ b/lib/mvp-db.ts @@ -872,6 +872,79 @@ export async function getDashboardData() { }; } +export type TaskReportRow = { + id: string; + name: string; + task_type: string; + brand: string; + platform: string; + status: string; + due_at: string; + created_at: string; + quantity: number; + project_name: string | null; + claimed_count: number; + published_count: number; + completed_count: number; + exposure_total: number | null; + views_total: number | null; + d2_likes: number | null; + d2_comments: number | null; + d2_collects: number | null; + d5_likes: number | null; + d5_comments: number | null; + d5_collects: number | null; + d7_likes: number | null; + d7_comments: number | null; + d7_collects: number | null; + latest_likes: number | null; + latest_comments: number | null; + latest_collects: number | null; + latest_shares: number | null; +}; + +export async function getTaskReportData(db: DatabaseClient) { + const result = await db + .prepare( + `SELECT + t.id, + t.name, + t.task_type, + t.brand, + t.platform, + t.status, + t.due_at, + t.created_at, + t.quantity, + p.name AS project_name, + COUNT(d.id) AS claimed_count, + SUM(CASE WHEN TRIM(COALESCE(d.publish_url, '')) != '' THEN 1 ELSE 0 END) AS published_count, + SUM(CASE WHEN d.status = 'complete' THEN 1 ELSE 0 END) AS completed_count, + SUM(d.exposure) AS exposure_total, + SUM(d.views) AS views_total, + SUM(d.d2_likes) AS d2_likes, + SUM(d.d2_comments) AS d2_comments, + SUM(d.d2_collects) AS d2_collects, + SUM(d.d5_likes) AS d5_likes, + SUM(d.d5_comments) AS d5_comments, + SUM(d.d5_collects) AS d5_collects, + SUM(d.d7_likes) AS d7_likes, + SUM(d.d7_comments) AS d7_comments, + SUM(d.d7_collects) AS d7_collects, + SUM(d.latest_likes) AS latest_likes, + SUM(d.latest_comments) AS latest_comments, + SUM(d.latest_collects) AS latest_collects, + SUM(d.latest_shares) AS latest_shares + FROM tasks t + LEFT JOIN distributions d ON d.task_id = t.id + LEFT JOIN projects p ON p.id = t.project_id + GROUP BY t.id + ORDER BY t.created_at DESC`, + ) + .all(); + return result.results; +} + export function uid(prefix: string) { return `${prefix}-${crypto.randomUUID().slice(0, 8)}`; } diff --git a/lib/task-report-csv.ts b/lib/task-report-csv.ts new file mode 100644 index 0000000..159ea05 --- /dev/null +++ b/lib/task-report-csv.ts @@ -0,0 +1,94 @@ +import type { TaskReportRow } from "./mvp-db"; + +const CSV_HEADERS = [ + "任务ID", + "任务名称", + "任务类型", + "品牌", + "平台", + "所属项目", + "任务状态", + "计划数量", + "领取数", + "发布数", + "完成数", + "完成率", + "截止日期", + "创建时间", + "曝光量合计", + "阅读量合计", + "D2点赞", + "D2评论", + "D2收藏", + "D5点赞", + "D5评论", + "D5收藏", + "D7点赞", + "D7评论", + "D7收藏", + "最新点赞", + "最新评论", + "最新收藏", + "最新转发", +]; + +function taskTypeLabel(value: string) { + return value === "screenshot_collect" ? "截图采集" : "内容发布"; +} + +function taskStatusLabel(value: string) { + return value === "active" ? "进行中" : value === "completed" ? "已完结" : value; +} + +function completionRate(row: TaskReportRow) { + if (!row.claimed_count) return ""; + return `${Math.round((row.completed_count / row.claimed_count) * 100)}%`; +} + +function csvCell(value: string | number | null | undefined) { + const text = value === null || value === undefined ? "" : String(value); + if (/[",\r\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`; + return text; +} + +export function buildTaskReportCsv(rows: TaskReportRow[]) { + const lines = [CSV_HEADERS.join(",")]; + for (const row of rows) { + lines.push( + [ + row.id, + row.name, + taskTypeLabel(row.task_type), + row.brand, + row.platform, + row.project_name ?? "", + taskStatusLabel(row.status), + row.quantity, + row.claimed_count, + row.published_count, + row.completed_count, + completionRate(row), + row.due_at, + row.created_at, + row.exposure_total, + row.views_total, + row.d2_likes, + row.d2_comments, + row.d2_collects, + row.d5_likes, + row.d5_comments, + row.d5_collects, + row.d7_likes, + row.d7_comments, + row.d7_collects, + row.latest_likes, + row.latest_comments, + row.latest_collects, + row.latest_shares, + ] + .map(csvCell) + .join(","), + ); + } + return `\uFEFF${lines.join("\r\n")}`; +} diff --git a/tests/task-report.test.mjs b/tests/task-report.test.mjs new file mode 100644 index 0000000..bfa536f --- /dev/null +++ b/tests/task-report.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildTaskReportCsv } from "../lib/task-report-csv.ts"; +import { getTaskReportData } from "../lib/mvp-db.ts"; + +const reportRow = { + id: "task-A", + name: "夏日种草计划", + task_type: "content_publish", + brand: "轻食品牌", + platform: "小红书", + status: "active", + due_at: "2026-08-20", + created_at: "2026-08-01 10:00:00", + quantity: 20, + project_name: "八月投放", + claimed_count: 10, + published_count: 8, + completed_count: 5, + exposure_total: 12000, + views_total: 9000, + d2_likes: 100, + d2_comments: 20, + d2_collects: 30, + d5_likes: 150, + d5_comments: 25, + d5_collects: 40, + d7_likes: 180, + d7_comments: 30, + d7_collects: 50, + latest_likes: 200, + latest_comments: 40, + latest_collects: 60, + latest_shares: 10, +}; + +test("buildTaskReportCsv writes BOM, headers and mapped rows", () => { + const csv = buildTaskReportCsv([reportRow]); + assert.ok(csv.startsWith(""), "starts with UTF-8 BOM"); + const lines = csv.slice(1).split("\r\n"); + assert.equal(lines.length, 2); + assert.equal( + lines[0], + [ + "任务ID", + "任务名称", + "任务类型", + "品牌", + "平台", + "所属项目", + "任务状态", + "计划数量", + "领取数", + "发布数", + "完成数", + "完成率", + "截止日期", + "创建时间", + "曝光量合计", + "阅读量合计", + "D2点赞", + "D2评论", + "D2收藏", + "D5点赞", + "D5评论", + "D5收藏", + "D7点赞", + "D7评论", + "D7收藏", + "最新点赞", + "最新评论", + "最新收藏", + "最新转发", + ].join(","), + ); + assert.equal( + lines[1], + [ + "task-A", + "夏日种草计划", + "内容发布", + "轻食品牌", + "小红书", + "八月投放", + "进行中", + 20, + 10, + 8, + 5, + "50%", + "2026-08-20", + "2026-08-01 10:00:00", + 12000, + 9000, + 100, + 20, + 30, + 150, + 25, + 40, + 180, + 30, + 50, + 200, + 40, + 60, + 10, + ].join(","), + ); +}); + +test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () => { + const csv = buildTaskReportCsv([ + { + ...reportRow, + name: '含,逗号"引号"任务', + project_name: null, + claimed_count: 0, + exposure_total: null, + views_total: null, + d2_likes: null, + d2_comments: null, + d2_collects: null, + d5_likes: null, + d5_comments: null, + d5_collects: null, + d7_likes: null, + d7_comments: null, + d7_collects: null, + latest_likes: null, + latest_comments: null, + latest_collects: null, + latest_shares: null, + }, + ]); + const rowLine = csv.slice(1).split("\r\n")[1]; + assert.ok(rowLine.includes('"含,逗号""引号""任务"')); + const cells = rowLine.split(","); + assert.equal(cells.at(-1), "", "null latest_shares becomes empty"); + assert.equal(cells.at(-18), "", "no claims means empty completion rate"); + assert.equal(cells.at(-16), "2026-08-01 10:00:00"); +}); + +test("getTaskReportData aggregates per task via distributions join", async () => { + let capturedSql = ""; + const db = { + prepare(sql) { + capturedSql = sql; + return { + async all() { + return { results: [reportRow] }; + }, + }; + }, + }; + const rows = await getTaskReportData(db); + assert.deepEqual(rows, [reportRow]); + assert.match(capturedSql, /FROM tasks t/); + assert.match(capturedSql, /LEFT JOIN distributions d ON d\.task_id = t\.id/); + assert.match(capturedSql, /LEFT JOIN projects p ON p\.id = t\.project_id/); + assert.match(capturedSql, /GROUP BY t\.id/); + assert.match(capturedSql, /publish_url/); + assert.match(capturedSql, /latest_shares/); +});