@puyinkai/xiaobao-cli 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{api-client-zDBFgk0y.mjs → api-client-CDNjkArS.mjs} +1 -1
- package/dist/{api-CX99_4K7.mjs → api-fsHHWWa3.mjs} +1 -1
- package/dist/{audio-ShfvRomP.mjs → audio-CRSMqaKo.mjs} +2 -2
- package/dist/{auth-DpK81Vp5.mjs → auth-_3hBh87U.mjs} +1 -1
- package/dist/cli.mjs +10 -10
- package/dist/{consultant-CS9B42d3.mjs → consultant-E33hTGUO.mjs} +1 -1
- package/dist/{customer-B1cxRbJ9.mjs → customer-DcQ83oKD.mjs} +1 -1
- package/dist/{focus-DfNPPhm5.mjs → focus-XMRxnHPw.mjs} +1 -1
- package/dist/{list-DI1OD95x.mjs → list-B3nN4pd2.mjs} +1 -1
- package/dist/{list-CD08BRLk.mjs → list-CXquxzex.mjs} +1 -1
- package/dist/{list-CKlx8YuL.mjs → list-Cc4zcaNz.mjs} +1 -1
- package/dist/{list-DCcCS6LM.mjs → list-CnlQdzPp.mjs} +1 -1
- package/dist/{list-D56gEqiB.mjs → list-CrMOrnS-.mjs} +1 -1
- package/dist/{list-CnlFIFiY.mjs → list-DFdDcHFq.mjs} +1 -1
- package/dist/{list-Cu6WzPpA.mjs → list-dm9YaCG1.mjs} +1 -1
- package/dist/{project-BuQQewOs.mjs → project-BD0g4oAW.mjs} +1 -1
- package/dist/{qa-CKsKiQIm.mjs → qa-CdMcxCE_.mjs} +1 -1
- package/dist/{resistance-DsE-Ha6Y.mjs → resistance-6_V6At3T.mjs} +1 -1
- package/dist/{text-C3jhdPoe.mjs → text-C3Ap6L5n.mjs} +1 -1
- package/dist/{visit-CX0Mc1n6.mjs → visit-DoeECi8w.mjs} +1 -1
- package/dist/{whoami-CZVez-rm.mjs → whoami-z-76WaCP.mjs} +1 -1
- package/package.json +5 -1
- package/skills/wangxiaobao-audio-query/SKILL.md +2 -2
- package/skills/wangxiaobao-audio-wiki/SKILL.md +4 -4
- package/skills/wangxiaobao-customer-focus-query/SKILL.md +2 -2
- package/skills/wangxiaobao-customer-query/SKILL.md +2 -2
- package/skills/wangxiaobao-customer-resistance-query/SKILL.md +2 -2
- package/skills/wangxiaobao-html-to-pdf/SKILL.md +116 -0
- package/skills/wangxiaobao-html-to-pdf/scripts/html2pdf.py +132 -0
- package/skills/wangxiaobao-html-to-pdf/scripts/html2pdf_exact.py +92 -0
- package/skills/wangxiaobao-project-daily-report/SKILL.md +170 -0
- package/skills/wangxiaobao-project-daily-report/_meta.json +9 -0
- package/skills/wangxiaobao-project-daily-report/references/report_template.html +308 -0
- package/skills/wangxiaobao-project-daily-report/scripts/fetch_data.py +219 -0
- package/skills/wangxiaobao-project-daily-report/scripts/generate_report.py +1029 -0
- package/skills/wangxiaobao-project-daily-report/scripts/run.py +34 -0
- package/skills/wangxiaobao-project-weekly-report/SKILL.md +169 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/generate_report.py +308 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/html2pdf_fast.py +161 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/logo_wangai.png +0 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/logo_wangxiaobao.png +0 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/prepare_data.py +682 -0
- package/skills/wangxiaobao-project-weekly-report/scripts/template.py +925 -0
- package/skills/wangxiaobao-quick-qa/SKILL.md +2 -2
- package/skills/wangxiaobao-shared/SKILL.md +5 -5
- package/skills/wangxiaobao-switch-project/SKILL.md +3 -3
- package/skills/wangxiaobao-visit-query/SKILL.md +2 -2
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
地产项目营销周报生成器 - 完整HTML模板
|
|
5
|
+
使用方法:
|
|
6
|
+
1. 修改顶部 DATA 区的数据变量和洞察文本
|
|
7
|
+
2. 运行: python template.py
|
|
8
|
+
3. 输出: [项目名]_周报_[日期].html + [项目名]_周报_[日期].pdf
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
import subprocess
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# ================================================================
|
|
17
|
+
# DATA 区 - 修改这里的数据和洞察即可,其余全部自动生成
|
|
18
|
+
# ================================================================
|
|
19
|
+
|
|
20
|
+
# -- 基本信息 --
|
|
21
|
+
# 项目标识默认从环境变量读取,未设置时使用中性占位符。
|
|
22
|
+
# 实际运行时这些值会被 generate_report.py 用 weekly_report_data.json 覆盖。
|
|
23
|
+
project_name = os.environ.get("XIAOBAO_PROJECT_NAME", "<项目名>")
|
|
24
|
+
brand_text = os.environ.get("XIAOBAO_BRAND_TEXT", "<项目名>项目 · 周度来访与成交深度分析")
|
|
25
|
+
week_label = "W1"
|
|
26
|
+
date_start = "2026.01.01"
|
|
27
|
+
date_end = "2026.01.07"
|
|
28
|
+
data_date = "2026.01.07 23:30"
|
|
29
|
+
|
|
30
|
+
# 注意:以下业务数据变量仅为占位/结构示例,运行时由 generate_report.py
|
|
31
|
+
# 用 weekly_report_data.json 的真实数据覆盖。请勿在此填写特定项目数据。
|
|
32
|
+
|
|
33
|
+
# -- 本周每日来访 [(日期str, 星期str, 来访量int), ...] --
|
|
34
|
+
week_days = []
|
|
35
|
+
|
|
36
|
+
# -- 上周每日来访 --
|
|
37
|
+
last_week_days = []
|
|
38
|
+
|
|
39
|
+
# -- 本月每日来访 [(日期str, 星期str, 来访量int), ...] --
|
|
40
|
+
may_days = []
|
|
41
|
+
|
|
42
|
+
# -- 顾问本周接待 [(姓名, 本周量, 占比%, 上周量, 环比%)] --
|
|
43
|
+
consultants_this = []
|
|
44
|
+
|
|
45
|
+
# -- 首访/复访 --
|
|
46
|
+
first_visit = 0
|
|
47
|
+
repeat_visit = 0
|
|
48
|
+
|
|
49
|
+
# -- 盘客完成量 --
|
|
50
|
+
panke_done = 0
|
|
51
|
+
|
|
52
|
+
# -- 意向等级:旺小宝API {"等级": 数量, ...} --
|
|
53
|
+
intent_api = {}
|
|
54
|
+
|
|
55
|
+
# -- 意向等级:盘客系统 {"等级": 数量, ...} --
|
|
56
|
+
intent_panke = {}
|
|
57
|
+
|
|
58
|
+
# -- 横向柱状图数据 [(标签, 值, 占比%), ...] --
|
|
59
|
+
house_types = []
|
|
60
|
+
budgets = []
|
|
61
|
+
channels = []
|
|
62
|
+
resistance = []
|
|
63
|
+
purposes = []
|
|
64
|
+
focus_points = []
|
|
65
|
+
payments = []
|
|
66
|
+
cycles = []
|
|
67
|
+
downpay = []
|
|
68
|
+
|
|
69
|
+
# -- 成交数据 --
|
|
70
|
+
deals_total = 0
|
|
71
|
+
deals_amount = 0
|
|
72
|
+
house_type_deals = []
|
|
73
|
+
channel_deals = []
|
|
74
|
+
deal_cycle = []
|
|
75
|
+
|
|
76
|
+
# -- 月度目标(示例值,可通过环境变量或 generate_report.py 覆盖)--
|
|
77
|
+
visit_target = int(os.environ.get("XIAOBAO_VISIT_TARGET", 500)) # 示例值
|
|
78
|
+
sales_target = int(os.environ.get("XIAOBAO_SALES_TARGET", 21000)) # 示例值
|
|
79
|
+
|
|
80
|
+
# -- 月度成交趋势 [(月份, 2025套数, 2025金额, 2026套数, 2026金额, 同比str)] --
|
|
81
|
+
monthly_trend = []
|
|
82
|
+
monthly_trend_total = ("合计/平均", "", "", "", "", "")
|
|
83
|
+
|
|
84
|
+
# -- 交叉分析表:看房周期 × 意向等级 --
|
|
85
|
+
# [周期, A, B, C, D, E, F, 合计]
|
|
86
|
+
cross_cycle_intent = []
|
|
87
|
+
|
|
88
|
+
# -- 交叉分析表:置业目的 × 意向等级 --
|
|
89
|
+
# [目的, A, B, C, D, E, 合计]
|
|
90
|
+
cross_purpose_intent = []
|
|
91
|
+
|
|
92
|
+
# -- 交叉分析表:渠道 × 意向等级 --
|
|
93
|
+
# [渠道, A, B, C, D, E, F, 合计, A+B率]
|
|
94
|
+
cross_channel_intent = []
|
|
95
|
+
|
|
96
|
+
# -- 下周建议 [(优先级, 行动项, 责任人, 时间节点)] --
|
|
97
|
+
suggestions = []
|
|
98
|
+
|
|
99
|
+
# ================================================================
|
|
100
|
+
# 洞察文本 - 根据新数据重新撰写,支持 {变量} 引用
|
|
101
|
+
# 注意:以下均为占位,运行时由 generate_report.py 根据数据覆盖。
|
|
102
|
+
# *_tpl 变量保留 {占位符} 以便 fmt() 正常格式化;请勿在此填写特定项目内容。
|
|
103
|
+
# ================================================================
|
|
104
|
+
|
|
105
|
+
insight_core_tpl = "<运行时由数据生成>"
|
|
106
|
+
|
|
107
|
+
insight_trend_tpl = "<运行时由数据生成>"
|
|
108
|
+
|
|
109
|
+
risk_trend_tpl = "<运行时由数据生成>"
|
|
110
|
+
|
|
111
|
+
insight_budget = ""
|
|
112
|
+
|
|
113
|
+
insight_cycle = ""
|
|
114
|
+
|
|
115
|
+
insight_purpose = ""
|
|
116
|
+
|
|
117
|
+
insight_channel = ""
|
|
118
|
+
|
|
119
|
+
risk_resistance = ""
|
|
120
|
+
|
|
121
|
+
insight_resistance = ""
|
|
122
|
+
|
|
123
|
+
insight_consultant = ""
|
|
124
|
+
|
|
125
|
+
finding_intent_tpl = "<运行时由数据生成>"
|
|
126
|
+
|
|
127
|
+
insight_intent = ""
|
|
128
|
+
|
|
129
|
+
insight_deals = ""
|
|
130
|
+
|
|
131
|
+
finding_deals = ""
|
|
132
|
+
|
|
133
|
+
insight_monthly_tpl = "<运行时由数据生成>"
|
|
134
|
+
|
|
135
|
+
summary_text_tpl = "<运行时由数据生成>"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ================================================================
|
|
139
|
+
# 自动计算 - 以下不需要修改
|
|
140
|
+
# (含 0 除保护,使占位空数据下 import 不报错;generate_html() 会按
|
|
141
|
+
# 实际数据重算并覆盖这些派生变量)
|
|
142
|
+
# ================================================================
|
|
143
|
+
|
|
144
|
+
week_total = sum(v for _, _, v in week_days)
|
|
145
|
+
week_avg = week_total / 7 if week_days else 0
|
|
146
|
+
last_week_total = sum(v for _, _, v in last_week_days)
|
|
147
|
+
may_total = sum(v for _, _, v in may_days)
|
|
148
|
+
wow_change = (week_total - last_week_total) / last_week_total * 100 if last_week_total > 0 else 0
|
|
149
|
+
wow_pct = abs(wow_change)
|
|
150
|
+
wow_direction = "下降" if wow_change < 0 else "增长"
|
|
151
|
+
first_pct = first_visit / week_total * 100 if week_total > 0 else 0
|
|
152
|
+
repeat_pct = repeat_visit / week_total * 100 if week_total > 0 else 0
|
|
153
|
+
panke_rate = panke_done / week_total * 100 if week_total > 0 else 0
|
|
154
|
+
intent_api_total = sum(intent_api.values())
|
|
155
|
+
intent_panke_total = sum(intent_panke.values())
|
|
156
|
+
api_ab = (intent_api.get('A', 0) + intent_api.get('B', 0)) / intent_api_total * 100 if intent_api_total > 0 else 0
|
|
157
|
+
panke_ab = (intent_panke.get('A', 0) + intent_panke.get('B', 0) + intent_panke.get('C+', 0)) / intent_panke_total * 100 if intent_panke_total > 0 else 0
|
|
158
|
+
api_c = intent_api.get('C', 0) / intent_api_total * 100 if intent_api_total > 0 else 0
|
|
159
|
+
panke_c = intent_panke.get('C', 0) / intent_panke_total * 100 if intent_panke_total > 0 else 0
|
|
160
|
+
visit_progress = may_total / visit_target * 100 if visit_target > 0 else 0
|
|
161
|
+
time_progress = 18 / 31 * 100
|
|
162
|
+
achievement_rate = visit_progress / time_progress * 100 if time_progress > 0 else 0
|
|
163
|
+
may_days_count = len(may_days)
|
|
164
|
+
|
|
165
|
+
# 工作日/周末均值
|
|
166
|
+
wd_this = week_days[0:4]
|
|
167
|
+
wd_last = last_week_days[0:4]
|
|
168
|
+
wd_avg = round(sum(v for _, _, v in wd_this) / len(wd_this), 1) if wd_this else 0
|
|
169
|
+
last_wd_avg = round(sum(v for _, _, v in wd_last) / len(wd_last), 1) if wd_last else 0
|
|
170
|
+
wd_drop = round((1 - sum(v for _, _, v in wd_this) / sum(v for _, _, v in wd_last)) * 100, 0) if wd_last and sum(v for _, _, v in wd_last) > 0 else 0
|
|
171
|
+
|
|
172
|
+
# 填充洞察变量
|
|
173
|
+
def fmt(template, **kw):
|
|
174
|
+
try:
|
|
175
|
+
return template.format(**kw)
|
|
176
|
+
except Exception:
|
|
177
|
+
return template
|
|
178
|
+
|
|
179
|
+
insight_core = fmt(insight_core_tpl, week_total=week_total, wow_pct=f"{wow_pct:.1f}",
|
|
180
|
+
wd_avg=wd_avg, last_wd_avg=last_wd_avg, wd_drop=wd_drop,
|
|
181
|
+
sat_val=week_days[4][2] if len(week_days) > 4 else 0, repeat_pct=f"{repeat_pct:.1f}",
|
|
182
|
+
repeat_up=f"{repeat_pct - 16.1:.1f}", panke_pct=f"{panke_rate:.1f}")
|
|
183
|
+
|
|
184
|
+
insight_trend = fmt(insight_trend_tpl, wd_avg=wd_avg, last_wd_avg=last_wd_avg, wd_drop=wd_drop,
|
|
185
|
+
sat_val=week_days[4][2] if len(week_days) > 4 else 0,
|
|
186
|
+
last_sat=last_week_days[4][2] if len(last_week_days) > 4 else 0,
|
|
187
|
+
sat_drop=f"{(1 - week_days[4][2]/last_week_days[4][2]) * 100:.1f}" if len(week_days) > 4 and len(last_week_days) > 4 and last_week_days[4][2] > 0 else "0.0",
|
|
188
|
+
sun_val=week_days[5][2] if len(week_days) > 5 else 0,
|
|
189
|
+
last_sun=last_week_days[5][2] if len(last_week_days) > 5 else 0,
|
|
190
|
+
sun_drop=f"{(1 - week_days[5][2]/last_week_days[5][2]) * 100:.1f}" if len(week_days) > 5 and len(last_week_days) > 5 and last_week_days[5][2] > 0 else "0.0",
|
|
191
|
+
wknd_loss=((last_week_days[4][2]+last_week_days[5][2])-(week_days[4][2]+week_days[5][2])) if len(week_days) > 5 and len(last_week_days) > 5 else 0,
|
|
192
|
+
retention=f"{week_days[5][2]/week_days[4][2]*100:.1f}" if len(week_days) > 5 and week_days[4][2] > 0 else "0.0")
|
|
193
|
+
|
|
194
|
+
risk_trend = fmt(risk_trend_tpl, mon_val=week_days[6][2] if len(week_days) > 6 else 0)
|
|
195
|
+
|
|
196
|
+
insight_monthly = fmt(insight_monthly_tpl, achieve_rate=f"{achievement_rate:.1f}",
|
|
197
|
+
no_holiday_avg=round((may_total - 166) / 14, 0) if may_total > 166 else 0,
|
|
198
|
+
projected=round((may_total - 166) / 14 * 31 + 166, 0) if may_total > 166 else 0)
|
|
199
|
+
|
|
200
|
+
finding_intent = fmt(finding_intent_tpl, api_ab=f"{api_ab:.1f}", panke_ab=f"{panke_ab:.1f}",
|
|
201
|
+
api_c=f"{api_c:.1f}", panke_c=f"{panke_c:.1f}")
|
|
202
|
+
|
|
203
|
+
summary_text = fmt(summary_text_tpl, week_total=week_total, wow_pct=f"{wow_pct:.1f}",
|
|
204
|
+
panke_pct=f"{panke_rate:.1f}", repeat_pct=f"{repeat_pct:.1f}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ================================================================
|
|
208
|
+
# HTML 生成函数 - 以下不需要修改
|
|
209
|
+
# ================================================================
|
|
210
|
+
|
|
211
|
+
def hbar(data, max_val=None):
|
|
212
|
+
"""横向柱状图"""
|
|
213
|
+
if not data:
|
|
214
|
+
return ""
|
|
215
|
+
if max_val is None:
|
|
216
|
+
max_val = max(v for _, v, _ in data) or 1
|
|
217
|
+
lines = []
|
|
218
|
+
for label, val, pct in data:
|
|
219
|
+
width = val / max_val * 100
|
|
220
|
+
lines.append(f'<div class="bar-row"><span class="bar-label">{label}</span><div class="bar-track"><div class="bar-fill" style="width:{width:.1f}%"></div></div><span class="bar-val">{val}组<small>({pct:.1f}%)</small></span></div>')
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def donut_svg(data, size=160, stroke=24):
|
|
225
|
+
"""SVG圆环图"""
|
|
226
|
+
if not data:
|
|
227
|
+
return ""
|
|
228
|
+
total = sum(v for _, v, _ in data) or 1
|
|
229
|
+
colors = ["#1A3C6E", "#3B7DD8", "#63B3ED", "#A0D2F5", "#C6E0F7", "#E2E8F0"]
|
|
230
|
+
cx, cy = size // 2, size // 2
|
|
231
|
+
r = (size - stroke) // 2
|
|
232
|
+
circ = 2 * 3.14159265 * r
|
|
233
|
+
segs = []
|
|
234
|
+
offset = 0
|
|
235
|
+
for i, (label, val, pct) in enumerate(data):
|
|
236
|
+
dash = val / total * circ
|
|
237
|
+
color = colors[i % len(colors)]
|
|
238
|
+
segs.append(f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="{color}" stroke-width="{stroke}" stroke-dasharray="{dash:.1f} {circ:.1f}" stroke-dashoffset="-{offset:.1f}" />')
|
|
239
|
+
offset += dash
|
|
240
|
+
legend = ""
|
|
241
|
+
for i, (label, val, pct) in enumerate(data):
|
|
242
|
+
color = colors[i % len(colors)]
|
|
243
|
+
legend += f'<div class="donut-legend-item"><span class="dot" style="background:{color}"></span><span>{label} {val}({pct:.1f}%)</span></div>'
|
|
244
|
+
svg = f'<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}">{"".join(segs)}</svg>'
|
|
245
|
+
return f'<div class="donut-wrap"><div class="donut-chart">{svg}</div><div class="donut-legend">{legend}</div></div>'
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def vbar_week(days_data, last_data):
|
|
249
|
+
"""本周每日柱状图(含环比)"""
|
|
250
|
+
if not days_data:
|
|
251
|
+
return ""
|
|
252
|
+
max_val = max(v for _, _, v in days_data) or 1
|
|
253
|
+
bars = []
|
|
254
|
+
for i, (date, wd, val) in enumerate(days_data):
|
|
255
|
+
height = val / max_val * 100
|
|
256
|
+
prev = last_data[i][2] if i < len(last_data) else 0
|
|
257
|
+
change = val - prev
|
|
258
|
+
cls = "up" if change > 0 else "down"
|
|
259
|
+
change_str = f"+{change}" if change > 0 else str(change)
|
|
260
|
+
bars.append(f'<div class="bar-day"><div class="bar-day-val">{val}</div><div class="bar-day-col"><div class="bar-day-fill" style="height:{height:.1f}%"></div></div><div class="bar-day-label">{date}<br><small>{wd}</small></div><div class="bar-day-change {cls}">{change_str}</div></div>')
|
|
261
|
+
return "\n".join(bars)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def vbar_month(days_data):
|
|
265
|
+
"""月度每日柱状图"""
|
|
266
|
+
if not days_data:
|
|
267
|
+
return ""
|
|
268
|
+
max_val = max(v for _, _, v in days_data) or 1
|
|
269
|
+
bars = []
|
|
270
|
+
for date, wd, val in days_data:
|
|
271
|
+
height = val / max_val * 100
|
|
272
|
+
is_wkend = wd in ["周六", "周日"]
|
|
273
|
+
cls = "month-bar" + (" weekend" if is_wkend else "")
|
|
274
|
+
day_num = date.split(".")[1]
|
|
275
|
+
bars.append(f'<div class="{cls}"><div class="month-bar-val">{val}</div><div class="month-bar-col"><div class="month-bar-fill" style="height:{height:.1f}%"></div></div><div class="month-bar-label">{day_num}<br><small>{wd}</small></div></div>')
|
|
276
|
+
return "\n".join(bars)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cross_cycle_table(rows):
|
|
280
|
+
"""看房周期 × 意向等级"""
|
|
281
|
+
h = '<table class="cross-table">\n<tr><th>周期</th><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>合计</th></tr>\n'
|
|
282
|
+
for r in rows:
|
|
283
|
+
h += f'<tr><td>{r[0]}</td>'
|
|
284
|
+
for c in r[1:]:
|
|
285
|
+
h += f'<td>{c}</td>'
|
|
286
|
+
h += '</tr>\n'
|
|
287
|
+
h += '</table>'
|
|
288
|
+
return h
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cross_purpose_table(rows):
|
|
292
|
+
"""置业目的 × 意向等级"""
|
|
293
|
+
h = '<table class="cross-table">\n<tr><th>目的</th><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>合计</th></tr>\n'
|
|
294
|
+
for r in rows:
|
|
295
|
+
h += f'<tr><td>{r[0]}</td>'
|
|
296
|
+
for c in r[1:]:
|
|
297
|
+
h += f'<td>{c}</td>'
|
|
298
|
+
h += '</tr>\n'
|
|
299
|
+
h += '</table>'
|
|
300
|
+
return h
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cross_channel_table(rows):
|
|
304
|
+
"""渠道 × 意向等级"""
|
|
305
|
+
h = '<table class="cross-table">\n<tr><th>渠道</th><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>合计</th><th>A+B率</th></tr>\n'
|
|
306
|
+
for r in rows:
|
|
307
|
+
h += f'<tr><td>{r[0]}</td>'
|
|
308
|
+
for c in r[1:]:
|
|
309
|
+
h += f'<td>{c}</td>'
|
|
310
|
+
h += '</tr>\n'
|
|
311
|
+
h += '</table>'
|
|
312
|
+
return h
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_suggestions(items):
|
|
316
|
+
"""建议表格"""
|
|
317
|
+
tag_map = {"P0": "tag-red", "P1": "tag-yellow", "P2": "tag-blue"}
|
|
318
|
+
h = '<table>\n<tr><th>优先级</th><th>行动项</th><th>责任人</th><th>时间节点</th></tr>\n'
|
|
319
|
+
for p, action, person, deadline in items:
|
|
320
|
+
cls = tag_map.get(p, "tag-blue")
|
|
321
|
+
h += f'<tr>\n <td><span class="tag {cls}">{p}</span></td>\n <td>{action}</td>\n <td>{person}</td>\n <td>{deadline}</td>\n</tr>\n'
|
|
322
|
+
h += '</table>'
|
|
323
|
+
return h
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def render_consultants(data):
|
|
327
|
+
"""顾问表格"""
|
|
328
|
+
h = '<table>\n<tr><th>排名</th><th>顾问</th><th class="num">本周接待</th><th class="num">占比</th><th class="num">上周接待</th><th class="num">环比</th><th class="num">人均/天</th><th>状态</th></tr>'
|
|
329
|
+
for i, (name, cnt, pct, last_cnt, chg) in enumerate(data, 1):
|
|
330
|
+
if chg > 0:
|
|
331
|
+
status, color = "↑", "#38A169"
|
|
332
|
+
elif chg < -20:
|
|
333
|
+
status, color = "↓", "#E53E3E"
|
|
334
|
+
else:
|
|
335
|
+
status, color = "→", "#A0AEC0"
|
|
336
|
+
h += f'<tr><td>{i}</td><td><b>{name}</b></td><td class="num">{cnt}</td><td class="num">{pct:.1f}%</td><td class="num">{last_cnt}</td><td class="num" style="color:{color}">{chg:+.1f}%</td><td class="num">{cnt/7:.1f}</td><td><span style="color:{color};font-weight:600;">{status}</span></td></tr>'
|
|
337
|
+
h += '</table>'
|
|
338
|
+
return h
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def render_intent_bars(data_dict):
|
|
342
|
+
"""意向等级柱状图"""
|
|
343
|
+
max_val = max(data_dict.values())
|
|
344
|
+
lines = []
|
|
345
|
+
total = sum(data_dict.values())
|
|
346
|
+
for label, val in data_dict.items():
|
|
347
|
+
pct = val / total * 100
|
|
348
|
+
width = val / max_val * 100
|
|
349
|
+
lines.append(f'<div class="bar-row"><span class="bar-label">{label}级</span><div class="bar-track"><div class="bar-fill" style="width:{width:.1f}%"></div></div><span class="bar-val">{val}组<small>({pct:.1f}%)</small></span></div>')
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def render_monthly_trend(rows, total_row):
|
|
354
|
+
"""月度成交趋势表"""
|
|
355
|
+
h = '<table>\n<tr><th>月份</th><th class="num">2025套数</th><th class="num">2025金额(万)</th><th class="num">2026套数</th><th class="num">2026金额(万)</th><th class="num">同比</th></tr>\n'
|
|
356
|
+
for r in rows:
|
|
357
|
+
yoy = r[5]
|
|
358
|
+
yoy_style = f' style="color:#E53E3E"' if yoy not in ["—", "待更新"] else ""
|
|
359
|
+
h += f'<tr><td>{r[0]}</td><td class="num">{r[1]}</td><td class="num">{r[2]}</td><td class="num">{r[3]}</td><td class="num">{r[4]}</td><td class="num"{yoy_style}>{yoy}</td></tr>\n'
|
|
360
|
+
h += f'<tr style="background:#F7FAFC; font-weight:600;"><td>{total_row[0]}</td><td class="num">{total_row[1]}</td><td class="num">{total_row[2]}</td><td class="num">{total_row[3]}</td><td class="num">{total_row[4]}</td><td class="num">{total_row[5]}</td></tr>\n'
|
|
361
|
+
h += '</table>'
|
|
362
|
+
return h
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ================================================================
|
|
366
|
+
# 完整 HTML 模板 - 以下不需要修改
|
|
367
|
+
# ================================================================
|
|
368
|
+
|
|
369
|
+
CSS = """ * { margin: 0; padding: 0; box-sizing: border-box; }
|
|
370
|
+
body { font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif; background: #F0F4F8; color: #1A202C; line-height: 1.6; padding: 24px; }
|
|
371
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
372
|
+
.header {
|
|
373
|
+
background: linear-gradient(135deg, #1A3C6E 0%, #2A5298 100%);
|
|
374
|
+
color: #fff; padding: 24px 32px; border-radius: 12px; margin-bottom: 20px;
|
|
375
|
+
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px;
|
|
376
|
+
}
|
|
377
|
+
.header-brand { display: flex; align-items: center; gap: 12px; align-self: flex-start; margin-top: 2px; }
|
|
378
|
+
.header-brand img { height: 32px; width: auto; }
|
|
379
|
+
.header-center { text-align: center; flex: 1; }
|
|
380
|
+
.header-center h1 { font-size: 26px; font-weight: 700; letter-spacing: 2px; }
|
|
381
|
+
.header-center .sub { font-size: 13px; color: rgba(255,255,255,0.7); margin-top: 4px; }
|
|
382
|
+
.header-right { text-align: right; }
|
|
383
|
+
.header-right .period { font-size: 18px; font-weight: 600; }
|
|
384
|
+
.header-right .cumulative { font-size: 13px; color: rgba(255,255,255,0.8); margin-top: 4px; }
|
|
385
|
+
.header-right .cumulative b { font-size: 22px; color: #90CDF4; }
|
|
386
|
+
.header-right .gen-time { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 4px; }
|
|
387
|
+
.card { background: #fff; border-radius: 10px; box-shadow: 0 1px 8px rgba(0,0,0,0.06); padding: 20px 28px; margin-bottom: 20px; }
|
|
388
|
+
.section-num { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: #1A3C6E; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; margin-right: 8px; flex-shrink: 0; }
|
|
389
|
+
.section-header { display: flex; align-items: center; font-size: 17px; font-weight: 700; color: #1A3C6E; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 2px solid #E2E8F0; }
|
|
390
|
+
.section-sub { font-size: 14px; font-weight: 600; color: #2D3748; margin: 16px 0 10px; padding-left: 12px; border-left: 3px solid #3B7DD8; }
|
|
391
|
+
.kpi-row { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
392
|
+
.kpi-card { flex: 1; min-width: 140px; background: #F7FAFC; border-radius: 8px; padding: 14px 16px; text-align: center; border-left: 3px solid #CBD5E0; }
|
|
393
|
+
.kpi-card.primary { border-left-color: #1A3C6E; background: linear-gradient(135deg,#EDF2F7 0%,#E2E8F0 100%); }
|
|
394
|
+
.kpi-card.success { border-left-color: #38A169; }
|
|
395
|
+
.kpi-card.warning { border-left-color: #D69E2E; }
|
|
396
|
+
.kpi-label { font-size: 12px; color: #718096; margin-bottom: 4px; }
|
|
397
|
+
.kpi-value { font-size: 26px; font-weight: 700; color: #1A202C; }
|
|
398
|
+
.kpi-delta { font-size: 11px; margin-top: 4px; }
|
|
399
|
+
.kpi-delta.up { color: #E53E3E; }
|
|
400
|
+
.kpi-delta.down { color: #38A169; }
|
|
401
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 10px 0; }
|
|
402
|
+
th, td { padding: 9px 10px; text-align: left; border-bottom: 1px solid #EDF2F7; }
|
|
403
|
+
th { background: #F7FAFC; font-weight: 600; color: #4A5568; font-size: 12px; }
|
|
404
|
+
tr:hover td { background: #F7FAFC; }
|
|
405
|
+
th.num, td.num { text-align: right; font-family: monospace; }
|
|
406
|
+
.summary-table { table-layout: fixed; }
|
|
407
|
+
.summary-table th:first-child, .summary-table td:first-child { width: 20%; }
|
|
408
|
+
.summary-table th.num, .summary-table td.num { width: 16%; }
|
|
409
|
+
.bar-chart { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; }
|
|
410
|
+
.bar-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
|
411
|
+
.bar-label { width: 100px; flex-shrink: 0; text-align: right; color: #4A5568; }
|
|
412
|
+
.bar-track { flex: 1; height: 22px; background: #EDF2F7; border-radius: 4px; overflow: hidden; position: relative; }
|
|
413
|
+
.bar-fill { height: 100%; background: linear-gradient(90deg, #3B7DD8, #63B3ED); border-radius: 4px; }
|
|
414
|
+
.bar-val { width: 90px; flex-shrink: 0; text-align: left; color: #2D3748; font-weight: 500; }
|
|
415
|
+
.bar-val small { color: #A0AEC0; font-size: 11px; margin-left: 3px; }
|
|
416
|
+
.vbar-chart { display: flex; align-items: flex-end; justify-content: center; gap: 16px; height: 260px; padding: 20px 10px 0; margin: 14px 0; border-bottom: 1px solid #E2E8F0; }
|
|
417
|
+
.bar-day { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; max-width: 80px; }
|
|
418
|
+
.bar-day-val { font-size: 14px; font-weight: 700; color: #1A202C; }
|
|
419
|
+
.bar-day-col { width: 36px; height: 180px; background: #EDF2F7; border-radius: 4px 4px 0 0; position: relative; display: flex; align-items: flex-end; }
|
|
420
|
+
.bar-day-fill { width: 100%; background: linear-gradient(180deg, #3B7DD8, #1A3C6E); border-radius: 4px 4px 0 0; min-height: 4px; }
|
|
421
|
+
.bar-day-label { font-size: 12px; color: #4A5568; text-align: center; line-height: 1.3; }
|
|
422
|
+
.bar-day-change { font-size: 10px; font-weight: 600; }
|
|
423
|
+
.bar-day-change.up { color: #E53E3E; }
|
|
424
|
+
.bar-day-change.down { color: #38A169; }
|
|
425
|
+
.month-chart { display: flex; align-items: flex-end; gap: 4px; height: 200px; padding: 10px 4px 0; margin: 14px 0; border-bottom: 1px solid #E2E8F0; overflow-x: auto; }
|
|
426
|
+
.month-bar { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 36px; }
|
|
427
|
+
.month-bar-val { font-size: 10px; font-weight: 600; color: #4A5568; }
|
|
428
|
+
.month-bar-col { width: 24px; height: 140px; background: #EDF2F7; border-radius: 3px 3px 0 0; position: relative; display: flex; align-items: flex-end; }
|
|
429
|
+
.month-bar-fill { width: 100%; background: linear-gradient(180deg, #63B3ED, #3B7DD8); border-radius: 3px 3px 0 0; min-height: 3px; }
|
|
430
|
+
.month-bar.weekend .month-bar-fill { background: linear-gradient(180deg, #4299E1, #2B6CB0); }
|
|
431
|
+
.month-bar-label { font-size: 9px; color: #718096; text-align: center; line-height: 1.2; }
|
|
432
|
+
.donut-wrap { display: flex; align-items: center; gap: 24px; margin: 14px 0; flex-wrap: wrap; }
|
|
433
|
+
.donut-chart { flex-shrink: 0; }
|
|
434
|
+
.donut-legend { display: flex; flex-direction: column; gap: 6px; }
|
|
435
|
+
.donut-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #4A5568; }
|
|
436
|
+
.donut-legend-item .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
437
|
+
.two-col { display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; }
|
|
438
|
+
.two-col > div { flex: 1; min-width: 300px; align-self: flex-start; display: flex; flex-direction: column; justify-content: flex-start; }
|
|
439
|
+
.insight { background: linear-gradient(135deg, #EBF8FF 0%, #E6FFFA 100%); border-left: 3px solid #3B7DD8; padding: 12px 16px; border-radius: 0 8px 8px 0; font-size: 13px; color: #2D3748; margin: 14px 0; line-height: 1.7; }
|
|
440
|
+
.insight strong { color: #1A3C6E; }
|
|
441
|
+
.finding { background: #FFFBEB; border-left: 3px solid #D69E2E; padding: 12px 16px; border-radius: 0 8px 8px 0; font-size: 13px; color: #744210; margin: 14px 0; line-height: 1.7; }
|
|
442
|
+
.finding .tag { display: inline-block; background: #D69E2E; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 8px; }
|
|
443
|
+
.risk-box { background: #FFF5F5; border-left: 3px solid #E53E3E; padding: 10px 14px; border-radius: 0 6px 6px 0; font-size: 12px; color: #742A2A; margin: 10px 0; }
|
|
444
|
+
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 6px; }
|
|
445
|
+
.tag-blue { background: #EBF8FF; color: #2B6CB0; }
|
|
446
|
+
.tag-green { background: #F0FFF4; color: #276749; }
|
|
447
|
+
.tag-yellow { background: #FFFBEB; color: #975A16; }
|
|
448
|
+
.tag-red { background: #FFF5F5; color: #C53030; }
|
|
449
|
+
.cross-table th, .cross-table td { font-size: 12px; padding: 6px 8px; text-align: center; }
|
|
450
|
+
.cross-table th { background: #EDF2F7; }
|
|
451
|
+
.cross-table td:first-child { text-align: left; font-weight: 500; background: #F7FAFC; }
|
|
452
|
+
.footer-bar { background: #fff; border-radius: 10px; padding: 14px 24px; margin-top: 16px; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #718096; box-shadow: 0 1px 8px rgba(0,0,0,0.06); }
|
|
453
|
+
.footer-brand { display: flex; align-items: center; gap: 8px; }
|
|
454
|
+
.footer-brand img { height: 20px; width: auto; }
|
|
455
|
+
@media (max-width: 768px) {
|
|
456
|
+
.header { flex-direction: column; text-align: center; }
|
|
457
|
+
.header-brand { order: 1; } .header-center { order: 2; } .header-right { order: 3; text-align: center; }
|
|
458
|
+
.two-col { flex-direction: column; }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ===== 打印/PDF 优化 ===== */
|
|
462
|
+
@page {
|
|
463
|
+
size: 1200px 1697px;
|
|
464
|
+
margin: 0;
|
|
465
|
+
}
|
|
466
|
+
@media print {
|
|
467
|
+
body { margin: 0; padding: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
468
|
+
.container { box-shadow: none; margin: 0; }
|
|
469
|
+
.card { break-inside: avoid; }
|
|
470
|
+
canvas { max-width: 100% !important; }
|
|
471
|
+
}"""
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ================================================================
|
|
475
|
+
# PDF 生成
|
|
476
|
+
# ================================================================
|
|
477
|
+
|
|
478
|
+
HTML2PDF_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "html2pdf_fast.py")
|
|
479
|
+
# 使用当前 Python 解释器;若不可用则回退到 PATH 中的 python3。
|
|
480
|
+
PYTHON_EXE = sys.executable or "python3"
|
|
481
|
+
# PDF 页面尺寸:A4 横向(标准尺寸,显示兼容性好)
|
|
482
|
+
PDF_ARGS = ["--landscape"]
|
|
483
|
+
|
|
484
|
+
def generate_pdf(html_path: str) -> str:
|
|
485
|
+
"""
|
|
486
|
+
调用 html2pdf.py 将 HTML 转换为 PDF。
|
|
487
|
+
返回生成的 PDF 文件路径;失败时返回 None。
|
|
488
|
+
"""
|
|
489
|
+
if not os.path.exists(HTML2PDF_SCRIPT):
|
|
490
|
+
print(f"WARN: html2pdf.py not found at {HTML2PDF_SCRIPT}, skipping PDF generation.")
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
pdf_path = html_path.rsplit(".", 1)[0] + ".pdf"
|
|
494
|
+
print(f"Generating PDF (landscape): {pdf_path} ...")
|
|
495
|
+
try:
|
|
496
|
+
result = subprocess.run(
|
|
497
|
+
[PYTHON_EXE, HTML2PDF_SCRIPT, html_path, pdf_path] + PDF_ARGS,
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
timeout=120,
|
|
501
|
+
)
|
|
502
|
+
if result.returncode == 0 and os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0:
|
|
503
|
+
print(f"OK: PDF generated ({os.path.getsize(pdf_path)} bytes)")
|
|
504
|
+
return pdf_path
|
|
505
|
+
else:
|
|
506
|
+
print(f"ERROR: PDF generation failed (returncode={result.returncode})")
|
|
507
|
+
print(f"stdout: {result.stdout[-500:]}")
|
|
508
|
+
print(f"stderr: {result.stderr[-500:]}")
|
|
509
|
+
return None
|
|
510
|
+
except Exception as e:
|
|
511
|
+
print(f"ERROR: Exception during PDF generation: {e}")
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ================================================================
|
|
516
|
+
# HTML 生成
|
|
517
|
+
# ================================================================
|
|
518
|
+
|
|
519
|
+
def generate_html():
|
|
520
|
+
|
|
521
|
+
# ── 重新计算派生变量(支持从外部覆盖数据)──
|
|
522
|
+
week_total = sum(v for _, _, v in week_days)
|
|
523
|
+
week_avg = week_total / 7
|
|
524
|
+
last_week_total = sum(v for _, _, v in last_week_days)
|
|
525
|
+
may_total = sum(v for _, _, v in may_days)
|
|
526
|
+
wow_change = (week_total - last_week_total) / last_week_total * 100 if last_week_total > 0 else 0
|
|
527
|
+
wow_pct = abs(wow_change)
|
|
528
|
+
wow_direction = "下降" if wow_change < 0 else "增长"
|
|
529
|
+
first_pct = first_visit / week_total * 100 if week_total > 0 else 0
|
|
530
|
+
repeat_pct = repeat_visit / week_total * 100 if week_total > 0 else 0
|
|
531
|
+
panke_rate = panke_done / week_total * 100 if week_total > 0 else 0
|
|
532
|
+
intent_api_total = sum(intent_api.values())
|
|
533
|
+
intent_panke_total = sum(intent_panke.values())
|
|
534
|
+
api_ab = (intent_api.get('A', 0) + intent_api.get('B', 0)) / intent_api_total * 100 if intent_api_total > 0 else 0
|
|
535
|
+
panke_ab = (intent_panke.get('A', 0) + intent_panke.get('B', 0) + intent_panke.get('C+', 0)) / intent_panke_total * 100 if intent_panke_total > 0 else 0
|
|
536
|
+
api_c = intent_api.get('C', 0) / intent_api_total * 100 if intent_api_total > 0 else 0
|
|
537
|
+
panke_c = intent_panke.get('C', 0) / intent_panke_total * 100 if intent_panke_total > 0 else 0
|
|
538
|
+
visit_progress = may_total / visit_target * 100 if visit_target > 0 else 0
|
|
539
|
+
time_progress = 18 / 31 * 100
|
|
540
|
+
achievement_rate = visit_progress / time_progress * 100 if time_progress > 0 else 0
|
|
541
|
+
may_days_count = len(may_days)
|
|
542
|
+
wd_this = week_days[0:4]
|
|
543
|
+
wd_last = last_week_days[0:4]
|
|
544
|
+
wd_avg = round(sum(v for _, _, v in wd_this) / len(wd_this), 1) if wd_this else 0
|
|
545
|
+
last_wd_avg = round(sum(v for _, _, v in wd_last) / len(wd_last), 1) if wd_last else 0
|
|
546
|
+
wd_drop = round((1 - sum(v for _, _, v in wd_this) / sum(v for _, _, v in wd_last)) * 100, 0) if wd_last and sum(v for _, _, v in wd_last) > 0 else 0
|
|
547
|
+
|
|
548
|
+
# 重新格式化洞察变量
|
|
549
|
+
def fmt(template, **kw):
|
|
550
|
+
try:
|
|
551
|
+
return template.format(**kw)
|
|
552
|
+
except:
|
|
553
|
+
return template
|
|
554
|
+
|
|
555
|
+
insight_core = fmt(insight_core_tpl, week_total=week_total, wow_pct=f"{wow_pct:.1f}",
|
|
556
|
+
wd_avg=wd_avg, last_wd_avg=last_wd_avg, wd_drop=wd_drop,
|
|
557
|
+
sat_val=week_days[4][2] if len(week_days) > 4 else 0, repeat_pct=f"{repeat_pct:.1f}",
|
|
558
|
+
repeat_up=f"{repeat_pct - 16.1:.1f}", panke_pct=f"{panke_rate:.1f}")
|
|
559
|
+
|
|
560
|
+
insight_trend = fmt(insight_trend_tpl, wd_avg=wd_avg, last_wd_avg=last_wd_avg, wd_drop=wd_drop,
|
|
561
|
+
sat_val=week_days[4][2] if len(week_days) > 4 else 0,
|
|
562
|
+
last_sat=last_week_days[4][2] if len(last_week_days) > 4 else 0,
|
|
563
|
+
sat_drop=f"{(1 - week_days[4][2]/last_week_days[4][2]) * 100:.1f}" if len(week_days) > 4 and len(last_week_days) > 4 and last_week_days[4][2] > 0 else "0.0",
|
|
564
|
+
sun_val=week_days[5][2] if len(week_days) > 5 else 0,
|
|
565
|
+
last_sun=last_week_days[5][2] if len(last_week_days) > 5 else 0,
|
|
566
|
+
sun_drop=f"{(1 - week_days[5][2]/last_week_days[5][2]) * 100:.1f}" if len(week_days) > 5 and len(last_week_days) > 5 and last_week_days[5][2] > 0 else "0.0",
|
|
567
|
+
wknd_loss=((last_week_days[4][2]+last_week_days[5][2])-(week_days[4][2]+week_days[5][2])) if len(week_days) > 5 and len(last_week_days) > 5 else 0,
|
|
568
|
+
retention=f"{week_days[5][2]/week_days[4][2]*100:.1f}" if len(week_days) > 5 and week_days[4][2] > 0 else "0.0")
|
|
569
|
+
|
|
570
|
+
risk_trend = fmt(risk_trend_tpl, mon_val=week_days[6][2] if len(week_days) > 6 else 0)
|
|
571
|
+
|
|
572
|
+
insight_monthly = fmt(insight_monthly_tpl, achievement_rate=f"{achievement_rate:.1f}",
|
|
573
|
+
no_holiday_avg=round((may_total - 166) / 14, 0) if may_total > 166 else 0,
|
|
574
|
+
projected=round((may_total - 166) / 14 * 31 + 166, 0) if may_total > 166 else 0)
|
|
575
|
+
|
|
576
|
+
finding_intent = fmt(finding_intent_tpl, api_ab=f"{api_ab:.1f}", panke_ab=f"{panke_ab:.1f}",
|
|
577
|
+
api_c=f"{api_c:.1f}", panke_c=f"{panke_c:.1f}")
|
|
578
|
+
|
|
579
|
+
summary_text = fmt(summary_text_tpl, week_total=week_total, wow_pct=f"{wow_pct:.1f}",
|
|
580
|
+
panke_pct=f"{panke_rate:.1f}", repeat_pct=f"{repeat_pct:.1f}")
|
|
581
|
+
|
|
582
|
+
"""生成完整HTML"""
|
|
583
|
+
# 意向等级bar chart
|
|
584
|
+
intent_api_bars = render_intent_bars(intent_api)
|
|
585
|
+
intent_panke_bars = render_intent_bars(intent_panke)
|
|
586
|
+
|
|
587
|
+
# 关注点表格
|
|
588
|
+
fp_table = '<table>\n <tr><th>关注点</th><th class="num">提及组数</th><th class="num">提及率</th></tr>\n'
|
|
589
|
+
for label, val, pct in focus_points:
|
|
590
|
+
fp_table += f' <tr><td>{label}</td><td class="num">{val}</td><td class="num">{pct:.1f}%</td></tr>\n'
|
|
591
|
+
fp_table += ' </table>'
|
|
592
|
+
|
|
593
|
+
# 关注点圆环图
|
|
594
|
+
fp_donut = donut_svg(focus_points)
|
|
595
|
+
|
|
596
|
+
# 环比颜色
|
|
597
|
+
wow_color = "#38A169" if wow_change < 0 else "#E53E3E"
|
|
598
|
+
wow_arrow = "↓" if wow_change < 0 else "↑"
|
|
599
|
+
first_chg = (first_visit - 130) / 130 * 100
|
|
600
|
+
repeat_chg = (repeat_visit - 25) / 25 * 100
|
|
601
|
+
avg_chg = (week_avg - 22) / 22 * 100
|
|
602
|
+
repeat_delta = f"{repeat_pct - 16.1:+.1f}"
|
|
603
|
+
|
|
604
|
+
html = f'''<!DOCTYPE html>
|
|
605
|
+
<html lang="zh-CN">
|
|
606
|
+
<head>
|
|
607
|
+
<meta charset="UTF-8">
|
|
608
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
609
|
+
<title>{project_name} 周报 {date_start}-{date_end}</title>
|
|
610
|
+
<style>
|
|
611
|
+
{CSS}
|
|
612
|
+
</style>
|
|
613
|
+
</head>
|
|
614
|
+
<body>
|
|
615
|
+
<div class="container">
|
|
616
|
+
|
|
617
|
+
<div class="header">
|
|
618
|
+
<div class="header-brand">
|
|
619
|
+
<img src="logo_wangai.png" alt="WangAI" title="WangAI智能分析引擎">
|
|
620
|
+
</div>
|
|
621
|
+
<div class="header-center">
|
|
622
|
+
<h1>{project_name} 营销周报</h1>
|
|
623
|
+
<div class="sub">{brand_text}</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="header-right">
|
|
626
|
+
<div class="period">{date_start} — {date_end.split(".")[1]}({week_label})</div>
|
|
627
|
+
<div class="cumulative">累计成交 <b>{deals_total}</b> 套 / <b>{deals_amount:,}</b> 万元</div>
|
|
628
|
+
<div class="gen-time">数据截至 {data_date} | 旺小宝WangAI生成</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<div class="card">
|
|
633
|
+
<div class="section-header"><span class="section-num">一</span> 核心数据一览</div>
|
|
634
|
+
<div class="kpi-row">
|
|
635
|
+
<div class="kpi-card primary">
|
|
636
|
+
<div class="kpi-label">本周来访</div>
|
|
637
|
+
<div class="kpi-value">{week_total}</div>
|
|
638
|
+
<div class="kpi-delta down">较上周{last_week_total}组 {wow_pct:.1f}%</div>
|
|
639
|
+
</div>
|
|
640
|
+
<div class="kpi-card">
|
|
641
|
+
<div class="kpi-label">首访 / 复访</div>
|
|
642
|
+
<div class="kpi-value">{first_visit} / {repeat_visit}</div>
|
|
643
|
+
<div class="kpi-delta">首访占比{first_pct:.1f}% | 复访{repeat_pct:.1f}%</div>
|
|
644
|
+
</div>
|
|
645
|
+
<div class="kpi-card">
|
|
646
|
+
<div class="kpi-label">日均来访</div>
|
|
647
|
+
<div class="kpi-value">{int(round(week_avg))}</div>
|
|
648
|
+
<div class="kpi-delta down">上周日均{int(round(last_week_total/7))}组</div>
|
|
649
|
+
</div>
|
|
650
|
+
<div class="kpi-card warning">
|
|
651
|
+
<div class="kpi-label">月度累计(1-18日)</div>
|
|
652
|
+
<div class="kpi-value">{may_total}</div>
|
|
653
|
+
<div class="kpi-delta">目标{visit_target}组 | 完成率{visit_progress:.1f}%</div>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="kpi-card success">
|
|
656
|
+
<div class="kpi-label">累计签约</div>
|
|
657
|
+
<div class="kpi-value">{deals_total}</div>
|
|
658
|
+
<div class="kpi-delta">总额{deals_amount/10000:.1f}亿 | 均价{deals_amount//deals_total if deals_total > 0 else 0}万</div>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
<table class="summary-table">
|
|
662
|
+
<tr><th>指标</th><th class="num">本周</th><th class="num">上周</th><th class="num">环比</th><th class="num">本月累计</th><th class="num">年度累计</th></tr>
|
|
663
|
+
<tr><td>来访量(组)</td><td class="num"><b>{week_total}</b></td><td class="num">{last_week_total}</td><td class="num" style="color:{wow_color}">{wow_pct:.1f}%</td><td class="num">{may_total}</td><td class="num">—</td></tr>
|
|
664
|
+
<tr><td>首访量(组)</td><td class="num">{first_visit}</td><td class="num">130</td><td class="num" style="color:#38A169">{first_chg:.1f}%</td><td class="num">—</td><td class="num">—</td></tr>
|
|
665
|
+
<tr><td>复访量(组)</td><td class="num">{repeat_visit}</td><td class="num">25</td><td class="num" style="color:#38A169">{repeat_chg:.1f}%</td><td class="num">—</td><td class="num">—</td></tr>
|
|
666
|
+
<tr><td>复访率</td><td class="num">{repeat_pct:.1f}%</td><td class="num">16.1%</td><td class="num" style="color:#E53E3E">{repeat_delta}pct</td><td class="num">—</td><td class="num">—</td></tr>
|
|
667
|
+
<tr><td>日均来访</td><td class="num">{int(round(week_avg))}</td><td class="num">{int(round(last_week_total/7))}</td><td class="num" style="color:#38A169">{avg_chg:.1f}%</td><td class="num">{may_total//may_days_count if may_days_count > 0 else 0}</td><td class="num">—</td></tr>
|
|
668
|
+
<tr><td>盘客完成率</td><td class="num">{panke_rate:.1f}%</td><td class="num">—</td><td class="num">—</td><td class="num">—</td><td class="num">—</td></tr>
|
|
669
|
+
<tr><td>签约套数(累计)</td><td class="num">—</td><td class="num">—</td><td class="num">—</td><td class="num">待更新</td><td class="num">{deals_total}</td></tr>
|
|
670
|
+
<tr><td>签约金额万元(累计)</td><td class="num">—</td><td class="num">—</td><td class="num">—</td><td class="num">待更新</td><td class="num">{deals_amount:,}</td></tr>
|
|
671
|
+
</table>
|
|
672
|
+
<div class="insight">
|
|
673
|
+
<strong>专家洞察:</strong>{insight_core}
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<div class="card">
|
|
678
|
+
<div class="section-header"><span class="section-num">二</span> 本周每日来访走势</div>
|
|
679
|
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
|
680
|
+
<span style="font-size:13px; color:#718096;">本周 {week_total} 组 vs 上周 {last_week_total} 组 | 环比 <span style="color:#38A169; font-weight:600;">{wow_pct:.1f}%</span></span>
|
|
681
|
+
<span style="font-size:12px; color:#A0AEC0;">柱子高度 = 实际来访量(最大{max(v for _,_,v in week_days)}组)</span>
|
|
682
|
+
</div>
|
|
683
|
+
<div class="vbar-chart">
|
|
684
|
+
{vbar_week(week_days, last_week_days)}
|
|
685
|
+
</div>
|
|
686
|
+
<div class="insight">
|
|
687
|
+
<strong>专家洞察:</strong>{insight_trend}
|
|
688
|
+
</div>
|
|
689
|
+
<div class="risk-box">
|
|
690
|
+
{risk_trend}
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
<div class="card">
|
|
695
|
+
<div class="section-header"><span class="section-num">三</span> 客户深度分析</div>
|
|
696
|
+
<p style="font-size:12px; color:#A0AEC0; margin-bottom:14px;">数据来源:盘客管理系统客户画像 + 旺小宝实时 API 客户样本</p>
|
|
697
|
+
|
|
698
|
+
<div class="section-sub">3.1 总价预算与支付力分析</div>
|
|
699
|
+
<div class="two-col">
|
|
700
|
+
<div>
|
|
701
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">总价预算分布</p>
|
|
702
|
+
<div class="bar-chart">{hbar(budgets)}</div>
|
|
703
|
+
</div>
|
|
704
|
+
<div>
|
|
705
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">付款方式偏好</p>
|
|
706
|
+
<div class="bar-chart">{hbar(payments)}</div>
|
|
707
|
+
<p style="font-size:12px; color:#4A5568; margin:16px 0 8px; font-weight:600;">首付能力判断</p>
|
|
708
|
+
<div class="bar-chart">{hbar(downpay)}</div>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
<div class="insight">
|
|
712
|
+
<strong>专家洞察:</strong>{insight_budget}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
<div class="section-sub">3.2 看房周期与决策成熟度</div>
|
|
716
|
+
<div class="two-col">
|
|
717
|
+
<div>
|
|
718
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">看房周期分布</p>
|
|
719
|
+
<div class="bar-chart">{hbar(cycles)}</div>
|
|
720
|
+
</div>
|
|
721
|
+
<div>
|
|
722
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">看房周期 × 意向等级交叉(有周期记录组)</p>
|
|
723
|
+
{cross_cycle_table(cross_cycle_intent)}
|
|
724
|
+
<p style="font-size:11px; color:#A0AEC0; margin-top:6px;">关注 A 级客户的看房周期分布,1 周内决策窗口的客户需优先跟进</p>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
<div class="insight">
|
|
728
|
+
<strong>专家洞察:</strong>{insight_cycle}
|
|
729
|
+
</div>
|
|
730
|
+
|
|
731
|
+
<div class="section-sub">3.3 置业目的与客户需求层级</div>
|
|
732
|
+
<div class="two-col">
|
|
733
|
+
<div>
|
|
734
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">置业目的分布</p>
|
|
735
|
+
<div class="bar-chart">{hbar(purposes)}</div>
|
|
736
|
+
<p style="font-size:12px; color:#4A5568; margin:16px 0 8px; font-weight:600;">置业目的 × 意向等级交叉</p>
|
|
737
|
+
{cross_purpose_table(cross_purpose_intent)}
|
|
738
|
+
</div>
|
|
739
|
+
<div>
|
|
740
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">客户关注层级(第一关注点)</p>
|
|
741
|
+
{fp_table}
|
|
742
|
+
<p style="font-size:11px; color:#A0AEC0; margin-top:6px;">注:盘客系统仅记录第一关注点,二三关注未完整采集</p>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
<div style="margin:14px 0;">{fp_donut}</div>
|
|
746
|
+
<div class="insight">
|
|
747
|
+
<strong>专家洞察:</strong>{insight_purpose}
|
|
748
|
+
</div>
|
|
749
|
+
|
|
750
|
+
<div class="section-sub">3.4 来访渠道与客户结构</div>
|
|
751
|
+
<div class="two-col">
|
|
752
|
+
<div>
|
|
753
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">渠道来访占比</p>
|
|
754
|
+
<div class="bar-chart">{hbar(channels)}</div>
|
|
755
|
+
</div>
|
|
756
|
+
<div>
|
|
757
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">渠道 × 意向等级交叉</p>
|
|
758
|
+
{cross_channel_table(cross_channel_intent)}
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="insight">
|
|
762
|
+
<strong>专家洞察:</strong>{insight_channel}
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div class="section-sub">3.5 核心抗性分析</div>
|
|
766
|
+
<div class="bar-chart">{hbar(resistance)}</div>
|
|
767
|
+
<div class="risk-box">{risk_resistance}</div>
|
|
768
|
+
<div class="insight">
|
|
769
|
+
<strong>专家洞察:</strong>{insight_resistance}
|
|
770
|
+
</div>
|
|
771
|
+
</div>
|
|
772
|
+
|
|
773
|
+
<div class="card">
|
|
774
|
+
<div class="section-header"><span class="section-num">四</span> 置业顾问来访承接情况</div>
|
|
775
|
+
{render_consultants(consultants_this)}
|
|
776
|
+
<div class="insight">
|
|
777
|
+
<strong>专家洞察:</strong>{insight_consultant}
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
|
|
781
|
+
<div class="card">
|
|
782
|
+
<div class="section-header"><span class="section-num">五</span> 意向等级分布</div>
|
|
783
|
+
<div class="two-col">
|
|
784
|
+
<div>
|
|
785
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">本周旺小宝实时样本</p>
|
|
786
|
+
<div class="bar-chart">{intent_api_bars}</div>
|
|
787
|
+
</div>
|
|
788
|
+
<div>
|
|
789
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">盘客系统存量画像</p>
|
|
790
|
+
<div class="bar-chart">{intent_panke_bars}</div>
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
<div class="finding">{finding_intent}</div>
|
|
794
|
+
<div class="insight">
|
|
795
|
+
<strong>专家洞察:</strong>{insight_intent}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
<div class="card">
|
|
800
|
+
<div class="section-header"><span class="section-num">六</span> 成交数据分析</div>
|
|
801
|
+
<div class="kpi-row">
|
|
802
|
+
<div class="kpi-card primary">
|
|
803
|
+
<div class="kpi-label">累计签约套数</div>
|
|
804
|
+
<div class="kpi-value">{deals_total}</div>
|
|
805
|
+
<div class="kpi-delta">历年累计签约</div>
|
|
806
|
+
</div>
|
|
807
|
+
<div class="kpi-card success">
|
|
808
|
+
<div class="kpi-label">累计签约金额</div>
|
|
809
|
+
<div class="kpi-value">{deals_amount/10000:.1f}亿</div>
|
|
810
|
+
<div class="kpi-delta">均价{deals_amount//deals_total if deals_total > 0 else 0}万/套</div>
|
|
811
|
+
</div>
|
|
812
|
+
<div class="kpi-card">
|
|
813
|
+
<div class="kpi-label">月均签约</div>
|
|
814
|
+
<div class="kpi-value">—</div>
|
|
815
|
+
<div class="kpi-delta">按成交台账统计</div>
|
|
816
|
+
</div>
|
|
817
|
+
<div class="kpi-card warning">
|
|
818
|
+
<div class="kpi-label">近期签约</div>
|
|
819
|
+
<div class="kpi-value">待更新</div>
|
|
820
|
+
<div class="kpi-delta">以最新成交台账为准</div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
<div class="two-col">
|
|
824
|
+
<div>
|
|
825
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">成交户型分布({deals_total}套)</p>
|
|
826
|
+
<div class="bar-chart">{hbar(house_type_deals)}</div>
|
|
827
|
+
</div>
|
|
828
|
+
<div>
|
|
829
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">成交渠道分布({deals_total}套)</p>
|
|
830
|
+
<div class="bar-chart">{hbar(channel_deals)}</div>
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
<p style="font-size:12px; color:#4A5568; margin:16px 0 8px; font-weight:600;">成交周期分布</p>
|
|
834
|
+
<div class="bar-chart">{hbar(deal_cycle)}</div>
|
|
835
|
+
<p style="font-size:12px; color:#4A5568; margin:16px 0 8px; font-weight:600;">2025-2026月度成交趋势</p>
|
|
836
|
+
{render_monthly_trend(monthly_trend, monthly_trend_total)}
|
|
837
|
+
<div class="insight">
|
|
838
|
+
<strong>专家洞察:</strong>{insight_deals}
|
|
839
|
+
</div>
|
|
840
|
+
<div class="finding">{finding_deals}</div>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
<div class="card">
|
|
844
|
+
<div class="section-header"><span class="section-num">七</span> 月度累计进度(截至5.18)</div>
|
|
845
|
+
<div class="kpi-row">
|
|
846
|
+
<div class="kpi-card primary">
|
|
847
|
+
<div class="kpi-label">本月来访累计</div>
|
|
848
|
+
<div class="kpi-value">{may_total}</div>
|
|
849
|
+
<div class="kpi-delta">目标{visit_target}组</div>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="kpi-card success">
|
|
852
|
+
<div class="kpi-label">来访完成率</div>
|
|
853
|
+
<div class="kpi-value">{visit_progress:.1f}%</div>
|
|
854
|
+
<div class="kpi-delta">时间进度{time_progress:.1f}%</div>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="kpi-card">
|
|
857
|
+
<div class="kpi-label">进度达成率</div>
|
|
858
|
+
<div class="kpi-value" style="color:#38A169">{achievement_rate:.1f}%</div>
|
|
859
|
+
<div class="kpi-delta">超额完成</div>
|
|
860
|
+
</div>
|
|
861
|
+
<div class="kpi-card warning">
|
|
862
|
+
<div class="kpi-label">本月签约金额</div>
|
|
863
|
+
<div class="kpi-value">待更新</div>
|
|
864
|
+
<div class="kpi-delta">目标2.1亿</div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
<p style="font-size:12px; color:#4A5568; margin-bottom:8px; font-weight:600;">5月每日来访走势(1-18日)</p>
|
|
868
|
+
<div class="month-chart">{vbar_month(may_days)}</div>
|
|
869
|
+
<p style="font-size:11px; color:#A0AEC0; text-align:center; margin-top:4px;">深蓝色=周末 | 浅蓝色=工作日 | 柱子高度=实际来访量</p>
|
|
870
|
+
<table>
|
|
871
|
+
<tr><th>指标</th><th class="num">目标值</th><th class="num">实际值</th><th class="num">完成率</th><th class="num">缺口</th></tr>
|
|
872
|
+
<tr><td>来访量(组)</td><td class="num">{visit_target}</td><td class="num">{may_total}</td><td class="num" style="color:#38A169; font-weight:600">{visit_progress:.1f}%</td><td class="num">{visit_target - may_total}</td></tr>
|
|
873
|
+
<tr><td>日均来访</td><td class="num">16</td><td class="num">{may_total//may_days_count if may_days_count > 0 else 0}</td><td class="num" style="color:#38A169">{(may_total//may_days_count)/16*100:.1f}%</td><td class="num">—</td></tr>
|
|
874
|
+
<tr><td>签约金额(万)</td><td class="num">21,000</td><td class="num">待更新</td><td class="num">—</td><td class="num">—</td></tr>
|
|
875
|
+
<tr><td>时间进度</td><td class="num">31天</td><td class="num">18天</td><td class="num">{time_progress:.1f}%</td><td class="num">13天</td></tr>
|
|
876
|
+
</table>
|
|
877
|
+
<div class="insight">
|
|
878
|
+
<strong>专家洞察:</strong>{insight_monthly}
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
<div class="card">
|
|
883
|
+
<div class="section-header"><span class="section-num">八</span> 下周工作建议</div>
|
|
884
|
+
{render_suggestions(suggestions)}
|
|
885
|
+
<div class="finding">
|
|
886
|
+
<span class="tag">总结</span>{summary_text}
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<div class="footer-bar">
|
|
891
|
+
<div class="footer-brand">
|
|
892
|
+
<img src="logo_wangxiaobao.png" alt="旺小宝">
|
|
893
|
+
<span>数据来源:旺小宝智能案场系统 · 项目营销数据中台</span>
|
|
894
|
+
</div>
|
|
895
|
+
<div class="footer-brand">
|
|
896
|
+
<span>AI生成:WangAI 智能分析引擎</span>
|
|
897
|
+
<img src="logo_wangai.png" alt="WangAI">
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
</div>
|
|
902
|
+
</body>
|
|
903
|
+
</html>'''
|
|
904
|
+
return html
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# ================================================================
|
|
908
|
+
# 输出
|
|
909
|
+
# ================================================================
|
|
910
|
+
|
|
911
|
+
if __name__ == "__main__":
|
|
912
|
+
output_file = f"{project_name.replace('·','').replace(' ','_')}_周报_{date_start.replace('.','')}-{date_end.replace('.','')}.html"
|
|
913
|
+
html = generate_html()
|
|
914
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
915
|
+
f.write(html)
|
|
916
|
+
print(f"OK: {output_file} ({len(html)} chars)")
|
|
917
|
+
|
|
918
|
+
# 生成 PDF
|
|
919
|
+
pdf = generate_pdf(output_file)
|
|
920
|
+
if pdf:
|
|
921
|
+
print(f"DONE: HTML + PDF 已全部生成")
|
|
922
|
+
print(f" HTML: {os.path.abspath(output_file)}")
|
|
923
|
+
print(f" PDF: {os.path.abspath(pdf)}")
|
|
924
|
+
else:
|
|
925
|
+
print(f"DONE: HTML 已生成,PDF 生成失败(详见上方错误信息)")
|