feat: 复盘报表视图与 CSV 导出(manager 专属)

This commit is contained in:
ABAPPLO
2026-09-01 17:46:11 +08:00
parent 3e4269729c
commit 4c1ff2d5b8
6 changed files with 639 additions and 0 deletions

View File

@@ -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="正在加载">

View File

@@ -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,

View 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 },
);
}
}