563 lines
17 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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();
|
|
}
|