Files
gaokao-volunteer-system/admin/share_page.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

351 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T7.5 分享页 WebUI 纯函数与默认文件加载器。"""
from __future__ import annotations
import json
import re
from html import escape
from pathlib import Path
from typing import Any, Iterable, Optional
from data.share.short_link import (
STATUS_EXPIRED,
STATUS_NOT_FOUND,
STATUS_PASSWORD_REQUIRED,
STATUS_PASSWORD_WRONG,
STATUS_REVOKED,
)
_STATUS_META = {
STATUS_NOT_FOUND: (404, "分享链接不存在", "请确认链接是否完整,或联系龙老师重新生成。"),
STATUS_REVOKED: (410, "分享已撤销", "该分享链接已被撤销,无法继续访问。"),
STATUS_EXPIRED: (410, "分享已过期", "该分享链接已经过期,请联系龙老师重新分享。"),
STATUS_PASSWORD_REQUIRED: (401, "需要访问密码", "该分享页已加密,请输入访问密码继续查看。"),
STATUS_PASSWORD_WRONG: (401, "密码错误", "密码不正确,请重新输入。"),
}
def load_report_from_directory(report_id: str, report_dir: str | Path) -> Optional[dict[str, Any]]:
"""从目录加载 ``{report_id}.json`` 报告。不存在或格式错误时返回 None。"""
if not report_id:
return None
if re.fullmatch(r"[A-Za-z0-9._-]+", report_id) is None:
return None
if report_id.startswith(".") or ".." in report_id:
return None
base_dir = Path(report_dir).resolve()
path = (base_dir / f"{report_id}.json").resolve()
if base_dir not in path.parents:
return None
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def status_code_for_result(result: dict[str, Any]) -> int:
status = str(result.get("status") or "")
if status == "ok":
return 200
return _STATUS_META.get(status, (400, "访问失败", "分享页当前不可用。"))[0]
def render_share_page(result: dict[str, Any], *, password: str | None = None) -> str:
status = str(result.get("status") or "")
if status != "ok":
return _render_status_page(status, code=str(result.get("code") or ""), password=password)
return _render_ok_page(result)
def _render_status_page(status: str, *, code: str, password: str | None) -> str:
_, title, message = _STATUS_META.get(status, (400, "访问失败", "分享页当前不可用。"))
error_block = ""
form_block = ""
if status == STATUS_PASSWORD_WRONG:
error_block = '<p class="error">密码错误,请重新输入。</p>'
if status in {STATUS_PASSWORD_REQUIRED, STATUS_PASSWORD_WRONG}:
form_block = f"""
<form class="password-form" method="get" action="/s/{escape(code)}">
<label for="pwd">访问密码</label>
<input id="pwd" name="pwd" type="password" autocomplete="current-password" placeholder="请输入访问密码" />
<button type="submit">进入分享页</button>
</form>
"""
return _page_shell(
title,
f"""
<section class="hero compact">
<span class="badge badge-warn">分享访问</span>
<h1>{escape(title)}</h1>
<p class="lead">{escape(message)}</p>
{error_block}
{form_block}
</section>
""",
)
def _render_ok_page(result: dict[str, Any]) -> str:
rendered = result.get("rendered") or {}
payload = rendered.get("payload") or {}
policy = rendered.get("policy") or {}
permission = str(rendered.get("permission") or payload.get("permission") or "read")
title = _pick_first(payload, "title") or "高考志愿填报方案"
summary = _pick_first(payload, "summary") or "微信内打开也能直接查看,支持手机端快速浏览。"
candidate_name = _pick_first(payload, "candidate_name", "customer_name", "student_name", "name")
score = payload.get("score")
rank = payload.get("rank")
province = payload.get("province")
year = payload.get("year")
report_id = payload.get("report_id") or result.get("report_id")
share_url = payload.get("share_url") or result.get("url")
info_cards = []
if candidate_name:
info_cards.append(_metric_card("考生", str(candidate_name)))
if score is not None:
info_cards.append(_metric_card("分数", str(score)))
if rank is not None:
info_cards.append(_metric_card("位次", _format_number(rank)))
if province or year:
info_cards.append(_metric_card("地区/年份", " / ".join([str(x) for x in (province, year) if x])))
info_cards_html = "".join(info_cards) if info_cards else _metric_card("状态", "已授权查看")
recommendations = _render_recommendations(payload.get("recommendations"))
volunteers = _render_volunteers(payload.get("volunteers"))
permission_hint = _permission_hint(permission, policy)
limited_block = ""
if permission == "read":
limited_block = """
<section class="panel notice">
<h2>分享信息受限</h2>
<p>当前链接只开放最小信息展示,完整方案需更高权限或联系龙老师。</p>
</section>
"""
report_meta = []
if report_id:
report_meta.append(f"报告ID{escape(str(report_id))}")
if share_url:
report_meta.append(f"分享链接:<code>{escape(str(share_url))}</code>")
report_meta_html = "<br />".join(report_meta) if report_meta else "公开分享页"
copy_button = ""
if share_url:
copy_button = f'<button type="button" id="copy-share-btn" class="ghost" data-share-url="{escape(str(share_url))}">复制链接</button>'
body = f"""
<section class="hero">
<span class="badge">龙老师 · 高考志愿填报方案</span>
<h1>{escape(str(title))}</h1>
<p class="lead">{escape(str(summary))}</p>
<p class="meta">{permission_hint}</p>
<div class="hero-actions">{copy_button}</div>
</section>
<section class="grid metrics">{info_cards_html}</section>
{limited_block}
{recommendations}
{volunteers}
<section class="panel footer-panel">
<h2>分享说明</h2>
<p>{report_meta_html}</p>
<p class="subtle">微信内打开也能直接查看,无需额外 App。</p>
</section>
<script>
(function () {{
const copyShareButton = document.getElementById('copy-share-btn');
if (!copyShareButton) return;
copyShareButton.addEventListener('click', async function () {{
const url = copyShareButton.dataset.shareUrl || '';
try {{
if (navigator.clipboard && navigator.clipboard.writeText) {{
await navigator.clipboard.writeText(url);
alert('链接已复制');
return;
}}
}} catch (err) {{}}
window.prompt('请手动复制链接', url);
}});
}})();
</script>
"""
return _page_shell(str(title), body)
def _pick_first(payload: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = payload.get(key)
if value not in (None, ""):
return value
return None
def _render_recommendations(items: Any) -> str:
rows = []
for item in _take_dicts(items, limit=3):
school = _pick_from_item(item, "school", "name") or "院校待补充"
major = _pick_from_item(item, "major") or "专业待补充"
prob = _pick_from_item(item, "prob", "probability")
prob_text = _format_probability(prob)
rows.append(f"<li><strong>{escape(str(school))}</strong><span>{escape(str(major))}</span><em>{escape(prob_text)}</em></li>")
if not rows:
return ""
return f"""
<section class="panel">
<h2>推荐院校 TOP3</h2>
<ol class="list">{''.join(rows)}</ol>
</section>
"""
def _render_volunteers(items: Any) -> str:
rows = []
for item in _take_dicts(items, limit=3):
school = _pick_from_item(item, "school", "name") or "志愿待补充"
majors = item.get("majors") if isinstance(item.get("majors"), list) else None
if majors:
detail = " / ".join(str(x) for x in majors[:3])
else:
detail = _pick_from_item(item, "major", "type") or ""
rows.append(f"<li><strong>{escape(str(school))}</strong><span>{escape(str(detail))}</span></li>")
if not rows:
return ""
return f"""
<section class="panel">
<h2>志愿预览</h2>
<ul class="list volunteers">{''.join(rows)}</ul>
</section>
"""
def _take_dicts(items: Any, *, limit: int) -> Iterable[dict[str, Any]]:
if not isinstance(items, list):
return []
out = []
for item in items:
if isinstance(item, dict):
out.append(item)
if len(out) >= limit:
break
return out
def _pick_from_item(item: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = item.get(key)
if value not in (None, ""):
return value
return None
def _permission_hint(permission: str, policy: dict[str, Any]) -> str:
labels = []
if policy.get("can_comment"):
labels.append("可评论")
if policy.get("can_edit"):
labels.append("可编辑")
if not labels:
labels.append("仅查看")
return f"权限:{escape(permission)} · {' / '.join(labels)}"
def _metric_card(label: str, value: str) -> str:
return f"""
<article class="metric-card">
<div class="metric-label">{escape(label)}</div>
<div class="metric-value">{escape(value)}</div>
</article>
"""
def _format_probability(value: Any) -> str:
if value in (None, ""):
return ""
try:
num = float(value)
except (TypeError, ValueError):
return str(value)
if num <= 1:
num *= 100
return f"录取概率 {num:.0f}%"
def _format_number(value: Any) -> str:
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return str(value)
def _page_shell(title: str, body: str) -> str:
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="format-detection" content="telephone=no,email=no,address=no" />
<title>{escape(title)}</title>
<style>
:root {{
color-scheme: light;
--bg: #f5f7fb;
--card: #ffffff;
--text: #0f172a;
--muted: #64748b;
--line: #dbe4f0;
--brand: #2563eb;
--brand-soft: #eff6ff;
--warn: #b45309;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; }}
.page {{ max-width: 760px; margin: 0 auto; padding: 16px; }}
.hero, .panel, .metric-card {{ background: var(--card); border: 1px solid var(--line); border-radius: 18px; box-shadow: 0 6px 24px rgba(15, 23, 42, 0.05); }}
.hero {{ padding: 22px 18px; margin-bottom: 14px; }}
.hero.compact {{ text-align: center; padding: 28px 18px; }}
.badge {{ display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 13px; font-weight: 600; margin-bottom: 10px; }}
.badge-warn {{ background: #fff7ed; color: var(--warn); }}
h1 {{ margin: 0 0 10px; font-size: 28px; line-height: 1.25; }}
h2 {{ margin: 0 0 10px; font-size: 18px; }}
.lead {{ margin: 0; color: var(--muted); font-size: 15px; line-height: 1.65; }}
.meta, .subtle {{ color: var(--muted); font-size: 14px; line-height: 1.6; }}
.hero-actions {{ margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }}
.grid.metrics {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }}
.metric-card {{ padding: 16px; }}
.metric-label {{ font-size: 13px; color: var(--muted); margin-bottom: 8px; }}
.metric-value {{ font-size: 24px; font-weight: 700; word-break: break-word; }}
.panel {{ padding: 18px; margin-bottom: 14px; }}
.panel.notice {{ border-style: dashed; background: #fcfcfd; }}
.list {{ list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }}
.list li {{ display: grid; gap: 4px; padding: 12px; background: #f8fafc; border-radius: 14px; }}
.list li strong {{ font-size: 15px; }}
.list li span, .list li em {{ font-size: 14px; color: var(--muted); font-style: normal; }}
.footer-panel code {{ white-space: pre-wrap; word-break: break-all; }}
.password-form {{ margin-top: 18px; display: grid; gap: 10px; text-align: left; }}
.password-form label {{ font-size: 14px; color: var(--muted); }}
.password-form input {{ width: 100%; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; font-size: 16px; }}
button {{ border: 0; border-radius: 12px; padding: 12px 16px; background: var(--brand); color: #fff; font-size: 15px; font-weight: 600; }}
button.ghost {{ background: #e2e8f0; color: var(--text); }}
.error {{ color: #b91c1c; font-size: 14px; margin-top: 10px; }}
@media (max-width: 640px) {{
.page {{ padding: 12px; }}
h1 {{ font-size: 24px; }}
.grid.metrics {{ grid-template-columns: 1fr; }}
.metric-value {{ font-size: 22px; }}
}}
</style>
</head>
<body>
<main class="page">{body}</main>
</body>
</html>
"""