马一丁

Add rendering support for PEST blocks

@@ -49,6 +49,7 @@ class HTMLRenderer: @@ -49,6 +49,7 @@ class HTMLRenderer:
49 "figure", 49 "figure",
50 "kpiGrid", 50 "kpiGrid",
51 "swotTable", 51 "swotTable",
  52 + "pestTable",
52 "engineQuote", 53 "engineQuote",
53 } 54 }
54 INLINE_ARTIFACT_KEYS = { 55 INLINE_ARTIFACT_KEYS = {
@@ -1023,6 +1024,7 @@ class HTMLRenderer: @@ -1023,6 +1024,7 @@ class HTMLRenderer:
1023 "list": self._render_list, 1024 "list": self._render_list,
1024 "table": self._render_table, 1025 "table": self._render_table,
1025 "swotTable": self._render_swot_table, 1026 "swotTable": self._render_swot_table,
  1027 + "pestTable": self._render_pest_table,
1026 "blockquote": self._render_blockquote, 1028 "blockquote": self._render_blockquote,
1027 "engineQuote": self._render_engine_quote, 1029 "engineQuote": self._render_engine_quote,
1028 "hr": lambda b: "<hr />", 1030 "hr": lambda b: "<hr />",
@@ -1426,6 +1428,254 @@ class HTMLRenderer: @@ -1426,6 +1428,254 @@ class HTMLRenderer:
1426 </li> 1428 </li>
1427 """ 1429 """
1428 1430
  1431 + # ==================== PEST 分析块 ====================
  1432 +
  1433 + def _render_pest_table(self, block: Dict[str, Any]) -> str:
  1434 + """
  1435 + 渲染四维度的PEST分析,同时生成两种布局:
  1436 + 1. 卡片布局(用于HTML网页显示)- 横向条状堆叠
  1437 + 2. 表格布局(用于PDF导出)- 结构化表格,支持分页
  1438 +
  1439 + PEST分析维度:
  1440 + - P: Political(政治因素)
  1441 + - E: Economic(经济因素)
  1442 + - S: Social(社会因素)
  1443 + - T: Technological(技术因素)
  1444 + """
  1445 + title = block.get("title") or "PEST 分析"
  1446 + summary = block.get("summary")
  1447 +
  1448 + # ========== 卡片布局(HTML用)==========
  1449 + card_html = self._render_pest_card_layout(block, title, summary)
  1450 +
  1451 + # ========== 表格布局(PDF用)==========
  1452 + table_html = self._render_pest_pdf_table_layout(block, title, summary)
  1453 +
  1454 + # 返回包含两种布局的容器
  1455 + return f"""
  1456 + <div class="pest-container">
  1457 + {card_html}
  1458 + {table_html}
  1459 + </div>
  1460 + """
  1461 +
  1462 + def _render_pest_card_layout(self, block: Dict[str, Any], title: str, summary: str | None) -> str:
  1463 + """渲染PEST卡片布局(用于HTML网页显示)- 横向条状堆叠设计"""
  1464 + dimensions = [
  1465 + ("political", "政治因素 Political", "P", "political"),
  1466 + ("economic", "经济因素 Economic", "E", "economic"),
  1467 + ("social", "社会因素 Social", "S", "social"),
  1468 + ("technological", "技术因素 Technological", "T", "technological"),
  1469 + ]
  1470 + strips_html = ""
  1471 + for idx, (key, label, code, css) in enumerate(dimensions):
  1472 + items = self._normalize_pest_items(block.get(key))
  1473 + caption_text = f"{len(items)} 条要点" if items else "待补充"
  1474 + list_html = "".join(self._render_pest_item(item) for item in items) if items else '<li class="pest-empty">尚未填入要点</li>'
  1475 + first_strip_class = " pest-strip--first" if idx == 0 else ""
  1476 + strips_html += f"""
  1477 + <div class="pest-strip pest-strip--pageable {css}{first_strip_class}" data-pest-key="{key}">
  1478 + <div class="pest-strip__indicator {css}">
  1479 + <span class="pest-code">{self._escape_html(code)}</span>
  1480 + </div>
  1481 + <div class="pest-strip__content">
  1482 + <div class="pest-strip__header">
  1483 + <div class="pest-strip__title">{self._escape_html(label)}</div>
  1484 + <div class="pest-strip__caption">{self._escape_html(caption_text)}</div>
  1485 + </div>
  1486 + <ul class="pest-list">{list_html}</ul>
  1487 + </div>
  1488 + </div>"""
  1489 + summary_html = f'<p class="pest-card__summary">{self._escape_html(summary)}</p>' if summary else ""
  1490 + title_html = f'<div class="pest-card__title">{self._escape_html(title)}</div>' if title else ""
  1491 + legend = """
  1492 + <div class="pest-legend">
  1493 + <span class="pest-legend__item political">P 政治</span>
  1494 + <span class="pest-legend__item economic">E 经济</span>
  1495 + <span class="pest-legend__item social">S 社会</span>
  1496 + <span class="pest-legend__item technological">T 技术</span>
  1497 + </div>
  1498 + """
  1499 + return f"""
  1500 + <div class="pest-card pest-card--html">
  1501 + <div class="pest-card__head">
  1502 + <div>{title_html}{summary_html}</div>
  1503 + {legend}
  1504 + </div>
  1505 + <div class="pest-strips">{strips_html}</div>
  1506 + </div>
  1507 + """
  1508 +
  1509 + def _render_pest_pdf_table_layout(self, block: Dict[str, Any], title: str, summary: str | None) -> str:
  1510 + """
  1511 + 渲染PEST表格布局(用于PDF导出)
  1512 +
  1513 + 设计说明:
  1514 + - 整体为一个大表格,包含标题行和4个维度区域
  1515 + - 每个维度有自己的子标题行和内容行
  1516 + - 使用合并单元格来显示维度标题
  1517 + - 通过CSS控制分页行为
  1518 + """
  1519 + dimensions = [
  1520 + ("political", "P", "政治因素 Political", "pest-pdf-political", "#8e44ad"),
  1521 + ("economic", "E", "经济因素 Economic", "pest-pdf-economic", "#16a085"),
  1522 + ("social", "S", "社会因素 Social", "pest-pdf-social", "#e84393"),
  1523 + ("technological", "T", "技术因素 Technological", "pest-pdf-technological", "#2980b9"),
  1524 + ]
  1525 +
  1526 + # 标题和摘要
  1527 + summary_row = ""
  1528 + if summary:
  1529 + summary_row = f"""
  1530 + <tr class="pest-pdf-summary-row">
  1531 + <td colspan="4" class="pest-pdf-summary">{self._escape_html(summary)}</td>
  1532 + </tr>"""
  1533 +
  1534 + # 生成四个维度的表格内容
  1535 + dimension_tables = ""
  1536 + for idx, (key, code, label, css_class, color) in enumerate(dimensions):
  1537 + items = self._normalize_pest_items(block.get(key))
  1538 +
  1539 + # 生成每个维度的内容行
  1540 + items_rows = ""
  1541 + if items:
  1542 + for item_idx, item in enumerate(items):
  1543 + item_title = item.get("title") or item.get("label") or item.get("text") or "未命名要点"
  1544 + item_detail = item.get("detail") or item.get("description") or ""
  1545 + item_source = item.get("source") or item.get("evidence") or ""
  1546 + item_trend = item.get("trend") or item.get("impact") or ""
  1547 +
  1548 + # 构建详情内容
  1549 + detail_parts = []
  1550 + if item_detail:
  1551 + detail_parts.append(item_detail)
  1552 + if item_source:
  1553 + detail_parts.append(f"来源:{item_source}")
  1554 + detail_text = "<br/>".join(detail_parts) if detail_parts else "-"
  1555 +
  1556 + # 构建标签
  1557 + tags = []
  1558 + if item_trend:
  1559 + tags.append(f'<span class="pest-pdf-tag">{self._escape_html(item_trend)}</span>')
  1560 + tags_html = " ".join(tags)
  1561 +
  1562 + # 第一行需要合并维度标题单元格
  1563 + if item_idx == 0:
  1564 + rowspan = len(items)
  1565 + items_rows += f"""
  1566 + <tr class="pest-pdf-item-row {css_class}">
  1567 + <td rowspan="{rowspan}" class="pest-pdf-dimension-label {css_class}">
  1568 + <span class="pest-pdf-code">{code}</span>
  1569 + <span class="pest-pdf-label-text">{self._escape_html(label.split()[0])}</span>
  1570 + </td>
  1571 + <td class="pest-pdf-item-num">{item_idx + 1}</td>
  1572 + <td class="pest-pdf-item-title">{self._escape_html(item_title)}</td>
  1573 + <td class="pest-pdf-item-detail">{detail_text}</td>
  1574 + <td class="pest-pdf-item-tags">{tags_html}</td>
  1575 + </tr>"""
  1576 + else:
  1577 + items_rows += f"""
  1578 + <tr class="pest-pdf-item-row {css_class}">
  1579 + <td class="pest-pdf-item-num">{item_idx + 1}</td>
  1580 + <td class="pest-pdf-item-title">{self._escape_html(item_title)}</td>
  1581 + <td class="pest-pdf-item-detail">{detail_text}</td>
  1582 + <td class="pest-pdf-item-tags">{tags_html}</td>
  1583 + </tr>"""
  1584 + else:
  1585 + # 没有内容时显示占位
  1586 + items_rows = f"""
  1587 + <tr class="pest-pdf-item-row {css_class}">
  1588 + <td class="pest-pdf-dimension-label {css_class}">
  1589 + <span class="pest-pdf-code">{code}</span>
  1590 + <span class="pest-pdf-label-text">{self._escape_html(label.split()[0])}</span>
  1591 + </td>
  1592 + <td class="pest-pdf-item-num">-</td>
  1593 + <td colspan="3" class="pest-pdf-empty">暂无要点</td>
  1594 + </tr>"""
  1595 +
  1596 + # 每个维度作为一个独立的tbody,便于分页控制
  1597 + dimension_tables += f"""
  1598 + <tbody class="pest-pdf-dimension {css_class}">
  1599 + {items_rows}
  1600 + </tbody>"""
  1601 +
  1602 + return f"""
  1603 + <div class="pest-pdf-wrapper">
  1604 + <table class="pest-pdf-table">
  1605 + <caption class="pest-pdf-caption">{self._escape_html(title)}</caption>
  1606 + <thead class="pest-pdf-thead">
  1607 + <tr>
  1608 + <th class="pest-pdf-th-dimension">维度</th>
  1609 + <th class="pest-pdf-th-num">序号</th>
  1610 + <th class="pest-pdf-th-title">要点</th>
  1611 + <th class="pest-pdf-th-detail">详细说明</th>
  1612 + <th class="pest-pdf-th-tags">趋势/影响</th>
  1613 + </tr>
  1614 + {summary_row}
  1615 + </thead>
  1616 + {dimension_tables}
  1617 + </table>
  1618 + </div>
  1619 + """
  1620 +
  1621 + def _normalize_pest_items(self, raw: Any) -> List[Dict[str, Any]]:
  1622 + """将PEST条目规整为统一结构,兼容字符串/对象两种写法"""
  1623 + normalized: List[Dict[str, Any]] = []
  1624 + if raw is None:
  1625 + return normalized
  1626 + if isinstance(raw, (str, int, float)):
  1627 + text = self._safe_text(raw).strip()
  1628 + if text:
  1629 + normalized.append({"title": text})
  1630 + return normalized
  1631 + if not isinstance(raw, list):
  1632 + return normalized
  1633 + for entry in raw:
  1634 + if isinstance(entry, (str, int, float)):
  1635 + text = self._safe_text(entry).strip()
  1636 + if text:
  1637 + normalized.append({"title": text})
  1638 + continue
  1639 + if not isinstance(entry, dict):
  1640 + continue
  1641 + title = entry.get("title") or entry.get("label") or entry.get("text")
  1642 + detail = entry.get("detail") or entry.get("description")
  1643 + source = entry.get("source") or entry.get("evidence")
  1644 + trend = entry.get("trend") or entry.get("impact")
  1645 + if not title and isinstance(detail, str):
  1646 + title = detail
  1647 + detail = None
  1648 + if not (title or detail or source):
  1649 + continue
  1650 + normalized.append(
  1651 + {
  1652 + "title": title,
  1653 + "detail": detail,
  1654 + "source": source,
  1655 + "trend": trend,
  1656 + }
  1657 + )
  1658 + return normalized
  1659 +
  1660 + def _render_pest_item(self, item: Dict[str, Any]) -> str:
  1661 + """输出单个PEST条目的HTML片段"""
  1662 + title = item.get("title") or item.get("label") or item.get("text") or "未命名要点"
  1663 + detail = item.get("detail") or item.get("description")
  1664 + source = item.get("source") or item.get("evidence")
  1665 + trend = item.get("trend") or item.get("impact")
  1666 + tags: List[str] = []
  1667 + if trend:
  1668 + tags.append(f'<span class="pest-tag">{self._escape_html(trend)}</span>')
  1669 + tags_html = f'<span class="pest-item-tags">{"".join(tags)}</span>' if tags else ""
  1670 + detail_html = f'<div class="pest-item-desc">{self._escape_html(detail)}</div>' if detail else ""
  1671 + source_html = f'<div class="pest-item-source">来源:{self._escape_html(source)}</div>' if source else ""
  1672 + return f"""
  1673 + <li class="pest-item">
  1674 + <div class="pest-item-title">{self._escape_html(title)}{tags_html}</div>
  1675 + {detail_html}{source_html}
  1676 + </li>
  1677 + """
  1678 +
1429 def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 1679 def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1430 """ 1680 """
1431 检测并修正仅有单列的竖排表,转换为标准网格。 1681 检测并修正仅有单列的竖排表,转换为标准网格。
@@ -2702,6 +2952,33 @@ class HTMLRenderer: @@ -2702,6 +2952,33 @@ class HTMLRenderer:
2702 --swot-cell-opportunity-border: rgba(31,90,179,0.35); 2952 --swot-cell-opportunity-border: rgba(31,90,179,0.35);
2703 --swot-cell-threat-border: rgba(179,107,22,0.35); 2953 --swot-cell-threat-border: rgba(179,107,22,0.35);
2704 --swot-item-border: rgba(0,0,0,0.05); 2954 --swot-item-border: rgba(0,0,0,0.05);
  2955 + /* PEST 分析变量 - 紫青色系 */
  2956 + --pest-political: #8e44ad;
  2957 + --pest-economic: #16a085;
  2958 + --pest-social: #e84393;
  2959 + --pest-technological: #2980b9;
  2960 + --pest-on-light: #1a1a2e;
  2961 + --pest-on-dark: #f8f9ff;
  2962 + --pest-text: var(--text-color);
  2963 + --pest-muted: rgba(0,0,0,0.55);
  2964 + --pest-surface: rgba(255,255,255,0.88);
  2965 + --pest-chip-bg: rgba(0,0,0,0.05);
  2966 + --pest-tag-border: var(--border-color);
  2967 + --pest-card-bg: linear-gradient(145deg, rgba(142,68,173,0.03), rgba(22,160,133,0.04)), var(--card-bg);
  2968 + --pest-card-border: var(--border-color);
  2969 + --pest-card-shadow: 0 16px 32px var(--shadow-color);
  2970 + --pest-card-blur: none;
  2971 + --pest-strip-base: linear-gradient(90deg, rgba(255,255,255,0.95), rgba(255,255,255,0.7));
  2972 + --pest-strip-border: rgba(0,0,0,0.06);
  2973 + --pest-strip-political-bg: linear-gradient(90deg, rgba(142,68,173,0.08), rgba(255,255,255,0.85)), var(--card-bg);
  2974 + --pest-strip-economic-bg: linear-gradient(90deg, rgba(22,160,133,0.08), rgba(255,255,255,0.85)), var(--card-bg);
  2975 + --pest-strip-social-bg: linear-gradient(90deg, rgba(232,67,147,0.08), rgba(255,255,255,0.85)), var(--card-bg);
  2976 + --pest-strip-technological-bg: linear-gradient(90deg, rgba(41,128,185,0.08), rgba(255,255,255,0.85)), var(--card-bg);
  2977 + --pest-strip-political-border: rgba(142,68,173,0.4);
  2978 + --pest-strip-economic-border: rgba(22,160,133,0.4);
  2979 + --pest-strip-social-border: rgba(232,67,147,0.4);
  2980 + --pest-strip-technological-border: rgba(41,128,185,0.4);
  2981 + --pest-item-border: rgba(0,0,0,0.06);
2705 }} 2982 }}
2706 .dark-mode {{ 2983 .dark-mode {{
2707 --bg-color: #121212; 2984 --bg-color: #121212;
@@ -2751,6 +3028,33 @@ class HTMLRenderer: @@ -2751,6 +3028,33 @@ class HTMLRenderer:
2751 --swot-cell-opportunity-border: rgba(31,90,179,0.68); 3028 --swot-cell-opportunity-border: rgba(31,90,179,0.68);
2752 --swot-cell-threat-border: rgba(179,107,22,0.68); 3029 --swot-cell-threat-border: rgba(179,107,22,0.68);
2753 --swot-item-border: rgba(255,255,255,0.14); 3030 --swot-item-border: rgba(255,255,255,0.14);
  3031 + /* PEST 分析变量 - 暗色模式 */
  3032 + --pest-political: #a569bd;
  3033 + --pest-economic: #48c9b0;
  3034 + --pest-social: #f06292;
  3035 + --pest-technological: #5dade2;
  3036 + --pest-on-light: #1a1a2e;
  3037 + --pest-on-dark: #f0f4ff;
  3038 + --pest-text: #f0f4ff;
  3039 + --pest-muted: rgba(240,244,255,0.7);
  3040 + --pest-surface: rgba(255,255,255,0.06);
  3041 + --pest-chip-bg: rgba(255,255,255,0.12);
  3042 + --pest-tag-border: rgba(255,255,255,0.22);
  3043 + --pest-card-bg: radial-gradient(130% 130% at 15% 15%, rgba(165,105,189,0.16), transparent 50%), radial-gradient(110% 130% at 85% 5%, rgba(72,201,176,0.14), transparent 48%), linear-gradient(155deg, #12162a 0%, #161b30 50%, #0f1425 100%);
  3044 + --pest-card-border: rgba(255,255,255,0.12);
  3045 + --pest-card-shadow: 0 28px 65px rgba(0, 0, 0, 0.55);
  3046 + --pest-card-blur: blur(10px);
  3047 + --pest-strip-base: linear-gradient(90deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02));
  3048 + --pest-strip-border: rgba(255,255,255,0.18);
  3049 + --pest-strip-political-bg: linear-gradient(90deg, rgba(142,68,173,0.25), rgba(142,68,173,0.1)), var(--pest-strip-base);
  3050 + --pest-strip-economic-bg: linear-gradient(90deg, rgba(22,160,133,0.25), rgba(22,160,133,0.1)), var(--pest-strip-base);
  3051 + --pest-strip-social-bg: linear-gradient(90deg, rgba(232,67,147,0.25), rgba(232,67,147,0.1)), var(--pest-strip-base);
  3052 + --pest-strip-technological-bg: linear-gradient(90deg, rgba(41,128,185,0.25), rgba(41,128,185,0.1)), var(--pest-strip-base);
  3053 + --pest-strip-political-border: rgba(165,105,189,0.6);
  3054 + --pest-strip-economic-border: rgba(72,201,176,0.6);
  3055 + --pest-strip-social-border: rgba(240,98,146,0.6);
  3056 + --pest-strip-technological-border: rgba(93,173,226,0.6);
  3057 + --pest-item-border: rgba(255,255,255,0.12);
2754 }} 3058 }}
2755 * {{ box-sizing: border-box; }} 3059 * {{ box-sizing: border-box; }}
2756 body {{ 3060 body {{
@@ -3482,6 +3786,313 @@ table th {{ @@ -3482,6 +3786,313 @@ table th {{
3482 page-break-inside: avoid; 3786 page-break-inside: avoid;
3483 }} 3787 }}
3484 }} 3788 }}
  3789 +
  3790 +/* ==================== PEST 分析样式 ==================== */
  3791 +.pest-card {{
  3792 + margin: 28px 0;
  3793 + padding: 20px 20px 16px;
  3794 + border-radius: 18px;
  3795 + border: 1px solid var(--pest-card-border);
  3796 + background: var(--pest-card-bg);
  3797 + box-shadow: var(--pest-card-shadow);
  3798 + color: var(--pest-text);
  3799 + backdrop-filter: var(--pest-card-blur);
  3800 + position: relative;
  3801 + overflow: hidden;
  3802 +}}
  3803 +.pest-card__head {{
  3804 + display: flex;
  3805 + justify-content: space-between;
  3806 + gap: 16px;
  3807 + align-items: flex-start;
  3808 + flex-wrap: wrap;
  3809 + margin-bottom: 16px;
  3810 +}}
  3811 +.pest-card__title {{
  3812 + font-size: 1.18rem;
  3813 + font-weight: 750;
  3814 + margin-bottom: 4px;
  3815 + background: linear-gradient(135deg, var(--pest-political), var(--pest-technological));
  3816 + -webkit-background-clip: text;
  3817 + -webkit-text-fill-color: transparent;
  3818 + background-clip: text;
  3819 +}}
  3820 +.pest-card__summary {{
  3821 + margin: 0;
  3822 + color: var(--pest-text);
  3823 + opacity: 0.8;
  3824 +}}
  3825 +.pest-legend {{
  3826 + display: flex;
  3827 + gap: 8px;
  3828 + flex-wrap: wrap;
  3829 + align-items: center;
  3830 +}}
  3831 +.pest-legend__item {{
  3832 + padding: 6px 14px;
  3833 + border-radius: 8px;
  3834 + font-weight: 700;
  3835 + font-size: 0.85rem;
  3836 + color: var(--pest-on-dark);
  3837 + border: 1px solid var(--pest-tag-border);
  3838 + box-shadow: 0 4px 14px rgba(0,0,0,0.18);
  3839 + text-shadow: 0 1px 2px rgba(0,0,0,0.3);
  3840 +}}
  3841 +.pest-legend__item.political {{ background: var(--pest-political); }}
  3842 +.pest-legend__item.economic {{ background: var(--pest-economic); }}
  3843 +.pest-legend__item.social {{ background: var(--pest-social); }}
  3844 +.pest-legend__item.technological {{ background: var(--pest-technological); }}
  3845 +.pest-strips {{
  3846 + display: flex;
  3847 + flex-direction: column;
  3848 + gap: 14px;
  3849 +}}
  3850 +.pest-strip {{
  3851 + display: flex;
  3852 + border-radius: 14px;
  3853 + border: 1px solid var(--pest-strip-border);
  3854 + background: var(--pest-strip-base);
  3855 + overflow: hidden;
  3856 + box-shadow: 0 6px 16px rgba(0,0,0,0.06);
  3857 + transition: transform 0.2s ease, box-shadow 0.2s ease;
  3858 +}}
  3859 +.pest-strip:hover {{
  3860 + transform: translateY(-2px);
  3861 + box-shadow: 0 10px 24px rgba(0,0,0,0.1);
  3862 +}}
  3863 +.pest-strip.political {{ border-color: var(--pest-strip-political-border); background: var(--pest-strip-political-bg); }}
  3864 +.pest-strip.economic {{ border-color: var(--pest-strip-economic-border); background: var(--pest-strip-economic-bg); }}
  3865 +.pest-strip.social {{ border-color: var(--pest-strip-social-border); background: var(--pest-strip-social-bg); }}
  3866 +.pest-strip.technological {{ border-color: var(--pest-strip-technological-border); background: var(--pest-strip-technological-bg); }}
  3867 +.pest-strip__indicator {{
  3868 + display: flex;
  3869 + align-items: center;
  3870 + justify-content: center;
  3871 + width: 56px;
  3872 + min-width: 56px;
  3873 + padding: 16px 8px;
  3874 + color: var(--pest-on-dark);
  3875 + text-shadow: 0 2px 4px rgba(0,0,0,0.25);
  3876 +}}
  3877 +.pest-strip__indicator.political {{ background: linear-gradient(180deg, var(--pest-political), rgba(142,68,173,0.8)); }}
  3878 +.pest-strip__indicator.economic {{ background: linear-gradient(180deg, var(--pest-economic), rgba(22,160,133,0.8)); }}
  3879 +.pest-strip__indicator.social {{ background: linear-gradient(180deg, var(--pest-social), rgba(232,67,147,0.8)); }}
  3880 +.pest-strip__indicator.technological {{ background: linear-gradient(180deg, var(--pest-technological), rgba(41,128,185,0.8)); }}
  3881 +.pest-code {{
  3882 + font-size: 1.6rem;
  3883 + font-weight: 900;
  3884 + letter-spacing: 0.02em;
  3885 +}}
  3886 +.pest-strip__content {{
  3887 + flex: 1;
  3888 + padding: 14px 16px;
  3889 + min-width: 0;
  3890 +}}
  3891 +.pest-strip__header {{
  3892 + display: flex;
  3893 + justify-content: space-between;
  3894 + align-items: baseline;
  3895 + gap: 12px;
  3896 + margin-bottom: 10px;
  3897 + flex-wrap: wrap;
  3898 +}}
  3899 +.pest-strip__title {{
  3900 + font-weight: 700;
  3901 + font-size: 1rem;
  3902 + color: var(--pest-text);
  3903 +}}
  3904 +.pest-strip__caption {{
  3905 + font-size: 0.85rem;
  3906 + color: var(--pest-text);
  3907 + opacity: 0.65;
  3908 +}}
  3909 +.pest-list {{
  3910 + list-style: none;
  3911 + padding: 0;
  3912 + margin: 0;
  3913 + display: flex;
  3914 + flex-direction: column;
  3915 + gap: 8px;
  3916 +}}
  3917 +.pest-item {{
  3918 + padding: 10px 14px;
  3919 + border-radius: 10px;
  3920 + background: var(--pest-surface);
  3921 + border: 1px solid var(--pest-item-border);
  3922 + box-shadow: 0 8px 18px rgba(0,0,0,0.06);
  3923 +}}
  3924 +.pest-item-title {{
  3925 + display: flex;
  3926 + justify-content: space-between;
  3927 + gap: 8px;
  3928 + font-weight: 650;
  3929 + color: var(--pest-text);
  3930 +}}
  3931 +.pest-item-tags {{
  3932 + display: inline-flex;
  3933 + gap: 6px;
  3934 + flex-wrap: wrap;
  3935 + font-size: 0.82rem;
  3936 +}}
  3937 +.pest-tag {{
  3938 + display: inline-block;
  3939 + padding: 3px 8px;
  3940 + border-radius: 6px;
  3941 + background: var(--pest-chip-bg);
  3942 + color: var(--pest-text);
  3943 + border: 1px solid var(--pest-tag-border);
  3944 + box-shadow: 0 4px 10px rgba(0,0,0,0.08);
  3945 + line-height: 1.2;
  3946 +}}
  3947 +.pest-item-desc {{
  3948 + margin-top: 5px;
  3949 + color: var(--pest-text);
  3950 + opacity: 0.88;
  3951 + font-size: 0.95rem;
  3952 +}}
  3953 +.pest-item-source {{
  3954 + margin-top: 4px;
  3955 + font-size: 0.88rem;
  3956 + color: var(--secondary-color);
  3957 + opacity: 0.9;
  3958 +}}
  3959 +.pest-empty {{
  3960 + padding: 14px;
  3961 + border-radius: 10px;
  3962 + border: 1px dashed var(--pest-card-border);
  3963 + text-align: center;
  3964 + color: var(--pest-muted);
  3965 + opacity: 0.65;
  3966 +}}
  3967 +
  3968 +/* ========== PEST PDF表格布局样式(默认隐藏)========== */
  3969 +.pest-pdf-wrapper {{
  3970 + display: none;
  3971 +}}
  3972 +
  3973 +/* PEST PDF表格样式定义(用于PDF渲染时显示) */
  3974 +.pest-pdf-table {{
  3975 + width: 100%;
  3976 + border-collapse: collapse;
  3977 + margin: 20px 0;
  3978 + font-size: 13px;
  3979 + table-layout: fixed;
  3980 +}}
  3981 +.pest-pdf-caption {{
  3982 + caption-side: top;
  3983 + text-align: left;
  3984 + font-size: 1.15rem;
  3985 + font-weight: 700;
  3986 + padding: 12px 0;
  3987 + color: var(--text-color);
  3988 +}}
  3989 +.pest-pdf-thead th {{
  3990 + background: #f5f3f7;
  3991 + padding: 10px 8px;
  3992 + text-align: left;
  3993 + font-weight: 600;
  3994 + border: 1px solid #e0dce3;
  3995 + color: #4a4458;
  3996 +}}
  3997 +.pest-pdf-th-dimension {{ width: 85px; }}
  3998 +.pest-pdf-th-num {{ width: 50px; text-align: center; }}
  3999 +.pest-pdf-th-title {{ width: 22%; }}
  4000 +.pest-pdf-th-detail {{ width: auto; }}
  4001 +.pest-pdf-th-tags {{ width: 100px; text-align: center; }}
  4002 +.pest-pdf-summary {{
  4003 + padding: 12px;
  4004 + background: #f8f6fa;
  4005 + color: #666;
  4006 + font-style: italic;
  4007 + border: 1px solid #e0dce3;
  4008 +}}
  4009 +.pest-pdf-dimension {{
  4010 + break-inside: avoid;
  4011 + page-break-inside: avoid;
  4012 +}}
  4013 +.pest-pdf-dimension-label {{
  4014 + text-align: center;
  4015 + vertical-align: middle;
  4016 + padding: 12px 8px;
  4017 + font-weight: 700;
  4018 + border: 1px solid #e0dce3;
  4019 + writing-mode: horizontal-tb;
  4020 +}}
  4021 +.pest-pdf-dimension-label.pest-pdf-political {{ background: rgba(142,68,173,0.12); color: #8e44ad; border-left: 4px solid #8e44ad; }}
  4022 +.pest-pdf-dimension-label.pest-pdf-economic {{ background: rgba(22,160,133,0.12); color: #16a085; border-left: 4px solid #16a085; }}
  4023 +.pest-pdf-dimension-label.pest-pdf-social {{ background: rgba(232,67,147,0.12); color: #e84393; border-left: 4px solid #e84393; }}
  4024 +.pest-pdf-dimension-label.pest-pdf-technological {{ background: rgba(41,128,185,0.12); color: #2980b9; border-left: 4px solid #2980b9; }}
  4025 +.pest-pdf-code {{
  4026 + display: block;
  4027 + font-size: 1.5rem;
  4028 + font-weight: 800;
  4029 + margin-bottom: 4px;
  4030 +}}
  4031 +.pest-pdf-label-text {{
  4032 + display: block;
  4033 + font-size: 0.75rem;
  4034 + font-weight: 600;
  4035 + letter-spacing: 0.02em;
  4036 +}}
  4037 +.pest-pdf-item-row td {{
  4038 + padding: 10px 8px;
  4039 + border: 1px solid #e0dce3;
  4040 + vertical-align: top;
  4041 +}}
  4042 +.pest-pdf-item-row.pest-pdf-political td {{ background: rgba(142,68,173,0.03); }}
  4043 +.pest-pdf-item-row.pest-pdf-economic td {{ background: rgba(22,160,133,0.03); }}
  4044 +.pest-pdf-item-row.pest-pdf-social td {{ background: rgba(232,67,147,0.03); }}
  4045 +.pest-pdf-item-row.pest-pdf-technological td {{ background: rgba(41,128,185,0.03); }}
  4046 +.pest-pdf-item-num {{
  4047 + text-align: center;
  4048 + font-weight: 600;
  4049 + color: #6c757d;
  4050 +}}
  4051 +.pest-pdf-item-title {{
  4052 + font-weight: 600;
  4053 + color: #212529;
  4054 +}}
  4055 +.pest-pdf-item-detail {{
  4056 + color: #495057;
  4057 + line-height: 1.5;
  4058 +}}
  4059 +.pest-pdf-item-tags {{
  4060 + text-align: center;
  4061 +}}
  4062 +.pest-pdf-tag {{
  4063 + display: inline-block;
  4064 + padding: 3px 8px;
  4065 + border-radius: 4px;
  4066 + font-size: 0.75rem;
  4067 + background: #ece9f1;
  4068 + color: #5a4f6a;
  4069 + margin: 2px;
  4070 +}}
  4071 +.pest-pdf-empty {{
  4072 + text-align: center;
  4073 + color: #adb5bd;
  4074 + font-style: italic;
  4075 +}}
  4076 +
  4077 +/* 打印模式下的PEST分页控制 */
  4078 +@media print {{
  4079 + .pest-card {{
  4080 + break-inside: auto;
  4081 + page-break-inside: auto;
  4082 + }}
  4083 + .pest-card__head {{
  4084 + break-after: avoid;
  4085 + page-break-after: avoid;
  4086 + }}
  4087 + .pest-pdf-dimension {{
  4088 + break-inside: avoid;
  4089 + page-break-inside: avoid;
  4090 + }}
  4091 + .pest-strip {{
  4092 + break-inside: avoid;
  4093 + page-break-inside: avoid;
  4094 + }}
  4095 +}}
3485 .callout {{ 4096 .callout {{
3486 border-left: 4px solid var(--primary-color); 4097 border-left: 4px solid var(--primary-color);
3487 padding: 16px; 4098 padding: 16px;
@@ -3705,6 +4316,7 @@ pre.code-block {{ @@ -3705,6 +4316,7 @@ pre.code-block {{
3705 .chart-card, 4316 .chart-card,
3706 .kpi-grid, 4317 .kpi-grid,
3707 .swot-card, 4318 .swot-card,
  4319 +.pest-card,
3708 .table-wrap, 4320 .table-wrap,
3709 figure, 4321 figure,
3710 blockquote {{ 4322 blockquote {{
@@ -3767,6 +4379,33 @@ blockquote {{ @@ -3767,6 +4379,33 @@ blockquote {{
3767 min-width: 240px; 4379 min-width: 240px;
3768 height: auto; 4380 height: auto;
3769 }} 4381 }}
  4382 + /* PEST 打印样式 */
  4383 + .pest-card,
  4384 + .pest-strip {{
  4385 + break-inside: avoid;
  4386 + page-break-inside: avoid;
  4387 + }}
  4388 + .pest-card {{
  4389 + color: var(--pest-text);
  4390 + break-inside: auto !important;
  4391 + page-break-inside: auto !important;
  4392 + }}
  4393 + .pest-card__head {{
  4394 + break-after: avoid;
  4395 + page-break-after: avoid;
  4396 + }}
  4397 + .pest-strips {{
  4398 + break-before: avoid;
  4399 + page-break-before: avoid;
  4400 + break-inside: auto;
  4401 + page-break-inside: auto;
  4402 + }}
  4403 + .pest-legend {{
  4404 + display: none !important;
  4405 + }}
  4406 + .pest-strip {{
  4407 + flex-direction: row;
  4408 + }}
3770 .table-wrap {{ 4409 .table-wrap {{
3771 overflow-x: auto; 4410 overflow-x: auto;
3772 max-width: 100%; 4411 max-width: 100%;