Files
gaokao-volunteer-system/admin/routes/web_public.py
Hermes Agent ef7ecb794a
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
fix(review): 复核结果页按钮失效+视觉改进
修复按钮失效根因:
- 馑token=None时,改为直接跳转首页(/review/action需要token必填)
- 按钮从form改为a链接,无token场景可正常跳转

视觉改进:
- 风险等级:颜色块+醒目提示(橙黄中等/绿低/红高)
- 用户信息:卡片化展示(5字段网格布局)
- 信息完整性:绿色提示条vs橙色警示条
- 整体层次:卡片分区+标题层级

剩余工作:
- 测试失败: test_report_page_routes_followup_step1_to_info
- 待补充: LLM具体复核结果(南方科技大学+605分分析)
- 待优化: 风险等级字号、按钮图标化
2026-06-29 12:09:14 +08:00

4816 lines
274 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.
"""用户端 Web 自助 MVP 公共入口页面与 Portal 路由。"""
from __future__ import annotations
import json
import logging
import secrets
from html import escape
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from urllib.parse import parse_qsl
import markdown
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
PlainTextResponse,
RedirectResponse,
)
from pydantic import BaseModel, Field
from admin.config import Settings, get_settings_dep
from data.customer_portal.token import (
PortalTokenError,
issue_portal_token,
verify_portal_token,
)
from data.crowd_db.loader import CrowdDBLoader
from data.llm import (
LLMClient,
LLMError,
build_audit_prompt,
build_full_plan_prompt,
)
from data.notifications.email_service import DeliveryNotificationService
from data.orders import crypto
from data.orders.dao import OrderNotFound, OrdersDAO
from data.orders.deletion_service import RETENTION_GUARDED_STATUSES
from data.orders.intake_schema import IntakePayload
from data.orders.intake_store import IntakeStore
from data.orders.models import Order, utc_now_iso
from data.orders.public_flow import (
PublicOrderCreate,
create_public_order,
)
from data.orders.crypto import MissingEncryptionKey
from data.payments.service import PaymentError, PaymentService
router = APIRouter(tags=["web-public"])
logger = logging.getLogger(__name__)
ServiceVersion = Literal["audit", "basic", "standard", "premium"]
_STAGE_META: dict[str, tuple[str, str]] = {
"pending_payment": ("待支付", "请先完成支付,系统会自动推进到资料填写。"),
"info_required": ("待填写资料", "支付已确认,请补充考生资料后进入处理队列。"),
"info_submitted": ("资料已提交", "资料已进入系统,待后台接单处理。"),
"processing": ("处理中", "后台已接单,正在生成审核/方案结果。"),
"report_ready": ("报告已就绪", "已可站内查看报告并下载 PDF。"),
"completed": ("已完成", "订单已完成,后续可继续查看历史交付内容。"),
"refunded": ("已退款", "该订单已完成退款。"),
}
_SERVICE_PRICES = {
"audit": 4900,
"basic": 4900,
"standard": 9900,
"premium": 19900,
}
_SIMULATED_PAYMENT_ROUTE_NOT_FOUND = "not found"
_DELETION_SCOPE_VALUES = ("order_only", "order_and_attachments", "full_account")
_DELETION_NEXT_STEP_OUTSIDE = (
"已超过 180 天保留期,可申请删除;提交成功后将由人工核验后处理"
)
_DELETION_NEXT_STEP_WITHIN = "提交成功后将由人工核验后处理(订单仍在 180 天保留期内)"
_DELETION_NEXT_STEP_PENDING = "提交成功后将由人工核验后处理"
class PublicOrderCreated(BaseModel):
order_id: str
source: str
status: str
service_version: str
amount_cents: int
next_step: str
checkout_url: str
portal_status_url: str
portal_info_url: str
class WebhookAck(BaseModel):
payment_id: str
processed: bool
idempotent: bool
order_status: str
class PortalIntakeResponse(BaseModel):
intake_status: str
stage: str
order_id: str
profile_minimum_complete: bool
profile_missing_fields: list[str]
class PortalAttachmentUploaded(BaseModel):
order_id: str
intake_status: str
stage: str
attachments: list[dict[str, Any]]
class ReviewResultContract(BaseModel):
review_result_id: str
risk_level: Literal["high", "medium", "low"]
top_findings: list[str]
recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"]
available_actions: list[Literal["go_cwb", "go_step1", "go_full_plan"]]
review_entry_source: Literal["home", "status", "report", "direct"]
review_followup_action: Literal["cwb", "step1", "full_plan", "none"]
review_input_summary: str = ""
review_input_attachments: list[str] = Field(default_factory=list)
review_constraints: dict[str, Any] = Field(default_factory=dict)
llm_generated: bool = False
llm_summary: str = ""
llm_cwb_suggestions: dict[str, list[str]] = Field(default_factory=dict)
class ReviewActionRequest(BaseModel):
token: str
action: Literal["cwb", "step1", "full_plan"]
class ReviewActionResponse(BaseModel):
review_result_id: str
review_followup_action: Literal["cwb", "step1", "full_plan"]
next_href: str
class DeletionRequestCreate(BaseModel):
# 2026-06-19 T12-C: scope 锁定到白名单, confirm_guardian 强制要求
requester_name: str = Field(..., min_length=1, max_length=64)
requester_contact: str = Field(..., min_length=1, max_length=128)
reason: str = Field(..., min_length=1, max_length=2000)
scope: Literal["order_only", "order_and_attachments", "full_account"]
confirm_guardian: bool = Field(...)
def _resolve_retention_status(order: Order, settings: Settings) -> str:
"""返回订单的保留期相对位置。
- "pending": 订单状态不在保留期门禁子集内 (例如 pending)
- "within": 订单在保留期门禁子集且未到保留期
- "outside": 订单在保留期门禁子集且已超过保留期
"""
if order.status not in RETENTION_GUARDED_STATUSES:
return "pending"
updated_raw = (
order.status_updated_at or order.completed_at or order.created_at or ""
).strip()
if not updated_raw:
return "within"
try:
updated = datetime.fromisoformat(updated_raw.replace("Z", "+00:00"))
except ValueError:
return "within"
if updated.tzinfo is None:
updated = updated.replace(tzinfo=timezone.utc)
elapsed = datetime.now(timezone.utc) - updated
if elapsed >= timedelta(days=settings.retention_days):
return "outside"
return "within"
def _next_step_for_retention(status: str) -> str:
if status == "outside":
return _DELETION_NEXT_STEP_OUTSIDE
if status == "within":
return _DELETION_NEXT_STEP_WITHIN
return _DELETION_NEXT_STEP_PENDING
class DeletionRequestCreated(BaseModel):
order_id: str
request_logged: bool
next_step: str
def _log_deletion_request(
order_id: str, payload: DeletionRequestCreate, settings: Settings
) -> None:
from datetime import datetime
path = Path(settings.deletion_request_log_path)
path.parent.mkdir(parents=True, exist_ok=True)
item = {
"created_at": datetime.utcnow().isoformat() + "Z",
"order_id": order_id,
"requester_name": payload.requester_name,
"requester_contact": payload.requester_contact,
"reason": payload.reason,
"scope": payload.scope,
"confirm_guardian": payload.confirm_guardian,
}
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(item, ensure_ascii=False) + "\n")
def _assert_simulated_payment_routes_allowed(settings: Settings) -> None:
if settings.env == "prod":
raise HTTPException(status_code=404, detail=_SIMULATED_PAYMENT_ROUTE_NOT_FOUND)
@router.get("/", include_in_schema=False)
def landing_page(
request: Request, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
return HTMLResponse(_render_landing_page(request, settings))
@router.get("/pricing", include_in_schema=False)
def pricing_page(request: Request) -> HTMLResponse:
return HTMLResponse(_render_pricing_page(request))
@router.get("/checkout/{service_version}", include_in_schema=False)
def checkout_page(service_version: ServiceVersion) -> HTMLResponse:
return HTMLResponse(_render_checkout_page(service_version))
@router.get("/portal/{token}/payment-success", include_in_schema=False)
def payment_success_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
return HTMLResponse(_render_payment_success_page(token, context))
@router.post("/api/public/orders", response_model=PublicOrderCreated, status_code=201)
def create_public_order_endpoint(
payload: PublicOrderCreate,
settings: Settings = Depends(get_settings_dep),
) -> PublicOrderCreated:
try:
payment_service = _payment_service(settings)
except PaymentError as exc:
raise HTTPException(
status_code=503,
detail=f"payment provider unavailable: {exc}",
) from exc
try:
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = create_public_order(dao, payload)
except MissingEncryptionKey as exc:
crypto.get_fernet.cache_clear()
logger.warning("public order create blocked by missing encryption key: %s", exc)
raise HTTPException(
status_code=503,
detail="当前暂时无法创建订单,请稍后重试或联系客服获取人工协助。",
) from exc
portal_token = issue_portal_token(order.id, settings.portal_token_secret)
try:
checkout = payment_service.create_checkout(order.id)
except PaymentError as exc:
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.delete(order.id)
raise HTTPException(
status_code=503,
detail=f"payment checkout unavailable: {exc}",
) from exc
return PublicOrderCreated(
order_id=order.id,
source=order.source,
status=order.status,
service_version=order.service_version,
amount_cents=order.amount_cents,
next_step="payment",
checkout_url=checkout.checkout_url,
portal_status_url=f"/portal/{portal_token}/status",
portal_info_url=f"/portal/{portal_token}/info",
)
@router.post("/api/public/payments/mock/webhook", response_model=WebhookAck)
def mock_payment_webhook(
payload: dict[str, Any],
request: Request,
settings: Settings = Depends(get_settings_dep),
) -> WebhookAck:
_assert_simulated_payment_routes_allowed(settings)
signature = request.headers.get("X-Mock-Signature", "")
service = _payment_service(settings)
try:
result = service.handle_webhook(payload, signature)
except PaymentError as exc:
message = str(exc)
status_code = 409 if "amount mismatch" in message else 400
raise HTTPException(status_code=status_code, detail=message) from exc
return WebhookAck(**result.__dict__)
@router.post("/api/public/payments/alipay/notify", include_in_schema=False)
async def alipay_notify_webhook(
request: Request,
settings: Settings = Depends(get_settings_dep),
) -> PlainTextResponse:
body = (await request.body()).decode("utf-8")
payload = {key: value for key, value in parse_qsl(body, keep_blank_values=True)}
signature = payload.pop("sign", "")
payload.pop("sign_type", None)
service = _payment_service(settings)
try:
service.handle_webhook(payload, signature)
except PaymentError as exc:
message = str(exc)
status_code = 409 if "amount mismatch" in message else 400
raise HTTPException(status_code=status_code, detail=message) from exc
return PlainTextResponse("success")
@router.get("/pay/mock/{payment_id}", include_in_schema=False)
def mock_payment_page(
payment_id: str,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
_assert_simulated_payment_routes_allowed(settings)
return _render_simulated_payment_page(payment_id, settings, provider_slug="mock")
@router.post("/pay/mock/{payment_id}/complete", include_in_schema=False)
def complete_mock_payment(
payment_id: str,
settings: Settings = Depends(get_settings_dep),
) -> RedirectResponse:
_assert_simulated_payment_routes_allowed(settings)
return _complete_simulated_payment(payment_id, settings, provider_slug="mock")
@router.get("/pay/alipay-sim/{payment_id}", include_in_schema=False)
def alipay_sim_payment_page(
payment_id: str,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
_assert_simulated_payment_routes_allowed(settings)
return _render_simulated_payment_page(
payment_id, settings, provider_slug="alipay-sim"
)
@router.post("/pay/alipay-sim/{payment_id}/complete", include_in_schema=False)
def complete_alipay_sim_payment(
payment_id: str,
settings: Settings = Depends(get_settings_dep),
) -> RedirectResponse:
_assert_simulated_payment_routes_allowed(settings)
return _complete_simulated_payment(payment_id, settings, provider_slug="alipay-sim")
@router.get("/portal/payment-return", include_in_schema=False)
def payment_return_page(
payment_id: str, settings: Settings = Depends(get_settings_dep)
) -> RedirectResponse:
service = _payment_service(settings)
payment = service.get_payment(payment_id)
if payment is None:
raise HTTPException(status_code=404, detail="payment not found")
if payment.status != "paid":
raise HTTPException(status_code=409, detail="payment not ready")
portal_token = issue_portal_token(payment.order_id, settings.portal_token_secret)
return RedirectResponse(
url=f"/portal/{portal_token}/payment-success", status_code=303
)
@router.get("/review/start", include_in_schema=False)
def review_start_page(
source: Literal["home", "status", "report", "direct"] = "direct",
token: str | None = None,
province: str | None = None,
score: str | None = None,
goal: str | None = None,
consult: str | None = None,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
review_input_summary = (consult or goal or "").strip() or None
review_constraints: dict[str, Any] = {}
if province:
review_constraints["candidate_province"] = province.strip()
if score:
parts = [part.strip() for part in score.replace("", "/").split("/", 1)]
if parts and parts[0]:
review_constraints["candidate_score"] = parts[0]
if len(parts) > 1 and parts[1]:
review_constraints["candidate_rank"] = parts[1]
contract = _start_review_result(
source=source,
token=token,
settings=settings,
review_input_summary=review_input_summary,
review_constraints=review_constraints or None,
)
return HTMLResponse(_render_review_start_page(contract, token))
@router.get("/portal/{token}/cwb", include_in_schema=False)
def cwb_placeholder_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
contract = _load_latest_review_result(order.id, settings)
context = _build_portal_context(order, settings)
return HTMLResponse(_render_cwb_placeholder_page(token, order, contract, context))
@router.get("/portal/{token}/full-plan", include_in_schema=False)
def full_plan_placeholder_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
contract = _load_latest_review_result(order.id, settings)
return HTMLResponse(
_render_full_plan_placeholder_page(
token, order, context, contract, settings=settings
)
)
@router.get("/portal/{token}/info", include_in_schema=False)
def order_info_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake = intake_store.get(order.id)
finally:
intake_store.close()
context = _build_portal_context(order, settings)
return HTMLResponse(
_render_info_page(
order,
token,
intake.payload if intake else {},
context["stage"],
settings,
)
)
def _assert_portal_info_mutable(stage: str) -> None:
if stage == "pending_payment":
raise HTTPException(status_code=409, detail="payment required before intake")
if stage in {
"processing",
"report_ready",
"completed",
"refunded",
}:
raise HTTPException(
status_code=409, detail="intake is read-only at current stage"
)
def _sanitize_upload_filename(name: str) -> str:
raw = Path(name or "upload.bin").name
return raw.replace("/", "_").replace("\\", "_")
def _validate_upload(
file_name: str, content_type: str | None, payload: bytes, settings: Settings
) -> None:
suffix = Path(file_name).suffix.lower()
allowed_suffixes = {
".pdf",
".txt",
".md",
".json",
".png",
".jpg",
".jpeg",
".webp",
}
if suffix not in allowed_suffixes:
raise HTTPException(
status_code=415,
detail=f"unsupported attachment type: {suffix or 'unknown'}",
)
if len(payload) > settings.portal_upload_max_bytes:
raise HTTPException(status_code=413, detail="attachment too large")
if not payload:
raise HTTPException(status_code=400, detail="empty attachment")
def _store_portal_attachment(
*,
order_id: str,
upload_name: str,
content_type: str | None,
payload: bytes,
settings: Settings,
) -> dict[str, Any]:
safe_name = _sanitize_upload_filename(upload_name)
_validate_upload(safe_name, content_type, payload, settings)
upload_dir = Path(settings.portal_upload_dir) / order_id
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{secrets.token_hex(8)}-{safe_name}"
target = upload_dir / stored_name
target.write_bytes(payload)
return {
"original_name": safe_name,
"stored_name": stored_name,
"content_type": content_type or "application/octet-stream",
"size_bytes": len(payload),
"kind": "portal_attachment",
}
@router.post("/portal/{token}/attachments", response_model=PortalAttachmentUploaded)
def upload_order_attachment(
token: str,
files: list[UploadFile] = File(...),
settings: Settings = Depends(get_settings_dep),
) -> PortalAttachmentUploaded:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
_assert_portal_info_mutable(context["stage"])
intake_store = IntakeStore.for_db(settings.orders_db_path)
uploaded: list[dict[str, Any]] = []
try:
current = intake_store.get(order.id)
payload = dict(current.payload) if current is not None else {}
attachments = list(payload.get("attachments") or [])
if len(attachments) + len(files) > settings.portal_upload_max_files:
raise HTTPException(
status_code=413,
detail=f"too many attachments: max {settings.portal_upload_max_files}",
)
for file in files:
raw = file.file.read()
attachment = _store_portal_attachment(
order_id=order.id,
upload_name=file.filename or "upload.bin",
content_type=file.content_type,
payload=raw,
settings=settings,
)
attachments.append(attachment)
uploaded.append(attachment)
payload["attachments"] = attachments
record = intake_store.save(
order_id=order.id,
payload=payload,
submit=(current.status == "submitted") if current is not None else False,
)
finally:
intake_store.close()
refreshed_order = _resolve_order_from_token(token, settings)
refreshed_context = _build_portal_context(refreshed_order, settings)
return PortalAttachmentUploaded(
order_id=order.id,
intake_status=record.status,
stage=refreshed_context["stage"],
attachments=uploaded,
)
@router.post("/portal/{token}/info", response_model=PortalIntakeResponse)
def submit_order_info(
token: str,
payload: IntakePayload,
settings: Settings = Depends(get_settings_dep),
) -> PortalIntakeResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
_assert_portal_info_mutable(context["stage"])
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order.id)
base_payload = dict(current.payload) if current is not None else {}
base_payload.update(payload.model_dump())
saved_payload = _ensure_profile_version_metadata(base_payload)
record = intake_store.save(
order_id=order.id,
payload=saved_payload,
submit=payload.mode == "submit",
)
finally:
intake_store.close()
updates: dict[str, Any] = {}
if payload.candidate_province is not None:
updates["candidate_province"] = payload.candidate_province
if payload.candidate_score is not None:
updates["candidate_score"] = payload.candidate_score
if payload.candidate_rank is not None:
updates["candidate_rank"] = payload.candidate_rank
if payload.candidate_subjects:
updates["candidate_subjects"] = payload.candidate_subjects
if payload.candidate_interests is not None:
updates["candidate_interests"] = payload.candidate_interests
if payload.guardian_notes is not None:
updates["notes"] = payload.guardian_notes
if updates or (payload.mode == "submit" and order.status == "paid"):
with OrdersDAO.connect(settings.orders_db_path) as dao:
if updates:
dao.update(
order.id, updates, actor="portal", reason=f"portal_{record.status}"
)
if payload.mode == "submit" and order.status == "paid":
dao.transition_status(
order.id,
"serving",
actor="portal",
reason="portal_submit_auto_queue",
)
refreshed_order = _resolve_order_from_token(token, settings)
refreshed_context = _build_portal_context(refreshed_order, settings)
return PortalIntakeResponse(
intake_status=record.status,
stage=refreshed_context["stage"],
order_id=order.id,
profile_minimum_complete=_is_profile_minimum_complete(record.payload),
profile_missing_fields=_profile_missing_fields(record.payload),
)
@router.post("/review/action", include_in_schema=False)
def review_action_endpoint(
token: str = Form(...),
action: Literal["cwb", "step1", "full_plan"] = Form(...),
settings: Settings = Depends(get_settings_dep),
) -> RedirectResponse:
result = _apply_review_followup_action(token, action, settings)
return RedirectResponse(url=result.next_href, status_code=303)
@router.get("/portal/{token}/deletion-request", include_in_schema=False)
def deletion_request_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
return HTMLResponse(_render_deletion_request_page(token, order, settings))
@router.post("/portal/{token}/deletion-request", response_model=DeletionRequestCreated)
def submit_deletion_request(
token: str,
payload: DeletionRequestCreate,
settings: Settings = Depends(get_settings_dep),
) -> DeletionRequestCreated:
order = _resolve_order_from_token(token, settings)
_log_deletion_request(order.id, payload, settings)
# 2026-06-19 T12-C: next_step 根据订单保留期分文案, 给用户明确预期
retention_status = _resolve_retention_status(order, settings)
return DeletionRequestCreated(
order_id=order.id,
request_logged=True,
next_step=_next_step_for_retention(retention_status),
)
@router.get("/portal/{token}/notifications", include_in_schema=False)
def notification_audit_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
events = notification_service.list_events(order.id)
finally:
notification_service.close()
return HTMLResponse(_render_notification_audit_page(token, order, events))
@router.get("/portal/{token}/status", include_in_schema=False)
def order_status_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
return HTMLResponse(_render_status_page(token, context))
@router.get("/portal/{token}/report", include_in_schema=False)
def report_view_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> HTMLResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
if context["stage"] not in {"report_ready", "completed"}:
raise HTTPException(status_code=409, detail="report not ready")
return HTMLResponse(_render_report_page(order, settings))
@router.get("/portal/{token}/report.pdf", include_in_schema=False)
def report_pdf_download(
token: str, settings: Settings = Depends(get_settings_dep)
) -> FileResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
if context["stage"] not in {"report_ready", "completed"}:
raise HTTPException(status_code=409, detail="report not ready")
if (
not order.pdf_path
or not _is_trusted_report_path(order.pdf_path, settings)
or not Path(order.pdf_path).is_file()
):
raise HTTPException(status_code=404, detail="pdf not found")
return FileResponse(
order.pdf_path, media_type="application/pdf", filename=Path(order.pdf_path).name
)
def _payment_service(settings: Settings) -> PaymentService:
return PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
provider_name=settings.payment_provider,
notify_url=settings.payment_notify_url,
return_url=settings.payment_return_url,
app_id=settings.payment_app_id,
merchant_id=settings.payment_merchant_id,
private_key_path=settings.payment_private_key_path,
alipay_public_key_path=settings.payment_alipay_public_key_path,
)
def _resolve_order_from_token(token: str, settings: Settings) -> Order:
try:
payload = verify_portal_token(token, settings.portal_token_secret)
except PortalTokenError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
with OrdersDAO.connect(settings.orders_db_path) as dao:
try:
return dao.get(str(payload["order_id"]))
except OrderNotFound as exc:
raise HTTPException(status_code=404, detail="order not found") from exc
def _is_trusted_report_path(path: str | None, settings: Settings) -> bool:
if not path:
return False
candidate = Path(path).resolve()
trusted_roots = (
Path(settings.share_report_dir).resolve(),
(
Path(settings.portal_upload_dir).resolve().parent / "order_artifacts"
).resolve(),
Path("data/examples").resolve(),
)
return any(root == candidate or root in candidate.parents for root in trusted_roots)
def _build_portal_context(order: Order, settings: Settings) -> dict[str, Any]:
payment_service = _payment_service(settings)
payment = payment_service.get_payment_by_order(order.id)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake = intake_store.get(order.id)
finally:
intake_store.close()
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
sent_station_events = notification_service.list_events(
order.id,
status="validated",
channel="station",
)
if not sent_station_events:
sent_station_events = notification_service.list_events(
order.id,
status="delivered",
channel="station",
)
finally:
notification_service.close()
stage = "pending_payment"
report_html_ready = bool(
order.audit_report
and _is_trusted_report_path(order.audit_report, settings)
and Path(order.audit_report).is_file()
)
report_pdf_ready = bool(
order.pdf_path
and _is_trusted_report_path(order.pdf_path, settings)
and Path(order.pdf_path).is_file()
)
report_artifacts_ready = report_html_ready and report_pdf_ready
if order.status == "refunded" or (
payment is not None and payment.status == "refunded"
):
stage = "refunded"
elif payment is None or payment.status in {"pending", "failed"}:
stage = "pending_payment"
elif order.status == "completed":
stage = "completed"
elif order.status == "delivered" and report_artifacts_ready:
stage = "report_ready"
elif order.status in {"serving", "delivered"}:
stage = "processing"
elif intake is None or intake.status == "draft":
stage = "info_required"
elif intake.status == "submitted":
stage = "info_submitted"
title, subtitle = _STAGE_META[stage]
latest_station_notice: dict[str, Any] | None = None
intake_summary = None
attachment_count = 0
attachment_items: list[dict[str, Any]] = []
profile_payload = (
intake.payload
if intake is not None
else {
"candidate_province": order.candidate_province,
"candidate_score": order.candidate_score,
"candidate_rank": order.candidate_rank,
"candidate_subjects": order.candidate_subjects,
}
)
if intake is not None:
attachment_items = [
item
for item in list((intake.payload or {}).get("attachments") or [])
if isinstance(item, dict)
]
attachment_count = len(attachment_items)
intake_summary = {
"candidate_province": intake.payload.get("candidate_province")
or order.candidate_province,
"candidate_score": intake.payload.get("candidate_score"),
"candidate_rank": intake.payload.get("candidate_rank"),
"candidate_subjects": intake.payload.get("candidate_subjects") or [],
"candidate_interests": intake.payload.get("candidate_interests"),
"target_cities": intake.payload.get("target_cities") or [],
"target_majors": intake.payload.get("target_majors") or [],
"university_preferences": intake.payload.get("university_preferences"),
"existing_plan_summary": intake.payload.get("existing_plan_summary"),
"guardian_notes": intake.payload.get("guardian_notes"),
}
if sent_station_events:
try:
latest_payload = json.loads(sent_station_events[-1].payload_json)
except json.JSONDecodeError:
latest_payload = {}
station_notice = latest_payload.get("station_notice")
if isinstance(station_notice, dict):
latest_station_notice = station_notice
return {
"stage": stage,
"stage_title": title,
"stage_subtitle": subtitle,
"payment": payment,
"payment_status": payment.status if payment is not None else "pending",
"intake": intake,
"intake_summary": intake_summary,
"attachment_count": attachment_count,
"attachments": attachment_items,
"report_html_ready": report_html_ready,
"report_pdf_ready": report_pdf_ready,
"order": order,
"station_notice": latest_station_notice,
"profile_minimum_complete": _is_profile_minimum_complete(profile_payload),
"profile_missing_fields": _profile_missing_fields(profile_payload),
}
def _render_global_nav() -> str:
"""全局统一导航栏。所有页面必须注入。"""
return '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="/data-query">数据查询</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
def _render_global_toast_script() -> str:
"""全局 toast + loading 注入脚本。在 {_render_global_toast_script()}</body> 前注入。"""
return """<div class="state-toast-stack" id="toast-stack" aria-live="polite" aria-atomic="true"></div>
<script>
window.showToast = function(msg, type) {
var stack = document.getElementById('toast-stack');
if (!stack) return;
var toast = document.createElement('div');
toast.className = 'state-toast state-toast--' + (type || 'info');
toast.textContent = msg;
stack.appendChild(toast);
requestAnimationFrame(function() { toast.classList.add('state-toast--visible'); });
setTimeout(function() {
toast.classList.remove('state-toast--visible');
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
}, 3000);
};
window.showLoading = function(container, msg) {
if (!container) return null;
var el = document.createElement('div');
el.className = 'state-loading';
el.innerHTML = '<div class="state-loading__spinner"></div><p class="state-loading__text">' + (msg || '加载中…') + '</p>';
container.appendChild(el);
return el;
};
window.hideLoading = function(el) {
if (el && el.parentNode) el.parentNode.removeChild(el);
};
</script>"""
def _render_footer_links(token: str | None = None) -> str:
privacy_href = "/privacy"
terms_href = "/service-terms"
if token:
privacy_href = "/privacy"
terms_href = "/service-terms"
deletion_href = f"/portal/{escape(token)}/deletion-request"
else:
deletion_href = "/deletion-policy"
return (
f'<footer style="margin-top:24px;color:#5b6b88;font-size:14px;">'
f'<a href="{privacy_href}">隐私政策</a> · '
f'<a href="{terms_href}">服务说明与免责声明</a> · '
f'<a href="{deletion_href}">删除申请 / 数据删除说明</a>'
f"</footer>"
)
def _render_basic_page(
*,
title: str,
eyebrow: str,
lead: str,
sections_html: str,
footer_token: str | None = None,
) -> str:
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
back_link = '<div style="margin-bottom:8px;"><a href="/" style="color:#194fb6;font-size:13px;text-decoration:none;">← 返回首页</a></div>'
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{escape(title)}</title>"
"<link rel='stylesheet' href='/static/portal-ui.css' />"
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;padding:0;color:#172033;margin:0}.wrap{max-width:920px;margin:0 auto;display:grid;gap:18px;padding:32px 20px}.panel{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}.eyebrow{display:inline-flex;padding:6px 10px;border-radius:999px;background:#eef6ff;color:#194fb6;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.lead{color:#5b6b88;line-height:1.8}.checklist{margin:0;padding-left:18px;color:#5b6b88;line-height:1.8}.meta{color:#5b6b88;line-height:1.8}</style></head>'
f"<body>{_render_global_nav()}{nav}<main class='wrap' role='main'>"
f"{back_link}"
f"<section class='panel'><span class='eyebrow'>{escape(eyebrow)}</span><h1>{escape(title)}</h1><p class='lead'>{lead}</p></section>"
f"{sections_html}"
f"{_render_footer_links(footer_token)}"
"</main>{_render_global_toast_script()}</body></html>"
)
def _render_placeholder_shell(
*,
title: str,
max_width: int,
body_html: str,
) -> str:
top_toolbar = '<div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div>'
return f"""<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>{escape(title)}</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f3f7fb;margin:0;padding:32px 20px;color:#142235}}.wrap{{max-width:{max_width}px;margin:0 auto;display:grid;gap:16px}}.panel{{background:#fff;border:1px solid #d7e3f1;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.meta{{color:#5b6b88;line-height:1.8}}.actions{{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700}}.btn-primary{{background:#1f6feb;color:#fff}}.btn-secondary{{background:#edf3ff;color:#194fb6}}.empty-state{{padding:18px;border-radius:14px;background:#f8fbff;border:1px solid #d7e3f1;color:#5b6b88}}.error-state{{padding:18px;border-radius:14px;background:#fff5f5;border:1px solid #f5c2c7;color:#b42318}}</style></head><body><nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav><main class="wrap">{top_toolbar}{body_html}</main>{_render_global_toast_script()}</body></html>"""
def _find_legal_doc_path(doc_filename: str) -> Path | None:
"""定位 docs/<doc_filename> 法务文档;找不到返回 None。
搜索顺序:项目根 docs/(开发态)→ 包内 admin/legal/(部署态)。
"""
candidates = [
Path(__file__).resolve().parents[2] / "docs" / doc_filename,
Path(__file__).resolve().parent / "legal" / doc_filename,
]
for path in candidates:
if path.is_file():
return path
return None
def _render_legal_doc_page(
*,
doc_filename: str,
title: str,
eyebrow: str,
lead: str,
footer_token: str | None = None,
) -> str | None:
"""渲染完整法务文档为 HTML 页面。
找不到文档时返回 None调用方应 fallback 到简版概要页。
"""
doc_path = _find_legal_doc_path(doc_filename)
if doc_path is None:
return None
try:
raw = doc_path.read_text(encoding="utf-8")
except OSError:
return None
body_html = markdown.markdown(
raw,
extensions=["tables", "fenced_code", "sane_lists"],
)
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
back_link = '<div style="margin-bottom:8px;"><a href="/" style="color:#194fb6;font-size:13px;text-decoration:none;">← 返回首页</a></div>'
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{escape(title)}</title>"
"<link rel='stylesheet' href='/static/portal-ui.css' />"
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;padding:0;color:#172033;margin:0}'
".wrap{max-width:920px;margin:0 auto;display:grid;gap:18px;padding:32px 20px}"
".panel{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:32px;box-shadow:0 18px 42px rgba(20,34,53,.08)}"
".eyebrow{display:inline-flex;padding:6px 10px;border-radius:999px;background:#eef6ff;color:#194fb6;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}"
".lead{color:#5b6b88;line-height:1.8;margin:8px 0 16px}"
".legal-doc h1{display:none}"
".legal-doc h2{color:#172033;font-size:18px;margin:24px 0 12px;border-bottom:2px solid #eef2fb;padding-bottom:6px}"
".legal-doc h3{color:#1f3a68;font-size:15px;margin:18px 0 8px}"
".legal-doc p{color:#34425b;line-height:1.85;margin:8px 0}"
".legal-doc ul,.legal-doc ol{color:#34425b;line-height:1.85;padding-left:22px;margin:8px 0}"
".legal-doc li{margin:4px 0}"
".legal-doc blockquote{border-left:3px solid #cfd9ee;color:#5b6b88;padding:8px 14px;margin:12px 0;background:#f8fbff;border-radius:0 8px 8px 0}"
".legal-doc table{border-collapse:collapse;width:100%;margin:12px 0;font-size:13.5px;background:#fff}"
".legal-doc th,.legal-doc td{border:1px solid #d7e3f1;padding:8px 10px;text-align:left;vertical-align:top}"
".legal-doc th{background:#eef4ff;color:#172033;font-weight:600}"
".legal-doc code{background:#f1f5fb;padding:1px 4px;border-radius:4px;font-size:12.5px;color:#1f3a68}"
"</style></head>"
f"<body>{_render_global_nav()}{nav}<main class='wrap' role='main'>"
f"{back_link}"
f"<section class='panel'><span class='eyebrow'>{escape(eyebrow)}</span><h1>{escape(title)}</h1><p class='lead'>{escape(lead)}</p></section>"
f"<section class='panel legal-doc'>{body_html}</section>"
f"{_render_footer_links(footer_token)}"
"</main>{_render_global_toast_script()}</body></html>"
)
@router.get("/privacy", include_in_schema=False)
def privacy_page(token: str | None = None) -> HTMLResponse:
full_html = _render_legal_doc_page(
doc_filename="PRIVACY_POLICY_DRAFT.md",
title="隐私政策",
eyebrow="隐私说明",
lead="运营主体龙某某个人开发者联系邮箱lon22@qq.com版本privacy-policy-v2026.06.25生效2026-06-25。我们只收集下单、资料填写、支付与交付所需的最小信息用于志愿服务流程不用于营销出售或无关用途。",
footer_token=token,
)
if full_html is not None:
return HTMLResponse(full_html)
sections_html = "<section class='panel'><h2>你可以预期什么</h2><ul class='checklist'><li>支付前只收必要下单信息</li><li>详细资料在支付后通过资料向导分步补充</li><li>隐私政策、服务说明与删除申请入口全程可见</li><li>如需撤回资料或申请删除,可通过删除申请入口提交请求</li></ul></section>"
return HTMLResponse(
_render_basic_page(
title="隐私政策",
eyebrow="隐私说明",
lead="运营主体龙某某个人开发者联系邮箱lon22@qq.com版本privacy-policy-v2026.06.25生效2026-06-25。我们只收集下单、资料填写、支付与交付所需的最小信息用于志愿服务流程不用于营销出售或无关用途。",
sections_html=sections_html,
footer_token=token,
)
)
@router.get("/service-terms", include_in_schema=False)
def service_terms_page(token: str | None = None) -> HTMLResponse:
full_html = _render_legal_doc_page(
doc_filename="SERVICE_TERMS.md",
title="服务说明与使用条款",
eyebrow="服务边界",
lead="运营主体龙某某个人开发者联系邮箱lon22@qq.com版本service-terms-v2026.06.25生效2026-06-25。本服务提供志愿填报辅助建议、方案审计与交付支持不承诺录取结果提交资料前请确认监护人与考生已知情。",
footer_token=token,
)
if full_html is not None:
return HTMLResponse(full_html)
sections_html = "<section class='panel'><h2>下单前请了解</h2><ul class='checklist'><li>我们优先帮助你审计现有方案与风险点</li><li>支付后可继续补充详细资料与附件</li><li>交付状态、通知与报告入口会在站内持续更新</li><li>如需撤回资料或删除交付物,可通过删除申请入口提交请求</li></ul></section>"
return HTMLResponse(
_render_basic_page(
title="服务说明与免责声明",
eyebrow="服务边界",
lead="运营主体龙某某个人开发者联系邮箱lon22@qq.com版本service-terms-v2026.06.25生效2026-06-25。本服务提供志愿填报辅助建议、方案审计与交付支持不承诺录取结果提交资料前请确认监护人与考生已知情。",
sections_html=sections_html,
footer_token=token,
)
)
def _normalized_profile_snapshot(payload: dict[str, Any]) -> dict[str, Any]:
def _clean_list(value: Any) -> list[str]:
return [str(item).strip() for item in list(value or []) if str(item).strip()]
return {
"candidate_province": str(payload.get("candidate_province") or "").strip(),
"candidate_subjects": _clean_list(payload.get("candidate_subjects")),
"candidate_score": payload.get("candidate_score"),
"candidate_rank": payload.get("candidate_rank"),
"target_cities": _clean_list(payload.get("target_cities")),
"target_majors": _clean_list(payload.get("target_majors")),
"target_schools": _clean_list(payload.get("target_schools")),
"school_region_preferences": _clean_list(
payload.get("school_region_preferences")
),
"school_preference_types": _clean_list(payload.get("school_preference_types")),
"disliked_majors": _clean_list(payload.get("disliked_majors")),
"employment_region_preferences": _clean_list(
payload.get("employment_region_preferences")
),
"candidate_interests": str(payload.get("candidate_interests") or "").strip(),
"university_preferences": str(
payload.get("university_preferences") or ""
).strip(),
"priority_strategy": str(payload.get("priority_strategy") or "").strip(),
"graduation_plan": str(payload.get("graduation_plan") or "").strip(),
"tuition_preference": str(payload.get("tuition_preference") or "").strip(),
"family_background": str(payload.get("family_background") or "").strip(),
"industry_resources": str(payload.get("industry_resources") or "").strip(),
"extra_notes": str(payload.get("extra_notes") or "").strip(),
"interest_assessment_type": str(
payload.get("interest_assessment_type") or ""
).strip(),
"interest_assessment_result": str(
payload.get("interest_assessment_result") or ""
).strip(),
"interest_assessment_notes": str(
payload.get("interest_assessment_notes") or ""
).strip(),
"existing_plan_summary": str(
payload.get("existing_plan_summary") or ""
).strip(),
"guardian_notes": str(payload.get("guardian_notes") or "").strip(),
}
def _profile_stage_label(index: int) -> str:
labels = ["初始档案方案", "查分后校准方案", "正式填报前调整方案"]
if index < len(labels):
return labels[index]
return f"{index + 1} 次档案快照"
def _profile_version_id(index: int) -> str:
return f"profile_v{index + 1}"
def _ensure_profile_version_metadata(
payload: dict[str, Any], *, source: str = "portal"
) -> dict[str, Any]:
updated = dict(payload)
snapshot = _normalized_profile_snapshot(updated)
versions = [
item
for item in list(updated.get("profile_versions") or [])
if isinstance(item, dict)
]
if versions and versions[-1].get("snapshot_payload") == snapshot:
updated["profile_versions"] = versions
updated["latest_profile_version_id"] = str(
versions[-1].get("profile_version_id")
or _profile_version_id(len(versions) - 1)
)
return updated
version = {
"profile_version_id": _profile_version_id(len(versions)),
"stage_label": _profile_stage_label(len(versions)),
"snapshot_payload": snapshot,
"created_at": utc_now_iso(),
"source": source,
}
versions.append(version)
updated["profile_versions"] = versions
updated["latest_profile_version_id"] = version["profile_version_id"]
return updated
def _latest_profile_version(payload: dict[str, Any]) -> dict[str, Any] | None:
versions = [
item
for item in list(payload.get("profile_versions") or [])
if isinstance(item, dict)
]
latest_id = str(payload.get("latest_profile_version_id") or "").strip()
for item in versions:
if str(item.get("profile_version_id") or "") == latest_id:
return item
return versions[-1] if versions else None
def _profile_version_label_from_payload(payload: dict[str, Any]) -> str:
latest = _latest_profile_version(payload)
if latest is not None:
return str(latest.get("profile_version_id") or "profile-step1-incomplete")
return _profile_version_label(
{
"intake_summary": payload,
"profile_minimum_complete": _is_profile_minimum_complete(payload),
}
)
def _report_version_profile_reference(
payload: dict[str, Any], *, report_version_id: str, fallback_profile_version_id: str
) -> str:
for item in list(payload.get("report_versions") or []):
if not isinstance(item, dict):
continue
if str(item.get("report_version_id") or "") != report_version_id:
continue
referenced = str(item.get("profile_version_id") or "").strip()
if referenced:
return referenced
return fallback_profile_version_id
def _ensure_report_version_metadata(
payload: dict[str, Any],
*,
report_version_id: str,
profile_version_id: str,
review_result_version_id: str | None,
artifact_refs: dict[str, Any],
) -> dict[str, Any]:
updated = dict(payload)
versions = [
item
for item in list(updated.get("report_versions") or [])
if isinstance(item, dict)
]
for item in versions:
if str(item.get("report_version_id") or "") == report_version_id:
updated["latest_report_version_id"] = report_version_id
return updated
versions.append(
{
"report_version_id": report_version_id,
"profile_version_id": profile_version_id,
"review_result_version_id": review_result_version_id,
"artifact_refs": artifact_refs,
"created_at": utc_now_iso(),
}
)
updated["report_versions"] = versions
updated["latest_report_version_id"] = report_version_id
return updated
def _current_intake_payload(context: dict[str, Any]) -> dict[str, Any]:
intake = context.get("intake")
if intake is not None and isinstance(getattr(intake, "payload", None), dict):
return dict(intake.payload)
summary = context.get("intake_summary")
if isinstance(summary, dict):
return dict(summary)
return {}
def _render_trust_banner_html(
*,
province: str,
official_source: str,
last_updated: str,
scope: str,
confidence_label: str,
boundary_note: str,
) -> str:
return (
'<div class="trust-banner">'
f'<p class="meta">可信度说明:官方来源:{escape(official_source)} · 更新时间:{escape(last_updated or "未公布")}</p>'
f'<p class="meta">适用范围:{escape(scope)} · 适用省份:{escape(province)} · 置信等级:{escape(confidence_label)}</p>'
f'<p class="meta">{escape(boundary_note)}</p>'
"</div>"
)
def _helper_entry_hrefs(payload: dict[str, Any], order: Order) -> tuple[str, str]:
province = (
str(
payload.get("candidate_province") or order.candidate_province or "湖南"
).strip()
or "湖南"
)
score = payload.get("candidate_score") or order.candidate_score or 0
return (
f"/policy-center?province={escape(province)}",
f"/same-score-reference?province={escape(province)}&score={escape(str(score))}",
)
def _profile_version_state(payload: dict[str, Any], profile_version_id: str) -> str:
latest_profile_version = str(
payload.get("latest_profile_version_id") or profile_version_id
)
if profile_version_id == latest_profile_version:
return "基于最新档案生成"
return "基于历史档案版本生成,建议刷新"
def _render_auxiliary_factor_section(payload: dict[str, Any]) -> str:
rows: list[str] = []
if payload.get("interest_assessment_result"):
rows.append(
f"<li>测评结果:{escape(str(payload.get('interest_assessment_result') or ''))}</li>"
)
if payload.get("family_background"):
rows.append(
f"<li>家庭背景:{escape(str(payload.get('family_background') or ''))}</li>"
)
if payload.get("industry_resources"):
rows.append(
f"<li>行业资源:{escape(str(payload.get('industry_resources') or ''))}</li>"
)
if payload.get("extra_notes"):
rows.append(
f"<li>补充说明:{escape(str(payload.get('extra_notes') or ''))}</li>"
)
if not rows:
return ""
return (
'<section class="panel"><h2>辅助判断因子</h2>'
'<p class="meta">这些内容只用于辅助解释,不作为唯一判断。</p>'
f"<ul>{''.join(rows)}</ul></section>"
)
def _sync_report_version_metadata(
order_id: str,
settings: Settings,
*,
report_version_id: str,
profile_version_id: str,
review_result_version_id: str | None,
artifact_refs: dict[str, Any],
) -> None:
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order_id)
if current is None:
return
updated_payload = _ensure_report_version_metadata(
current.payload,
report_version_id=report_version_id,
profile_version_id=profile_version_id,
review_result_version_id=review_result_version_id,
artifact_refs=artifact_refs,
)
if updated_payload != current.payload:
intake_store.save(
order_id=order_id,
payload=updated_payload,
submit=(current.status == "submitted"),
)
finally:
intake_store.close()
def _public_supported_provinces() -> set[str]:
return {
"北京",
"天津",
"上海",
"重庆",
"河北",
"河南",
"山东",
"山西",
"陕西",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"湖北",
"湖南",
"广东",
"海南",
"四川",
"贵州",
"云南",
"甘肃",
"青海",
"新疆",
}
@router.get("/policy-center", include_in_schema=False)
def policy_center_page(province: str = "湖南") -> HTMLResponse:
safe_province = province or "湖南"
supported = safe_province in _public_supported_provinces()
trust_banner = _render_trust_banner_html(
province=safe_province,
official_source=f"{safe_province}省教育考试院",
last_updated="2026-06-22",
scope="政策摘要与填报提醒",
confidence_label="参考" if supported else "暂不支持",
boundary_note=(
"未覆盖内容必须显式标注;页面仅提供摘要,不替代官方全文。"
if supported
else "当前省份尚未进入公开支持集,请不要把本页内容视为可用填报依据。"
),
)
support_notice = (
""
if supported
else "<section class='panel'><h2>当前省份暂不支持</h2><p class='meta'>公开政策摘要与同分段参考当前未覆盖该省份,请勿继续使用本页作为填报依据。</p></section>"
)
same_score_href = f"/same-score-reference?province={escape(safe_province)}&score=0"
body = f"""<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>政策中心</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.eyebrow{{display:inline-flex;padding:6px 10px;border-radius:999px;background:#eef6ff;color:#194fb6;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}}.meta{{color:#5b6b88;line-height:1.8}}.actions{{display:flex;gap:12px;flex-wrap:wrap}}a{{color:#1f6feb;text-decoration:none}}</style></head><body>{_render_global_nav()}<main class="wrap"><section class="panel"><span class="eyebrow">政策中心</span><h1>政策中心</h1><p class="meta">适用省份:{escape(safe_province)}</p>{trust_banner}<div class="actions"><a href=\"{same_score_href}\">查看同分段参考</a><a href=\"/\">返回首页</a></div></section>{support_notice}<section class="panel"><h2>时间节点</h2><ul><li>查分后先核对成绩、位次与选科要求。</li><li>正式填报前再次确认批次与专业组选科约束。</li></ul></section><section class="panel"><h2>批次规则</h2><p class="meta">当前只整理普通类主链的关键规则;特殊批次、艺体类等未覆盖内容需另行核对。</p></section><section class="panel"><h2>提前批与专项计划</h2><p class="meta">提前批和专项计划是可以降分进入好学校的特殊渠道,但各有报考条件限制。以下列出主要类型:</p><ul><li><strong>军校(本科提前批)</strong>:入学即入伍,毕业分配工作。需通过政审+军检年龄不超过20周岁。比同层次普通院校低30-80分。</li><li><strong>公安院校(本科提前批)</strong>毕业参加公安联考入警率90%+,需通过体检+体能测试+政审。注意只有公安专业才能参加联考。</li><li><strong>国家专项计划</strong>面向脱贫县农村考生需当地连续3年户籍+学籍。通常降10-30分。</li><li><strong>地方专项计划</strong>面向农村户籍考生各省自定实施区域。通常降15-40分。</li><li><strong>高校专项计划</strong>教育部直属高校面向农村考生需4-5月提前报名。通常降20-60分。</li><li><strong>公费师范生</strong>:免学费+住宿费+补贴毕业包分配有编有岗需回生源省任教6年。</li><li><strong>免费医学定向</strong>免学费毕业回基层卫生院事业编需服务6年。</li></ul><p class="meta">具体资格要求、体检标准和降分幅度以当地教育考试院和各院校官方招生简章为准。</p></section><section class="panel"><h2>选科要求</h2><p class="meta">先核对目标专业组选科要求,再判断是否要调整冲稳保结构。</p></section><section class="panel"><h2>常见误区</h2><ul><li>不要把同分段参考当成最终结论。</li><li>不要用去年的批次规则替代当年正式发布口径。</li></ul><p class="meta">以{escape(safe_province)}省教育考试院官方信息为准。</p></section>{_render_footer_links()}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/same-score-reference", include_in_schema=False)
def same_score_reference_page(province: str = "湖南", score: int = 0) -> HTMLResponse:
safe_province = province or "湖南"
supported = safe_province in _public_supported_provinces()
loader = CrowdDBLoader(warn_low_confidence=False)
metadata = loader.load_metadata(safe_province) if supported else None
metadata = metadata or {
"province": safe_province,
"last_updated": "",
"source": "",
"confidence": None,
}
recommendations = (
loader.find_recommendations(safe_province, score)
if (score and supported)
else []
)
schools = [
rec.get("name", "") for rec in recommendations[:5] if isinstance(rec, dict)
]
majors = [
rec.get("major", "") for rec in recommendations[:5] if isinstance(rec, dict)
]
cities: list[str] = []
for rec in recommendations[:5]:
if not isinstance(rec, dict):
continue
for alt in rec.get("alternatives", []) or []:
if isinstance(alt, dict) and alt.get("name"):
cities.append(str(alt.get("name")))
confidence = metadata.get("confidence")
confidence_label = (
""
if supported and isinstance(confidence, (int, float)) and confidence >= 0.8
else ("参考" if supported else "暂不支持")
)
trust_banner = _render_trust_banner_html(
province=safe_province,
official_source=str(
metadata.get("source") or f"{safe_province}省公开同分段参考整理"
),
last_updated=str(metadata.get("last_updated") or "未公布"),
scope="同分段参考与扎堆风险提示",
confidence_label=confidence_label,
boundary_note=(
"仅作参考,不替代正式填报判断;非高置信数据不得作为强推荐依据。当前高置信省份以数据质量白名单为准。"
if supported
else "当前省份暂不支持公开同分段参考,请不要把“暂无”理解为普通空数据。"
),
)
unsupported_notice = (
""
if supported
else "<section class='panel'><h2>当前省份暂不支持</h2><p class='meta'>公开同分段参考当前未覆盖该省份;请勿把当前页面的“暂无”视为普通空数据。</p></section>"
)
policy_href = f"/policy-center?province={escape(safe_province)}"
body = f"""<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>同分段参考</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:1000px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.eyebrow{{display:inline-flex;padding:6px 10px;border-radius:999px;background:#eef6ff;color:#194fb6;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}}.meta{{color:#5b6b88;line-height:1.8}}.actions{{display:flex;gap:12px;flex-wrap:wrap}}a{{color:#1f6feb;text-decoration:none}}</style></head><body>{_render_global_nav()}<main class="wrap"><section class="panel"><span class="eyebrow">同分段参考</span><h1>同分段参考</h1><p class="meta">适用省份:{escape(safe_province)} · 置信等级:{escape(confidence_label)} · 数据说明:仅作参考,不替代正式填报判断</p>{trust_banner}<div class="actions"><a href=\"{policy_href}\">查看政策中心</a><a href=\"/\">返回首页</a></div></section>{unsupported_notice}<section class="panel"><h2>同分段热门学校</h2><p>{escape("".join([item for item in schools if item]) or "暂无")}</p></section><section class="panel"><h2>同分段热门专业</h2><p>{escape("".join([item for item in majors if item]) or "暂无")}</p></section><section class="panel"><h2>同分段热门城市</h2><p>{escape("".join([item for item in cities if item]) or "暂无")}</p></section><section class="panel"><h2>扎堆风险提示</h2><p class="meta">若热门学校/专业高度集中,建议回到审核页或冲稳保页重新看梯度风险。</p></section>{_render_footer_links()}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/deletion-policy", include_in_schema=False)
def deletion_policy_page() -> HTMLResponse:
sections_html = "<section class='panel'><h2>你可以怎么做</h2><ul class='checklist'><li>在 Portal 中提交删除申请</li><li>填写申请人姓名、联系方式、删除范围与原因</li><li>确认监护人已知情并同意发起删除申请</li><li>处理结果会回到站内状态链路中查看</li></ul></section>"
return HTMLResponse(
_render_basic_page(
title="删除申请 / 数据删除说明",
eyebrow="数据删除",
lead="如需申请删除订单资料、附件或交付物,可在支付后的 Portal 中提交删除申请;系统会保留必要的审计记录,并由人工核验后处理。",
sections_html=sections_html,
)
)
@router.get("/my-orders", include_in_schema=False)
def my_orders_page(
request: Request,
token: str | None = None,
phone: str
| None = None, # 已废弃C 方案下不再支持手机号直查,保留参数仅为兼容旧链接不报错
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""C 方案:必须持有有效 portal token 才能查看订单。
旧逻辑(手机号直查 → 现签 portal token已废弃因为它构成越权
任何知道家长手机号的人都能枚举他人订单和交付物入口。
现逻辑:
- 无 token → 显示说明页,引导用户通过下单时收到的 portal 链接进入
- 有 token → 验证 token → 只展示该 token 对应的那一个订单(不查全量)
- phone 参数保留仅为兼容旧链接,不再触发任何查询
"""
from data.customer_portal.token import verify_portal_token, PortalTokenError
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
# 无 token显示引导说明不再显示手机号查询表单
if not token:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的订单</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的订单</h1><section class="state-empty" role="status"><h2 class="state-empty__title">请通过订单链接进入</h2><p class="state-empty__hint">为保护你的订单隐私,我们不再支持凭手机号查询订单。请使用下单时收到的订单进度链接(短信/页面跳转中已包含),直接进入你的订单状态页。如果链接已丢失,请联系客服核实身份后重新获取。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
# 有 token验证并只展示该 token 对应的订单
try:
payload = verify_portal_token(token, settings.portal_token_secret)
order_id = str(payload["order_id"])
except PortalTokenError:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的订单</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的订单</h1><section class="state-error" role="alert"><h2 class="state-error__title">链接无效或已过期</h2><p class="state-error__hint">你访问的订单链接无效。请使用下单时收到的最新链接,或联系客服。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body, status_code=401)
from data.orders.dao import OrdersDAO, OrderNotFound
try:
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = dao.get(order_id)
except OrderNotFound:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的订单</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的订单</h1><section class="state-empty" role="status"><h2 class="state-empty__title">订单不存在</h2><p class="state-empty__hint">该订单可能已被删除。如有疑问请联系客服。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body, status_code=404)
# 只展示这一个订单,不查全量,不签发新 token复用当前 token
status_url = f"/portal/{token}/status"
orders_html = (
f"<section class='panel'><h2>当前订单</h2>"
f"<table style='width:100%;border-collapse:collapse;font-size:13px;'>"
f"<thead><tr style='text-align:left;border-bottom:2px solid #d7e3f1;'>"
f"<th style='padding:8px;'>订单号</th>"
f"<th style='padding:8px;'>套餐</th>"
f"<th style='padding:8px;'>状态</th>"
f"<th style='padding:8px;'>创建时间</th>"
f"<th style='padding:8px;'>操作</th>"
f"</tr></thead><tbody>"
f"<tr><td style='padding:8px;'>{escape(order.id)}</td>"
f"<td style='padding:8px;'>{escape(order.service_version)}</td>"
f"<td style='padding:8px;'>{escape(order.status)}</td>"
f"<td style='padding:8px;'>{escape(str(order.created_at or '')[:16])}</td>"
f"<td style='padding:8px;'><a href=\"{status_url}\">查看详情</a></td></tr>"
f"</tbody></table>"
f"<p class='meta' style='margin-top:10px;font-size:12px;'>仅显示当前链接对应的订单。如需查看其他订单,请使用对应订单的进度链接。</p>"
f"</section>"
)
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的订单</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}table{{width:100%;border-collapse:collapse;font-size:13px}}th,td{{padding:8px;text-align:left;border-bottom:1px solid #eef2f7}}th{{font-weight:600;color:#172033}}a{{color:#1f6feb;text-decoration:none}}</style></head><body>{_render_global_nav()}{nav}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的订单</h1></section>{orders_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/my-reports", include_in_schema=False)
def my_reports_page(
request: Request,
token: str | None = None,
phone: str | None = None, # 已废弃C 方案下不再支持手机号直查
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""C 方案:必须持有有效 portal token 才能查看报告。
旧逻辑(手机号直查)已废弃,原因同 my_orders_page。
- 无 token → 显示说明页
- 有 token → 验证 → 只展示该 token 对应订单的报告(如有交付)
"""
from data.customer_portal.token import verify_portal_token, PortalTokenError
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
if not token:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的报告</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的报告</h1><section class="state-empty" role="status"><h2 class="state-empty__title">请通过订单链接进入</h2><p class="state-empty__hint">为保护你的报告隐私,我们不再支持凭手机号查询报告。请使用下单时收到的订单进度链接进入,在订单状态页查看已交付的报告。如果链接已丢失,请联系客服核实身份后重新获取。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
try:
payload = verify_portal_token(token, settings.portal_token_secret)
order_id = str(payload["order_id"])
except PortalTokenError:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的报告</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的报告</h1><section class="state-error" role="alert"><h2 class="state-error__title">链接无效或已过期</h2><p class="state-error__hint">你访问的订单链接无效。请使用下单时收到的最新链接,或联系客服。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body, status_code=401)
from data.orders.dao import OrdersDAO, OrderNotFound
try:
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = dao.get(order_id)
except OrderNotFound:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的报告</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{_render_global_nav()}{nav}<main class="wrap" style="max-width:680px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的报告</h1><section class="state-empty" role="status"><h2 class="state-empty__title">订单不存在</h2><p class="state-empty__hint">该订单可能已被删除。如有疑问请联系客服。</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body, status_code=404)
# 只展示该订单的报告(如果已交付)
if order.status in ("delivered", "completed") and order.audit_report:
report_url = f"/portal/{token}/report"
reports_html = (
f"<section class='panel'><h2>当前订单的报告</h2>"
f"<table style='width:100%;border-collapse:collapse;font-size:13px;'>"
f"<thead><tr style='text-align:left;border-bottom:2px solid #d7e3f1;'>"
f"<th style='padding:8px;'>订单号</th>"
f"<th style='padding:8px;'>套餐</th>"
f"<th style='padding:8px;'>状态</th>"
f"<th style='padding:8px;'>创建时间</th>"
f"<th style='padding:8px;'>操作</th>"
f"</tr></thead><tbody>"
f"<tr><td style='padding:8px;'>{escape(order.id)}</td>"
f"<td style='padding:8px;'>{escape(order.service_version)}</td>"
f"<td style='padding:8px;'>已交付</td>"
f"<td style='padding:8px;'>{escape(str(order.created_at or '')[:16])}</td>"
f"<td style='padding:8px;'><a href=\"{report_url}\">查看报告</a></td></tr>"
f"</tbody></table></section>"
)
else:
reports_html = (
"<section class='state-empty' role='status'>"
"<h2 class='state-empty__title'>当前订单暂无可查看的报告</h2>"
"<p class='state-empty__hint'>报告在订单完成交付后才会显示。请关注订单状态页的进度更新。</p>"
"</section>"
)
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>我的报告</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}table{{width:100%;border-collapse:collapse;font-size:13px}}th,td{{padding:8px;text-align:left;border-bottom:1px solid #eef2f7}}th{{font-weight:600;color:#172033}}a{{color:#1f6feb;text-decoration:none}}</style></head><body>{_render_global_nav()}{nav}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>我的报告</h1></section>{reports_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/data-query", include_in_schema=False)
def data_query_page(
request: Request,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""数据查询入口页:分数线 / 位次 / 政策 / 同分段。"""
body = (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />'
'<meta name="viewport" content="width=device-width, initial-scale=1" />'
"<title>数据查询</title>"
'<link rel="stylesheet" href="/static/portal-ui.css" />'
"<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}"
".wrap{max-width:980px;margin:0 auto;display:grid;gap:18px}"
".panel{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}"
".btn{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}"
".meta{color:#5b6b88;line-height:1.8}"
".query-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-top:16px}"
".query-card{padding:20px;border-radius:16px;background:#f8fbff;border:1px solid #d7e3f1}"
".query-card h3{margin:0 0 8px;font-size:18px}"
".query-card p{margin:0 0 12px;color:#5b6b88;font-size:14px;line-height:1.6}"
"a{color:#1f6feb;text-decoration:none}"
"@media(max-width:768px){.query-grid{grid-template-columns:1fr}}"
"</style></head><body>"
+ '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="/data-query">数据查询</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
+ '<main class="wrap" role="main"><section class="panel">'
+ '<div style="margin-bottom:8px;"><a class="btn" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div>'
+ '<h1>数据查询</h1><p class="meta">查询高考相关的基础数据,辅助你做志愿填报决策。</p>'
+ '<div class="query-grid">'
+ '<div class="query-card"><h3>分数线查询</h3><p>查询全国各省本科线、特殊类型控制线等官方录取分数线。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/score-line-query">进入分数线查询</a></div>'
+ '<div class="query-card"><h3>位次估算</h3><p>输入分数,基于一分一段数据估算全省位次(支持省份取决于数据覆盖)。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/rank-estimator">进入位次估算</a></div>'
+ '<div class="query-card"><h3>专业库</h3><p>搜索教育部公布的专业目录,查看专业代码、学科门类、学位类型。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/majors-query">进入专业库查询</a></div>'
+ '<div class="query-card"><h3>院校库</h3><p>搜索院校,查看院校代码、招生专业和所属省份。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/schools-query">进入院校库查询</a></div>'
+ '<div class="query-card"><h3>政策中心</h3><p>查看各省高考政策摘要、批次规则、选科要求和常见误区。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/policy-center?province=广东">进入政策中心</a></div>'
+ '<div class="query-card"><h3>同分段参考</h3><p>查看某个分数段在各省份的热门学校、专业和城市分布。</p><a class="btn" style="font-size:13px;min-height:36px;" href="/same-score-reference?province=广东&score=578">进入同分段参考</a></div>'
+ "</div></section>"
+ _render_footer_links()
+ "</main>{_render_global_toast_script()}</body></html>"
)
return HTMLResponse(body)
@router.get("/score-line-query", include_in_schema=False)
def score_line_query_page(
request: Request,
province: str | None = None,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""分数线查询页:从 crowd_db 的 score_distribution 读各省本科线。"""
from data.crowd_db.loader import CrowdDBLoader
loader = CrowdDBLoader(warn_low_confidence=False)
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="/data-query">数据查询</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
# 查询逻辑
result_html = ""
query_province = (province or "").strip()
if query_province:
try:
data = loader.load_province(query_province)
if data and data.get("score_distribution"):
sd = data["score_distribution"]
subjects = sd.get("subjects", {})
rows = []
for subject_name, subj_data in subjects.items():
bsl = subj_data.get("bachelor_score_line", 0)
rows.append(
f"<tr><td style='padding:8px;'>{escape(subject_name)}</td>"
f"<td style='padding:8px;'><strong>{bsl}</strong></td>"
f"<td style='padding:8px;'>{escape(str(sd.get('data_year', '')))}</td>"
f'<td style=\'padding:8px;\'><a href="{escape(str(sd.get("source_url", "")))}" target="_blank">查看来源</a></td></tr>'
)
if rows:
result_html = (
f"<section class='panel'><h2>{escape(query_province)} 本科分数线</h2>"
f"<table style='width:100%;border-collapse:collapse;font-size:13px;'>"
f"<thead><tr style='text-align:left;border-bottom:2px solid #d7e3f1;'>"
f"<th style='padding:8px;'>科类</th><th style='padding:8px;'>本科线</th>"
f"<th style='padding:8px;'>数据年份</th><th style='padding:8px;'>来源</th>"
f"</tr></thead><tbody>{''.join(rows)}</tbody></table></section>"
)
else:
result_html = "<section class='state-empty' role='status'><h2 class='state-empty__title'>该省份暂无分数线数据</h2><p class='state-empty__hint'>当前 crowd_db 尚未接入该省份的分数线数据。</p></section>"
else:
result_html = "<section class='state-empty' role='status'><h2 class='state-empty__title'>该省份暂无分数线数据</h2><p class='state-empty__hint'>当前 crowd_db 尚未接入该省份的分数线数据。</p></section>"
except Exception:
result_html = "<section class='state-error' role='alert'><h2 class='state-error__title'>查询失败</h2><p class='state-error__hint'>数据加载出错,请稍后重试。</p></section>"
# 省份选项
all_provinces = loader.list_supported_provinces()
province_options = "".join(
f'<option value="{p}" {"selected" if p == query_province else ""}>{p}</option>'
for p in all_provinces
)
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>分数线查询</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}table{{width:100%;border-collapse:collapse;font-size:13px}}th,td{{padding:8px;text-align:left;border-bottom:1px solid #eef2f7}}th{{font-weight:600}}a{{color:#1f6feb;text-decoration:none}}</style></head><body>{_render_global_nav()}{nav}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/data-query">返回数据查询</a></div><h1>分数线查询</h1><p class="meta">选择省份,查看官方公布的本科录取分数线。</p><form method="get" action="/score-line-query" style="margin-top:12px;"><select name="province" style="padding:10px;border-radius:10px;border:1px solid #d7e3f1;font-size:14px;"><option value="">请选择省份</option>{province_options}</select><button class="btn" type="submit" style="margin-left:8px;">查询</button></form></section>{result_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/rank-estimator", include_in_schema=False)
def rank_estimator_page(
request: Request,
province: str | None = None,
score: int | None = None,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""位次估算页:基于 crowd_db 的 score_distribution.benchmarks 估算位次。"""
from data.crowd_db.loader import CrowdDBLoader
loader = CrowdDBLoader(warn_low_confidence=False)
nav = '<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="/data-query">数据查询</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>'
result_html = ""
query_province = (province or "").strip()
query_score = score or 0
if query_province and query_score:
try:
data = loader.load_province(query_province)
if data and data.get("score_distribution"):
sd = data["score_distribution"]
subjects = sd.get("subjects", {})
estimates = []
for subject_name, subj_data in subjects.items():
benchmarks = subj_data.get("benchmarks", [])
# 线性插值估算位次
estimated_rank = None
if benchmarks:
sorted_bms = sorted(
benchmarks, key=lambda b: b.get("score", 0), reverse=True
)
for i, bm in enumerate(sorted_bms):
if query_score >= bm["score"]:
estimated_rank = bm["cumulative_count"]
break
if estimated_rank is None:
estimated_rank = sorted_bms[-1].get("cumulative_count", 0)
if estimated_rank is not None:
estimates.append(
f"<li>{escape(subject_name)}:估算累计位次约 <strong>{estimated_rank:,}</strong> 名(基于{escape(query_province)}一分一段数据插值)</li>"
)
if estimates:
result_html = f"<section class='panel'><h2>{escape(query_province)} 分数 {query_score} 位次估算</h2><ul style='line-height:2;'>{''.join(estimates)}</ul><p class='meta' style='font-size:12px;margin-top:8px;'>⚠ 这是基于公开一分一段锚点的估算值,非精确位次。最终位次以官方一分一段表为准。</p></section>"
else:
result_html = "<section class='state-empty' role='status'><h2 class='state-empty__title'>该省份暂无位次锚点数据</h2><p class='state-empty__hint'>当前 crowd_db 尚未接入该省份的一分一段表。</p></section>"
else:
result_html = "<section class='state-empty' role='status'><h2 class='state-empty__title'>该省份暂无位次数据</h2></section>"
except Exception:
result_html = "<section class='state-error' role='alert'><h2 class='state-error__title'>查询失败</h2></section>"
all_provinces = loader.list_supported_provinces()
province_options = "".join(
f'<option value="{p}" {"selected" if p == query_province else ""}>{p}</option>'
for p in all_provinces
)
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>位次估算</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}</style></head><body>{_render_global_nav()}{nav}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/data-query">返回数据查询</a></div><h1>位次估算</h1><p class="meta">选择省份并输入分数,基于公开一分一段数据估算你的全省位次。</p><form method="get" action="/rank-estimator" style="margin-top:12px;"><select name="province" style="padding:10px;border-radius:10px;border:1px solid #d7e3f1;font-size:14px;"><option value="">请选择省份</option>{province_options}</select><input name="score" type="number" value="{query_score}" placeholder="例如578" style="padding:10px;border-radius:10px;border:1px solid #d7e3f1;font-size:14px;margin-left:8px;width:120px;" /><button class="btn" type="submit" style="margin-left:8px;">估算</button></form></section>{result_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/majors-query", include_in_schema=False)
def majors_query_page(
request: Request,
q: str | None = None,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""专业库查询页:从 majors_catalog 搜索专业。"""
import json as _json
from pathlib import Path as _Path
nav_html = _render_global_nav()
result_html = ""
query = (q or "").strip()
if query:
try:
data = _json.loads(
_Path("data/majors_catalog/national/latest.json").read_text()
)
all_majors = data.get("majors", [])
# 过滤匹配的
matched = [
m
for m in (all_majors or [])
if query.lower() in m.get("name", "").lower()
or query.lower() in m.get("category", "").lower()
or query.lower() in m.get("discipline", "").lower()
]
if matched:
rows = []
for m in matched:
rows.append(
f"<tr>"
f"<td style='padding:8px;'>{m.get('code', '')}</td>"
f"<td style='padding:8px;'><strong>{m.get('name', '')}</strong></td>"
f"<td style='padding:8px;'>{m.get('discipline', '')}</td>"
f"<td style='padding:8px;'>{m.get('category', '')}</td>"
f"<td style='padding:8px;'>{m.get('degree', '')}</td>"
f"<td style='padding:8px;'>{m.get('status', '')}</td>"
f"</tr>"
)
result_html = (
f"<section class='panel'><h2>搜索结果({len(matched)} 个专业)</h2>"
f"<table style='width:100%;border-collapse:collapse;font-size:13px;'>"
f"<thead><tr style='text-align:left;border-bottom:2px solid #d7e3f1;'>"
f"<th style='padding:8px;'>代码</th><th style='padding:8px;'>专业名称</th>"
f"<th style='padding:8px;'>学科门类</th><th style='padding:8px;'>专业类</th>"
f"<th style='padding:8px;'>学位</th><th style='padding:8px;'>状态</th>"
f"</tr></thead><tbody>{''.join(rows)}</tbody></table></section>"
)
else:
result_html = f"<section class='state-empty' role='status'><h2 class='state-empty__title'>未找到匹配&quot;{escape(query)}&quot;的专业</h2><p class='state-empty__hint'>当前专业目录为 MVP 子集13 个核心专业),完整目录待后续扩充。</p></section>"
except Exception as e:
result_html = f"<section class='state-error' role='alert'><h2 class='state-error__title'>查询失败</h2><p class='state-error__hint'>{escape(str(e))}</p></section>"
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>专业库查询</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}table{{width:100%;border-collapse:collapse;font-size:13px}}th,td{{padding:8px;text-align:left;border-bottom:1px solid #eef2f7}}th{{font-weight:600}}</style></head><body>{nav_html}{_render_global_nav()}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/data-query">返回数据查询</a></div><h1>专业库查询</h1><p class="meta">搜索教育部公布的专业目录,查看专业代码、学科门类、学位类型。</p><form method="get" action="/majors-query" style="margin-top:12px;"><input name="q" value="{escape(query)}" placeholder="例如:计算机、经济学、工学" style="padding:10px;border-radius:10px;border:1px solid #d7e3f1;font-size:14px;min-width:240px;" /><button class="btn" type="submit" style="margin-left:8px;">搜索</button></form></section>{result_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/schools-query", include_in_schema=False)
def schools_query_page(
request: Request,
q: str | None = None,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""院校库查询页:从 majors_catalog/schools/ 搜索院校。"""
import json as _json
from pathlib import Path as _Path
nav_html = _render_global_nav()
result_html = ""
query = (q or "").strip()
if query:
try:
schools_dir = _Path("data/majors_catalog/schools/2026")
school_files = list(schools_dir.glob("*.json"))
matched = []
for f in school_files:
data = _json.loads(f.read_text())
if query.lower() in data.get(
"school_name", ""
).lower() or query in data.get("province", ""):
offerings = data.get("offerings", [])
offering_html = "".join(
f"<li>{escape(o.get('major_name', ''))} ({escape(o.get('major_code', ''))})</li>"
for o in offerings
)
matched.append(
f"<section class='panel'><h2>{escape(data.get('school_name', ''))}</h2>"
f"<p class='meta'>院校代码:{escape(data.get('school_code', ''))} · 省份:{escape(data.get('province', ''))} · 招生年份:{escape(str(data.get('admission_year', '')))}</p>"
f"<h3>开设专业({len(offerings)} 个)</h3>"
f"<ul style='line-height:1.8;'>{offering_html}</ul></section>"
)
if matched:
result_html = "".join(matched)
else:
result_html = f"<section class='state-empty' role='status'><h2 class='state-empty__title'>未找到匹配&quot;{escape(query)}&quot;的院校</h2><p class='state-empty__hint'>当前院校目录仅含 MVP 子集,完整目录待后续扩充。</p></section>"
except Exception as e:
result_html = f"<section class='state-error' role='alert'><h2 class='state-error__title'>查询失败</h2><p class='state-error__hint'>{escape(str(e))}</p></section>"
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>院校库查询</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:980px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}</style></head><body>{nav_html}{_render_global_nav()}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/data-query">返回数据查询</a></div><h1>院校库查询</h1><p class="meta">搜索院校,查看院校代码、招生专业和所属省份。</p><form method="get" action="/schools-query" style="margin-top:12px;"><input name="q" value="{escape(query)}" placeholder="例如:北京大学、湖南" style="padding:10px;border-radius:10px;border:1px solid #d7e3f1;font-size:14px;min-width:240px;" /><button class="btn" type="submit" style="margin-left:8px;">搜索</button></form></section>{result_html}</main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
@router.get("/compare-reports", include_in_schema=False)
def compare_reports_page(
request: Request,
token: str | None = None,
tokens: str | None = None,
phone: str | None = None, # 已废弃C 方案下不再支持手机号直查
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
"""报告对比闭环:支持用户输入多个 portal token 进行真实对比。"""
from data.customer_portal.token import verify_portal_token, PortalTokenError
nav = _render_global_nav()
# 解析 token 列表query param tokens=token1,token2,...
token_list = []
if tokens:
token_list = [t.strip() for t in tokens.split(",") if t.strip()]
elif token:
token_list = [token]
# 无 token显示输入表单
if not token_list:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>报告对比</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{nav}<main class="wrap" style="max-width:760px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>报告对比</h1><section class="state-empty" role="status"><h2 class="state-empty__title">请输入 2 份以上报告链接中的 token</h2><p class="state-empty__hint">为保护你的报告隐私,我们不再支持凭手机号查询报告。请把多个订单链接中的 token 复制到这里(逗号分隔),系统会并排对比这些报告的关键信息。</p></section><form method="get" action="/compare-reports" style="margin-top:16px;"><textarea name="tokens" placeholder="token1,token2,token3" style="width:100%;min-height:120px;padding:12px;border-radius:12px;border:1px solid #d7e3f1;font-size:14px;"></textarea><div style="margin-top:12px;"><button class="btn" type="submit">开始对比</button></div></form></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
# 验证每个 token 并收集数据
compare_rows = []
invalid_tokens = []
from data.orders.dao import OrdersDAO, OrderNotFound
for tk in token_list:
try:
payload = verify_portal_token(tk, settings.portal_token_secret)
order_id = str(payload["order_id"])
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = dao.get(order_id)
context = _build_portal_context(order, settings)
intake = context.get("intake_summary") or {}
latest_review = _load_latest_review_result(order.id, settings)
compare_rows.append(
{
"token": tk,
"order_id": order.id,
"service_version": order.service_version,
"status": order.status,
"score": intake.get("candidate_score")
or order.candidate_score
or "-",
"rank": intake.get("candidate_rank") or order.candidate_rank or "-",
"subjects": ", ".join(
intake.get("candidate_subjects")
or order.candidate_subjects
or []
)
or "-",
"target_cities": ", ".join(intake.get("target_cities") or [])
or "-",
"target_majors": ", ".join(intake.get("target_majors") or [])
or "-",
"latest_review": latest_review.review_result_id
if latest_review
else "-",
"has_pdf": "" if context.get("report_pdf_ready") else "",
"report_url": f"/portal/{tk}/report",
"status_url": f"/portal/{tk}/status",
}
)
except (PortalTokenError, OrderNotFound):
invalid_tokens.append(tk)
# 对比结果
if len(compare_rows) < 2:
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>报告对比</title><link rel="stylesheet" href="/static/portal-ui.css" /></head><body>{nav}<main class="wrap" style="max-width:760px;margin:0 auto;padding:32px 20px;display:grid;gap:16px;" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>报告对比</h1><section class="state-empty" role="status"><h2 class="state-empty__title">有效报告不足 2 份,无法对比</h2><p class="state-empty__hint">请提供至少 2 个有效的 portal token。无效 token{escape(", ".join(invalid_tokens) or "")}</p></section></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
# 构建对比表(以列为报告)
headers = [f"报告 {i + 1}" for i in range(len(compare_rows))]
fields = [
("订单号", "order_id"),
("套餐", "service_version"),
("状态", "status"),
("分数", "score"),
("位次", "rank"),
("选科", "subjects"),
("目标城市", "target_cities"),
("目标专业", "target_majors"),
("最近复核版本", "latest_review"),
("是否有 PDF", "has_pdf"),
]
rows_html = []
for label, key in fields:
cells = "".join(
f"<td style='padding:10px;border-bottom:1px solid #eef2f7;'>{escape(str(row[key]))}</td>"
for row in compare_rows
)
rows_html.append(
f"<tr><th style='padding:10px;text-align:left;background:#f8fbff;border-bottom:1px solid #d7e3f1;'>{label}</th>{cells}</tr>"
)
quick_links = "".join(
f"<li><a href='{escape(str(row['report_url']))}'>查看 {escape(str(row['order_id']))} 报告</a> · <a href='{escape(str(row['status_url']))}'>查看状态</a></li>"
for row in compare_rows
)
body = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>报告对比</title><link rel="stylesheet" href="/static/portal-ui.css" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:1100px;margin:0 auto;display:grid;gap:18px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}table{{width:100%;border-collapse:collapse;font-size:13px}}th,td{{padding:8px;text-align:left;border-bottom:1px solid #eef2f7;vertical-align:top}}th{{font-weight:600}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:12px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff;border:none;cursor:pointer}}.meta{{color:#5b6b88;line-height:1.8}}</style></head><body>{nav}<main class="wrap" role="main"><section class="panel"><div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;background:#edf3ff;color:#194fb6;" href="/">返回首页</a></div><h1>报告对比</h1><p class="meta">已对比 {len(compare_rows)} 份有效报告。无效 token{escape(", ".join(invalid_tokens) or "")}</p><table><thead><tr><th style='padding:10px;background:#f8fbff;border-bottom:2px solid #d7e3f1;'>字段</th>{"".join(f"<th style='padding:10px;background:#f8fbff;border-bottom:2px solid #d7e3f1;'>{escape(h)}</th>" for h in headers)}</tr></thead><tbody>{"".join(rows_html)}</tbody></table></section><section class='panel'><h2>快速跳转</h2><ul style='line-height:1.8;'>{quick_links}</ul></section></main>{_render_global_toast_script()}</body></html>"""
return HTMLResponse(body)
def _render_landing_page(request: Request, settings: Settings) -> str:
query = dict(request.query_params)
escape(str(query.get("consult") or ""))
consult_province = escape(str(query.get("province") or ""))
consult_score = escape(str(query.get("score") or ""))
consult_goal = escape(str(query.get("goal") or ""))
consult_subjects = escape(str(query.get("subjects") or query.get("subject") or ""))
portal_token = str(query.get("token") or "").strip()
latest_review_href = (
f"/review/start?source=home&amp;token={escape(portal_token)}"
if portal_token
else ""
)
latest_review_version_html = ""
latest_contract: ReviewResultContract | None = None
workspace_context: dict[str, Any] | None = None
if portal_token:
try:
order = _resolve_order_from_token(portal_token, settings)
latest_contract = _load_latest_review_result(order.id, settings)
workspace_context = _build_portal_context(order, settings)
if latest_contract is not None:
latest_review_version_html = f'<p class="hero-note" style="margin-top:10px;">最新版本:{escape(latest_contract.review_result_id)}</p>'
except HTTPException:
latest_contract = None
workspace_context = None
latest_review_version_html = ""
latest_review_html = (
f'<div class="consult-card" style="margin-top:14px;"><h2>最近一次复核结果</h2><p class="hero-note" style="margin-top:0;">如果你刚完成过复核,可以从这里继续回到复核入口,决定是去冲稳保、补 Step 1还是进入完整规划。</p>{latest_review_version_html}<div class="consult-actions"><a class="btn btn-secondary" href="{latest_review_href}">查看最近一次复核结果</a></div></div>'
if latest_contract is not None
else ""
)
workspace_primary_label = "先做方案复核"
workspace_primary_href = "/review/start?source=home"
if portal_token:
workspace_primary_label = "继续补充 Step 1"
workspace_primary_href = f"/portal/{escape(portal_token)}/info"
if latest_contract is not None:
workspace_primary_label = "继续查看最近一次复核"
workspace_primary_href = latest_review_href
elif workspace_context is not None and workspace_context.get(
"profile_minimum_complete"
):
workspace_primary_label = "开始方案复核"
workspace_primary_href = latest_review_href
workspace_card_html = (
f'<div class="consult-card" style="margin-top:14px;"><h2>工作台主动作</h2><p class="hero-note" style="margin-top:0;">根据当前资料完整度与最近一次复核结果,给出默认下一步。</p><div class="consult-actions"><a class="btn btn-secondary" href="{workspace_primary_href}">{workspace_primary_label}</a></div></div>'
if portal_token
else ""
)
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>高考志愿填报智能规划服务</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
:root {{
color-scheme: light;
--bg: #f3f7fb;
--surface: #ffffff;
--surface-soft: #eef5ff;
--border: #d7e3f1;
--text: #142235;
--muted: #5b6b88;
--primary: #1f6feb;
--primary-dark: #194fb6;
--accent: #0f766e;
--accent-soft: #dff7f1;
--shadow: 0 18px 42px rgba(20, 34, 53, 0.08);
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: linear-gradient(180deg,#0e1a2b 0,#13243d 38%,var(--bg) 38%,var(--bg) 100%); color: var(--text); }}
.global-nav {{ position: sticky; top: 0; z-index: 100; background: rgba(14,26,43,.92); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,.08); padding: 12px 20px; }}
.global-nav-inner {{ max-width: 1180px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 16px; }}
.global-nav-brand {{ font-size: 18px; font-weight: 700; color: #fff; text-decoration: none; }}
.global-nav-links {{ display: flex; gap: 20px; align-items: center; }}
.global-nav-link {{ color: #b8c8e4; text-decoration: none; font-size: 14px; font-weight: 500; transition: color .18s; }}
.global-nav-link:hover {{ color: #fff; }}
@media (max-width: 768px) {{ .global-nav {{ padding: 10px 14px; }} .global-nav-brand {{ font-size: 16px; }} .global-nav-links {{ gap: 12px; }} .global-nav-link {{ font-size: 13px; }} }}
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 40px 20px 72px; }}
.hero {{ display: grid; grid-template-columns: minmax(0, 1.22fr) minmax(340px, .78fr); gap: 32px; align-items: stretch; }}
.hero-copy {{ color: #ecf4ff; padding: 40px 8px 18px 0; }}
.eyebrow {{ display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; background: rgba(223,247,241,.12); border: 1px solid rgba(223,247,241,.24); color: #c9fff3; font-size: 13px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }}
h1 {{ margin: 18px 0 14px; max-width: 760px; font-size: clamp(36px, 6vw, 56px); line-height: 1.04; letter-spacing: -0.04em; }}
.sub {{ margin: 0; max-width: 700px; color: #d4ddea; line-height: 1.82; font-size: 17px; }}
.hero-actions {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; align-items: center; }}
.consult-card {{ margin-top: 18px; padding: 18px; border-radius: 18px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.14); }}
.consult-card h2 {{ margin: 0 0 8px; font-size: 18px; color: #fff; }}
.consult-grid {{ display:grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap:10px; }}
.consult-field {{ display:flex; flex-direction:column; gap:6px; }}
.consult-field label {{ color:#475569; font-size:13px; font-weight:600; }}
.consult-field input, .consult-field textarea, .consult-field select {{ width:100%; padding:11px 12px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background: #ffffff; color:#142235; font-size:14px; }} .consult-field select {{ -webkit-appearance:none; -moz-appearance:none; appearance:none; background-color:#fff !important; min-height:44px; line-height:1.5; background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path fill="%23142235" d="M6 8L2 4h8z"/></svg>'); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px; }}
.consult-field textarea {{ min-height:74px; resize:vertical; }} .consult-field select option {{ background:#fff; color:#142235; }}
.consult-actions {{ display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }}
.consult-privacy {{ margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; background: #f0f7ff; border: 1px solid #c7d7ef; color: #0f172a; font-size: 13px; line-height: 1.65; }}
.consult-privacy strong {{ color: #1e40af; font-weight: 700; }}
.consult-privacy-tail {{ margin: 10px 0 0; color: #475569; font-size: 13px; line-height: 1.6; }}
.btn {{ display: inline-flex; align-items: center; justify-content: center; min-height: 46px; padding: 0 18px; border-radius: 14px; text-decoration: none; font-weight: 700; transition: .18s ease; }}
.btn-primary {{ min-height: 54px; padding: 0 28px; font-size: 17px; background: linear-gradient(135deg,#2d7cff,#0f4fd6); color: #fff; box-shadow: 0 22px 40px rgba(31,111,235,.42), inset 0 1px 0 rgba(255,255,255,.18); letter-spacing: .01em; }}
.btn-primary:hover {{ background: linear-gradient(135deg,#276fe7,#0d45bf); transform: translateY(-1px); }}
.btn-secondary {{ background: rgba(255,255,255,.12); color: #e2e8f0; border: 1px solid rgba(255,255,255,.25); min-height: 44px; padding: 0 14px; font-size: 14px; }}
.btn-secondary:hover {{ background: rgba(255,255,255,.14); }}
.btn-text {{ color:#cfe0ff; padding: 0 6px; min-height: 44px; font-size: 14px; text-decoration: underline; text-underline-offset: 4px; }}
.hero-note {{ margin-top: 12px; color:#e8edf7; font-size:13px; line-height:1.6; }}
.hero-risk-band {{ display:grid; gap:6px; margin-bottom:16px; padding:12px 14px; border-radius:14px; background: rgba(255,255,255,.7); border:1px solid #f3d49f; }}
.hero-risk-band strong {{ font-size:14px; color:#4a3700; }}
.hero-risk-band span {{ color:#5c3d00; font-size:13px; line-height:1.55; }}
.hero-trust {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:18px; }}
.hero-trust-item {{ padding:12px 14px; border-radius:14px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); color:#d9e7ff; font-size:13px; line-height:1.5; }}
.hero-trust-item strong {{ display:block; color:#fff; margin-bottom:4px; font-size:14px; }}
.hero-points {{ display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin-top: 24px; }}
.point {{ padding: 14px; border-radius: 16px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); }}
.point.lead {{ background: rgba(31,111,235,.18); border-color: rgba(125,180,255,.42); }}
.point strong {{ display: block; color: #fff; margin-bottom: 6px; font-size: 15px; }}
.point span {{ color: #b8c8e4; font-size: 13px; line-height: 1.6; }}
.hero-divider {{ margin: 28px 0 0; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.18), transparent); }}
.hero-panel {{ background: rgba(255,255,255,.98); border: 1px solid rgba(215,227,241,.88); border-radius: var(--radius-xl); box-shadow: var(--shadow); padding: 24px; align-self: end; }}
.hero-panel h2 {{ margin: 0 0 10px; font-size: 22px; }}
.hero-panel p {{ margin: 0 0 16px; color: var(--muted); line-height: 1.7; }}
.metric-list {{ display: grid; gap: 12px; }}
.metric {{ padding: 14px 16px; border-radius: 16px; background: var(--surface-soft); border: 1px solid var(--border); }}
.metric strong {{ display: block; font-size: 15px; margin-bottom: 6px; }}
.metric span {{ color: var(--muted); font-size: 13px; line-height: 1.6; }}
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: var(--shadow); padding: 30px; margin-top: 26px; }}
.section h2 {{ margin: 0 0 10px; font-size: 28px; letter-spacing: -0.02em; }}
.section-intro {{ margin: 0 0 22px; color: var(--muted); line-height: 1.75; }}
.grid-3 {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }}
.card {{ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 22px; }}
.card .tag {{ display: inline-flex; margin-bottom: 12px; padding: 6px 10px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700; }}
.card h3 {{ margin: 0 0 8px; font-size: 20px; }}
.card p, .card li {{ color: var(--muted); line-height: 1.7; }}
.flow {{ position:relative; display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:18px; margin-top:6px; }}
.flow::before {{ content:''; position:absolute; left:6%; right:6%; top:42px; height:2px; background:linear-gradient(90deg,#c7d7ef,#dfe9f8); z-index:0; }}
.flow-step {{ position:relative; z-index:1; padding: 22px 18px 18px; border-radius: 20px; background: linear-gradient(180deg,#f9fbff,#eef5ff); border: 1px solid var(--border); }}
.flow-step strong {{ display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; margin-bottom: 12px; border-radius:999px; background:#1f6feb; color:#fff; font-size:13px; letter-spacing: .02em; text-transform: none; }}
.flow-step h3 {{ margin: 0 0 8px; font-size: 18px; }}
.flow-step p {{ margin: 0; color: var(--muted); line-height: 1.7; }}
.trust {{ display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 16px; }}
.trust-item {{ padding: 18px 20px; border-radius: 18px; background:#f8fbff; border:1px solid var(--border); }}
.trust-item strong {{ display:block; margin-bottom:8px; }}
.cta-band {{ display:flex; justify-content:space-between; gap:18px; align-items:center; flex-wrap:wrap; background: linear-gradient(135deg,#16355e,#1f6feb); color:#fff; }}
.cta-band p {{ margin: 0; color: rgba(255,255,255,.82); }}
.cta-band .btn-secondary {{ background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.22); }}
@media (max-width: 960px) {{
.hero, .grid-3, .flow, .trust {{ grid-template-columns: 1fr; }}
.hero-points {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<nav class="global-nav" aria-label="全局导航" role="navigation">
<div class="global-nav-inner">
<a class="global-nav-brand" href="/">高考志愿填报</a>
<div class="global-nav-links">
<a class="global-nav-link" href="/">首页</a>
<a class="global-nav-link" href="/pricing">套餐</a>
<a class="global-nav-link" href="mailto:lon22@qq.com">客服</a>
</div>
</div>
</nav>
<main class="wrap">
<section class="hero">
<div class="hero-copy">
<div class="eyebrow">新高考志愿填报 · 志愿决策支持</div>
<h1>高考志愿填报智能规划服务</h1>
<p class="sub">先免费复核一次你的现有方案,再决定要不要继续付费。</p>
<div class="hero-actions">
<a class="btn btn-primary" href="/review/start?source=home">先做方案复核</a>
<a class="btn btn-primary" href="/pricing">查看套餐</a>
</div>
<p class="hero-note">提交省份、分数、目标后,我们先判断你的方案是否需要复核。复核现有方案本身免费;新方案生成与深度辅导在支付后启动。</p>
{workspace_card_html}{latest_review_html}
</div>
<aside class="hero-panel">
<div class="hero-risk-band"><strong>最常见的不是“不会选”,而是先选错方向</strong><span>我们优先帮你筛出踩线、扎堆、梯度失衡这三类最容易带来后悔成本的风险。</span></div>
<h2>先看你的方案值不值得继续</h2>
<p>如果你已经拿到老师、机构或其他平台给出的方案,后续可以直接上传文档继续核查。</p>
</aside>
</section>
<section class="section" id="consult-box">
<h2>先做一次免费复核</h2>
<p class="section-intro">先提交最小必要信息,我们会判断你当前方案值不值得继续;如果你已经拿到老师、机构或其他平台给出的方案,后续资料页也可以上传文档继续复核。</p>
<div class="consult-card" style="margin-top:0; background:#f8fbff; border:1px solid var(--border); padding:24px;">
<p class="consult-privacy" aria-label="隐私说明">🔒 这些输入只用于判断要不要复核你的方案,<strong>不会留底、不会用于生成方案、不会发邮件推销</strong>。如果你决定不进入付费方案,提交的资料不会保存到我们的数据库。</p>
<form action="/review/start" method="get" aria-label="免费复核表单">
<input type="hidden" name="source" value="home" />
{f'<input type="hidden" name="token" value="{escape(portal_token)}" />' if portal_token else ""}
<div class="consult-grid">
<div class="consult-field"><label for="consult-province">考试省份</label><select id="consult-province" name="province">{_province_options_html(consult_province)}</select></div>
<div class="consult-field"><label for="consult-score">分数 / 位次</label><input id="consult-score" name="score" value="{consult_score}" placeholder="例如578 / 12034" /></div>
</div>
<div class="consult-grid" style="margin-top:10px;">
<div class="consult-field"><label for="consult-subjects">选科组合</label><input id="consult-subjects" name="subjects" value="{consult_subjects}" placeholder="例如:物理、化学、生物" /></div>
<div class="consult-field"><label for="consult-goal">目标学校或方向</label><input id="consult-goal" name="goal" value="{consult_goal}" placeholder="例如:广东工业大学 / 先复核现有方案" /></div>
</div>
<p style="margin-top:10px;color:#475569;font-size:13px;line-height:1.6;">后续资料页可以<strong style="color:#1f6feb;">上传已有方案文档</strong>,我们会先做免费复核,再决定是否进入付费完整规划。</p>
<div class="consult-actions">
<button class="btn btn-primary" type="submit">获取复核与推荐</button>
<a class="btn btn-secondary" style="min-height:48px;padding:0 22px;font-size:15px;background:#fff;color:#1f6feb;border:2px solid #1f6feb;" href="/pricing">直接做完整规划</a>
</div>
</form>
<script>
(function(){{
var form = document.querySelector('#consult-box form[action="/review/start"]');
if (!form) return;
var province = form.querySelector('select[name="province"]');
var score = form.querySelector('input[name="score"]');
var subjects = form.querySelector('input[name="subjects"]');
var goal = form.querySelector('input[name="goal"]');
function validateField(field, rules) {{
var errs = [];
for (var i = 0; i < rules.length; i++) {{
var msg = rules[i](field);
if (msg) errs.push(msg);
}}
return errs;
}}
function showFieldError(field, errs) {{
if (!field) return;
var hintId = field.getAttribute('data-error-id') || (field.name + '-error');
field.setAttribute('data-error-id', hintId);
var hint = document.getElementById(hintId);
if (!hint) {{
hint = document.createElement('div');
hint.id = hintId;
hint.className = 'field-error-hint';
hint.setAttribute('role', 'alert');
hint.setAttribute('aria-live', 'polite');
if (field.parentNode) field.parentNode.appendChild(hint);
}}
if (errs.length) {{
hint.textContent = errs.join('');
hint.style.display = 'block';
field.style.borderColor = '#b42318';
field.setAttribute('aria-invalid', 'true');
}} else {{
hint.style.display = 'none';
field.style.borderColor = '';
field.removeAttribute('aria-invalid');
}}
}}
function validateProvince() {{
return validateField(province, [
function(f) {{ return (!f || !f.value || f.value.indexOf('请选择') === 0) ? '请选择考试省份' : null; }}
]);
}}
function validateScore() {{
return validateField(score, [
function(f) {{
if (!f || !f.value.trim()) return null; // 分数可选
var parts = f.value.split('/').map(function(s) {{ return s.trim(); }});
var sc = parseInt(parts[0]);
if (isNaN(sc) || sc < 0 || sc > 800) return '分数应在 0-800 之间';
if (parts.length === 2) {{
var rank = parseInt(parts[1]);
if (isNaN(rank) || rank < 1) return '位次应为正整数';
}}
return null;
}}
]);
}}
function validateSubjects() {{
return validateField(subjects, [
function(f) {{
if (!f || !f.value.trim()) return null;
var count = f.value.split(/[,,、\s]+/).filter(function(s) {{ return s.trim(); }}).length;
if (count > 6) return '选科组合最多 6 科';
return null;
}}
]);
}}
function validateGoal() {{
return validateField(goal, [
function(f) {{
if (!f || !f.value.trim()) return null;
if (f.value.length > 200) return '目标说明不超过 200 字';
return null;
}}
]);
}}
function validateAll() {{
var allErrs = [].concat(validateProvince(), validateScore(), validateSubjects(), validateGoal());
showFieldError(province, validateProvince());
showFieldError(score, validateScore());
showFieldError(subjects, validateSubjects());
showFieldError(goal, validateGoal());
// 全局提示
var globalHint = document.getElementById('consult-error-hint');
if (!globalHint) {{
globalHint = document.createElement('div');
globalHint.id = 'consult-error-hint';
globalHint.className = 'field-error-hint';
globalHint.setAttribute('role', 'alert');
form.appendChild(globalHint);
}}
if (allErrs.length) {{
globalHint.innerHTML = allErrs.join('');
globalHint.style.display = 'block';
}} else {{
globalHint.style.display = 'none';
}}
return allErrs.length === 0;
}}
form.addEventListener('submit', function(e) {{
if (!validateAll()) e.preventDefault();
}});
// 即时校验:每个字段 blur/change 时即时反馈
if (province) province.addEventListener('change', function() {{ showFieldError(province, validateProvince()); }});
if (score) score.addEventListener('blur', function() {{ showFieldError(score, validateScore()); }});
if (subjects) subjects.addEventListener('blur', function() {{ showFieldError(subjects, validateSubjects()); }});
if (goal) goal.addEventListener('blur', function() {{ showFieldError(goal, validateGoal()); }});
// 输入时清除错误(用户开始修正时立即移除红色提示)
if (score) score.addEventListener('input', function() {{ if (score.getAttribute('aria-invalid')) showFieldError(score, validateScore()); }});
if (subjects) subjects.addEventListener('input', function() {{ if (subjects.getAttribute('aria-invalid')) showFieldError(subjects, validateSubjects()); }});
if (goal) goal.addEventListener('input', function() {{ if (goal.getAttribute('aria-invalid')) showFieldError(goal, validateGoal()); }});
}})();
</script>
<style>
.field-error-hint {{
margin-top: 4px;
padding: 6px 10px;
border-radius: 8px;
background: #fff5f5;
border: 1px solid #f5c2c7;
color: #b42318;
font-size: 12px;
line-height: 1.5;
display: none;
}}
</style>
<div class="upload-hint" style="margin-top:16px; padding:20px; border-radius:14px; background:rgba(31,111,235,.06); border:2px dashed rgba(31,111,235,.35); text-align:center;">
<div style="font-size:36px; margin-bottom:8px;">📄</div>
<p style="margin:0 0 4px; color:#1f6feb; font-size:15px; font-weight:600; line-height:1.5;">已有方案文档?拖拽或点击上传</p>
<p style="margin:0 0 12px; color:#5b6b88; font-size:13px; line-height:1.5;">支持 Word / PDF / 截图格式,最大 10MB。上传后先做免费复核再决定是否进入付费完整规划。</p>
<a class="btn btn-secondary" style="font-size:14px;min-height:40px;padding:8px 20px;background:#fff;color:#1f6feb;border:2px solid #1f6feb;" href="/pricing">上传方案文档并进入完整规划 →</a>
</div>
<p class="consult-privacy-tail">不会收到营销短信,提交后你也可以随时要求删除已填资料。</p>
</div>
</section>
<section class="section">
<h2>为什么选择我们</h2>
<p class="section-intro">高考志愿填报不是只看一个分数,而是要在时间、信息、风险和沟通成本之间做平衡。我们的特点不是只“生成方案”,而是先帮你审计现有方案、识别扎堆和踩线风险,再决定是否进入更完整的规划与交付。</p>
<div class=\"grid-3\">
<article class="card">
<span class="tag">方案审计</span>
<h3>先判断现有方案值不值得继续</h3>
<p>如果你已经拿到老师、机构或 AI 给出的方案,我们会先审计是否踩线、是否扎堆、是否存在明显结构风险,再决定下一步。</p>
</article>
<article class="card">
<span class="tag">风险沟通</span>
<h3>把风险解释清楚</h3>
<p>不只输出结果,还会说明冲稳保梯度、可能踩线的位置,以及需要重点确认的选择风险。</p>
</article>
<article class="card">
<span class="tag">交付透明</span>
<h3>过程可追踪</h3>
<p>从下单、资料填写、通知到报告查看,都能在站内看到当前进度,减少反复追问。</p>
</article>
</div>
</section>
<section id="service-flow" class="section">
<h2>服务流程</h2>
<p class="section-intro">你先拿到判断,再决定是否付费进入完整规划。流程越清楚,越不容易在关键节点反复来回确认。</p>
<div class="flow">
<article class="flow-step"><strong>1</strong><h3>先判断入口</h3><p>判断你需要的是方案复核(免费)还是完整方案 / 深度辅导(付费)。</p></article>
<article class="flow-step"><strong>2</strong><h3>确认下单</h3><p>选完整方案或深度辅导时,先填写考生姓名、手机号等最小信息后再支付。</p></article>
<article class="flow-step"><strong>3</strong><h3>补充资料</h3><p>支付成功后进入资料向导,分步提交分数、位次、偏好与已有方案附件。</p></article>
<article class="flow-step"><strong>4</strong><h3>查看交付</h3><p>在站内追踪状态、查看通知,并在交付就绪后在线阅读或下载 PDF。</p></article>
</div>
</section>
<section class="section cta-band">
<div>
<h2 style="margin:0 0 8px;font-size:28px;">已有方案?先免费复核;明确要做?看付费套餐</h2>
<p>如果已经拿到一版方案,可先做一次免费复核;明确要完整方案或深度辅导,可直接进入套餐页查看内容和下单说明。</p>
</div>
<div class="hero-actions" style="margin-top:0;">
<a class="btn btn-primary" href="/pricing">进入套餐页</a>
<a class="btn btn-secondary" href="/service-terms">查看服务说明</a>
</div>
</section>
{_render_footer_links()}
</main>
{_render_global_toast_script()}</body>
</html>
"""
def _render_pricing_page(request: Request) -> str:
query = dict(request.query_params)
consult_text = str(query.get("consult") or "").strip()
province = str(query.get("province") or "").strip()
score = str(query.get("score") or "").strip()
goal = str(query.get("goal") or "").strip()
recommendation_title = "你可以先从 99 元完整志愿方案开始"
recommendation_body = (
"如果你没有现成方案,直接进入完整规划最省时间;如果已有方案,再先审计。"
)
if consult_text or "审计" in goal or "审核" in goal:
recommendation_title = "更适合先做 49 元方案审核"
recommendation_body = "你提到已有方案或需要先判断风险,先做审核更符合当前目标。"
elif "深度" in goal or "沟通" in goal:
recommendation_title = "更适合 199 元深度辅导版"
recommendation_body = "如果你需要多轮解释或目标冲突较大,深度辅导更合适。"
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>服务套餐 - 高考志愿填报智能规划服务</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
:root {{
--bg: #f3f7fb;
--surface: #ffffff;
--surface-soft: #eef5ff;
--border: #d7e3f1;
--text: #142235;
--muted: #5b6b88;
--primary: #1f6feb;
--primary-dark: #194fb6;
--accent: #0f766e;
--accent-soft: #dff7f1;
--warning-soft: #fff7e6;
--warning-text: #8a5a00;
--shadow: 0 18px 42px rgba(20,34,53,.08);
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: var(--bg); color: var(--text); }}
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 40px 20px 72px; }}
.hero {{ display:grid; grid-template-columns: minmax(0,1.1fr) 320px; gap: 20px; align-items:start; }}
.panel {{ background: var(--surface); border: 1px solid var(--border); border-radius: 26px; box-shadow: var(--shadow); padding: 28px; }}
h1 {{ margin: 0 0 12px; font-size: clamp(32px, 5vw, 44px); letter-spacing: -0.03em; }}
.lead {{ margin: 0; color: var(--muted); line-height: 1.8; font-size: 16px; }}
.summary {{ background: linear-gradient(180deg,#f7fbff,#eef5ff); }}
.summary h2 {{ margin: 0 0 12px; font-size: 22px; }}
.summary ul {{ margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.75; }}
.pricing-grid {{ display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 18px; margin-top: 24px; }}
.card {{ position: relative; display:flex; flex-direction:column; gap: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 24px; box-shadow: var(--shadow); }}
.card.recommended {{ border: 2px solid var(--primary); transform: translateY(-8px); background: linear-gradient(180deg,#f8fbff,#eef5ff); box-shadow: 0 24px 52px rgba(31,111,235,.16); }}
.card.recommended::before {{ content:''; position:absolute; left:0; right:0; top:0; height:6px; border-radius:24px 24px 0 0; background:linear-gradient(90deg,#1f6feb,#38bdf8); }}
.badge {{ position:absolute; top:14px; left:50%; transform:translateX(-50%); display:inline-flex; padding:7px 12px; border-radius:999px; background: var(--primary); color: #fff; font-size:12px; font-weight:700; box-shadow:0 10px 20px rgba(31,111,235,.22); }}
.eyebrow {{ font-size: 13px; font-weight: 700; color: var(--primary); letter-spacing: .04em; text-transform: uppercase; }}
.price {{ font-size: 40px; font-weight: 800; letter-spacing: -0.04em; }}
.price small {{ font-size: 15px; font-weight: 600; color: var(--muted); margin-left: 4px; }}
.desc {{ color: var(--muted); line-height: 1.75; min-height: 72px; }}
.feature-list {{ margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.75; display:grid; gap:6px; }}
.button {{ display:inline-flex; align-items:center; justify-content:center; min-height:46px; border-radius:14px; background: var(--primary); color:#fff; text-decoration:none; font-weight:700; box-shadow:0 14px 28px rgba(31,111,235,.2); }}
.button:hover {{ background: var(--primary-dark); }}
.button.secondary {{ background:transparent; color:var(--primary-dark); border:1px solid #c7d7ef; box-shadow:none; }}
.button.secondary:hover {{ background:#eef5ff; }}
.button.recommended-cta {{ background:#0f4fd6; box-shadow:0 18px 34px rgba(15,79,214,.28); }}
.button.recommended-cta:hover {{ background:#0b43ba; }}
.trust-band {{ display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 14px; margin-top: 24px; }}
.trust-item {{ padding: 18px 20px; border-radius: 18px; background: #fff; border:1px solid var(--border); }}
.trust-item strong {{ display:block; margin-bottom:8px; }}
.trust-proof {{ display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; margin-top: 22px; }}
.trust-proof-item {{ padding:14px 16px; border-radius:16px; background:linear-gradient(180deg,#f8fbff,#eef5ff); border:1px solid var(--border); }}
.trust-proof-item strong {{ display:block; margin-bottom:4px; font-size:15px; }}
.trust-proof-item span {{ color:var(--muted); font-size:13px; line-height:1.6; }}
.faq {{ margin-top: 24px; display:grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }}
.faq-item {{ padding: 20px; border-radius: 20px; background: #fff; border:1px solid var(--border); }}
.faq-item h3 {{ margin:0 0 8px; font-size:18px; }}
.faq-item p {{ margin:0; color:var(--muted); line-height:1.7; }}
.notice {{ margin-top:26px; padding:16px 18px; border-radius:16px; background:var(--warning-soft); color:var(--warning-text); border:1px solid #f4d39b; line-height:1.7; }}
.consult-summary {{ margin-top:18px; padding:18px 20px; border-radius:18px; background:linear-gradient(180deg,#f8fbff,#eef5ff); border:1px solid var(--border); }}
.consult-summary h2 {{ margin:0 0 8px; font-size:20px; }}
.consult-summary p {{ margin:0; color:var(--muted); line-height:1.75; }}
.consult-reassure {{ margin-top: 10px !important; padding: 10px 12px; border-radius: 12px; background: var(--success-soft); color: var(--accent); border: 1px solid #b9e7dd; font-size: 14px; }}
.consult-reassure a {{ color: var(--accent); font-weight: 700; }}
.summary-reassure {{ margin-top: 14px; padding: 10px 12px; border-radius: 12px; background: var(--success-soft); color: var(--accent); border: 1px solid #b9e7dd; font-size: 13px; line-height: 1.6; }}
.summary-reassure a {{ color: var(--accent); font-weight: 700; }}
@media (max-width: 980px) {{
.hero, .pricing-grid, .trust-band, .faq, .trust-proof {{ grid-template-columns: 1fr; }}
.card.recommended {{ transform: none; }}
}}
</style>
</head>
<body>
<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>
<main class="wrap">
<section class="hero">
<div class="panel">
<div style="margin-bottom:8px;"><a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;" href="/">返回首页</a></div>
<h1>服务套餐</h1>
<p class="lead">先按服务深度选择适合自己的方案:<strong>复核现有方案本身免费</strong>;如果你已经拿到其他方案,先做复核判断风险;如果希望一次拿到完整建议,优先看 99 元完整志愿方案;如果需要更多人工沟通和多轮修订,再选择 199 元深度辅导版。</p>
<div class="consult-summary">
<h2>{escape(recommendation_title)}</h2>
<p>{escape(recommendation_body)}{" 当前输入:" if (province or score or goal) else ""}{escape(" ".join(filter(None, [province, score, goal])))}</p>
<p class="consult-reassure">💡 如果你还没决定,<a href="/#consult-box">先做一次免费复核</a>,再决定要不要进入付费方案。</p>
</div>
<div class="trust-proof">
<article class="trust-proof-item"><strong>复核免费 / 方案付费</strong><span>免费帮你审现有方案的风险;新方案生成和深度辅导在支付后启动。</span></article>
<article class="trust-proof-item"><strong>主推档更清晰</strong><span>99 元方案覆盖大多数用户最关心的完整线上交付路径。</span></article>
<article class="trust-proof-item"><strong>进度站内可查</strong><span>资料、通知与报告状态都能在站内持续追踪。</span></article>
<article class="trust-proof-item"><strong>隐私与删除入口可见</strong><span>隐私政策、服务说明和删除申请入口始终保留。</span></article>
</div>
</div>
<aside class="panel summary">
<h2>下单前你会看到什么</h2>
<ul>
<li>套餐说明与适用场景</li>
<li>下单后需要补充的资料范围</li>
<li>交付形式:站内查看 + PDF 下载</li>
<li>隐私政策、服务说明与删除申请入口</li>
</ul>
<p class="summary-reassure">🔒 还没下单前,<a href="/#consult-box">提交的基本情况</a>仅用于判断是否需要复核,不会留底。</p>
</aside>
</section>
<section class="pricing-grid">
<article class="card" data-package="audit">
<div class="eyebrow">复核 / 风险</div>
<h2>49元 AI方案审核</h2>
<div class="price">¥49<small>/ 份</small></div>
<p class="desc">适合已经拿到其他 AI 志愿方案,想先判断方案是否踩线、是否扎堆、是否存在明显风险的家庭。审核只针对你提供的现有方案,不会重新生成志愿表。</p>
<ul class="feature-list">
<li>针对你提供的现有方案做风险复核</li>
<li>聚焦风险点、冲稳保结构与明显异常</li>
<li>给出是否值得继续深做的判断</li>
<li>不重新生成志愿表(生成需选 99 元)</li>
</ul>
<a class="button secondary" href="/checkout/audit">选择付费审核</a>
</article>
<article class="card recommended" data-package="standard">
<span class="badge">推荐方案</span>
<div class="eyebrow">生成 / 完整</div>
<h2>99元 完整志愿方案</h2>
<div class="price">¥99<small>/ 份</small></div>
<p class="desc">适合大多数希望一次拿到完整志愿建议的家庭:先完成线上下单,再在资料向导里补充分数、位次、偏好与已有方案信息。方案生成与详细报告在支付后启动。</p>
<ul class="feature-list">
<li>适合首次系统化做志愿规划的用户</li>
<li>支付后启动方案生成与报告交付</li>
<li>站内追踪资料、通知与交付状态</li>
<li>支持在线查看报告与 PDF 下载</li>
</ul>
<a class="button recommended-cta" href="/checkout/standard">支付并启动方案生成</a>
</article>
<article class="card" data-package="premium">
<div class="eyebrow">生成 / 深度</div>
<h2>199元 深度辅导版</h2>
<div class="price">¥199<small>/ 份</small></div>
<p class="desc">适合志愿范围复杂、目标城市/专业冲突较大,或需要更多人工沟通、反复修订和深度解释的家庭。在 99 元完整方案基础上提供多轮修订和深度解释。</p>
<ul class="feature-list">
<li>适合目标复杂或分歧较大的家庭</li>
<li>包含 99 元完整方案的所有交付</li>
<li>留出多轮沟通与补充说明空间</li>
<li>更强调过程解释与决策支持</li>
</ul>
<a class="button secondary" href="/checkout/premium">选择深度辅导</a>
</article>
</section>
<section class="trust-band">
<article class="trust-item"><strong>站内可追踪</strong><span>下单后可以查看资料提交、通知记录和交付状态,不需要反复追问进度。</span></article>
<article class="trust-item"><strong>资料入口清晰</strong><span>支付前只收必要下单信息,详细资料在支付后通过资料向导分步补充。</span></article>
<article class="trust-item"><strong>复核免费 / 方案付费</strong><span>还没决定?回到首页 <a href="/#consult-box">先做一次免费复核</a>,再决定要不要进入付费方案。</span></article>
</section>
<section class="section" style="text-align:center;">
<h2 style="margin:0 0 8px;font-size:24px;">家长和学生的选择</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center;margin-top:18px;">
<div style="flex:1;min-width:200px;max-width:280px;padding:20px;border-radius:18px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);">
<div style="font-size:28px;font-weight:800;color:var(--primary);letter-spacing:-.03em;">31 省</div>
<div style="color:var(--muted);font-size:13px;margin-top:4px;">覆盖省级录取数据</div>
</div>
<div style="flex:1;min-width:200px;max-width:280px;padding:20px;border-radius:18px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);">
<div style="font-size:28px;font-weight:800;color:var(--primary);letter-spacing:-.03em;">3 个版本</div>
<div style="color:var(--muted);font-size:13px;margin-top:4px;">从免费复核到深度辅导</div>
</div>
<div style="flex:1;min-width:200px;max-width:280px;padding:20px;border-radius:18px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);">
<div style="font-size:28px;font-weight:800;color:var(--primary);letter-spacing:-.03em;">站内全流程</div>
<div style="color:var(--muted);font-size:13px;margin-top:4px;">下单→支付→资料→交付→追踪</div>
</div>
</div>
<p style="margin:18px 0 0;color:var(--muted);font-size:14px;line-height:1.7;">已有方案?先 <a href="/#consult-box" style="color:var(--primary);font-weight:600;">免费复核</a>,再决定是否进入付费方案。</p>
</section>
<section class="faq">
<article class="faq-item"><h3>复核是免费的吗?包含什么?</h3><p>提交基本情况后我们免费帮你判断你的现有方案是否需要复核,并说明后续可走的步骤。复核本身免费;如需正式 AI 方案审核报告49 元或重新生成完整方案99 元起)才需要支付。</p></article>
<article class="faq-item"><h3>为什么推荐 99 元完整志愿方案?</h3><p>它覆盖大多数用户最关心的完整资料收集、站内进度追踪与报告交付是当前最适合线上自助下单的标准路径。99 元是 <em>生成</em> 一份完整方案的价格,不是 <em>查看</em> 价格。</p></article>
<article class="faq-item"><h3>支付后还要补哪些信息?</h3><p>主要是分数、位次、选科、目标城市/专业、已有方案说明与附件。支付前不让用户一次填太长的表单。</p></article>
<article class="faq-item"><h3>我已经有一版志愿方案怎么办?</h3><p>先 <a href="/#consult-box">免费复核</a> 看是否值得继续;如需正式审核报告再选 49 元版本;如已决定要重做或生成完整方案,再进入 99 元完整方案。</p></article>
</section>
<div class="notice">还没决定?<a href="/#consult-box">先做一次免费复核</a>再决定要不要进入付费方案。如果你已经拿到一版方案49 元审核版会给出明确风险判断;如果已经决定要做完整方案,优先选择 99 元完整志愿方案。</div>
{_render_footer_links()}
</main>
{_render_global_toast_script()}</body>
</html>
"""
def _render_checkout_page(service_version: str) -> str:
amount = _SERVICE_PRICES[service_version]
service_label = {
"audit": "49元 AI方案审核",
"basic": "49元 基础版",
"standard": "99元 完整志愿方案",
"premium": "199元 深度辅导版",
}[service_version]
service_desc = {
"audit": "适合已经拿到其他方案、希望先快速校验风险的家庭。",
"basic": "适合需求较轻、希望先收敛选校范围的家庭。",
"standard": "适合大多数希望一次拿到完整建议与站内交付追踪的家庭。",
"premium": "适合需要更多沟通、补充说明与深度修订支持的家庭。",
}[service_version]
province_options = [
"",
"北京",
"上海",
"天津",
"重庆",
"河北",
"河南",
"山东",
"山西",
"陕西",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"甘肃",
"青海",
"宁夏",
"新疆",
"内蒙古",
"西藏",
]
province_html = "".join(
f'<option value="{escape(p)}">{escape(p) or "请选择考试省份(可稍后补充)"}</option>'
for p in province_options
)
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>下单 - {escape(service_label)}</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
:root {{
--bg: #f3f7fb;
--surface: #ffffff;
--surface-soft: #f8fbff;
--border: #d7e3f1;
--text: #142235;
--muted: #5b6b88;
--primary: #1f6feb;
--primary-dark: #194fb6;
--success: #0f766e;
--success-soft: #dff7f1;
--danger: #b42318;
--shadow: 0 18px 42px rgba(20,34,53,.08);
}}
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: var(--bg); color: var(--text); margin:0; }}
.wrap {{ max-width: 1160px; margin:0 auto; padding:32px 20px 72px; }}
.header {{ margin-bottom: 18px; }}
.eyebrow {{ display:inline-flex; padding:6px 10px; border-radius:999px; background: var(--success-soft); color: var(--success); font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }}
h1 {{ margin: 14px 0 10px; font-size: clamp(30px, 5vw, 42px); letter-spacing:-0.03em; }}
.lead {{ margin:0; max-width:760px; color: var(--muted); line-height: 1.8; }}
.layout {{ display:grid; grid-template-columns: minmax(0,1.1fr) 360px; gap: 20px; align-items:start; }}
.panel {{ background:#fff; border:1px solid var(--border); border-radius:24px; padding:24px; box-shadow: var(--shadow); }}
.summary {{ position: sticky; top: 20px; background: linear-gradient(180deg,#f9fbff,#eef5ff); }}
.summary h2, .form-panel h2 {{ margin: 0 0 10px; font-size: 22px; }}
.summary p, .summary li {{ color: var(--muted); line-height: 1.7; }}
.summary-list {{ display:grid; gap:12px; margin:18px 0; }}
.summary-item {{ padding:14px 16px; border-radius:16px; background:#fff; border:1px solid var(--border); }}
.summary-item strong {{ display:block; margin-bottom:6px; font-size:15px; }}
.price-box {{ display:flex; align-items:end; justify-content:space-between; gap:12px; margin-top:18px; padding:16px 18px; border-radius:18px; background:#fff; border:1px solid var(--border); }}
.price-box .label {{ color: var(--muted); font-size: 13px; }}
.price-box .amount {{ font-size: 36px; font-weight: 800; letter-spacing: -0.04em; }}
.grid {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
.field {{ display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }}
.field label {{ font-size:14px; color:#334155; font-weight:600; }}
.required {{ color:#dc2626; margin-left:4px; }}
input, textarea, select {{ width:100%; padding:12px; border-radius:12px; border:1px solid #cfd7e6; font-size:14px; background:#fff; color:var(--text); }}
textarea {{ min-height: 112px; resize: vertical; }}
input:focus, textarea:focus, select:focus {{ outline:none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(31,111,235,.12); }}
.hint {{ color: var(--muted); font-size: 12px; line-height: 1.6; }}
.error {{ color: var(--danger); font-size:12px; min-height:18px; }}
.helper {{ margin: 0 0 18px; color: var(--muted); line-height: 1.7; }}
.trust-strip {{ display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin: 8px 0 20px; }}
.trust-card {{ padding: 14px 16px; border-radius: 16px; background: var(--surface-soft); border:1px solid var(--border); }}
.trust-card strong {{ display:block; margin-bottom:6px; font-size:14px; }}
.actions {{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top: 8px; }}
button {{ border:none; border-radius:14px; background: var(--primary); color:#fff; font-weight:700; padding:13px 18px; cursor:pointer; min-height:48px; }}
button:hover {{ background: var(--primary-dark); }}
button[disabled] {{ opacity:.6; cursor:not-allowed; }}
#result {{ margin-top:14px; min-height:24px; color:var(--muted); line-height:1.7; }}
.service-note {{ margin-top: 18px; padding: 16px 18px; border-radius: 16px; background:#fff7e6; border:1px solid #f4d39b; color:#8a5a00; line-height:1.7; }}
.form-proof {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin: 0 0 18px; }}
.form-proof-item {{ padding:14px 16px; border-radius:16px; background:linear-gradient(180deg,#f8fbff,#eef5ff); border:1px solid var(--border); }}
.form-proof-item strong {{ display:block; margin-bottom:4px; font-size:14px; }}
.form-proof-item span {{ color:var(--muted); font-size:13px; line-height:1.6; }}
.summary-badges {{ display:grid; gap:10px; margin-bottom:16px; }}
.summary-badge {{ padding:12px 14px; border-radius:14px; background:#fff; border:1px solid var(--border); color:var(--muted); line-height:1.6; }}
.summary-badge strong {{ display:block; margin-bottom:4px; color:var(--text); }}
@media (max-width: 980px) {{
.layout, .grid, .trust-strip, .form-proof {{ grid-template-columns: 1fr; }}
.summary {{ position: static; }}
}}
</style>
</head>
<body>
<nav class="global-nav" aria-label="全局导航" role="navigation"><div class="global-nav-inner"><a class="global-nav-brand" href="/">高考志愿填报</a><div class="global-nav-links"><a class="global-nav-link" href="/">首页</a><a class="global-nav-link" href="/pricing">套餐</a><a class="global-nav-link" href="mailto:lon22@qq.com">客服</a></div></div></nav>
<main class="wrap">
<header class="header">
<div style="margin-bottom:12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;" href="/">← 返回首页</a>
<div class="stepper" style="display:flex;align-items:center;gap:0;margin-left:auto;">
<div class="step active" style="display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px 0 0 8px;background:#1f6feb;color:#fff;font-size:13px;font-weight:600;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,.25);font-size:11px;">1</span>
填写信息
</div>
<div class="step" style="display:flex;align-items:center;gap:6px;padding:6px 12px;background:#e2e8f0;color:#64748b;font-size:13px;border-left:1px solid #cbd5e1;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#cbd5e1;color:#94a3b8;font-size:11px;">2</span>
支付
</div>
<div class="step" style="display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:0 8px 8px 0;background:#e2e8f0;color:#64748b;font-size:13px;border-left:1px solid #cbd5e1;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#cbd5e1;color:#94a3b8;font-size:11px;">3</span>
补充资料
</div>
</div>
</div>
<span class="eyebrow">在线下单</span>
<h1>{escape(service_label)}</h1>
<p class="lead">{escape(service_desc)} 现在先确认联系人与考生基础信息;支付成功后,再进入资料向导补充分数、位次、偏好和已有方案附件。</p>
</header>
<section class="layout">
<section class=\"panel form-panel\">
<h2>填写下单信息</h2>
<p class="helper">当前这一步只收会影响下单与后续联系的必要信息,不要求你一次性填完整个长表单。</p>
<div class="form-proof">
<article class="form-proof-item"><strong>先下单再补资料</strong><span>先把关键联系信息确认下来,再进入资料向导补充分数、位次和偏好。</span></article>
<article class="form-proof-item"><strong>隐私入口始终可见</strong><span>隐私政策、服务说明与删除申请入口在这条链路上持续可访问。</span></article>
</div>
<form id=\"checkout-form\">
<div class="grid">
<div class="field">
<label>考生姓名<span class="required">*</span></label>
<input name=\"candidate_name\" required maxlength=\"32\" placeholder=\"请输入考生姓名\" />
<div class="error" data-error=\"candidate_name\"></div>
</div>
<div class="field">
<label>手机号<span class="required">*</span></label>
<input name=\"customer_phone\" required inputmode=\"tel\" maxlength=\"20\" placeholder=\"请输入便于联系的手机号\" />
<div class="error" data-error=\"customer_phone\"></div>
</div>
</div>
<div class="grid">
<div class="field">
<label>称呼</label>
<input name=\"customer_name\" maxlength=\"32\" placeholder=\"可选,例如:张同学 / 张家长\" />
</div>
<div class="field">
<label>邮箱(接收通知)</label>
<input name=\"customer_email\" type=\"email\" maxlength=\"120\" placeholder=\"可选,用于接收交付提醒\" />
</div>
</div>
<div class="grid">
<div class="field">
<label>考试省份<span class="required">*</span></label>
<select name=\"candidate_province\">{province_html}</select>
<div class="hint">如果你暂时还没决定,也可以支付后在资料向导中补充。</div>
</div>
<div class="field">
<label>备注</label>
<textarea name=\"notes\" maxlength=\"500\" placeholder=\"可选,例如希望重点关注的城市、专业或顾虑\"></textarea>
</div>
</div>
<div class="actions">
<button type=\"submit\" id=\"submit-btn\">立即支付 ¥{amount / 100:.0f}</button>
<span class="hint">提交后会进入支付与资料完善流程。</span>
</div>
</form>
<p id=\"result\"></p>
<div class="service-note">
<div style="display:flex;gap:16px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<strong style="display:block;margin-bottom:4px;">💳 支付方式</strong>
<span style="font-size:13px;">支持支付宝在线支付</span>
</div>
<div style="flex:1;min-width:200px;">
<strong style="display:block;margin-bottom:4px;">↩️ 退款政策</strong>
<span style="font-size:13px;">报告交付前可全额退款;交付后如有不满意,联系客服协商处理。</span>
</div>
<div style="flex:1;min-width:200px;">
<strong style="display:block;margin-bottom:4px;">🔒 支付安全</strong>
<span style="font-size:13px;">通过官方支付渠道处理,不存储银行卡信息。</span>
</div>
</div>
<p style="margin:12px 0 0;font-size:13px;">支付后可进入资料向导继续补充详细信息;提交资料后,后续状态、通知与交付入口都将在站内持续更新。</p>
</div>
{_render_footer_links()}
</section>
<aside class=\"panel summary\">
<h2>订单摘要</h2>
<p>你当前选择的是 <strong>{escape(service_label)}</strong>。这一步先确认下单人与考生基础信息,支付成功后再继续补完整资料。</p>
<div class="summary-badges">
<div class="summary-badge"><strong>当前建议</strong><span>如果你已经有现成方案49 元审核版适合先判断风险99 元适合直接进入完整线上规划。</span></div>
<div class="summary-badge"><strong>交付方式</strong><span>站内资料向导、通知状态、在线报告与 PDF 下载入口。</span></div>
</div>
<div class="summary-list">
<div class="summary-item"><strong>适合谁</strong><span>{escape(service_desc)}</span></div>
<div class="summary-item"><strong>你会得到什么</strong><span>站内资料向导、通知状态、报告在线查看和 PDF 下载入口。</span></div>
<div class="summary-item"><strong>下单后下一步</strong><span>支付成功 → 进入资料向导 → 查看状态与交付。</span></div>
</div>
<div class="price-box">
<div>
<div class="label">当前应付</div>
<div class="amount"{amount / 100:.0f}</div>
</div>
<div class="label">完成支付后即可继续补完整资料</div>
</div>
</aside>
</section>
</main>
<script>
function validateCheckout(form) {{
const errors = {{ candidate_name: '', customer_phone: '' }};
const name = String(form.get('candidate_name') || '').trim();
const phone = String(form.get('customer_phone') || '').trim();
if (!name) errors.candidate_name = '请填写考生姓名';
if (!phone) errors.customer_phone = '请填写手机号';
if (phone && !/^1[3-9]\\d{{9}}$/.test(phone)) errors.customer_phone = '手机号格式不正确';
for (const [key, msg] of Object.entries(errors)) {{
const node = document.querySelector(`[data-error="${{key}}"]`);
if (node) node.textContent = msg;
}}
return !errors.candidate_name && !errors.customer_phone;
}}
function setSubmitState(loading) {{
const button = document.getElementById('submit-btn');
button.disabled = loading;
button.textContent = loading ? '正在创建订单…' : '立即支付 ¥{amount / 100:.0f}';
}}
document.getElementById('checkout-form').addEventListener('input', function (event) {{
const form = new FormData(event.currentTarget);
validateCheckout(form);
}});
document.getElementById('checkout-form').addEventListener('submit', async function (event) {{
event.preventDefault();
const form = new FormData(event.target);
if (!validateCheckout(form)) return;
const payload = {{
service_version: '{service_version}',
amount_cents: {amount},
customer_name: form.get('customer_name') || null,
customer_phone: form.get('customer_phone'),
customer_email: form.get('customer_email') || null,
candidate_name: form.get('candidate_name'),
candidate_province: form.get('candidate_province') || null,
notes: form.get('notes') || null,
}};
const resultNode = document.getElementById('result');
resultNode.textContent = '正在创建订单,请稍候…';
setSubmitState(true);
try {{
const resp = await fetch('/api/public/orders', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload),
}});
const body = await resp.json();
if (!resp.ok) {{
resultNode.textContent = body.detail || body.message || '当前暂时无法创建订单,请稍后重试或联系客服。';
return;
}}
resultNode.textContent = '订单创建成功,正在进入支付流程…';
window.location.href = body.checkout_url;
}} catch (error) {{
resultNode.textContent = '网络异常,请稍后重试或联系客服获取人工协助。';
}} finally {{
setSubmitState(false);
}}
}});
</script>
{_render_global_toast_script()}</body>
</html>
"""
def _render_payment_success_page(token: str, context: dict[str, Any]) -> str:
stage = str(context.get("stage") or "")
next_action = "立即补充资料"
next_href = f"/portal/{escape(token)}/info"
if stage in {"processing", "report_ready", "completed"}:
next_action = "查看订单状态"
next_href = f"/portal/{escape(token)}/status"
payment_status = escape(str(context.get("payment_status") or "pending"))
next_hint = "当前最重要的是补充分数、位次、选科和目标信息,提交后系统会继续推进。"
if stage in {"processing", "report_ready", "completed"}:
next_hint = "订单已进入后续处理阶段,可以直接查看进度和交付结果。"
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>支付成功</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f3f7fb; color:#142235; }}
.wrap {{ max-width:980px; margin:0 auto; padding:32px 20px 72px; display:grid; gap:16px; }}
.panel {{ background:#fff; border:1px solid #d7e3f1; border-radius:24px; box-shadow:0 18px 42px rgba(20,34,53,.08); padding:24px; }}
.eyebrow {{ display:inline-flex; padding:6px 10px; border-radius:999px; background:#dff7f1; color:#0f766e; font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }}
h1 {{ margin:14px 0 10px; font-size:clamp(30px,5vw,42px); letter-spacing:-0.03em; }}
.lead {{ margin:0; color:#5b6b88; line-height:1.8; }}
.hero-actions {{ display:flex; gap:12px; flex-wrap:wrap; margin-top:20px; }}
.btn {{ display:inline-flex; align-items:center; justify-content:center; min-height:48px; padding:0 18px; border-radius:14px; text-decoration:none; font-weight:700; }}
.btn-primary {{ background:#1f6feb; color:#fff; }}
.btn-secondary {{ background:#edf3ff; color:#194fb6; }}
.grid {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }}
.item {{ padding:14px 16px; border-radius:16px; background:#f8fbff; border:1px solid #d7e3f1; }}
.item strong {{ display:block; margin-bottom:6px; font-size:14px; }}
.meta {{ color:#5b6b88; line-height:1.7; font-size:14px; }}
@media (max-width: 860px) {{ .grid {{ grid-template-columns:1fr; }} }}
</style>
</head>
<body>{_render_global_nav()}
<main class="wrap">
<section class="panel">
<span class="eyebrow">支付成功</span>
<h1>订单已创建,下一步继续补资料</h1>
<p class="lead">支付状态:{payment_status}{escape(next_hint)}</p>
<div class="hero-actions">
<a class=\"btn btn-primary\" href=\"{next_href}\">{escape(next_action)}</a>
<a class=\"btn btn-secondary\" href=\"/portal/{escape(token)}/status\">查看订单进度</a>
</div>
</section>
<section class="panel">
<h2 style=\"margin:0 0 10px;\">你现在要做什么</h2>
<div class="grid">
<div class="item"><strong>补充基础信息</strong><span class="meta">先填分数、位次、选科。</span></div>
<div class="item"><strong>填写偏好目标</strong><span class="meta">补充城市、专业或院校偏好。</span></div>
<div class="item"><strong>持续查看进度</strong><span class="meta">资料提交后可继续查看状态、通知和报告。</span></div>
</div>
</section>
</main>
{_render_global_toast_script()}</body>
</html>"""
def _render_mock_payment_page(payment_id: str, amount_cents: int) -> str:
return _render_simulated_payment_html(
title="支付确认页",
provider_slug="mock",
payment_id=payment_id,
amount_cents=amount_cents,
submit_label="确认支付并返回订单页",
)
def _render_alipay_sim_payment_page(payment_id: str, amount_cents: int) -> str:
return _render_simulated_payment_html(
title="支付宝模拟收银台",
provider_slug="alipay-sim",
payment_id=payment_id,
amount_cents=amount_cents,
submit_label="模拟支付宝支付成功并返回订单页",
)
def _render_simulated_payment_html(
*,
title: str,
provider_slug: str,
payment_id: str,
amount_cents: int,
submit_label: 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\" /><title>{escape(title)}</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /></head>
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fb;padding:24px;margin:0;\">
{_render_global_nav()}
<main style=\"max-width:640px;margin:24px auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<div style="margin-bottom:8px;"><a href="/" style="color:#194fb6;font-size:13px;text-decoration:none;">← 返回首页</a></div>
<h1>{escape(title)}</h1>
<p>订单支付单号:{escape(payment_id)}</p>
<p>支付金额:¥{amount_cents / 100:.2f}</p>
<form method=\"post\" action=\"/pay/{escape(provider_slug)}/{escape(payment_id)}/complete\">
<button type=\"submit\" style=\"border:none;border-radius:12px;background:#1f6feb;color:#fff;font-weight:700;padding:12px 18px;cursor:pointer;\">{escape(submit_label)}</button>
</form>
</main>
{_render_global_toast_script()}</body>
</html>
"""
def _is_profile_minimum_complete(payload: dict[str, Any]) -> bool:
return bool(
payload.get("candidate_province")
and payload.get("candidate_subjects")
and payload.get("candidate_score") is not None
and payload.get("candidate_rank") is not None
)
def _profile_missing_fields(payload: dict[str, Any]) -> list[str]:
missing: list[str] = []
if not payload.get("candidate_province"):
missing.append("candidate_province")
if payload.get("candidate_score") is None:
missing.append("candidate_score")
if payload.get("candidate_rank") is None:
missing.append("candidate_rank")
if not payload.get("candidate_subjects"):
missing.append("candidate_subjects")
return missing
def _province_options_html(selected: str) -> str:
province_options = [
"",
"北京",
"上海",
"天津",
"重庆",
"河北",
"河南",
"山东",
"山西",
"陕西",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"湖北",
"湖南",
"广东",
"海南",
"四川",
"贵州",
"云南",
"甘肃",
"青海",
"新疆",
]
return "".join(
f'<option value="{escape(p)}"'
+ (" selected" if p == selected else "")
+ f">{escape(p) or '请选择考试省份(可稍后补充)'}</option>"
for p in province_options
)
def _render_simulated_payment_page(
payment_id: str,
settings: Settings,
*,
provider_slug: str,
) -> HTMLResponse:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
provider_name=settings.payment_provider,
)
payment = service.get_payment(payment_id)
if payment is None:
raise HTTPException(status_code=404, detail="payment not found")
if provider_slug == "mock":
body = _render_mock_payment_page(payment_id, payment.amount_cents)
elif provider_slug == "alipay-sim":
body = _render_alipay_sim_payment_page(payment_id, payment.amount_cents)
else:
raise HTTPException(
status_code=404, detail="unsupported simulated payment page"
)
return HTMLResponse(body)
def _complete_simulated_payment(
payment_id: str,
settings: Settings,
*,
provider_slug: str,
) -> RedirectResponse:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
provider_name=settings.payment_provider,
)
payment = service.get_payment(payment_id)
if payment is None:
raise HTTPException(status_code=404, detail="payment not found")
payload, headers = service.provider.build_webhook_request(
payment_id=payment.id,
amount_cents=payment.amount_cents,
provider_trade_no=f"SIM-{payment.id}",
)
if provider_slug == "mock":
signature = headers["X-Mock-Signature"]
elif provider_slug == "alipay-sim":
signature = headers["X-Alipay-Sim-Signature"]
else:
raise HTTPException(
status_code=404, detail="unsupported simulated payment completion"
)
service.handle_webhook(payload, signature)
portal_token = issue_portal_token(payment.order_id, settings.portal_token_secret)
return RedirectResponse(
url=f"/portal/{portal_token}/payment-success", status_code=303
)
def _render_info_page(
order: Order,
token: str,
payload: dict[str, Any],
stage: str,
settings: Settings,
) -> str:
consent_version = str(payload.get("consent_version") or settings.consent_version)
consent_scope = str(payload.get("consent_scope") or settings.consent_scope_portal)
privacy_checked = "checked" if payload.get("privacy_accepted") else ""
service_terms_checked = "checked" if payload.get("service_terms_accepted") else ""
guardian_checked = "checked" if payload.get("guardian_confirmed") else ""
attachments = payload.get("attachments") or []
missing_items: list[str] = []
if not payload.get("candidate_province"):
missing_items.append("省份")
if not payload.get("candidate_score"):
missing_items.append("分数")
if not payload.get("candidate_rank"):
missing_items.append("位次")
if not payload.get("candidate_subjects"):
missing_items.append("选科")
attachment_items = []
for item in attachments:
if not isinstance(item, dict):
continue
attachment_items.append(
f"<li>{escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}"
f"<span>{escape(str(item.get('size_bytes') or '0'))} bytes</span></li>"
)
attachments_html = "".join(attachment_items) or "<li class='empty'>暂无附件</li>"
stage_title, stage_subtitle = _STAGE_META[stage]
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>考生资料填写</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
:root {{
--bg: #f3f7fb;
--surface: #ffffff;
--surface-soft: #f7fbff;
--border: #d7e3f1;
--text: #142235;
--muted: #5b6b88;
--primary: #1f6feb;
--primary-soft: rgba(31,111,235,.12);
--success: #0f766e;
--success-soft: #dff7f1;
--warning: #8a5a00;
--warning-soft: #fff7e6;
--shadow: 0 18px 42px rgba(20,34,53,.08);
}}
* {{ box-sizing: border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);margin:0;color:var(--text); }}
.wrap {{ max-width: 1160px; margin:0 auto; padding:32px 20px 72px; }}
.hero {{ display:grid; grid-template-columns:minmax(0,1.12fr) 340px; gap:20px; align-items:start; }}
.panel {{ background:var(--surface); border:1px solid var(--border); border-radius:24px; box-shadow:var(--shadow); padding:24px; }}
.eyebrow {{ display:inline-flex; padding:6px 10px; border-radius:999px; background:var(--success-soft); color:var(--success); font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }}
h1 {{ margin:14px 0 10px; font-size:clamp(30px,5vw,42px); letter-spacing:-0.03em; }}
.lead {{ margin:0; color:var(--muted); line-height:1.8; }}
.status-card {{ background:linear-gradient(180deg,#f9fbff,#eef5ff); }}
.status-card h2, .main-panel h2 {{ margin:0 0 10px; font-size:22px; }}
.status-card p {{ margin:0 0 12px; color:var(--muted); line-height:1.7; }}
.status-pill {{ display:inline-flex; padding:8px 12px; border-radius:999px; background:var(--primary); color:#fff; font-weight:700; font-size:13px; }}
.status-list {{ display:grid; gap:12px; margin-top:16px; }}
.status-item {{ padding:14px 16px; border-radius:16px; background:#fff; border:1px solid var(--border); }}
.status-item strong {{ display:block; margin-bottom:6px; font-size:14px; }}
.compliance-note {{ margin-top:16px; padding:16px 18px; border-radius:16px; background:var(--warning-soft); border:1px solid #f4d39b; color:var(--warning); line-height:1.7; }}
.wizard-head {{ margin-top:20px; padding:18px 20px; border-radius:18px; background:var(--surface-soft); border:1px solid var(--border); }}
.wizard-head h2 {{ margin:0 0 8px; font-size:22px; }}
.wizard-head p {{ margin:0; color:var(--muted); line-height:1.7; }}
.wizard-steps {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; list-style:none; padding:0; margin:18px 0 0; }}
.wizard-steps li {{ padding:10px 12px; border-radius:999px; background:#e8eef8; color:#334155; text-align:center; font-size:13px; font-weight:700; }}
.step-panel {{ margin-top:18px; padding:20px; border-radius:20px; background:#fff; border:1px solid var(--border); }}
.step-panel h3 {{ margin:0 0 8px; font-size:20px; }}
.step-panel > p {{ margin:0 0 16px; color:var(--muted); line-height:1.7; }}
.field-grid {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
.field {{ display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }}
.field label {{ font-size:14px; font-weight:600; color:#334155; }}
input, textarea, select {{ width:100%; padding:12px; border-radius:12px; border:1px solid #cfd7e6; font-size:14px; background:#fff; color:var(--text); }}
textarea {{ min-height:110px; resize:vertical; }}
input:focus, textarea:focus, select:focus {{ outline:none; border-color:var(--primary); box-shadow:0 0 0 4px var(--primary-soft); }}
.helper {{ color:var(--muted); font-size:12px; line-height:1.6; }}
.upload-box {{ margin-top:16px; padding:16px 18px; border:1px solid var(--border); border-radius:16px; background:var(--surface-soft); }}
.attachment-list {{ list-style:none; padding:0; margin:12px 0 0; display:grid; gap:8px; }}
.attachment-list li {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; border-radius:14px; background:#fff; border:1px solid var(--border); color:var(--muted); }}
.attachment-list li.empty {{ justify-content:flex-start; }}
.check-list {{ display:grid; gap:10px; margin-top:12px; }}
.check-list label {{ display:flex; gap:10px; align-items:flex-start; color:var(--text); font-weight:500; }}
.check-list input {{ width:auto; margin-top:2px; }}
.actions {{ display:flex; gap:10px; flex-wrap:wrap; margin-top:18px; }}
button {{ border:none; border-radius:14px; background:var(--primary); color:#fff; font-weight:700; padding:13px 18px; cursor:pointer; min-height:46px; }}
.wizard-actions {{ position: sticky; bottom: 0; z-index: 5; margin-top: 20px; padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0px)); background: var(--bg); }}
.wizard-actions::before {{ content: ""; position: absolute; left: 0; right: 0; top: -16px; height: 16px; background: linear-gradient(180deg, rgba(243,247,251,0), var(--bg)); pointer-events: none; }}
.wizard-actions .actions {{ background:#fff; border:1px solid var(--border); border-radius:18px; box-shadow:var(--shadow); padding:12px; margin-top:0; }}
.wizard-actions button {{ flex:1 1 180px; }}
button:hover {{ background:#194fb6; }}
button[disabled] {{ opacity:.55; cursor:not-allowed; }}
#prev-step, .ghost {{ background:#edf3ff; color:#194fb6; }}
#result {{ margin-top:16px; min-height:24px; color:var(--muted); line-height:1.7; white-space:pre-wrap; }}
.summary-box {{ background:#f8fafc; border:1px solid var(--border); border-radius:16px; padding:14px 16px; white-space:pre-wrap; font-size:13px; line-height:1.7; color:var(--muted); }}
.progress-box {{ margin-top:14px; padding:14px 16px; border-radius:16px; background:#fff7e6; border:1px solid #f4d39b; color:#8a5a00; line-height:1.7; }}
.footer-link {{ margin-top:18px; }}
.main-panel {{ position: relative; }}
/* 移动端关键操作按钮固定在视口底部, 滚动时持续可达 */
@media (max-width: 980px) {{
.hero, .field-grid, .wizard-steps {{ grid-template-columns:1fr; }}
.wizard-actions {{ position: fixed; left: 0; right: 0; bottom: 0; padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px)); margin-top: 0; background: rgba(243,247,251,.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); box-shadow: 0 -8px 24px rgba(20,34,53,.08); }}
.wizard-actions .actions {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; padding:10px; }}
.wizard-actions button {{ width:100%; min-height:52px; }}
#submit-step {{ grid-column: 1 / -1; }}
/* 主面板底部留出空间, 防止按钮遮挡最后内容 */
.main-panel {{ padding-bottom: 168px; }}
}}
@media (max-width: 640px) {{
.wrap {{ padding:20px 14px 88px; }}
.panel {{ padding:18px; border-radius:20px; }}
.wizard-actions .actions {{ grid-template-columns:1fr; }}
}}
</style>
</head>
<body>{_render_global_nav()}
<main class="wrap">
<section class="hero">
<section class=\"panel main-panel\">
<span class="eyebrow">资料填写向导</span>
<h1>考生资料填写</h1>
<p class="helper" style=\"margin:8px 0 0;\">资料填写向导</p>
<p class="lead">支付完成后,请按向导逐步补充分数、位次、偏好与已有方案信息。我们会把这份资料作为后续方案分析与交付的基础。</p>
<section class="progress-box"><strong>当前还需要补充:</strong><span>{escape("".join(missing_items) or "资料已基本完整,可以继续提交")}</span></section>
<section class="wizard-head">
<h2>五步资料向导</h2>
<p>你不需要一次性完成所有内容。可以先保存草稿,再回来继续补充;第 4 步完成偏好与协议确认,第 5 步补已有方案与附件。</p>
<ol class="wizard-steps">
<li data-step-badge="1">基础信息</li>
<li data-step-badge="2">院校偏好</li>
<li data-step-badge="3">专业偏好</li>
<li data-step-badge="4">其他偏好与确认</li>
<li data-step-badge="5">已有方案与附件</li>
</ol>
</section>
<form id="intake-form">
<section class="step-panel" data-step="1">
<h3>基础信息</h3>
<p>先确认最影响志愿方案判断的基本数据:省份、分数、位次、选科。</p>
<div class="field-grid">
<div class="field"><label>考试省份</label><select name="candidate_province">{_province_options_html(str(payload.get("candidate_province") or order.candidate_province or ""))}</select><span class="helper">这是 Step 1 的第一项,先选省份再继续。</span></div>
<div class="field"><label>高考分数</label><input name="candidate_score" value="{escape(str(payload.get("candidate_score") or ""))}" /><span class="helper">如暂未最终确认,也可先保存草稿。</span></div>
<div class="field"><label>位次</label><input name="candidate_rank" value="{escape(str(payload.get("candidate_rank") or ""))}" /><span class="helper">建议填写与成绩单一致的最新位次。</span></div>
</div>
<div class="field"><label>选科(逗号分隔)</label><input name="candidate_subjects" value="{escape(",".join(payload.get("candidate_subjects") or []))}" /><span class="helper">例如:物理,化学,生物</span></div>
</section>
<section class="step-panel" data-step="2" style="display:none;">
<h3>院校偏好</h3>
<p>用于判断你更偏向哪些地区、哪些学校层次,以及是否已经有明确目标院校。</p>
<div class="field-grid">
<div class="field"><label>院校地域偏好(逗号分隔)</label><input name="school_region_preferences" value="{escape(",".join(payload.get("school_region_preferences") or []))}" /></div>
<div class="field"><label>院校类型偏好(逗号分隔)</label><input name="school_preference_types" value="{escape(",".join(payload.get("school_preference_types") or []))}" /></div>
</div>
<div class="field-grid">
<div class="field"><label>目标院校(逗号分隔)</label><input name="target_schools" value="{escape(",".join(payload.get("target_schools") or []))}" /></div>
<div class="field"><label>目标城市(逗号分隔)</label><input name="target_cities" value="{escape(",".join(payload.get("target_cities") or []))}" /></div>
</div>
<div class="field"><label>院校偏好说明</label><textarea name="university_preferences">{escape(str(payload.get("university_preferences") or ""))}</textarea></div>
</section>
<section class="step-panel" data-step="3" style="display:none;">
<h3>专业偏好</h3>
<p>用于判断你的专业方向、明确排斥项,以及优先策略更偏学校还是专业。</p>
<div class="field"><label>兴趣方向</label><input name="candidate_interests" value="{escape(str(payload.get("candidate_interests") or ""))}" /></div>
<div class="field-grid">
<div class="field"><label>目标专业(逗号分隔)</label><input name="target_majors" value="{escape(",".join(payload.get("target_majors") or []))}" /></div>
<div class="field"><label>不接受专业(逗号分隔)</label><input name="disliked_majors" value="{escape(",".join(payload.get("disliked_majors") or []))}" /></div>
</div>
<div class="field"><label>优先策略</label><input name="priority_strategy" value="{escape(str(payload.get("priority_strategy") or ""))}" placeholder="例如major_first" /></div>
</section>
<section class="step-panel" data-step="4" style="display:none;">
<h3>其他偏好与确认</h3>
<p>用于补充毕业规划、预算、就业区域和家庭背景,不强制提交,也不会阻塞审核入口。</p>
<div class="field-grid">
<div class="field"><label>毕业规划</label><input name="graduation_plan" value="{escape(str(payload.get("graduation_plan") or ""))}" /></div>
<div class="field"><label>学费倾向</label><input name="tuition_preference" value="{escape(str(payload.get("tuition_preference") or ""))}" /></div>
</div>
<div class="field"><label>就业地域偏好(逗号分隔)</label><input name="employment_region_preferences" value="{escape(",".join(payload.get("employment_region_preferences") or []))}" /></div>
<div class="field"><label>家庭背景</label><textarea name="family_background">{escape(str(payload.get("family_background") or ""))}</textarea></div>
<div class="field"><label>行业资源</label><textarea name="industry_resources">{escape(str(payload.get("industry_resources") or ""))}</textarea></div>
<div class="field"><label>深层补充说明</label><textarea name="extra_notes">{escape(str(payload.get("extra_notes") or ""))}</textarea></div>
<div class="field-grid">
<div class="field"><label>测评类型</label><input name="interest_assessment_type" value="{escape(str(payload.get("interest_assessment_type") or ""))}" placeholder="例如holland / mbti" /></div>
<div class="field"><label>测评结果</label><input name="interest_assessment_result" value="{escape(str(payload.get("interest_assessment_result") or ""))}" placeholder="例如R型+I型" /></div>
</div>
<div class="field"><label>测评补充说明</label><textarea name="interest_assessment_notes">{escape(str(payload.get("interest_assessment_notes") or ""))}</textarea></div>
<div class="compliance-note">测评结果只作为辅助因子,不作为唯一判断依据。</div>
<div class="field"><label>补充说明</label><textarea name="guardian_notes">{escape(str(payload.get("guardian_notes") or ""))}</textarea></div>
<input type="hidden" name="consent_version" value="{escape(consent_version)}" />
<input type="hidden" name="consent_scope" value="{escape(consent_scope)}" />
<div class="check-list">
<label><input type="checkbox" name="privacy_accepted" {privacy_checked} /> <span>我已阅读并同意隐私政策草案</span></label>
<label><input type="checkbox" name="service_terms_accepted" {service_terms_checked} /> <span>我已阅读并同意服务说明与免责声明</span></label>
<label><input type="checkbox" name="guardian_confirmed" {guardian_checked} /> <span>我确认监护人已知情并同意提交资料</span></label>
</div>
<div class="compliance-note">当前资料状态会随提交结果同步更新;未勾选必要同意项时,系统不会进入正式处理阶段。</div>
<div id="confirm-summary" class="summary-box"></div>
</section>
<section class="step-panel" data-step="5" style="display:none;">
<h3>已有方案与附件</h3>
<p>如果你已经拿到其他平台、老师或 AI 生成的方案,可以在这里上传并说明担心点。</p>
<div class="field"><label>已有方案说明</label><textarea name="existing_plan_summary">{escape(str(payload.get("existing_plan_summary") or ""))}</textarea></div>
<section class="upload-box">
<h4 style="margin:0 0 10px; font-size:18px;">已上传附件</h4>
<p class="helper" style="margin:0 0 12px;">支持继续补充方案附件、成绩截图或其他参考资料。</p>
<div id="attachment-panel">
<input type="file" name="files" multiple />
<div class="actions" style="margin-top:12px;">
<button type="button" onclick="uploadAttachment()">上传方案与资料附件</button>
</div>
</div>
<ul class="attachment-list">{attachments_html}</ul>
</section>
</section>
</form>
<div class="wizard-actions">
<div class="actions">
<button type="button" id="prev-step" onclick="moveStep(-1)" disabled>上一步</button>
<button type="button" id="next-step" onclick="moveStep(1)">下一步</button>
<button type="button" class="ghost" onclick="submitIntake('draft')">保存草稿</button>
<button type="button" id="submit-step" style="display:none;" onclick="submitIntake('submit')">确认并提交资料</button>
</div>
</div>
<div class="footer-link"><a href="/portal/{escape(token)}/status">返回订单状态页</a></div>
{_render_footer_links(token)}
<div id=\"result\"></div>
</section>
<aside class=\"panel status-card\">
<h2>当前资料状态</h2>
<span class="status-pill">{escape(stage_title)}</span>
<p style=\"margin-top:12px;\">{escape(stage_subtitle)}</p>
<div class="status-list">
<div class="status-item"><strong>订单号</strong><span>{escape(order.id)}</span></div>
<div class="status-item"><strong>当前服务版本</strong><span>{escape(order.service_version)}</span></div>
<div class="status-item"><strong>你可以怎么做</strong><span>保存草稿、继续补资料,或在最后一步统一提交进入后续处理。</span></div>
</div>
<div class="compliance-note">提交资料即表示:监护人已知情并同意将考生资料用于志愿填报服务;当前版本号:{escape(consent_version)}</div>
</aside>
</section>
</main>
<script>
let currentStep = 1;
const totalSteps = 5;
function collectPayload(mode) {{
const form = new FormData(document.getElementById('intake-form'));
const subjects = String(form.get('candidate_subjects') || '').split(',').map(s => s.trim()).filter(Boolean);
const targetCities = String(form.get('target_cities') || '').split(',').map(s => s.trim()).filter(Boolean);
const targetMajors = String(form.get('target_majors') || '').split(',').map(s => s.trim()).filter(Boolean);
const schoolRegions = String(form.get('school_region_preferences') || '').split(',').map(s => s.trim()).filter(Boolean);
const schoolTypes = String(form.get('school_preference_types') || '').split(',').map(s => s.trim()).filter(Boolean);
const targetSchools = String(form.get('target_schools') || '').split(',').map(s => s.trim()).filter(Boolean);
const dislikedMajors = String(form.get('disliked_majors') || '').split(',').map(s => s.trim()).filter(Boolean);
const employmentRegions = String(form.get('employment_region_preferences') || '').split(',').map(s => s.trim()).filter(Boolean);
return {{
mode,
candidate_province: form.get('candidate_province') || null,
candidate_score: form.get('candidate_score') ? Number(form.get('candidate_score')) : null,
candidate_rank: form.get('candidate_rank') ? Number(form.get('candidate_rank')) : null,
candidate_subjects: subjects,
candidate_interests: form.get('candidate_interests') || null,
target_cities: targetCities,
target_majors: targetMajors,
university_preferences: form.get('university_preferences') || null,
school_region_preferences: schoolRegions,
school_preference_types: schoolTypes,
target_schools: targetSchools,
disliked_majors: dislikedMajors,
priority_strategy: form.get('priority_strategy') || null,
graduation_plan: form.get('graduation_plan') || null,
tuition_preference: form.get('tuition_preference') || null,
employment_region_preferences: employmentRegions,
family_background: form.get('family_background') || null,
industry_resources: form.get('industry_resources') || null,
extra_notes: form.get('extra_notes') || null,
interest_assessment_type: form.get('interest_assessment_type') || null,
interest_assessment_result: form.get('interest_assessment_result') || null,
interest_assessment_notes: form.get('interest_assessment_notes') || null,
existing_plan_summary: form.get('existing_plan_summary') || null,
guardian_notes: form.get('guardian_notes') || null,
consent_version: form.get('consent_version') || null,
consent_scope: form.get('consent_scope') || null,
privacy_accepted: !!document.querySelector('input[name="privacy_accepted"]')?.checked,
service_terms_accepted: !!document.querySelector('input[name="service_terms_accepted"]')?.checked,
guardian_confirmed: !!document.querySelector('input[name="guardian_confirmed"]')?.checked,
}};
}}
function renderConfirmSummary() {{
const payload = collectPayload('draft');
const list = [
['考试省份', payload.candidate_province || '待补充'],
['高考分数', payload.candidate_score ?? '待补充'],
['位次', payload.candidate_rank ?? '待补充'],
['选科', (payload.candidate_subjects || []).join('') || '待补充'],
['兴趣方向', payload.candidate_interests || '待补充'],
['目标城市', (payload.target_cities || []).join('') || '待补充'],
['目标专业', (payload.target_majors || []).join('') || '待补充'],
['院校偏好', payload.university_preferences || '待补充'],
['已有方案说明', payload.existing_plan_summary || '待补充'],
];
document.getElementById('confirm-summary').replaceChildren(...list.map(([label, value]) => {{
const row = document.createElement('div');
row.style.cssText = 'padding:8px 0;border-bottom:1px solid #e5edf7;';
const strong = document.createElement('strong');
strong.textContent = `${{label}}`;
const span = document.createElement('span');
span.textContent = String(value);
row.append(strong, span);
return row;
}}));
}}
function updateWizard() {{
document.querySelectorAll('[data-step]').forEach((node) => {{
node.style.display = Number(node.getAttribute('data-step')) === currentStep ? '' : 'none';
}});
document.querySelectorAll('[data-step-badge]').forEach((node) => {{
const step = Number(node.getAttribute('data-step-badge'));
if (step === currentStep) {{
node.style.background = '#1f6feb';
node.style.color = '#fff';
}} else {{
node.style.background = '#e8eef8';
node.style.color = '#334155';
}}
}});
document.getElementById('prev-step').disabled = currentStep === 1;
document.getElementById('next-step').style.display = currentStep === totalSteps ? 'none' : '';
document.getElementById('submit-step').style.display = currentStep === totalSteps ? '' : 'none';
if (currentStep === totalSteps) renderConfirmSummary();
}}
function moveStep(delta) {{
currentStep = Math.min(totalSteps, Math.max(1, currentStep + delta));
updateWizard();
}}
function validateStep1Fields() {{
var errs = [];
var province = document.querySelector('select[name="candidate_province"]');
var score = document.querySelector('input[name="candidate_score"]');
var rank = document.querySelector('input[name="candidate_rank"]');
var subjects = document.querySelector('input[name="candidate_subjects"]');
// 只在 submit 模式下强制必填draft 模式放行
// 但即时校验仍然给格式反馈
function showFieldError(input, msg) {{
if (!input) return;
var hintId = input.name + '-inline-error';
var hint = document.getElementById(hintId);
if (!hint) {{
hint = document.createElement('div');
hint.id = hintId;
hint.className = 'field-error-hint';
hint.setAttribute('role', 'alert');
input.parentNode.appendChild(hint);
}}
if (msg) {{
hint.textContent = msg;
hint.style.display = 'block';
input.style.borderColor = '#b42318';
input.setAttribute('aria-invalid', 'true');
}} else {{
hint.style.display = 'none';
input.style.borderColor = '';
input.removeAttribute('aria-invalid');
}}
}}
// 格式校验(即时反馈)
if (score && score.value.trim()) {{
var sc = Number(score.value.trim());
if (isNaN(sc) || sc < 0 || sc > 800) {{
errs.push('分数格式错误');
showFieldError(score, '分数应在 0-800 之间');
}} else {{
showFieldError(score, '');
}}
}}
if (rank && rank.value.trim()) {{
var rk = Number(rank.value.trim());
if (isNaN(rk) || rk < 1 || rk > 5000000) {{
errs.push('位次格式错误');
showFieldError(rank, '位次应为 1-5000000 之间的正整数');
}} else {{
showFieldError(rank, '');
}}
}}
if (subjects && subjects.value.trim()) {{
var subjCount = subjects.value.split(/[,,、\\s]+/).filter(function(s){{return s.trim();}}).length;
if (subjCount > 6) {{
errs.push('选科超过6科');
showFieldError(subjects, '选科组合最多 6 科');
}} else {{
showFieldError(subjects, '');
}}
}}
return errs.length === 0;
}}
// 即时校验绑定
(function() {{
var score = document.querySelector('input[name="candidate_score"]');
var rank = document.querySelector('input[name="candidate_rank"]');
var subjects = document.querySelector('input[name="candidate_subjects"]');
if (score) score.addEventListener('blur', validateStep1Fields);
if (rank) rank.addEventListener('blur', validateStep1Fields);
if (subjects) subjects.addEventListener('blur', validateStep1Fields);
if (score) score.addEventListener('input', function() {{ if (score.getAttribute('aria-invalid')) validateStep1Fields(); }});
if (rank) rank.addEventListener('input', function() {{ if (rank.getAttribute('aria-invalid')) validateStep1Fields(); }});
if (subjects) subjects.addEventListener('input', function() {{ if (subjects.getAttribute('aria-invalid')) validateStep1Fields(); }});
}})();
async function submitIntake(mode) {{
// 提交前校验draft 也校验格式,但不强制必填)
if (!validateStep1Fields()) {{
document.getElementById('result').textContent = '资料格式有误,请修正红色提示后再提交。';
return;
}}
const payload = collectPayload(mode);
const resultNode = document.getElementById('result');
resultNode.textContent = '';
var loadingEl = window.showLoading ? window.showLoading(document.querySelector('.main-panel'), mode === 'draft' ? '正在保存草稿…' : '正在提交资料…') : null;
resultNode.textContent = mode === 'draft' ? '正在保存草稿…' : '正在提交资料…';
const resp = await fetch('/portal/{escape(token)}/info', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload),
}});
const body = await resp.json();
if (!resp.ok) {{
if (loadingEl) window.hideLoading(loadingEl);
resultNode.textContent = body.detail || body.message || '提交失败,请检查资料后重试。';
if (window.showToast) window.showToast('提交失败', 'error');
return;
}}
if (loadingEl) window.hideLoading(loadingEl);
resultNode.textContent = mode === 'draft' ? '草稿已保存,可稍后继续补充。' : '资料提交成功,正在进入订单状态页…';
if (window.showToast) window.showToast(mode === 'draft' ? '草稿已保存' : '提交成功', 'success');
if (resp.ok && mode === 'submit') window.location.href = '/portal/{escape(token)}/status';
}}
async function uploadAttachment() {{
const input = document.querySelector('#attachment-panel input[name="files"]');
if (!input || !input.files || input.files.length === 0) {{
document.getElementById('result').textContent = '请先选择至少一个附件';
return;
}}
const data = new FormData();
Array.from(input.files).forEach((file) => data.append('files', file));
const resp = await fetch('/portal/{escape(token)}/attachments', {{
method: 'POST',
body: data,
}});
const body = await resp.json();
document.getElementById('result').textContent = resp.ok ? '附件上传成功,页面将刷新显示最新结果。' : (body.detail || body.message || '附件上传失败');
if (resp.ok) window.location.reload();
}}
updateWizard();
</script>
{_render_global_toast_script()}</body>
</html>
"""
def _render_delivery_next_steps(token: str, stage: str) -> str:
"""当报告已交付时,给出明确的下一步行动引导。"""
if stage not in ("report_ready", "completed"):
return ""
return f"""
<div style="margin-top:16px;padding:16px;border-radius:14px;background:#f0f7ff;border:1px solid #1f6feb;">
<p style="margin:0 0 10px;font-size:14px;font-weight:700;color:#1f6feb;">报告已就绪,你可以:</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<a class="btn btn-primary" style="font-size:14px;min-height:40px;padding:8px 16px;" href="/portal/{escape(token)}/report.pdf">下载 PDF</a>
<a class="btn btn-secondary" style="font-size:14px;min-height:40px;padding:8px 16px;" href="/portal/{escape(token)}/report">在线查看</a>
<a class="btn btn-secondary" style="font-size:14px;min-height:40px;padding:8px 16px;" href="/pricing">继续规划其他方案</a>
</div>
<p style="margin:10px 0 6px;font-size:12px;color:#5a7cb8;">如需与家人商量,可复制当前页面链接或调用系统分享。</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-secondary" id="report-copy-link" style="font-size:12px;min-height:32px;padding:6px 10px;">复制链接</button>
<button class="btn btn-secondary" id="report-share-btn" style="font-size:12px;min-height:32px;padding:6px 10px;">系统分享</button>
</div>
<p id="report-share-status" style="margin:6px 0 0;font-size:12px;color:#5a7cb8;" role="status" aria-live="polite"></p>
<script>
(function() {{
var statusEl = document.getElementById('report-share-status');
function setStatus(msg, ok) {{ statusEl.textContent = msg; statusEl.style.color = ok ? '#1f6feb' : '#b42318'; setTimeout(function() {{ if (statusEl.textContent === msg) statusEl.textContent = ''; }}, 3000); }}
var copyBtn = document.getElementById('report-copy-link');
if (copyBtn) copyBtn.addEventListener('click', function() {{
function fallback() {{ try {{ var ta = document.createElement('textarea'); ta.value = window.location.href; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.focus(); ta.select(); var ok = document.execCommand('copy'); document.body.removeChild(ta); setStatus(ok ? '链接已复制' : '复制失败,请手动复制地址栏', ok); }} catch(e) {{ setStatus('复制失败', false); }} }}
if (navigator.clipboard && navigator.clipboard.writeText) {{ navigator.clipboard.writeText(window.location.href).then(function() {{ setStatus('链接已复制', true); }}, function() {{ fallback(); }}); }} else {{ fallback(); }}
}});
var shareBtn = document.getElementById('report-share-btn');
if (shareBtn) shareBtn.addEventListener('click', function() {{
if (navigator.share) {{ navigator.share({{ title: '志愿报告', url: window.location.href }}).then(function() {{ setStatus('已通过系统分享发送', true); }}, function() {{ setStatus('分享已取消', false); }}); }}
else {{ if (copyBtn) copyBtn.click(); setStatus('当前浏览器不支持系统分享,已改为复制链接', true); }}
}});
}})();
</script>
</div>"""
def _render_status_page(token: str, context: dict[str, Any]) -> str:
order = context["order"]
stage = str(context["stage"])
report_links = ""
can_access_delivery = stage in {"report_ready", "completed"}
if can_access_delivery and context["report_html_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report">查看在线报告</a></li>'
)
if can_access_delivery and context["report_pdf_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report.pdf">下载 PDF</a></li>'
)
if not report_links:
report_links = "<li>报告生成中,交付物就绪后这里会显示查看/下载入口。</li>"
station_notice_html = ""
station_notice = context.get("station_notice")
if isinstance(station_notice, dict):
title = escape(str(station_notice.get("title") or "站内通知"))
body = escape(str(station_notice.get("body") or ""))
sent_at = escape(
str(
station_notice.get("sent_at")
or station_notice.get("delivered_at")
or ""
)
)
station_notice_html = f"""
<section class=\"panel notice-panel\">
<h2>通知已发送</h2>
<p><strong>{title}</strong></p>
<p>{body}</p>
<p class="meta">发送时间:{sent_at}</p>
</section>"""
summary_html = ""
intake_summary = context.get("intake_summary") or {}
attachment_count = int(context.get("attachment_count") or 0)
attachments = context.get("attachments") or []
if intake_summary or attachments:
summary_items = [
f"<li>分数:{escape(str(intake_summary.get('candidate_score') or '-'))}</li>",
f"<li>位次:{escape(str(intake_summary.get('candidate_rank') or '-'))}</li>",
f"<li>选科:{escape(','.join(intake_summary.get('candidate_subjects') or [])) or '-'}</li>",
f"<li>兴趣方向:{escape(str(intake_summary.get('candidate_interests') or '-'))}</li>",
f"<li>目标城市:{escape(','.join(intake_summary.get('target_cities') or [])) or '-'}</li>",
f"<li>目标专业:{escape(','.join(intake_summary.get('target_majors') or [])) or '-'}</li>",
f"<li>院校偏好:{escape(str(intake_summary.get('university_preferences') or '-'))}</li>",
f"<li>已有方案说明:{escape(str(intake_summary.get('existing_plan_summary') or '-'))}</li>",
f"<li>附件数量:{attachment_count}</li>",
]
attachment_lines = []
for item in attachments:
if not isinstance(item, dict):
continue
attachment_lines.append(
f"<li>{escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}"
f"<span>{escape(str(item.get('content_type') or '-'))} · {escape(str(item.get('size_bytes') or '0'))} bytes</span></li>"
)
attachment_html = "".join(attachment_lines) or "<li class='empty'>暂无附件</li>"
summary_html = f"""
<section class="panel">
<h2>当前资料摘要</h2>
<ul class="summary-list">{"".join(summary_items)}</ul>
<h3>已上传附件</h3>
<ul class="attachment-list">{attachment_html}</ul>
</section>"""
payment_status = escape(str(context.get("payment_status") or "pending"))
delivery_html = f"""
<section class="panel">
<h2>支付与交付状态</h2>
<div class="status-grid">
<div class="status-item"><strong>支付状态</strong><span>{payment_status}</span></div>
<div class="status-item"><strong>资料状态</strong><span>{escape(context["stage_title"])}</span></div>
<div class="status-item"><strong>HTML 报告</strong><span>{"已就绪" if context["report_html_ready"] else "未就绪"}</span></div>
<div class="status-item"><strong>PDF 报告</strong><span>{"已就绪" if context["report_pdf_ready"] else "未就绪"}</span></div>
<div class="status-item"><strong>交付阶段</strong><span>{escape(context["stage"])}</span></div>
</div>
</section>"""
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>订单进度总览</title>
<link rel=\"stylesheet\" href=\"/static/portal-ui.css\" />
<style>
:root {{
--bg: #f3f7fb;
--surface: #ffffff;
--surface-soft: #f7fbff;
--border: #d7e3f1;
--text: #142235;
--muted: #5b6b88;
--primary: #1f6feb;
--success: #0f766e;
--success-soft: #dff7f1;
--notice-soft: #eef6ff;
--notice-border: #b9d7ff;
--shadow: 0 18px 42px rgba(20,34,53,.08);
}}
* {{ box-sizing: border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);margin:0;color:var(--text); }}
.wrap {{ max-width: 1100px; margin:0 auto; padding:32px 20px 72px; }}
.hero {{ display:grid; grid-template-columns:minmax(0,1.08fr) 340px; gap:20px; align-items:start; }}
.panel {{ background:#fff; border:1px solid var(--border); border-radius:24px; box-shadow:var(--shadow); padding:24px; }}
.eyebrow {{ display:inline-flex; padding:6px 10px; border-radius:999px; background:var(--success-soft); color:var(--success); font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }}
h1 {{ margin:14px 0 10px; font-size:clamp(30px,5vw,42px); letter-spacing:-0.03em; }}
.lead {{ margin:0; color:var(--muted); line-height:1.8; }}
.stage-pill {{ display:inline-flex; margin-top:16px; padding:10px 14px; border-radius:999px; background:var(--primary); color:#fff; font-weight:700; }}
.progress-bar {{ display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:18px; }}
.progress-step {{ padding:12px 14px; border-radius:14px; background:var(--surface-soft); border:1px solid var(--border); color:var(--muted); text-align:center; }}
.progress-step.active {{ background:var(--primary); color:#fff; border-color:var(--primary); }}
.progress-step.done {{ background:var(--success-soft); color:var(--success); border-color:var(--success); }}
.hero-actions {{ display:flex; gap:12px; flex-wrap:wrap; margin-top:18px; }}
.hero-btn {{ display:inline-flex; align-items:center; justify-content:center; min-height:48px; padding:0 18px; border-radius:14px; text-decoration:none; font-weight:700; }}
.hero-btn-primary {{ background:var(--primary); color:#fff; }}
.hero-btn-secondary {{ background:#edf3ff; color:#194fb6; }}
.hero-meta {{ display:grid; gap:12px; margin-top:18px; }}
.hero-meta-item {{ padding:14px 16px; border-radius:16px; background:var(--surface-soft); border:1px solid var(--border); }}
.hero-meta-item strong {{ display:block; margin-bottom:6px; font-size:14px; }}
.summary-list, .action-list {{ margin:0; padding-left:18px; color:var(--muted); line-height:1.8; }}
.attachment-list {{ list-style:none; padding:0; margin:12px 0 0; display:grid; gap:8px; }}
.attachment-list li {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; border-radius:14px; background:var(--surface-soft); border:1px solid var(--border); color:var(--muted); }}
.attachment-list li.empty {{ justify-content:flex-start; }}
.status-grid {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }}
.status-item {{ padding:14px 16px; border-radius:16px; background:var(--surface-soft); border:1px solid var(--border); }}
.status-item strong {{ display:block; margin-bottom:6px; font-size:14px; }}
.notice-panel {{ background:var(--notice-soft); border-color:var(--notice-border); }}
.meta {{ color:#4f6480; font-size:14px; }}
.sections {{ display:grid; gap:16px; margin-top:20px; }}
@media (max-width: 980px) {{
.hero, .status-grid {{ grid-template-columns:1fr; }}
}}
</style>
</head>
<body>{_render_global_nav()}
<main class="wrap">
<section class="hero">
<section class="panel">
<span class="eyebrow">订单进度总览</span>
<h1>{escape(context["stage_title"])}</h1>
<div class="progress-bar"><div class="progress-step {"done" if stage not in ("pending_payment",) else "active"}">1. 支付成功</div><div class="progress-step {"done" if stage in ("processing", "report_ready", "completed") else ("active" if stage in ("paid", "serving", "info_submitted") else "")}">2. 资料处理中</div><div class="progress-step {"done" if stage in ("completed",) else ("active" if stage in ("report_ready",) else "")}">3. 报告交付</div></div>
<p class="lead">{escape(context["stage_subtitle"])}</p>
<span class="stage-pill">当前阶段:{escape(context["stage"])}</span>
<div class="hero-actions">
<a class="hero-btn hero-btn-primary" href="/review/start?source=status&amp;token={escape(token)}">查看当前进度 / 先复核当前方案</a>
<a class="hero-btn hero-btn-secondary" href="/portal/{escape(token)}/info">继续补充资料</a>
<a class="hero-btn hero-btn-secondary" href="/">返回首页</a>
</div>
<div class="hero-meta">
<div class="hero-meta-item"><strong>订单号</strong><span>{escape(order.id)}</span></div>
<div class="hero-meta-item"><strong>服务版本</strong><span>{escape(order.service_version)}</span></div>
<div class="hero-meta-item"><strong>当前订单状态</strong><span>{escape(order.status)}</span></div>
<div class="hero-meta-item"><strong>Step 1 最小建档</strong><span>{"已完成" if context["profile_minimum_complete"] else "未完成"}</span></div>
</div>
</section>
<aside class="panel">
<h2>下一步建议</h2>
<ul class="action-list">
<li>如资料还未完善,可返回资料页继续补充。</li>
<li>如报告尚未就绪,请以后续通知与状态页更新为准。</li>
<li>如交付已完成,可优先查看报告与 PDF 下载入口。</li>
</ul>
</aside>
</section>
<section class="sections">
{summary_html}
<div id=\"delivery-status\">{delivery_html}</div>
{station_notice_html}
<section class="panel">
<h2>Step 1 最小建档状态</h2>
<ul class="action-list">
<li>Step 1 最小建档:{"已完成" if context["profile_minimum_complete"] else "未完成"}</li>
<li>缺失字段:{escape("".join(context["profile_missing_fields"]) or "")}</li>
</ul>
</section>
<section class="panel">
<h2>下一步操作</h2>
<ul class="action-list">
<li><a href=\"/portal/{escape(token)}/info\">填写 / 更新资料</a></li>
<li><a href=\"/review/start?source=status&amp;token={escape(token)}\">从状态页进入方案复核</a></li>
<li><a href=\"/portal/{escape(token)}/notifications\">查看通知记录</a></li>
{report_links}
</ul>
{_render_delivery_next_steps(token, stage)}
</section>
</section>
</main>
{_render_global_toast_script()}</body>
</html>
"""
def _render_notification_audit_page(token: str, order: Order, events: list[Any]) -> str:
rows = []
for event in events:
failure_reason = escape(str(event.failure_reason or "-"))
rows.append(
"<tr>"
f"<td>{escape(str(event.channel))}</td>"
f"<td>{escape(str(event.event_type))}</td>"
f"<td>{escape(str(event.status))}</td>"
f"<td>{escape(str(event.attempt_count))}</td>"
f"<td>{escape(str(event.last_attempt_at or '-'))}</td>"
f"<td>{failure_reason}</td>"
"</tr>"
)
rows_html = "".join(rows) or "<tr><td colspan='6'>暂无通知事件</td></tr>"
return f"""<!doctype html>
<html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>通知审计</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;margin:0;padding:32px 20px;color:#172033}}.wrap{{max-width:1100px;margin:0 auto;display:grid;gap:16px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.meta{{color:#5b6b88;line-height:1.7}}table{{width:100%;border-collapse:collapse}}th,td{{padding:12px 10px;border-bottom:1px solid #e5edf7;text-align:left;font-size:14px}}th{{color:#475569;font-size:13px;text-transform:uppercase;letter-spacing:.03em}}.toolbar{{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}}</style></head>
<body>{_render_global_nav()}
<main class="wrap">
<section class="panel">
<div class="toolbar"><div><h1>通知审计</h1><p class="meta">订单号:{escape(order.id)} · 服务版本:{escape(order.service_version)}</p></div><div><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></div></div>
<p class="meta">这里只展示通知摘要,方便你确认系统是否已经发出站内提醒或邮件通知;原始 payload 与附件路径不会在前台显示。</p>
</section>
<section class="panel" style=\"overflow:auto\">
<table>
<thead>
<tr>
<th>渠道</th><th>事件</th><th>状态</th><th>尝试次数</th><th>最后尝试时间</th><th>失败原因</th>
</tr>
</thead>
<tbody>{rows_html}</tbody>
</table>
</section>
</main>
{_render_global_toast_script()}</body>
</html>
"""
def _report_version_label(order: Order) -> str:
timestamp = (
order.delivered_at or order.completed_at or order.paid_at or order.created_at
)
return f"report-{timestamp}" if timestamp else f"report-{order.id}"
def _profile_version_label(context: dict[str, Any]) -> str:
intake_summary = context.get("intake_summary") or {}
if context.get("profile_minimum_complete"):
province = intake_summary.get("candidate_province") or "unknown"
score = intake_summary.get("candidate_score") or "x"
rank = intake_summary.get("candidate_rank") or "x"
return f"profile-step1-{province}-{score}-{rank}"
return "profile-step1-incomplete"
def _review_version_label(contract: ReviewResultContract | None) -> str:
if contract is None:
return "review-none"
return contract.review_result_id
def _review_history_summary(order_id: str, settings: Settings) -> str:
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order_id)
finally:
intake_store.close()
if current is None:
return "暂无版本历史"
review_map = dict(current.payload.get("review_results") or {})
if not review_map:
return "暂无版本历史"
return f"版本历史:{len(review_map)} 个版本,最新 {escape(str(current.payload.get('latest_review_result_id') or 'unknown'))}"
def _render_report_shell(
token: str,
order: Order,
context: dict[str, Any],
report_body_html: str,
settings: Settings,
) -> str:
contract = _load_latest_review_result(order.id, settings)
payload = _current_intake_payload(context)
report_version = _report_version_label(order)
latest_profile_version = (
_profile_version_label_from_payload(payload)
if payload
else _profile_version_label(context)
)
profile_version = _report_version_profile_reference(
payload,
report_version_id=report_version,
fallback_profile_version_id=latest_profile_version,
)
review_version = _review_version_label(contract)
history_summary = _review_history_summary(order.id, settings)
_sync_report_version_metadata(
order.id,
settings,
report_version_id=report_version,
profile_version_id=profile_version,
review_result_version_id=(review_version if contract is not None else None),
artifact_refs={
"audit_report": order.audit_report,
"pdf_path": order.pdf_path,
"plan_file": order.plan_file,
},
)
next_href_map = {
"cwb": f"/portal/{escape(token)}/cwb",
"step1": f"/portal/{escape(token)}/info",
"full_plan": f"/portal/{escape(token)}/full-plan",
"none": f"/portal/{escape(token)}/full-plan",
}
next_href = next_href_map.get(
(contract.review_followup_action if contract is not None else "none"),
f"/portal/{escape(token)}/full-plan",
)
review_summary = (
f"<pre>{escape(json.dumps(contract.model_dump(), ensure_ascii=False, indent=2))}</pre>"
if contract is not None
else "<p class='meta'>当前暂无复核结果,可先从状态页或首页进入方案复核。</p>"
)
policy_href, same_score_href = _helper_entry_hrefs(payload, order)
auxiliary_html = _render_auxiliary_factor_section(payload)
version_state = _profile_version_state(payload, profile_version)
return f"""<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>志愿报告资产页</title><link rel=\"stylesheet\" href=\"/static/portal-ui.css\" /><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f3f7fb;margin:0;padding:32px 20px;color:#142235}}.wrap{{max-width:1100px;margin:0 auto;display:grid;gap:16px}}.panel{{background:#fff;border:1px solid #d7e3f1;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.meta{{color:#5b6b88;line-height:1.8}}.actions{{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border-radius:14px;text-decoration:none;font-weight:700;background:#1f6feb;color:#fff}}.btn.secondary{{background:#eef6ff;color:#1f4fb6}}ul{{padding-left:20px;line-height:1.8}}pre{{white-space:pre-wrap;background:#f8fbff;border:1px solid #d7e3f1;border-radius:16px;padding:16px;overflow:auto}}</style></head><body>{_render_global_nav()}<main class="wrap"><section class="panel"><h1>志愿报告资产页</h1><p class="meta">报告版本:{escape(report_version)} · 基于哪个档案版本:{escape(profile_version)} · 当前复核版本:{escape(review_version)}</p><p class="meta">{escape(version_state)} · {history_summary}</p><div class="actions"><a class="btn" href=\"{next_href}\">继续下一步</a><a class=\"btn secondary\" href=\"{policy_href}\">查看政策中心</a><a class=\"btn secondary\" href=\"{same_score_href}\">查看同分段参考</a></div></section><section class="panel"><h2>当前复核摘要</h2>{review_summary}</section>{auxiliary_html}<section class="panel"><h2>原始报告内容</h2>{report_body_html}</section><section class="panel"><h2>下一步建议</h2><ul><li><a href=\"/portal/{escape(token)}/info\">回到资料页继续补充</a></li><li><a href=\"{next_href}\">继续当前主路径</a></li><li><a href=\"/review/start?source=report&amp;token={escape(token)}\">重新发起方案复核</a></li></ul></section>{_render_footer_links(token)}</main>{_render_global_toast_script()}</body></html>"""
def _render_report_page(order: Order, settings: Settings) -> str:
token = issue_portal_token(order.id, settings.portal_token_secret)
context = _build_portal_context(order, settings)
if (
order.audit_report
and _is_trusted_report_path(order.audit_report, settings)
and Path(order.audit_report).is_file()
):
path = Path(order.audit_report)
content = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".html":
return _render_report_shell(token, order, context, content, settings)
if path.suffix.lower() == ".json":
pretty = json.dumps(json.loads(content), ensure_ascii=False, indent=2)
return _render_report_shell(
token, order, context, f"<pre>{escape(pretty)}</pre>", settings
)
return _render_report_shell(
token, order, context, f"<pre>{escape(content)}</pre>", settings
)
if (
order.plan_file
and _is_trusted_report_path(order.plan_file, settings)
and Path(order.plan_file).is_file()
):
content = Path(order.plan_file).read_text(encoding="utf-8")
return _render_report_shell(
token, order, context, f"<pre>{escape(content)}</pre>", settings
)
raise HTTPException(status_code=409, detail="report not ready")
def _review_payload_key() -> str:
return "review_result_contract"
def _review_constraints_subjects_text(value: Any) -> str:
if isinstance(value, list):
parts = [str(item).strip() for item in value if str(item).strip()]
return " / ".join(parts) or "待补充"
text = str(value or "").strip()
return text or "待补充"
def _review_constraints_display(value: Any) -> str:
text = str(value or "").strip()
return text or "待补充"
def _get_crowd_db_recs_for_review(constraints: dict[str, Any]) -> list[dict[str, Any]]:
"""根据当前约束从 crowd_db 取同分段参考。"""
province = str(constraints.get("candidate_province") or "").strip()
score = constraints.get("candidate_score")
if not province or score in (None, ""):
return []
try:
score_int = int(str(score))
except (TypeError, ValueError):
return []
try:
loader = CrowdDBLoader(warn_low_confidence=False)
return loader.find_recommendations(province, score_int)
except Exception:
return []
def _llm_review_contract(
*,
settings: Settings,
review_result_id: str,
source: Literal["home", "status", "report", "direct"],
resolved_summary: str,
resolved_constraints: dict[str, Any],
resolved_attachments: list[str],
) -> ReviewResultContract | None:
"""尝试用 LLM 生成审核结果;未配置或失败时返回 None。"""
client = LLMClient(settings)
if not client.is_configured:
return None
province = (
str(resolved_constraints.get("candidate_province") or "").strip() or "湖南"
)
score = resolved_constraints.get("candidate_score")
rank = resolved_constraints.get("candidate_rank")
subjects = list(resolved_constraints.get("candidate_subjects") or [])
crowd_recs = _get_crowd_db_recs_for_review(resolved_constraints)
system, user = build_audit_prompt(
province=province,
score=int(str(score)) if score not in (None, "") else None,
rank=int(str(rank)) if rank not in (None, "") else None,
subjects=[str(s) for s in subjects],
existing_plan=resolved_summary,
crowd_db_recs=crowd_recs,
)
try:
resp = client.chat_with_system(system, user, temperature=0.3)
data = json.loads(resp.content)
except (LLMError, json.JSONDecodeError, TypeError, ValueError):
return None
risk_level = str(data.get("risk_level") or "medium").lower()
if risk_level not in {"low", "medium", "high"}:
risk_level = "medium"
findings = [
str(x).strip() for x in list(data.get("key_findings") or []) if str(x).strip()
][:5]
if not findings:
findings = [str(data.get("risk_summary") or "当前方案可继续复核")]
cwb = data.get("cwb_suggestions") or {}
cwb_suggestions = {
"rush": [
f"{item.get('school', '?')} - {item.get('major', '?')}"
for item in list(cwb.get("rush") or [])
if isinstance(item, dict)
],
"stable": [
f"{item.get('school', '?')} - {item.get('major', '?')}"
for item in list(cwb.get("stable") or [])
if isinstance(item, dict)
],
"safety": [
f"{item.get('school', '?')} - {item.get('major', '?')}"
for item in list(cwb.get("safety") or [])
if isinstance(item, dict)
],
}
profile_ready = _is_profile_minimum_complete(resolved_constraints)
recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = (
"go_cwb" if profile_ready else "go_step1"
)
return ReviewResultContract(
review_result_id=review_result_id,
risk_level=risk_level, # type: ignore[arg-type]
top_findings=findings,
recommended_action=recommended_action,
available_actions=["go_cwb", "go_step1", "go_full_plan"],
review_entry_source=source,
review_followup_action="none",
review_input_summary=resolved_summary or "未提供现有方案说明",
review_input_attachments=resolved_attachments,
review_constraints=resolved_constraints,
llm_generated=True,
llm_summary=str(data.get("risk_summary") or ""),
llm_cwb_suggestions=cwb_suggestions,
)
def _start_review_result(
*,
source: Literal["home", "status", "report", "direct"],
token: str | None,
settings: Settings,
review_input_summary: str | None = None,
review_constraints: dict[str, Any] | None = None,
) -> ReviewResultContract:
review_result_id = f"rvw_{secrets.token_hex(4)}"
resolved_summary = (review_input_summary or "").strip()
resolved_constraints = dict(review_constraints or {})
resolved_attachments: list[str] = []
if token:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
intake_summary = context.get("intake_summary") or {}
if not resolved_summary:
resolved_summary = str(
intake_summary.get("existing_plan_summary") or ""
).strip()
if not resolved_constraints:
resolved_constraints = {
"candidate_province": intake_summary.get("candidate_province")
or order.candidate_province
or "",
"candidate_subjects": intake_summary.get("candidate_subjects")
or order.candidate_subjects
or [],
"candidate_score": intake_summary.get("candidate_score")
or order.candidate_score,
"candidate_rank": intake_summary.get("candidate_rank")
or order.candidate_rank,
}
resolved_attachments = [
str(item.get("original_name") or item.get("stored_name") or "").strip()
for item in list(context.get("attachments") or [])
if isinstance(item, dict)
and (item.get("original_name") or item.get("stored_name"))
]
profile_ready = _is_profile_minimum_complete(resolved_constraints)
recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = (
"go_cwb" if profile_ready else "go_step1"
)
# 优先尝试 LLM 生成审核结果;未配置或失败时回退到规则默认逻辑
contract = _llm_review_contract(
settings=settings,
review_result_id=review_result_id,
source=source,
resolved_summary=resolved_summary,
resolved_constraints=resolved_constraints,
resolved_attachments=resolved_attachments,
)
if contract is None:
top_finding = (
"Step 1 已齐全,可直接进入冲稳保微调,再决定是否进入完整规划。"
if profile_ready
else "当前方案建议先补充 Step 1 后再继续判断梯度风险。"
)
contract = ReviewResultContract(
review_result_id=review_result_id,
risk_level="medium",
top_findings=[top_finding],
recommended_action=recommended_action,
available_actions=["go_cwb", "go_step1", "go_full_plan"],
review_entry_source=source,
review_followup_action="none",
review_input_summary=resolved_summary or "未提供现有方案说明",
review_input_attachments=resolved_attachments,
review_constraints=resolved_constraints,
)
if token:
order = _resolve_order_from_token(token, settings)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order.id)
payload = dict(current.payload) if current is not None else {}
payload["latest_review_result_id"] = contract.review_result_id
review_map = dict(payload.get("review_results") or {})
review_map[contract.review_result_id] = contract.model_dump()
payload["review_results"] = review_map
intake_store.save(
order_id=order.id,
payload=payload,
submit=(current.status == "submitted")
if current is not None
else False,
)
finally:
intake_store.close()
return contract
def _render_review_start_page(contract: ReviewResultContract, token: str | None) -> str:
token_input = (
f'<input type="hidden" name="token" value="{escape(token)}" />' if token else ""
)
findings_html = (
"".join(f"<li>{escape(str(item))}</li>" for item in contract.top_findings)
or "<li>暂无核心问题</li>"
)
attachment_html = (
"".join(
escape(str(item))
for item in contract.review_input_attachments
if str(item).strip()
)
or "无附件"
)
llm_summary_html = (
f'<section class="panel"><h2>AI 风险总结</h2><p class="meta">{escape(contract.llm_summary)}</p></section>'
if contract.llm_generated and contract.llm_summary
else ""
)
constraints = contract.review_constraints or {}
profile_ready = bool(constraints.get("candidate_province") and constraints.get("candidate_score"))
recommended_label = {
"go_cwb": "先去看冲稳保建议",
"go_step1": "先补齐基础信息",
"go_full_plan": "进入完整规划",
}[contract.recommended_action]
# 根据是否有token决定按钮行为无token时跳首页表单有token时走portal流程
if token is None:
# 免费复核无订单场景:引导用户先去首页完善信息或下单
primary_action = (
"完善信息后重新复核",
"home",
"当前是免费复核,建议先完善分数、位次、选科等信息,或选择付费服务获得完整分析。",
)
primary_href = "/"
else:
# 有订单场景跳转到portal相应页面
primary_action = {
"go_cwb": (
"去看冲稳保建议",
"cwb",
"如果你的基础信息已经完整,下一步先看冲稳保梯度更合适。",
),
"go_step1": (
"先补齐基础信息",
"step1",
"当前更适合先补齐省份、分数、位次、选科等基础信息,再继续判断梯度风险。",
),
"go_full_plan": (
"进入完整规划",
"full_plan",
"如果你已经明确要进入完整服务,可以直接继续到完整规划页。",
),
}[contract.recommended_action]
primary_href = {
"go_cwb": f"/portal/{token}/cwb",
"go_step1": f"/portal/{token}/info",
"go_full_plan": f"/portal/{token}/full-plan",
}[contract.recommended_action]
summary = escape(contract.review_input_summary or "未提供现有方案说明")
# 按钮逻辑无token时隐藏冲稳保按钮需要先创建订单
cwb_btn = (
f'<a class="btn btn-secondary" href="/portal/{token}/cwb">查看冲稳保建议</a>'
if token else ""
)
# 风险等级视觉映射
risk_level_map = {
"low": ("偏低(风险可控)", "#1f6feb", "#eef6ff", "#d7e3f1"),
"medium": ("中等(需要关注)", "#f59e0b", "#fffbeb", "#fcd34d"),
"high": ("偏高(尽快调整)", "#dc2626", "#fef2f2", "#fecaca"),
}
risk_label, risk_color, risk_bg, risk_border = risk_level_map.get(
contract.risk_level.lower(), risk_level_map["medium"]
)
# 建议标签
recommended_label = {
"go_cwb": "先去看冲稳保建议",
"go_step1": "先补齐基础信息",
"go_full_plan": "进入完整规划",
}[contract.recommended_action]
province = escape(
_review_constraints_display(constraints.get("candidate_province"))
)
subjects = escape(
_review_constraints_subjects_text(constraints.get("candidate_subjects"))
)
score = escape(_review_constraints_display(constraints.get("candidate_score")))
# 信息完整性提示
info_complete_html = (
'<div style="margin-top:16px;padding:14px;border-radius:12px;background:#fffbeb;border:1px solid #fcd34d;">'
'<p style="margin:0;font-size:14px;color:#92400e;"><strong>⚠ 信息不完整</strong>:选科组合或位次为"待补充",无法给出具体录取差距分析。</p>'
'</div>'
) if not profile_ready else (
'<div style="margin-top:16px;padding:14px;border-radius:12px;background:#ecfdf5;border:1px solid #a7f3d0;">'
'<p style="margin:0;font-size:14px;color:#065f46;"><strong>✓ 信息完整</strong>:已具备生成冲稳保建议的条件。</p>'
'</div>'
)
rank = escape(_review_constraints_display(constraints.get("candidate_rank")))
share_hint = "此页链接可直接复制分享"
body_html = f"""
<section class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span class="eyebrow">免费复核结果</span>
<a class="btn btn-secondary" style="font-size:13px;min-height:32px;padding:6px 12px;" href="/">返回首页</a>
</div>
<h1>复核结果</h1>
<p class="meta">基于你当前提交的信息,下面先告诉你当前的风险判断和最适合的下一步。</p>
<div style="margin-top:12px;padding:12px 14px;border-radius:12px;background:#f0f7ff;border:1px solid #1f6feb;">
<p style="margin:0 0 6px;font-size:13px;color:#1f6feb;"><strong>分享给家人商量</strong></p>
<p style="margin:0 0 8px;font-size:12px;color:#5a7cb8;">{share_hint}。把当前页面链接复制发给家人,或调用系统分享,一起讨论结果。</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-secondary" id="copy-link-btn" style="font-size:12px;min-height:32px;padding:6px 10px;">复制链接</button>
<button class="btn btn-secondary" id="share-btn" style="font-size:12px;min-height:32px;padding:6px 10px;">微信分享</button>
<button class="btn btn-secondary" id="save-draft-btn" style="font-size:12px;min-height:32px;padding:6px 10px;">保存到本地草稿</button>
</div>
<p id="share-status" style="margin:8px 0 0;font-size:12px;color:#5a7cb8;" role="status" aria-live="polite"></p>
</div>
</section>
{llm_summary_html}
<script>
(function() {{
var statusEl = document.getElementById('share-status');
var url = window.location.href;
var summary = "高考志愿复核结果 - 风险等级:{escape(contract.risk_level)}";
function setStatus(msg, ok) {{
statusEl.textContent = msg;
statusEl.style.color = ok ? '#1f6feb' : '#b42318';
setTimeout(function() {{ if (statusEl.textContent === msg) statusEl.textContent = ''; }}, 3500);
}}
// 1) 复制链接:优先 navigator.clipboard回退到 textarea 兜底(旧浏览器/非 HTTPS
document.getElementById('copy-link-btn').addEventListener('click', function() {{
function fallback() {{
try {{
var ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
setStatus(ok ? '链接已复制,可粘贴发送给家人' : '复制失败,请手动复制地址栏链接', ok);
}} catch(e) {{
setStatus('复制失败,请手动复制地址栏链接', false);
}}
}}
if (navigator.clipboard && navigator.clipboard.writeText) {{
navigator.clipboard.writeText(url).then(
function() {{ setStatus('链接已复制,可粘贴发送给家人', true); }},
function() {{ fallback(); }}
);
}} else {{ fallback(); }}
}});
// 2) 系统分享:调用原生 Web Share API不支持时回退到复制链接
document.getElementById('share-btn').addEventListener('click', function() {{
if (navigator.share) {{
navigator.share({{ title: summary, text: summary, url: url }}).then(
function() {{ setStatus('已通过系统分享发送', true); }},
function(err) {{ setStatus('分享已取消或失败:' + (err && err.message || err), false); }}
);
}} else {{
document.getElementById('copy-link-btn').click();
setStatus('当前浏览器不支持系统分享,已改为复制链接', true);
}}
}});
// 3) 保存草稿:把当前 URL + 风险摘要落到 localStorage下次回来可一键恢复
document.getElementById('save-draft-btn').addEventListener('click', function() {{
try {{
var drafts = JSON.parse(localStorage.getItem('gaokao_review_drafts') || '[]');
drafts.unshift({{
url: url,
summary: summary,
savedAt: new Date().toISOString(),
province: "{escape(province)}",
score: "{escape(score)}"
}});
if (drafts.length > 10) drafts = drafts.slice(0, 10);
localStorage.setItem('gaokao_review_drafts', JSON.stringify(drafts));
setStatus('已保存到本机草稿(仅此浏览器可见),最多保留 10 条', true);
}} catch(e) {{
setStatus('保存失败:浏览器禁用了 localStorage', false);
}}
}});
}})();
</script>
<section class="panel">
<h2>你当前提交的信息</h2>
<div style="display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));">
<div style="padding:12px 14px;border-radius:12px;background:#f8fbff;border:1px solid #d7e3f1;">
<p style="margin:0;font-size:12px;color:#5b6b88;">目标院校/方案</p>
<p style="margin:4px 0 0;font-size:16px;font-weight:600;color:#172033;">{summary}</p>
</div>
<div style="padding:12px 14px;border-radius:12px;background:#f8fbff;border:1px solid #d7e3f1;">
<p style="margin:0;font-size:12px;color:#5b6b88;">考试省份</p>
<p style="margin:4px 0 0;font-size:16px;font-weight:600;color:#172033;">{province}</p>
</div>
<div style="padding:12px 14px;border-radius:12px;background:#f8fbff;border:1px solid #d7e3f1;">
<p style="margin:0;font-size:12px;color:#5b6b88;">高考分数</p>
<p style="margin:4px 0 0;font-size:16px;font-weight:600;color:#172033;">{score}</p>
</div>
<div style="padding:12px 14px;border-radius:12px;background:#f8fbff;border:1px solid #d7e3f1;">
<p style="margin:0;font-size:12px;color:#5b6b88;">选科组合</p>
<p style="margin:4px 0 0;font-size:16px;font-weight:600;color:#172033;">{subjects}</p>
</div>
<div style="padding:12px 14px;border-radius:12px;background:#f8fbff;border:1px solid #d7e3f1;">
<p style="margin:0;font-size:12px;color:#5b6b88;">位次</p>
<p style="margin:4px 0 0;font-size:16px;font-weight:600;color:#172033;">{rank}</p>
</div>
</div>
{info_complete_html}
</section>
<section class="panel">
<h2>初步评估结果</h2>
<div style="margin:16px 0;padding:18px 20px;border-radius:14px;background:{risk_bg};border:1px solid {risk_border};">
<p style="margin:0;font-size:20px;font-weight:700;color:{risk_color};">风险等级:{risk_label}</p>
<p style="margin:8px 0 0;font-size:14px;color:#5b6b88;">当前建议:{escape(recommended_label)}</p>
</div>
<h3>核心问题</h3>
<ul>{findings_html}</ul>
<p class="meta" style=\"margin-top:8px;\">风险等级说明:低 = 当前方案结构基本合理,微调即可;中 = 存在踩线或扎堆风险,需要进一步判断;高 = 存在明显梯度失衡或结构风险,建议尽快调整。</p>
</section>
<section class="panel">
<h2>下一步建议</h2>
<p class="meta">{escape(primary_action[2])}</p>
<div class="actions">
<a class="btn btn-primary" href="{primary_href}">{escape(primary_action[0])}</a>
{cwb_btn}
<a class="btn btn-secondary" href="/pricing">进入完整规划(付费)</a>
</div>
<p class="meta" style="margin-top:10px;">免费复核帮你判断风险方向;完整规划和深度辅导在支付后启动,会给你逐志愿解析、冲稳保梯度表和交付报告。</p>
</section>
"""
return _render_placeholder_shell(
title="复核结果", max_width=960, body_html=body_html
)
def _apply_review_followup_action(
token: str, action: Literal["cwb", "step1", "full_plan"], settings: Settings
) -> ReviewActionResponse:
order = _resolve_order_from_token(token, settings)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order.id)
if current is None:
raise HTTPException(status_code=404, detail="review result not found")
payload = dict(current.payload)
review_id = payload.get("latest_review_result_id")
review_map = dict(payload.get("review_results") or {})
contract_raw = review_map.get(review_id) if review_id else None
if not isinstance(contract_raw, dict):
raise HTTPException(status_code=404, detail="review result not found")
contract = ReviewResultContract.model_validate(contract_raw)
updated = contract.model_copy(update={"review_followup_action": action})
review_map[updated.review_result_id] = updated.model_dump()
payload["review_results"] = review_map
intake_store.save(
order_id=order.id,
payload=payload,
submit=(current.status == "submitted"),
)
finally:
intake_store.close()
next_href = {
"cwb": f"/portal/{token}/cwb",
"step1": f"/portal/{token}/info",
"full_plan": f"/portal/{token}/full-plan",
}[action]
return ReviewActionResponse(
review_result_id=updated.review_result_id,
review_followup_action=action,
next_href=next_href,
)
def _load_latest_review_result(
order_id: str, settings: Settings
) -> ReviewResultContract | None:
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order_id)
finally:
intake_store.close()
if current is None:
return None
review_id = current.payload.get("latest_review_result_id")
review_map = dict(current.payload.get("review_results") or {})
contract_raw = review_map.get(review_id) if review_id else None
if not isinstance(contract_raw, dict):
return None
return ReviewResultContract.model_validate(contract_raw)
def _render_cwb_placeholder_page(
token: str,
order: Order,
contract: ReviewResultContract | None,
context: dict[str, Any],
) -> str:
payload = (
contract.model_dump()
if contract is not None
else {"review_followup_action": "cwb"}
)
intake_payload = _current_intake_payload(context)
policy_href, same_score_href = _helper_entry_hrefs(intake_payload, order)
auxiliary_html = _render_auxiliary_factor_section(intake_payload)
findings = contract.top_findings if contract is not None else []
findings_html = (
"".join(f"<li>{escape(str(item))}</li>" for item in findings)
or "<li>当前暂无复核摘要,请先回到复核入口。</li>"
)
recommendation = {
"go_cwb": "优先微调冲稳保梯度,先补齐冲刺 / 稳妥 / 保底三档,再决定是否进入完整规划。",
"go_step1": "先补齐 Step 1 的省份 / 分数 / 位次 / 选科,再回到冲稳保判断。",
"go_full_plan": "当前资料已可进入完整规划,建议继续补学校、专业与家庭约束。",
}.get(
(contract.recommended_action if contract is not None else "go_step1"),
"先补齐关键信息再继续。",
)
# 基于考生分数和 crowd_db 生成三档真实建议
candidate_score_raw = intake_payload.get("candidate_score") or order.candidate_score
candidate_province = (
intake_payload.get("candidate_province") or order.candidate_province or "湖南"
)
candidate_score = None
try:
candidate_score = int(candidate_score_raw) if candidate_score_raw else None
except (ValueError, TypeError):
pass
def _cwb_tier(title: str, offset: int, color: str) -> str:
"""根据分数偏移生成一档建议,优先使用 LLM 建议fallback 到 crowd_db。"""
# 1. 优先使用 LLM 生成的三档建议
tier_key = {"冲刺建议": "rush", "稳妥建议": "stable", "保底建议": "safety"}.get(
title
)
if contract is not None and contract.llm_cwb_suggestions and tier_key:
llm_suggestions = contract.llm_cwb_suggestions.get(tier_key) or []
if llm_suggestions:
schools_html = "".join(
f"<li>{escape(s)}</li>" for s in llm_suggestions[:3]
)
return (
f'<article style="padding:16px;border-radius:14px;background:{color};border:1px solid #d7e3f1;">'
f"<h2>{title}</h2>"
f'<ul style="margin:8px 0;padding-left:18px;line-height:1.8;">{schools_html}</ul>'
f'<p class="meta" style="margin-top:6px;color:#1f6feb;">🤖 LLM 结合你的分数、位次和同分段数据生成</p>'
f"</article>"
)
# 2. fallback 到 crowd_db
if candidate_score is None:
return (
f'<article style="padding:16px;border-radius:14px;background:{color};border:1px solid #d7e3f1;">'
f"<h2>{title}</h2>"
f'<p class="meta">补齐当前分数后,这里会基于同分段数据给出具体的院校方向建议。</p>'
f"</article>"
)
target_score = candidate_score + offset
try:
from data.crowd_db.loader import CrowdDBLoader
loader = CrowdDBLoader(warn_low_confidence=False)
recs = loader.find_recommendations(candidate_province, target_score)
schools = [
f"{r.get('name', '?')} - {r.get('major', '?')}"
for r in recs[:3]
if isinstance(r, dict)
]
if not schools:
schools = ["该分段暂无 crowd_db 推荐,请参考同分段参考页"]
except Exception:
schools = ["数据加载中,请稍后查看"]
risk_note = ""
if offset > 0:
risk_note = f'<p class="meta" style="color:#b42318;margin-top:6px;">⚠ 这档分数要求更高(目标分 ≈ {target_score}),存在不被录取的风险,只作为冲刺方向参考。</p>'
elif offset < 0:
risk_note = f'<p class="meta" style="color:#1f7a4d;margin-top:6px;">✓ 这档分数要求更低(目标分 ≈ {target_score}),录取概率更高,作为保底。</p>'
else:
risk_note = f'<p class="meta" style="color:#1f6feb;margin-top:6px;">→ 围绕当前分数({candidate_score})匹配,作为主力选择。</p>'
schools_html = "".join(f"<li>{escape(s)}</li>" for s in schools)
return (
f'<article style="padding:16px;border-radius:14px;background:{color};border:1px solid #d7e3f1;">'
f"<h2>{title}</h2>"
f'<ul style="margin:8px 0;padding-left:18px;line-height:1.8;">{schools_html}</ul>'
f"{risk_note}"
f"</article>"
)
cwb_tier_html = (
f'<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;">'
f"{_cwb_tier('冲刺建议', 20, '#fff5f5')}"
f"{_cwb_tier('稳妥建议', 0, '#f0f7ff')}"
f"{_cwb_tier('保底建议', -20, '#f0fff4')}"
f"</div>"
)
score_context = (
f'<p class="meta">当前分数:{escape(str(candidate_score or "待补充"))} · 省份:{escape(candidate_province)} · 冲刺+20分 / 稳妥0分 / 保底-20分</p>'
if candidate_score
else ""
)
body_html = f"""<section class="panel"><h1>冲稳保建议页</h1><p class="meta">订单号:{escape(order.id)}。基于你当前分数和同分段数据,这里给出三档策略建议。</p>{score_context}<div class="actions"><a href="{policy_href}">查看政策中心</a><a href="{same_score_href}">查看同分段参考</a></div></section><section class="panel"><h2>当前建议</h2><p class="meta">{escape(recommendation)}</p><ul>{findings_html}</ul></section><section class="panel">{cwb_tier_html}</section><section class="panel"><h2>当前复核摘要</h2><pre>{escape(json.dumps(payload, ensure_ascii=False, indent=2))}</pre></section>{auxiliary_html}<section class="panel"><h2>下一步建议</h2><ul><li><a href="/portal/{escape(token)}/full-plan">进入完整规划</a></li><li><a href="/portal/{escape(token)}/status">返回订单状态页</a></li></ul></section>"""
return _render_placeholder_shell(
title="冲稳保建议页", max_width=1080, body_html=body_html
)
def _render_full_plan_placeholder_page(
token: str,
order: Order,
context: dict[str, Any],
contract: ReviewResultContract | None,
settings: Settings | None = None,
) -> str:
payload = (
contract.model_dump()
if contract is not None
else {"review_followup_action": "full_plan"}
)
missing = "".join(context.get("profile_missing_fields") or []) or ""
intake_payload = _current_intake_payload(context)
versions = [
item
for item in list(intake_payload.get("profile_versions") or [])
if isinstance(item, dict)
]
version_lines = (
"".join(
f"<li>{escape(str(item.get('stage_label') or item.get('profile_version_id') or '未知版本'))}</li>"
for item in versions
)
or "<li>暂无版本历史</li>"
)
auxiliary_html = _render_auxiliary_factor_section(intake_payload)
# LLM 生成完整规划方案(如果配置了 LLM
llm_plan_html = ""
llm_client = LLMClient(settings) if settings else None
if llm_client and llm_client.is_configured:
try:
intake_summary = context.get("intake_summary") or {}
crowd_recs = _get_crowd_db_recs_for_review(intake_summary)
system, user = build_full_plan_prompt(
province=str(
intake_summary.get("candidate_province")
or order.candidate_province
or "湖南"
),
score=int(
intake_summary.get("candidate_score") or order.candidate_score or 0
)
if intake_summary.get("candidate_score") or order.candidate_score
else 0,
rank=int(
intake_summary.get("candidate_rank") or order.candidate_rank or 0
)
if intake_summary.get("candidate_rank") or order.candidate_rank
else None,
subjects=list(intake_summary.get("candidate_subjects") or []),
target_cities=list(intake_summary.get("target_cities") or []),
target_majors=list(intake_summary.get("target_majors") or []),
family_background=str(intake_summary.get("family_background") or ""),
interest_assessment=str(
intake_summary.get("interest_assessment_result") or ""
),
existing_plan=str(intake_summary.get("existing_plan_summary") or ""),
crowd_db_recs=crowd_recs,
)
resp = llm_client.chat_with_system(system, user, temperature=0.5)
plan_data = json.loads(resp.content)
volunteers = plan_data.get("volunteers") or []
vol_rows = []
for v in volunteers:
tier_badge = {"": "🔴", "": "🟢", "": "🔵"}.get(
v.get("tier", ""), ""
)
vol_rows.append(
f'<tr><td style="padding:8px;">{tier_badge} {escape(v.get("tier", ""))}</td>'
f'<td style="padding:8px;"><strong>{escape(v.get("school", ""))}</strong></td>'
f'<td style="padding:8px;">{escape(v.get("major", ""))}</td>'
f'<td style="padding:8px;color:#5b6b88;font-size:12px;">{escape(v.get("reason", ""))}</td>'
f"</tr>"
)
llm_plan_html = (
f'<section class="panel"><h2>🤖 AI 生成的完整志愿方案</h2>'
f'<p class="meta">{escape(plan_data.get("overall_assessment", ""))}</p>'
f'<p class="meta" style="margin-top:6px;"><strong>核心策略:</strong>{escape(plan_data.get("strategy", ""))}</p>'
f'<table style="width:100%;border-collapse:collapse;font-size:13px;margin-top:12px;">'
f'<thead><tr style="text-align:left;border-bottom:2px solid #d7e3f1;">'
f'<th style="padding:8px;">档位</th><th style="padding:8px;">院校</th>'
f'<th style="padding:8px;">专业</th><th style="padding:8px;">推荐理由</th>'
f"</tr></thead><tbody>{''.join(vol_rows)}</tbody></table>"
f"</section>"
)
warnings = plan_data.get("warnings") or []
if warnings:
warn_html = "".join(f"<li>{escape(str(w))}</li>" for w in warnings[:5])
llm_plan_html += f'<section class="panel"><h2>⚠️ 注意事项</h2><ul>{warn_html}</ul></section>'
except (LLMError, json.JSONDecodeError, TypeError, ValueError):
llm_plan_html = '<section class="panel"><h2>AI 方案生成</h2><p class="meta">AI 方案生成暂时不可用,请稍后重试或联系客服。</p></section>'
planning_summary = (
"当前资料已进入完整规划阶段,可继续围绕学校、专业、预算与家庭约束做结构化取舍。"
)
body_html = f"""<section class="panel"><h1>完整规划建议页</h1><p class="meta">订单号:{escape(order.id)}。这里承接复核后的完整规划建议,不再只是入口说明。</p><p class="meta">{escape(planning_summary)}</p><ul><li>Step 1 最小建档:{"已完成" if context.get("profile_minimum_complete") else "未完成"}</li><li>缺失字段:{escape(missing)}</li></ul></section>{llm_plan_html}<section class="panel"><h2>方案优先级</h2><ul><li>先按省份 / 分数 / 位次确认基本梯度</li><li>再按院校偏好、专业偏好与家庭约束收敛方案</li><li>最后结合已有方案与附件做增量修订</li></ul></section><section class="panel"><h2>版本历史</h2><ul>{version_lines}</ul></section>{auxiliary_html}<section class="panel"><h2>当前复核摘要</h2><pre>{escape(json.dumps(payload, ensure_ascii=False, indent=2))}</pre></section><section class="panel"><a href=\"/portal/{escape(token)}/info\">继续补充资料</a></section>"""
return _render_placeholder_shell(
title="完整规划建议页", max_width=960, body_html=body_html
)
def _render_deletion_request_page(token: str, order: Order, settings: Settings) -> str:
# 2026-06-19 T12-C: 让用户先看见"我的订单处于哪个保留期阶段", 决定是否现在就申请
retention_status = _resolve_retention_status(order, settings)
if retention_status == "outside":
retention_card = (
"<section class='panel'>"
"<span class='eyebrow'>保留期状态</span>"
"<p class='lead'><strong>已超过 180 天保留期</strong>,可申请删除订单资料、附件与交付物。"
"提交后我们会同步做人工核验,并在订单状态链路中继续反馈处理进度。</p>"
"</section>"
)
elif retention_status == "within":
retention_card = (
"<section class='panel'>"
"<span class='eyebrow'>保留期状态</span>"
"<p class='lead'>当前订单仍在 <strong>180 天保留期</strong>内。提交删除申请后,我们仍会先做人工核验,"
"并按既定保留期规则处理订单、资料与附件。</p>"
"</section>"
)
else:
retention_card = (
"<section class='panel'>"
"<span class='eyebrow'>保留期状态</span>"
"<p class='lead'>本订单当前不需要走保留期清理流程(尚未进入服务完成阶段)。"
"如需撤回资料或申请删除,提交申请后我们会先做人工核验。</p>"
"</section>"
)
return f"""<!doctype html>
<html lang='zh-CN'><head><meta charset='utf-8' /><meta name='viewport' content='width=device-width, initial-scale=1' /><title>删除申请</title><link rel='stylesheet' href='/static/portal-ui.css' /><style>body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}}.wrap{{max-width:920px;margin:0 auto;display:grid;gap:16px}}.panel{{background:#fff;border:1px solid #dbe3f0;border-radius:20px;padding:24px;box-shadow:0 18px 42px rgba(20,34,53,.08)}}.eyebrow{{display:inline-flex;padding:6px 10px;border-radius:999px;background:#fff7e6;color:#8a5a00;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}}.lead{{color:#5b6b88;line-height:1.8}}.field{{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}}input,textarea{{width:100%;padding:12px;border-radius:12px;border:1px solid #cfd7e6}}textarea{{min-height:120px;resize:vertical}}.check{{display:flex;gap:10px;align-items:flex-start;margin:12px 0}}button{{border:none;border-radius:14px;background:#1f6feb;color:#fff;font-weight:700;padding:13px 18px;cursor:pointer}}#result{{margin-top:14px;min-height:24px;color:#5b6b88;white-space:pre-wrap}}</style></head>
<body>{_render_global_nav()}<main class='wrap' role='main'>
<section class='panel'><span class='eyebrow'>删除申请</span><h1>提交删除申请</h1><p class='lead'>可用于申请删除资料、附件或交付物。提交后,系统会保留必要审计记录,并由人工核验订单与监护人信息后处理。</p></section>
{retention_card}
<section class='panel'>
<p><strong>订单号:</strong>{escape(order.id)}</p>
<form id='deletion-request-form'>
<div class='field'><label>申请人姓名</label><input name='requester_name' required /></div>
<div class='field'><label>联系方式</label><input name='requester_contact' required /></div>
<div class='field'><label>删除范围</label><input name='scope' value='order_and_attachments' required /></div>
<div class='field'><label>申请原因</label><textarea name='reason' required></textarea></div>
<label class='check'><input type='checkbox' name='confirm_guardian' /> <span>我确认监护人已知情并同意发起删除申请</span></label>
<button type='button' onclick='submitDeletionRequest()'>提交删除申请</button>
</form>
{_render_footer_links(token)}
<div id='result'></div>
</section>
<script>
async function submitDeletionRequest() {{
const form = new FormData(document.getElementById('deletion-request-form'));
const payload = {{
requester_name: form.get('requester_name') || '',
requester_contact: form.get('requester_contact') || '',
scope: form.get('scope') || '',
reason: form.get('reason') || '',
confirm_guardian: form.get('confirm_guardian') === 'on',
}};
const resultNode = document.getElementById('result');
resultNode.textContent = '正在提交删除申请…';
const resp = await fetch('/portal/{escape(token)}/deletion-request', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload),
}});
const body = await resp.json();
resultNode.textContent = resp.ok ? '删除申请已提交,后续可在订单状态链路中继续跟踪。' : JSON.stringify(body, null, 2);
}}
</script>
</main>{_render_global_toast_script()}</body></html>"""