([]);
+ 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/);
+});