feat: 复盘报表视图与 CSV 导出(manager 专属)
This commit is contained in:
@@ -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 && (
|
||||
<UsersPage currentUser={currentUser} />
|
||||
)}
|
||||
{activeNav === "reports" && isManager && (
|
||||
<ReportsPage setToast={setToast} />
|
||||
)}
|
||||
{activeNav === "recovery" && (
|
||||
<RecoveryPage
|
||||
tasks={data.tasks}
|
||||
@@ -3307,6 +3318,43 @@ const PUSH_KIND_LABELS: Record<string, string> = {
|
||||
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<TaskReportRow[]>([]);
|
||||
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 (
|
||||
<section className="panel table-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>任务复盘</h2>
|
||||
<p>
|
||||
共 {filtered.length} 个任务 · 领取 {totals.claimed} · 发布 {totals.published} ·
|
||||
完成 {totals.completed} · 曝光 {formatNumber(totals.exposure || null)} · 阅读{" "}
|
||||
{formatNumber(totals.views || null)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="resource-heading-actions">
|
||||
<div className="filter-chips">
|
||||
{REPORT_STATUS_FILTERS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={statusFilter === item.key ? "active" : ""}
|
||||
onClick={() => setStatusFilter(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="filter-chips">
|
||||
{[
|
||||
{ key: "all", label: "全部类型" },
|
||||
{ key: "content_publish", label: "内容发布" },
|
||||
{ key: "screenshot_collect", label: "截图采集" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={typeFilter === item.key ? "active" : ""}
|
||||
onClick={() => setTypeFilter(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => void loadReports()} disabled={loading}>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
className="primary-button soft"
|
||||
onClick={() => void exportCsv()}
|
||||
disabled={exporting || loading}
|
||||
>
|
||||
{exporting ? "导出中…" : "导出 CSV"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-toolbar">
|
||||
<div className="resource-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
aria-label="搜索任务名称、品牌或项目"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索任务名称 / 品牌 / 项目 / 平台"
|
||||
type="search"
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>项目</th>
|
||||
<th>截止日</th>
|
||||
<th>状态</th>
|
||||
<th>计划</th>
|
||||
<th>领取</th>
|
||||
<th>发布</th>
|
||||
<th>完成</th>
|
||||
<th>完成率</th>
|
||||
<th>曝光</th>
|
||||
<th>阅读</th>
|
||||
<th>最新点赞</th>
|
||||
<th>最新评论</th>
|
||||
<th>最新收藏</th>
|
||||
<th>最新转发</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="content-cell">
|
||||
<span className="content-id">
|
||||
{row.task_type === "screenshot_collect" ? "搜" : "文"}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{row.name}</strong>
|
||||
<span>
|
||||
{row.brand || "未填品牌"} · {row.platform}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{row.project_name || <span className="muted">—</span>}</td>
|
||||
<td><span className="date-value">{formatDate(row.due_at)}</span></td>
|
||||
<td>
|
||||
<span className={`status-pill ${row.status === "active" ? "claimed" : "complete"}`}>
|
||||
{row.status === "active" ? "进行中" : "已完结"}
|
||||
</span>
|
||||
</td>
|
||||
<td><strong className="plain-strong">{row.quantity}</strong></td>
|
||||
<td><strong className="plain-strong">{row.claimed_count}</strong></td>
|
||||
<td><strong className="plain-strong">{row.published_count}</strong></td>
|
||||
<td><strong className="plain-strong">{row.completed_count}</strong></td>
|
||||
<td>
|
||||
{row.claimed_count
|
||||
? `${Math.round((row.completed_count / row.claimed_count) * 100)}%`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{formatNumber(row.exposure_total)}</td>
|
||||
<td>{formatNumber(row.views_total)}</td>
|
||||
<td>{formatNumber(row.latest_likes)}</td>
|
||||
<td>{formatNumber(row.latest_comments)}</td>
|
||||
<td>{formatNumber(row.latest_collects)}</td>
|
||||
<td>{formatNumber(row.latest_shares)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="table-empty">暂无符合条件的任务</div>
|
||||
)}
|
||||
{loading && <div className="table-empty">正在读取复盘数据…</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="loading-layout" aria-label="正在加载">
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
app/api/task-report-export/route.ts
Normal file
29
app/api/task-report-export/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TaskReportRow>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
export function uid(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
94
lib/task-report-csv.ts
Normal file
94
lib/task-report-csv.ts
Normal file
@@ -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")}`;
|
||||
}
|
||||
164
tests/task-report.test.mjs
Normal file
164
tests/task-report.test.mjs
Normal file
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user