Ethscriptions commited on
Commit
3812465
·
verified ·
1 Parent(s): 5613e0b

Upload 🆕 新API影片映出日累计报表测试.py

Browse files
pages/🆕 新API影片映出日累计报表测试.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """新 API(center.hengdianfilm.com / cinema/movieshow/page)测试页面。
2
+
3
+ 复用 app.py 的「影片映出日累计报表」生成逻辑,但数据来源切换为:
4
+ 1. 直接调用新 API 拉取(需 Bearer Token)
5
+ 2. 手动粘贴 API 返回 JSON 文本
6
+
7
+ 最终生成同样格式的报表,并支持下载 XLSX。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import json
14
+ import os
15
+ import re
16
+ from datetime import date as dt_date, datetime, time as dt_time, timedelta
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import requests
22
+ import streamlit as st
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 页面配置
27
+ # ---------------------------------------------------------------------------
28
+ st.set_page_config(layout="wide", page_title="🆕 新 API 影片映出日累计报表测试")
29
+ st.title("🆕 新 API 影片映出日累计报表测试")
30
+ st.caption(
31
+ "测试 `https://center.hengdianfilm.com/cinema/movieshow/page` 接口,"
32
+ "数据可由 API 抓取或直接粘贴 JSON 返回。生成与 `app.py` 一致格式的「影片映出日累计报表」。"
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # 常量与路径
38
+ # ---------------------------------------------------------------------------
39
+ ROOT_DIR = Path(__file__).resolve().parent.parent
40
+ CINEMA_CACHE_DIR = ROOT_DIR / "cinema_cache"
41
+ MOVIE_NUM_NAME_MAP_FILE = CINEMA_CACHE_DIR / "movie_num_name_map.json"
42
+ NEW_API_URL = "https://center.hengdianfilm.com/cinema/movieshow/page"
43
+ DEFAULT_PAGE_SIZE = 200
44
+ DEFAULT_BEARER_TOKEN = ""
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 工具函数(与 app.py 保持一致;为保持本页独立,做了最小复制)
49
+ # ---------------------------------------------------------------------------
50
+ def _normalize_movie_num_key(movie_num) -> str:
51
+ return re.sub(r"[^A-Z0-9]", "", str(movie_num or "").strip().upper())
52
+
53
+
54
+ @st.cache_data(show_spinner=False, ttl=120)
55
+ def _load_movie_num_name_map() -> dict:
56
+ """读取 cinema_cache/movie_num_name_map.json,返回 {规范化 movieNum: 官方名称}。"""
57
+ if not MOVIE_NUM_NAME_MAP_FILE.exists():
58
+ return {}
59
+ try:
60
+ with open(MOVIE_NUM_NAME_MAP_FILE, "r", encoding="utf-8") as f:
61
+ payload = json.load(f)
62
+ except Exception:
63
+ return {}
64
+ movie_num_map = payload.get("movie_num_map", {})
65
+ if not isinstance(movie_num_map, dict):
66
+ return {}
67
+ result = {}
68
+ for movie_num, entry in movie_num_map.items():
69
+ key = _normalize_movie_num_key(movie_num)
70
+ if not key:
71
+ continue
72
+ if isinstance(entry, str):
73
+ name = entry.strip()
74
+ elif isinstance(entry, dict):
75
+ name = str(entry.get("official_name") or "").strip()
76
+ else:
77
+ continue
78
+ if name:
79
+ result[key] = name
80
+ return result
81
+
82
+
83
+ def clean_movie_title(raw_title, canonical_names=None):
84
+ """与 app.py 同名函数完全一致的精简版。"""
85
+ if not isinstance(raw_title, str):
86
+ return raw_title
87
+ base_name = None
88
+ if canonical_names:
89
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
90
+ for name in sorted_names:
91
+ if name in raw_title:
92
+ base_name = name
93
+ break
94
+ if not base_name:
95
+ base_name = raw_title.split(" ", 1)[0]
96
+
97
+ raw_upper = raw_title.upper()
98
+ suffix = ""
99
+ if "HDR LED" in raw_upper:
100
+ suffix = "(HDR LED)"
101
+ elif "CINITY" in raw_upper:
102
+ suffix = "(CINITY)"
103
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
104
+ suffix = "(杜比视界)"
105
+ elif "IMAX" in raw_upper:
106
+ suffix = "(数字IMAX3D)" if "3D" in raw_upper else "(数字IMAX)"
107
+ elif "巨幕" in raw_upper:
108
+ suffix = "(中国巨幕立体)" if "立体" in raw_upper else "(中国巨幕)"
109
+ elif "3D" in raw_upper:
110
+ suffix = "(数字3D)"
111
+
112
+ if suffix and suffix not in base_name:
113
+ return f"{base_name}{suffix}"
114
+ return base_name
115
+
116
+
117
+ def resolve_movie_name_from_schedule_item(raw_movie_name, movie_num=None, canonical_names=None):
118
+ """与 app.py 同名函数一致:优先用 movieNum 映射,回退到 canonical_names / 空格分割。"""
119
+ if not isinstance(raw_movie_name, str):
120
+ return raw_movie_name
121
+ if movie_num:
122
+ key = _normalize_movie_num_key(movie_num)
123
+ if key:
124
+ official = _load_movie_num_name_map().get(key)
125
+ if official:
126
+ return official
127
+
128
+ if canonical_names:
129
+ for name in sorted(canonical_names, key=len, reverse=True):
130
+ if name in raw_movie_name:
131
+ return name
132
+ return raw_movie_name.split(" ", 1)[0]
133
+
134
+
135
+ def normalize_report_time_value(value):
136
+ if pd.isna(value):
137
+ return None
138
+ if isinstance(value, datetime):
139
+ return value.time().replace(second=0, microsecond=0)
140
+ if isinstance(value, dt_time):
141
+ return value.replace(second=0, microsecond=0)
142
+ parsed = pd.to_datetime(str(value).strip(), errors="coerce")
143
+ if pd.isna(parsed):
144
+ return None
145
+ return parsed.time().replace(second=0, microsecond=0)
146
+
147
+
148
+ def infer_daily_report_date(date_series, fallback_date_str=None):
149
+ parsed = pd.to_datetime(date_series, errors="coerce").dropna()
150
+ if not parsed.empty:
151
+ return parsed.dt.date.mode().iloc[0]
152
+ if fallback_date_str:
153
+ fb = pd.to_datetime(fallback_date_str, errors="coerce")
154
+ if pd.notna(fb):
155
+ return fb.date()
156
+ return None
157
+
158
+
159
+ def build_daily_report_from_source_df(source_df, selected_date_str=None, canonical_names=None):
160
+ """与 app.py 中 build_daily_report_from_source_df 一致,返回 (df, display_date)。"""
161
+ if source_df is None or source_df.empty:
162
+ return pd.DataFrame(), infer_daily_report_date(pd.Series(dtype=object), selected_date_str)
163
+
164
+ required_cols = ["影片名称", "放映时间", "影厅名称", "总人次", "座位数"]
165
+ missing = [c for c in required_cols if c not in source_df.columns]
166
+ if missing:
167
+ st.error(f"数据缺少必要列: {', '.join(missing)}")
168
+ return pd.DataFrame(), None
169
+
170
+ df = source_df.copy()
171
+ if "放映日期" not in df.columns:
172
+ df["放映日期"] = selected_date_str
173
+
174
+ display_date = infer_daily_report_date(df["放映日期"], selected_date_str)
175
+ display_date_str = display_date.strftime("%Y-%m-%d") if display_date else selected_date_str
176
+
177
+ df["影片名称"] = df["影片名称"].astype(str).str.strip()
178
+ df["影厅名称"] = df["影厅名称"].fillna("未知影厅").astype(str).str.strip()
179
+ df["总人次"] = pd.to_numeric(df["总人次"], errors="coerce").fillna(0).round().astype(int)
180
+ df["座位数"] = pd.to_numeric(df["座位数"], errors="coerce").fillna(0).round().astype(int)
181
+ df["_放映时间对象"] = df["放映时间"].apply(normalize_report_time_value)
182
+ df["放映日期"] = pd.to_datetime(df["放映日期"], errors="coerce").dt.strftime("%Y-%m-%d")
183
+ if display_date_str:
184
+ df["放映日期"] = df["放映日期"].fillna(display_date_str)
185
+
186
+ df.dropna(subset=["影片名称", "_放映时间对象"], inplace=True)
187
+ df = df[df["影片名称"].ne("") & df["影片名称"].ne("nan")].copy()
188
+ df = df[df["总人次"] > 0].copy()
189
+
190
+ if df.empty:
191
+ st.info("所有场次的观影人数均为 0,没有可显示的数据。")
192
+ return pd.DataFrame(), display_date
193
+
194
+ if "movieNum" in df.columns:
195
+ df["影片"] = df.apply(
196
+ lambda row: resolve_movie_name_from_schedule_item(
197
+ row["影片名称"],
198
+ movie_num=row.get("movieNum"),
199
+ canonical_names=canonical_names,
200
+ ),
201
+ axis=1,
202
+ )
203
+ else:
204
+ df["影片"] = df["影片名称"].apply(lambda x: clean_movie_title(x, canonical_names))
205
+ df["影厅"] = df["影厅名称"]
206
+ df["人数合计"] = df["总人次"]
207
+
208
+ with np.errstate(divide="ignore", invalid="ignore"):
209
+ df["上座率%"] = np.divide(df["人数合计"], df["座位数"]) * 100
210
+ df["上座率%"] = df["上座率%"].replace([np.inf, -np.inf], 0).fillna(0)
211
+
212
+ df["放映时间"] = df["_放映时间对象"].apply(lambda x: x.strftime("%H:%M:%S"))
213
+ result = df[["影片", "放映日期", "放映时间", "影厅", "人数合计", "座位数", "上座率%"]].copy()
214
+ result = result.sort_values(by=["放映日期", "放映时间", "影厅", "影片"]).reset_index(drop=True)
215
+ return result, display_date
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # 新 API 数据获取与解析
220
+ # ---------------------------------------------------------------------------
221
+ def _build_request_headers(bearer_token: str) -> dict:
222
+ return {
223
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0",
224
+ "Accept": "application/json, text/plain, */*",
225
+ "Accept-Language": "zh-CN,zh;q=0.9",
226
+ "Content-Type": "application/json;charset=utf-8",
227
+ "Authorization": f"Bearer {bearer_token.strip()}",
228
+ "TENANT-ID": "1",
229
+ "Channel": "4",
230
+ "Origin": "https://center.hengdianfilm.com",
231
+ "Referer": "https://center.hengdianfilm.com/",
232
+ "Connection": "keep-alive",
233
+ }
234
+
235
+
236
+ def fetch_movieshow_page(bearer_token: str, show_date: str, current: int = 1, size: int = DEFAULT_PAGE_SIZE):
237
+ """单次拉取一页排片数据。返回原始 JSON dict。"""
238
+ payload = {
239
+ "size": size,
240
+ "current": current,
241
+ "entity": {"cinemaMovieShowDate": show_date},
242
+ }
243
+ resp = requests.post(
244
+ NEW_API_URL,
245
+ headers=_build_request_headers(bearer_token),
246
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
247
+ timeout=20,
248
+ )
249
+ resp.raise_for_status()
250
+ return resp.json()
251
+
252
+
253
+ def fetch_all_movieshow(bearer_token: str, show_date: str, size: int = DEFAULT_PAGE_SIZE) -> list:
254
+ """自动翻页拉取所有场次记录。"""
255
+ all_records: list = []
256
+ current = 1
257
+ while True:
258
+ data = fetch_movieshow_page(bearer_token, show_date, current=current, size=size)
259
+ if data.get("code") != 0:
260
+ raise RuntimeError(f"接口返回失败:{data.get('msg') or data}")
261
+ body = data.get("data") or {}
262
+ records = body.get("records") or []
263
+ all_records.extend(records)
264
+ total = int(body.get("total") or 0)
265
+ if len(all_records) >= total or not records:
266
+ break
267
+ current += 1
268
+ if current > 50: # 安全阈值
269
+ break
270
+ return all_records
271
+
272
+
273
+ def parse_pasted_response(raw_text: str) -> list:
274
+ """解析用户粘贴的 JSON 响应文本,返回 records 列表。"""
275
+ text = (raw_text or "").strip()
276
+ if not text:
277
+ raise ValueError("粘贴内容为空。")
278
+ try:
279
+ payload = json.loads(text)
280
+ except json.JSONDecodeError as exc:
281
+ raise ValueError(f"JSON 解析失败:{exc}") from exc
282
+
283
+ # 用户既可能粘贴整个响应,也可能直接粘贴 records 列表
284
+ if isinstance(payload, list):
285
+ return payload
286
+ if not isinstance(payload, dict):
287
+ raise ValueError("无法识别的 JSON 结构,期望对象或数组。")
288
+
289
+ if "data" in payload and isinstance(payload["data"], dict):
290
+ records = payload["data"].get("records")
291
+ if isinstance(records, list):
292
+ return records
293
+ if "records" in payload and isinstance(payload["records"], list):
294
+ return payload["records"]
295
+ raise ValueError("未在 JSON 中找到 records 数组。请粘贴接口完整返回或 records 列表。")
296
+
297
+
298
+ def records_to_source_df(records: list) -> pd.DataFrame:
299
+ """将新 API 返回的场次列表转换成 build_daily_report_from_source_df 期望的列结构。"""
300
+ if not records:
301
+ return pd.DataFrame()
302
+ rows = []
303
+ for item in records:
304
+ if not isinstance(item, dict):
305
+ continue
306
+ rows.append(
307
+ {
308
+ "影片名称": item.get("cinemaMovieName") or "",
309
+ "放映时间": item.get("cinemaMovieShowStartTime") or "",
310
+ "影厅名称": item.get("cinemaHallName") or "",
311
+ "总人次": item.get("cinemaMovieShowSoldNum") or 0,
312
+ "座位数": item.get("cinemaHallSeatNum") or 0,
313
+ "放映日期": item.get("cinemaMovieShowDate") or "",
314
+ "movieNum": item.get("cinemaMovieNum") or "",
315
+ }
316
+ )
317
+ return pd.DataFrame(rows)
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Session State 初始化
322
+ # ---------------------------------------------------------------------------
323
+ def _init_state():
324
+ defaults = {
325
+ "new_api_bearer_token": os.getenv("HENGDIAN_CENTER_TOKEN", DEFAULT_BEARER_TOKEN),
326
+ "new_api_query_date": dt_date.today() + timedelta(days=1),
327
+ "new_api_records": [],
328
+ "new_api_report_df": pd.DataFrame(),
329
+ "new_api_display_date": None,
330
+ "new_api_data_source": "",
331
+ "new_api_paste_text": "",
332
+ }
333
+ for key, value in defaults.items():
334
+ st.session_state.setdefault(key, value)
335
+
336
+
337
+ _init_state()
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # UI
342
+ # ---------------------------------------------------------------------------
343
+ with st.expander("📘 接口说明", expanded=False):
344
+ st.markdown(
345
+ f"""
346
+ - **接口**:`POST {NEW_API_URL}`
347
+ - **必备 Header**:`Authorization: Bearer <token>`、`TENANT-ID: 1`、`Channel: 4`、`Content-Type: application/json;charset=utf-8`
348
+ - **请求体**:`{{"size": 100, "current": 1, "entity": {{"cinemaMovieShowDate": "YYYY-MM-DD"}}}}`
349
+ - **关键返回字段**:
350
+ - `cinemaMovieName` → 影片名称(含制式)
351
+ - `cinemaMovieNum` → 影片编号(用于 `movie_num_name_map.json` 标准名映射)
352
+ - `cinemaMovieShowStartTime` → 放映开始时间
353
+ - `cinemaHallName` / `cinemaHallSeatNum` → 影厅名称 / 座位数
354
+ - `cinemaMovieShowSoldNum` → 已售人次
355
+ """
356
+ )
357
+
358
+ with st.expander("🔑 如何获取 Bearer Token(浏览器开发者工具教程)", expanded=False):
359
+ st.markdown(
360
+ """
361
+ > Token 是登录后由浏览器临时保存的身份凭证,**有效期通常只有几小时到一天**,失效后需要重新抓取。
362
+
363
+ #### 方法 A:从「Network(网络)」面板抓取(推荐,最准确)
364
+
365
+ 1. 用 Chrome / Edge / Firefox 打开 [https://center.hengdianfilm.com/](https://center.hengdianfilm.com/) 并**正常登录**。
366
+ 2. 按 **F12**(或右键 → **检查 / Inspect**)打开开发者工具,切换到 **Network(网络)** 面板。
367
+ 3. 勾选 **Preserve log(保留日志)**,在过滤框里输入关键字 `movieshow` 或 `cinema/`,过滤出业务接口。
368
+ 4. 在网页上**点击任意会触发刷新数据的操作**(例如切换日期、点查询),让接口重新发一次请求。
369
+ 5. 在过滤结果里点击��条形如 `movieshow/page` 的请求 → 切到 **Headers(标头)** 子面板 → 找到 **Request Headers(请求标头)**。
370
+ 6. 复制 `Authorization:` 这一行后面的内容,**去掉开头的 `Bearer `**,只保留后面那串形如 `40ea061e-598e-4e6f-8f98-...` 的字符串,粘贴到下方输入框即可。
371
+
372
+ #### 方法 B:从「Application / 存储」面板里直接读取
373
+
374
+ 1. 同样打开开发者工具,切到 **Application(应用)** 面板(Firefox 叫 **Storage / 存储**)。
375
+ 2. 左侧依次点开 **Local Storage** 和 **Cookies**,目标域名选 `https://center.hengdianfilm.com`。
376
+ 3. 在右侧的键值对里搜索关键字: `token`、`access_token`、`Authorization`、`auth`、`satoken`。
377
+ 4. 找到形如 UUID(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)的值,复制粘贴到下方输入框。
378
+
379
+ #### 方法 C:Console(控制台)一键提取
380
+
381
+ 打开 **Console(控制台)** 面板,粘贴下面任意一行回车,即可直接打印出 token:
382
+
383
+ ```js
384
+ // 如果存在 localStorage 里
385
+ copy(localStorage.getItem('Authorization') || localStorage.getItem('token') || localStorage.getItem('access_token'));
386
+ ```
387
+
388
+ ```js
389
+ // 一键查找所有可能键
390
+ Object.keys(localStorage).filter(k => /token|auth/i.test(k)).forEach(k => console.log(k, '=', localStorage.getItem(k)));
391
+ ```
392
+
393
+ 执行 `copy(...)` 后 token 会被复制到剪贴板,直接粘贴即可。
394
+
395
+ #### 常见问题
396
+
397
+ - **过期 / 401 Unauthorized** → 重新登录 hengdianfilm 后台,再次按上面步骤抓取。
398
+ - **找不到 `Authorization` 头** → 确认你已经登录,并且过滤的是业务接口(URL 含 `/cinema/`),而不是登录接口本身。
399
+ - **复制时多了 `Bearer ` 前缀** → 输入框里**不要**带 `Bearer `,代码里会自动拼接。
400
+ """
401
+ )
402
+
403
+ input_tab_api, input_tab_paste = st.tabs(["🌐 API 拉取", "📋 粘贴 JSON"])
404
+
405
+
406
+ with input_tab_api:
407
+ col_token, col_date = st.columns([3, 2])
408
+ with col_token:
409
+ bearer_input = st.text_input(
410
+ "Bearer Token",
411
+ value=st.session_state.new_api_bearer_token,
412
+ help="对应 curl 中的 `Authorization: Bearer <token>`。",
413
+ key="new_api_bearer_input",
414
+ )
415
+ with col_date:
416
+ query_date = st.date_input(
417
+ "查询排片日期",
418
+ value=st.session_state.new_api_query_date,
419
+ key="new_api_date_input",
420
+ )
421
+
422
+ page_size = st.slider("每页大小(自动翻页直到取完)", min_value=50, max_value=500, value=DEFAULT_PAGE_SIZE, step=50)
423
+
424
+ if st.button("🫵 拉取并生成报表", type="primary", use_container_width=False):
425
+ token = (bearer_input or "").strip()
426
+ if not token:
427
+ st.error("请先填写 Bearer Token。")
428
+ else:
429
+ st.session_state.new_api_bearer_token = token
430
+ st.session_state.new_api_query_date = query_date
431
+ date_str = query_date.strftime("%Y-%m-%d")
432
+ with st.spinner(f"正在调用接口拉取 {date_str} 的场次..."):
433
+ try:
434
+ records = fetch_all_movieshow(token, date_str, size=page_size)
435
+ except requests.HTTPError as exc:
436
+ st.error(f"HTTP 错误:{exc.response.status_code} {exc.response.reason}")
437
+ records = []
438
+ except Exception as exc: # noqa: BLE001
439
+ st.error(f"调用接口失败:{exc}")
440
+ records = []
441
+
442
+ if records:
443
+ st.session_state.new_api_records = records
444
+ st.session_state.new_api_data_source = f"API 拉取({len(records)} 条)"
445
+ source_df = records_to_source_df(records)
446
+ report_df, display_date = build_daily_report_from_source_df(
447
+ source_df,
448
+ selected_date_str=date_str,
449
+ )
450
+ st.session_state.new_api_report_df = report_df
451
+ st.session_state.new_api_display_date = display_date or query_date
452
+ if not report_df.empty:
453
+ st.toast(f"成功生成 {len(report_df)} 条报表数据。", icon="✅")
454
+
455
+
456
+ with input_tab_paste:
457
+ st.markdown("将接口返回的整个 JSON 文本(或 `data.records` 数组)粘贴到下方:")
458
+ paste_text = st.text_area(
459
+ "JSON 文本",
460
+ value=st.session_state.new_api_paste_text,
461
+ height=260,
462
+ placeholder='{"code":0,"data":{"records":[ ... ]}}',
463
+ key="new_api_paste_textarea",
464
+ )
465
+ fallback_date = st.date_input(
466
+ "用于补充缺失日期的回退值",
467
+ value=st.session_state.new_api_query_date,
468
+ key="new_api_paste_fallback_date",
469
+ )
470
+
471
+ if st.button("📥 解析并生成报表", key="new_api_parse_btn"):
472
+ st.session_state.new_api_paste_text = paste_text
473
+ try:
474
+ records = parse_pasted_response(paste_text)
475
+ except ValueError as exc:
476
+ st.error(str(exc))
477
+ records = []
478
+
479
+ if records:
480
+ st.session_state.new_api_records = records
481
+ st.session_state.new_api_data_source = f"粘贴 JSON({len(records)} 条)"
482
+ source_df = records_to_source_df(records)
483
+ fallback_str = fallback_date.strftime("%Y-%m-%d")
484
+ report_df, display_date = build_daily_report_from_source_df(
485
+ source_df,
486
+ selected_date_str=fallback_str,
487
+ )
488
+ st.session_state.new_api_report_df = report_df
489
+ st.session_state.new_api_display_date = display_date or fallback_date
490
+ if not report_df.empty:
491
+ st.toast(f"成功生成 {len(report_df)} 条报表数据。", icon="✅")
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # 报表展示与下载
496
+ # ---------------------------------------------------------------------------
497
+ st.divider()
498
+
499
+ report_df: pd.DataFrame = st.session_state.new_api_report_df
500
+ display_date = st.session_state.new_api_display_date
501
+
502
+ if isinstance(report_df, pd.DataFrame) and not report_df.empty:
503
+ if display_date is None:
504
+ display_date = dt_date.today()
505
+ st.caption(f"当前数据来源:{st.session_state.new_api_data_source}")
506
+ st.markdown(f"#### {display_date.strftime('%Y-%m-%d')} 影片映出日累计报表")
507
+
508
+ st.dataframe(
509
+ report_df.style.format(
510
+ {"人数合计": "{:,.0f}", "座位数": "{:,.0f}", "上座率%": "{:.2f}%"}
511
+ ),
512
+ width="stretch",
513
+ hide_index=True,
514
+ )
515
+
516
+ total_attendance = pd.to_numeric(report_df.get("人数合计", 0), errors="coerce").fillna(0).sum()
517
+ st.markdown(
518
+ f"""
519
+ <div style="margin: 12px 0 18px; padding: 14px 18px; border-left: 6px solid #D83B01; background: #FFF4ED;">
520
+ <span style="font-size: 18px; font-weight: 700; color: #5C1F00;">已售人次:</span>
521
+ <span style="font-size: 26px; font-weight: 800; color: #D83B01;">{total_attendance:,.0f}</span>
522
+ <span style="font-size: 18px; font-weight: 700; color: #5C1F00;"> 人</span>
523
+ </div>
524
+ """,
525
+ unsafe_allow_html=True,
526
+ )
527
+
528
+ output_buffer = io.BytesIO()
529
+ report_df.to_excel(output_buffer, index=False, engine="openpyxl")
530
+ st.download_button(
531
+ label="📥 下载 XLSX 报表文件",
532
+ data=output_buffer.getvalue(),
533
+ file_name=f"{display_date.strftime('%Y-%m-%d')}_影片映出日累计报表.xlsx",
534
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
535
+ )
536
+
537
+ with st.expander("查看原始接口数据(最多 10 条)"):
538
+ sample = st.session_state.new_api_records[:10]
539
+ st.json(sample)
540
+ else:
541
+ st.info("尚无数据。请通过「API 拉取」或「粘贴 JSON」生成报表。")