Files
gaokao-volunteer-system/admin/share_page.py

351 lines
13 KiB
Python
Raw Normal View History

2026-06-13 14:49:58 +08:00
"""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>
"""