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="正在加载">
|
||||
|
||||
Reference in New Issue
Block a user