PMFlow/frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx

563 lines
17 KiB
TypeScript

import type {
DailyBriefingV2Payload,
ExecutiveBriefingHero,
ExecutiveBriefingSection,
} from "@/pages/ai-steward/types";
import { formatTechnicalIdentifierLabel } from "@/lib/display";
import {
getBriefingItemDisplaySource,
getBriefingItemDisplaySubtitle,
getBriefingItemDisplayTitle,
groupBriefingItemsByProject,
} from "./BriefingSectionCard";
export interface PrintableBriefingPayload {
brief_date: string;
mode: "snapshot" | "live";
hero: ExecutiveBriefingHero;
sections: ExecutiveBriefingSection[];
export_title: string;
viewer_scope?: DailyBriefingV2Payload["viewer_scope"];
personal_summary?: DailyBriefingV2Payload["personal_summary"];
personal_workload?: DailyBriefingV2Payload["personal_workload"];
}
function escapeHtml(value: string | number | null | undefined): string {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function toneLabel(tone: string): string {
switch (tone) {
case "critical":
return "紧急";
case "warning":
return "关注";
case "positive":
return "正常";
case "info":
return "提示";
default:
return "状态";
}
}
function modeLabel(mode: PrintableBriefingPayload["mode"]): string {
return mode === "live" ? "实时版" : "晨报快照";
}
function formatTimestamp(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function renderMetrics(metrics: ExecutiveBriefingSection["metrics"]): string {
if (!metrics.length) {
return '<p class="muted">当前没有额外指标。</p>';
}
return `
<div class="metrics">
${metrics
.map(
(metric) => `
<div class="metric-card">
<div class="metric-label">${escapeHtml(metric.label)}</div>
<div class="metric-value">${escapeHtml(metric.value)}</div>
</div>
`
)
.join("")}
</div>
`;
}
function renderTargetLabel(sectionTitle: string, target?: { label?: string | null; type?: string } | null): string {
const label = String(target?.label || "").trim();
if (label) return label;
switch (target?.type) {
case "project_board":
return "进入项目";
case "workload_detail":
return "查看负载";
case "user_profile":
return "查看成员";
case "ai_steward_tab":
return `打开${sectionTitle}`;
default:
return "查看详情";
}
}
function renderItemMeta(item: ExecutiveBriefingSection["items"][number]): Array<{ label: string; value: string }> {
if (Array.isArray(item.meta) && item.meta.length) {
return item.meta;
}
const fallback: Array<{ label: string; value: string }> = [];
if (item.display_source) {
fallback.push({ label: "来源", value: item.display_source });
}
if (item.project_name) {
fallback.push({ label: "项目", value: item.project_name });
}
if (item.owner_name) {
fallback.push({ label: "负责人", value: item.owner_name });
}
if (item.occurred_at) {
fallback.push({ label: "时间", value: item.occurred_at });
}
return fallback;
}
function renderItemDetailLines(item: ExecutiveBriefingSection["items"][number]): string[] {
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
return item.detail_lines;
}
const detail = getBriefingItemDisplaySubtitle(item);
return detail ? [detail] : [];
}
function renderItems(section: ExecutiveBriefingSection): string {
const groups = groupBriefingItemsByProject(section.items);
if (!section.items.length) {
return '<div class="empty-card">当前区块暂无需要补充到报告的异常或跟进项。</div>';
}
return `
<div class="project-group-list">
${groups
.map(
(group) => `
<section class="project-group">
<div class="project-head">
<div>
<div class="project-title">${escapeHtml(group.title)}</div>
<div class="project-kind">${escapeHtml(group.categoryLabel)}</div>
</div>
<div class="project-count">${escapeHtml(group.items.length)} 条</div>
</div>
<div class="item-list">
${group.items
.map((item) => {
const meta = renderItemMeta(item);
const detailLines = renderItemDetailLines(item);
return `
<div class="item-card">
<div class="item-head">
<div>
<span class="item-title">${escapeHtml(getBriefingItemDisplayTitle(item))}</span>
<span class="item-kind">${escapeHtml(getBriefingItemDisplaySource(item))}</span>
</div>
<span class="tone tone-${escapeHtml(item.tone)}">${toneLabel(item.tone)}</span>
</div>
<div class="item-detail">${escapeHtml(getBriefingItemDisplaySubtitle(item))}</div>
<div class="meta-grid">
${meta
.map(
(meta) => `
<div class="meta-chip">
<div class="meta-label">${escapeHtml(meta.label)}</div>
<div class="meta-value">${escapeHtml(meta.value)}</div>
</div>
`
)
.join("")}
</div>
${item.technical_identifier ? `<div class="action-tag">${escapeHtml(formatTechnicalIdentifierLabel(item.technical_identifier))}</div>` : ""}
<div class="detail-lines">
${detailLines
.map((line) => `<div class="detail-line">${escapeHtml(line)}</div>`)
.join("")}
</div>
<div class="action-tag">处理动作:${escapeHtml(renderTargetLabel(section.title, item.target))}</div>
</div>
`
})
.join("")}
</div>
</section>
`
)
.join("")}
</div>
`;
}
export function buildBriefingPrintHtml(payload: PrintableBriefingPayload, viewerName?: string): string {
const hero = payload.hero;
const summaryBlock = payload.personal_summary
? `
<section class="summary-banner">
<h2>今日结论</h2>
<p>${escapeHtml(payload.personal_summary.headline)}</p>
</section>
`
: "";
const workloadBlock = payload.personal_workload
? `
<section class="workload-card">
<div class="workload-head">个人负载</div>
<div class="workload-name">${escapeHtml(payload.personal_workload.user_name)}</div>
<div class="workload-ratio">${escapeHtml(payload.personal_workload.workload_ratio)}%</div>
<div class="workload-detail">
已分配 ${escapeHtml(payload.personal_workload.assigned_hours)} 小时 / 周上限
${escapeHtml(payload.personal_workload.max_weekly_hours)} 小时
</div>
</section>
`
: "";
const hasWorkloadBlock = Boolean(payload.personal_workload);
return `
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(payload.export_title)}</title>
<style>
:root {
color-scheme: light;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f7f4ee;
color: #1f2937;
}
.page {
width: 960px;
margin: 0 auto;
padding: 40px 32px 56px;
}
.hero {
padding: 28px;
border-radius: 24px;
background: linear-gradient(135deg, #fff8ef 0%, #ffe6bf 100%);
border: 1px solid #f3d39b;
}
.eyebrow {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
background: #111827;
color: #fff;
font-size: 12px;
}
h1 {
margin: 16px 0 8px;
font-size: 30px;
line-height: 1.25;
}
.hero-subtitle {
margin: 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.hero-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.hero-card,
.metric-card,
.workload-card {
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid #eadac0;
padding: 18px;
}
.hero-label,
.metric-label,
.workload-head {
font-size: 12px;
color: #64748b;
}
.hero-value,
.metric-value,
.workload-ratio {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #0f172a;
}
.meta {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-top: 18px;
font-size: 13px;
color: #475569;
}
.summary-banner {
margin-top: 24px;
padding: 22px 24px;
border-radius: 20px;
background: #ffffff;
border: 1px solid #e2e8f0;
}
.summary-banner h2,
.section-card h3 {
margin: 0 0 10px;
font-size: 20px;
}
.summary-banner p,
.section-summary,
.item-detail,
.muted,
.workload-detail {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
gap: 18px;
margin-top: 18px;
}
.section-list {
display: grid;
gap: 16px;
}
.section-card {
padding: 22px 24px;
border-radius: 20px;
background: #fff;
border: 1px solid #e2e8f0;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.item-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.project-group-list {
display: grid;
gap: 14px;
margin-top: 16px;
}
.project-group {
border-radius: 18px;
background: #fff8ef;
border: 1px solid #f0dfc1;
padding: 14px;
}
.project-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.project-title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.project-kind {
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.project-count {
font-size: 12px;
color: #64748b;
}
.item-card,
.empty-card {
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 14px 16px;
}
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.item-title,
.workload-name {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.item-kind {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 500;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.meta-chip {
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
padding: 10px 12px;
}
.meta-label {
font-size: 11px;
color: #64748b;
}
.meta-value {
margin-top: 4px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.detail-lines {
display: grid;
gap: 8px;
margin-top: 12px;
}
.detail-line {
border-radius: 10px;
border: 1px dashed #d8e0ea;
background: #ffffff;
padding: 10px 12px;
font-size: 13px;
line-height: 1.6;
color: #475569;
}
.action-tag {
margin-top: 12px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.tone {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.tone-critical {
background: #ffe4e6;
color: #be123c;
}
.tone-warning {
background: #fef3c7;
color: #b45309;
}
.tone-positive {
background: #dcfce7;
color: #15803d;
}
.tone-info {
background: #e0f2fe;
color: #0369a1;
}
@media print {
body {
background: #fff;
}
.page {
width: auto;
padding: 0;
}
.layout {
display: block;
}
.section-list + .section-list {
margin-top: 18px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<span class="eyebrow">${escapeHtml(modeLabel(payload.mode))}</span>
<h1>${escapeHtml(payload.export_title)}</h1>
<p class="hero-subtitle">
${escapeHtml(hero.title)}${viewerName ? ` · 查看人:${escapeHtml(viewerName)}` : ""}
</p>
<div class="meta">
<span>查看日期:${escapeHtml(payload.brief_date)}</span>
<span>晨报快照:${escapeHtml(formatTimestamp(hero.baseline_generated_at))}</span>
<span>实时刷新:${escapeHtml(formatTimestamp(hero.live_generated_at))}</span>
<span>异常总数:${escapeHtml(hero.anomaly_count)}</span>
</div>
<div class="hero-grid">
<div class="hero-card">
<div class="hero-label">需关注事项</div>
<div class="hero-value">${escapeHtml(hero.decision_count)}</div>
</div>
<div class="hero-card">
<div class="hero-label">异常总数</div>
<div class="hero-value">${escapeHtml(hero.anomaly_count)}</div>
</div>
<div class="hero-card">
<div class="hero-label">查看范围</div>
<div class="hero-value">${escapeHtml(payload.viewer_scope === "personal" ? "个人" : "全租户")}</div>
</div>
</div>
</section>
${summaryBlock}
<section class="layout">
<div class="section-list">
${payload.sections
.map(
(section) => `
<article class="section-card">
<h3>${escapeHtml(section.title)}</h3>
<p class="section-summary">${escapeHtml(section.summary)}</p>
${renderMetrics(section.metrics)}
${renderItems(section)}
</article>
`
)
.join("")}
</div>
${hasWorkloadBlock ? `<aside class="section-list">${workloadBlock}</aside>` : ""}
</section>
</main>
</body>
</html>
`;
}
export function printBriefingReport(payload: PrintableBriefingPayload, viewerName?: string): void {
const win = window.open("", "_blank");
if (!win) {
return;
}
win.document.write(buildBriefingPrintHtml(payload, viewerName));
win.document.close();
win.focus();
win.print();
}