- Landing: 主 CTA “先做快速审核” 升级为主按钮,移除多余的次按钮,添加 hero-note 与 hero-divider 让首屏节奏更稳
- Landing: hero-points 收口为 3 个差异化卖点,第 1 个加 lead 背景,右侧风险卡降饱和、避免压过主 CTA
- Landing: 删去旧“志愿方案审计更聚焦/先下单后补资料/站内可追踪交付”重复卡片
- Public: /privacy /service-terms /deletion-policy 接 portal-ui.css 并改成双段 panel + 列表结构
- Portal: /portal/{token}/notifications /portal/{token}/deletion-request 接 portal-ui.css 与新的工具条 / 表单字段化
- Admin: /admin/orders/new 接 portal-ui.css,添加“人工补录更直接/与后台订单主链一致/字段业务语义化”信任块
- Admin: /admin/notifications /admin/ops-alerts 接 portal-ui.css,添加 meta-grid 摘要并把 “payload / details” 改名为“通知内容摘要 / 详细上下文”
- Tests: 锁住 shared CSS、关键产品化文案、Portal 与 admin 页结构断言
1996 lines
108 KiB
Python
1996 lines
108 KiB
Python
"""用户端 Web 自助 MVP 公共入口页面与 Portal 路由。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
from html import escape
|
||
from pathlib import Path
|
||
from typing import Any, Literal
|
||
from urllib.parse import parse_qsl
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||
from fastapi.responses import (
|
||
FileResponse,
|
||
HTMLResponse,
|
||
PlainTextResponse,
|
||
RedirectResponse,
|
||
)
|
||
from pydantic import BaseModel
|
||
|
||
from admin.config import Settings, get_settings_dep
|
||
from data.customer_portal.token import (
|
||
PortalTokenError,
|
||
issue_portal_token,
|
||
verify_portal_token,
|
||
)
|
||
from data.notifications.email_service import DeliveryNotificationService
|
||
from data.orders import crypto
|
||
from data.orders.dao import OrderNotFound, OrdersDAO
|
||
from data.orders.intake_schema import IntakePayload
|
||
from data.orders.intake_store import IntakeStore
|
||
from data.orders.models import Order
|
||
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": ("已退款", "该订单已完成退款。"),
|
||
"payment_failed": ("支付失败", "支付未成功,请重新发起支付。"),
|
||
}
|
||
_SERVICE_PRICES = {
|
||
"audit": 4900,
|
||
"basic": 4900,
|
||
"standard": 9900,
|
||
"premium": 19900,
|
||
}
|
||
|
||
|
||
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
|
||
|
||
|
||
class PortalAttachmentUploaded(BaseModel):
|
||
order_id: str
|
||
intake_status: str
|
||
stage: str
|
||
attachments: list[dict[str, Any]]
|
||
|
||
|
||
class DeletionRequestCreate(BaseModel):
|
||
requester_name: str
|
||
requester_contact: str
|
||
reason: str
|
||
scope: str
|
||
confirm_guardian: bool
|
||
|
||
|
||
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")
|
||
|
||
|
||
@router.get("/", include_in_schema=False)
|
||
def landing_page() -> HTMLResponse:
|
||
return HTMLResponse(_render_landing_page())
|
||
|
||
|
||
@router.get("/pricing", include_in_schema=False)
|
||
def pricing_page() -> HTMLResponse:
|
||
return HTMLResponse(_render_pricing_page())
|
||
|
||
|
||
@router.get("/checkout/{service_version}", include_in_schema=False)
|
||
def checkout_page(service_version: ServiceVersion) -> HTMLResponse:
|
||
return HTMLResponse(_render_checkout_page(service_version))
|
||
|
||
|
||
@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, portal_token=portal_token)
|
||
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:
|
||
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,
|
||
token: str,
|
||
settings: Settings = Depends(get_settings_dep),
|
||
) -> HTMLResponse:
|
||
return _render_simulated_payment_page(
|
||
payment_id, token, settings, provider_slug="mock"
|
||
)
|
||
|
||
|
||
@router.post("/pay/mock/{payment_id}/complete", include_in_schema=False)
|
||
def complete_mock_payment(
|
||
payment_id: str,
|
||
token: str,
|
||
settings: Settings = Depends(get_settings_dep),
|
||
) -> RedirectResponse:
|
||
return _complete_simulated_payment(
|
||
payment_id, token, settings, provider_slug="mock"
|
||
)
|
||
|
||
|
||
@router.get("/pay/alipay-sim/{payment_id}", include_in_schema=False)
|
||
def alipay_sim_payment_page(
|
||
payment_id: str,
|
||
token: str,
|
||
settings: Settings = Depends(get_settings_dep),
|
||
) -> HTMLResponse:
|
||
return _render_simulated_payment_page(
|
||
payment_id, token, 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,
|
||
token: str,
|
||
settings: Settings = Depends(get_settings_dep),
|
||
) -> RedirectResponse:
|
||
return _complete_simulated_payment(
|
||
payment_id, token, settings, provider_slug="alipay-sim"
|
||
)
|
||
|
||
|
||
@router.get("/portal/payment-return", include_in_schema=False)
|
||
def payment_return_page(
|
||
token: str, settings: Settings = Depends(get_settings_dep)
|
||
) -> RedirectResponse:
|
||
_resolve_order_from_token(token, settings)
|
||
return RedirectResponse(url=f"/portal/{token}/status", status_code=303)
|
||
|
||
|
||
@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"]
|
||
)
|
||
)
|
||
|
||
|
||
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),
|
||
"storage_path": str(target),
|
||
"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:
|
||
record = intake_store.save(
|
||
order_id=order.id,
|
||
payload=payload.model_dump(),
|
||
submit=payload.mode == "submit",
|
||
)
|
||
finally:
|
||
intake_store.close()
|
||
|
||
updates: dict[str, Any] = {}
|
||
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,
|
||
)
|
||
|
||
|
||
@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))
|
||
|
||
|
||
@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)
|
||
return DeletionRequestCreated(
|
||
order_id=order.id,
|
||
request_logged=True,
|
||
next_step="客服将在核验后处理删除申请",
|
||
)
|
||
|
||
|
||
@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))
|
||
|
||
|
||
@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 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 _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 Path(order.audit_report).is_file())
|
||
report_pdf_ready = bool(order.pdf_path 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 not None and payment.status == "failed":
|
||
stage = "payment_failed"
|
||
elif payment is None or payment.status == "pending":
|
||
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]] = []
|
||
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_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,
|
||
}
|
||
|
||
|
||
def _render_footer_links(token: str | None = None) -> str:
|
||
privacy_href = "/privacy"
|
||
terms_href = "/service-terms"
|
||
if token:
|
||
privacy_href = f"/privacy?token={escape(token)}"
|
||
terms_href = f"/service-terms?token={escape(token)}"
|
||
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>"
|
||
)
|
||
|
||
|
||
@router.get("/privacy", include_in_schema=False)
|
||
def privacy_page(token: str | None = None) -> 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,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}.wrap{max-width:920px;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:#dff7f1;color:#0f766e;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}</style></head>"
|
||
"<body><main class='wrap'>"
|
||
"<section class='panel'><span class='eyebrow'>隐私说明</span><h1>隐私政策</h1>"
|
||
"<p class='lead'>我们只收集下单、资料填写、支付与交付所需的最小信息,用于志愿服务流程,不用于营销出售或无关用途。</p></section>"
|
||
"<section class='panel'><h2>你可以预期什么</h2><ul class='checklist'><li>支付前只收必要下单信息</li><li>详细资料在支付后通过资料向导分步补充</li><li>隐私政策、服务说明与删除申请入口全程可见</li><li>如需撤回资料或申请删除,可通过删除申请入口提交请求</li></ul>"
|
||
+ _render_footer_links(token)
|
||
+ "</section></main></body></html>"
|
||
)
|
||
return HTMLResponse(body)
|
||
|
||
|
||
@router.get("/service-terms", include_in_schema=False)
|
||
def service_terms_page(token: str | None = None) -> 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,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}.wrap{max-width:920px;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:#eef5ff;color:#1f6feb;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}</style></head>"
|
||
"<body><main class='wrap'>"
|
||
"<section class='panel'><span class='eyebrow'>服务边界</span><h1>服务说明与免责声明</h1>"
|
||
"<p class='lead'>本服务提供志愿填报辅助建议、方案审计与交付支持,不承诺录取结果;提交资料前请确认监护人与考生已知情。</p></section>"
|
||
"<section class='panel'><h2>下单前请了解</h2><ul class='checklist'><li>我们优先帮助你审计现有方案与风险点</li><li>支付后可继续补充详细资料与附件</li><li>交付状态、通知与报告入口会在站内持续更新</li><li>如需撤回资料或删除交付物,可通过删除申请入口提交请求</li></ul>"
|
||
+ _render_footer_links(token)
|
||
+ "</section></main></body></html>"
|
||
)
|
||
return HTMLResponse(body)
|
||
|
||
|
||
@router.get("/deletion-policy", include_in_schema=False)
|
||
def deletion_policy_page() -> 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,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px 20px;color:#172033;margin:0}.wrap{max-width:920px;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:#fff7e6;color:#8a5a00;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}</style></head>"
|
||
"<body><main class='wrap'>"
|
||
"<section class='panel'><span class='eyebrow'>数据删除</span><h1>删除申请 / 数据删除说明</h1>"
|
||
"<p class='lead'>如需申请删除订单资料、附件或交付物,可在支付后的 Portal 中提交删除申请;系统会保留必要的审计记录,并由人工核验后处理。</p></section>"
|
||
"<section class='panel'><h2>你可以怎么做</h2><ul class='checklist'><li>在 Portal 中提交删除申请</li><li>填写申请人姓名、联系方式、删除范围与原因</li><li>确认监护人已知情并同意发起删除申请</li><li>处理结果会回到站内状态链路中查看</li></ul>"
|
||
+ _render_footer_links()
|
||
+ "</section></main></body></html>"
|
||
)
|
||
return HTMLResponse(body)
|
||
|
||
|
||
def _render_landing_page() -> 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>高考志愿填报智能规划服务</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); }}
|
||
.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: #b8c8e4; line-height: 1.82; font-size: 17px; }}
|
||
.hero-actions {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; align-items: center; }}
|
||
.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,.08); color: #fff; border: 1px solid rgba(255,255,255,.18); 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:#8fb0df; 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:#7a5a00; }}
|
||
.hero-risk-band span {{ color:#7a5c24; 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>
|
||
<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="/pricing">先做快速审核</a>
|
||
<a class="btn btn-text" href="#service-flow">了解服务流程 →</a>
|
||
</div>
|
||
<p class="hero-note">适合已经拿到一版志愿方案、想先判断有没有明显风险的人。</p>
|
||
<div class="hero-trust">
|
||
<article class="hero-trust-item"><strong>先审计后规划</strong><span>先判断现有方案有没有明显风险,再决定要不要进入完整规划。</span></article>
|
||
<article class="hero-trust-item"><strong>风险重点可解释</strong><span>重点识别踩线、扎堆、梯度失衡和结构异常,不只给结果。</span></article>
|
||
<article class="hero-trust-item"><strong>进度站内可查</strong><span>资料、通知、报告、下载入口都能在站内持续追踪。</span></article>
|
||
<article class="hero-trust-item"><strong>隐私与删除入口可见</strong><span>隐私政策、服务说明与删除申请入口全程可访问。</span></article>
|
||
</div>
|
||
<div class="hero-divider"></div>
|
||
<div class="hero-points">
|
||
<article class="point lead"><strong>先把方案看清,再决定要不要重做</strong><span>如果你已经拿到一版方案,先做审核看是否踩线、扎堆、梯度失衡,再决定是否进入完整规划。</span></article>
|
||
<article class="point"><strong>重点可解释</strong><span>不只给一份结论,更说明为什么这样选、需要确认什么。</span></article>
|
||
<article class="point"><strong>资料、通知、报告都在站内</strong><span>支付后只需按提示补充资料,状态、通知、报告、下载入口全部站内可查。</span></article>
|
||
</div>
|
||
</div>
|
||
<aside class="hero-panel">
|
||
<div class="hero-risk-band"><strong>最常见的不是“不会选”,而是先选错方向</strong><span>我们优先帮你筛出踩线、扎堆、梯度失衡这三类最容易带来后悔成本的风险。</span></div>
|
||
<h2>我们先把现有方案看明白</h2>
|
||
<p>不让你在海量院校、专业、城市与预算之间盲目来回切换,先审计现有方案的风险与结构,再决定是否进入更完整的志愿规划。</p>
|
||
<div class="metric-list">
|
||
<div class="metric"><strong>资料采集更克制</strong><span>首屏只收真正影响下单的最小信息,详细资料支付后分步完成。</span></div>
|
||
<div class="metric"><strong>方案表达更可读</strong><span>优先解释为什么这样选,而不是堆给用户一页难消化的数据。</span></div>
|
||
<div class="metric"><strong>交付链路更透明</strong><span>资料、通知、报告、下载入口都放在同一条可追踪用户路径里。</span></div>
|
||
</div>
|
||
</aside>
|
||
</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>01</strong><h3>选择服务深度</h3><p>先判断你需要的是方案审核、完整方案,还是更重人工沟通的深度辅导。</p></article>
|
||
<article class=\"flow-step\"><strong>02</strong><h3>确认下单</h3><p>先填写考生姓名、手机号等最小信息,避免在支付前被长表单劝退。</p></article>
|
||
<article class=\"flow-step\"><strong>03</strong><h3>补充资料</h3><p>支付成功后进入资料向导,分步提交分数、位次、偏好与已有方案附件。</p></article>
|
||
<article class=\"flow-step\"><strong>04</strong><h3>查看交付</h3><p>在站内追踪状态、查看通知,并在交付就绪后在线阅读或下载 PDF。</p></article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class=\"section\">
|
||
<h2>基础信任说明</h2>
|
||
<div class=\"trust\">
|
||
<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>资料提交、通知、报告状态都会在用户可见页面持续更新。</span></article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class=\"section cta-band\">
|
||
<div>
|
||
<h2 style=\"margin:0 0 8px;font-size:28px;\">先看套餐,再决定是否立即下单</h2>
|
||
<p>如果你已经明确要做完整方案,可以直接进入 99 元方案页查看内容和下单说明。</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>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_pricing_page() -> 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>服务套餐 - 高考志愿填报智能规划服务</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; }}
|
||
@media (max-width: 980px) {{
|
||
.hero, .pricing-grid, .trust-band, .faq, .trust-proof {{ grid-template-columns: 1fr; }}
|
||
.card.recommended {{ transform: none; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class=\"wrap\">
|
||
<section class=\"hero\">
|
||
<div class=\"panel\">
|
||
<h1>服务套餐</h1>
|
||
<p class=\"lead\">先按服务深度选择适合自己的方案:如果你已经拿到其他方案,先做审核;如果希望一次拿到完整建议,优先看完整志愿方案;如果需要更多人工沟通和多轮修订,再选择深度辅导版。</p>
|
||
<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>
|
||
</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>
|
||
</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>支持在线查看报告与 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\">适合志愿范围复杂、目标城市/专业冲突较大,或需要更多人工沟通、反复修订和深度解释的家庭。</p>
|
||
<ul class=\"feature-list\">
|
||
<li>适合目标复杂或分歧较大的家庭</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>隐私政策、服务说明与删除申请入口始终保留在页面底部。</span></article>
|
||
</section>
|
||
|
||
<section class=\"faq\">
|
||
<article class=\"faq-item\"><h3>为什么推荐 99 元完整志愿方案?</h3><p>它覆盖大多数用户最关心的完整资料收集、站内进度追踪与报告交付,是当前最适合线上自助下单的标准路径。</p></article>
|
||
<article class=\"faq-item\"><h3>支付后还要补哪些信息?</h3><p>主要是分数、位次、选科、目标城市/专业、已有方案说明与附件。支付前不让用户一次填太长的表单。</p></article>
|
||
<article class=\"faq-item\"><h3>下单前需要准备什么?</h3><p>准备好考生姓名、手机号即可先完成下单;详细资料和现有方案可在支付后再补充。</p></article>
|
||
<article class=\"faq-item\"><h3>我已经有一版志愿方案怎么办?</h3><p>可以先走 49 元审核版看风险;如果已经确定要直接拿完整线上建议,再进入 99 元完整方案。</p></article>
|
||
</section>
|
||
|
||
<div class=\"notice\">如果你已经有一版志愿方案,建议先从 49 元审核版判断风险;如果希望直接进入完整线上规划,优先选择 99 元完整志愿方案。</div>
|
||
{_render_footer_links()}
|
||
</main>
|
||
</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>
|
||
<main class=\"wrap\">
|
||
<header class=\"header\">
|
||
<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>
|
||
<article class=\"form-proof-item\"><strong>隐私入口始终可见</strong><span>隐私政策、服务说明与删除申请入口在这条链路上持续可访问。</span></article>
|
||
</div>
|
||
<div class=\"trust-strip\">
|
||
<article class=\"trust-card\"><strong>步骤更短</strong><span>先支付,再补详细资料。</span></article>
|
||
<article class=\"trust-card\"><strong>进度可追踪</strong><span>资料、通知和交付状态都可站内查看。</span></article>
|
||
<article class=\"trust-card\"><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>考试省份</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>
|
||
{_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>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_mock_payment_page(payment_id: str, amount_cents: int, token: str) -> str:
|
||
return _render_simulated_payment_html(
|
||
title="支付确认页",
|
||
provider_slug="mock",
|
||
payment_id=payment_id,
|
||
amount_cents=amount_cents,
|
||
token=token,
|
||
submit_label="确认支付并返回订单页",
|
||
)
|
||
|
||
|
||
def _render_alipay_sim_payment_page(
|
||
payment_id: str, amount_cents: int, token: str
|
||
) -> str:
|
||
return _render_simulated_payment_html(
|
||
title="支付宝模拟收银台",
|
||
provider_slug="alipay-sim",
|
||
payment_id=payment_id,
|
||
amount_cents=amount_cents,
|
||
token=token,
|
||
submit_label="模拟支付宝支付成功并返回订单页",
|
||
)
|
||
|
||
|
||
def _render_simulated_payment_html(
|
||
*,
|
||
title: str,
|
||
provider_slug: str,
|
||
payment_id: str,
|
||
amount_cents: int,
|
||
token: str,
|
||
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></head>
|
||
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fb;padding:24px;\">
|
||
<main style=\"max-width:640px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
|
||
<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?token={escape(token)}\">
|
||
<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>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_simulated_payment_page(
|
||
payment_id: str,
|
||
token: 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, token)
|
||
elif provider_slug == "alipay-sim":
|
||
body = _render_alipay_sim_payment_page(payment_id, payment.amount_cents, token)
|
||
else:
|
||
raise HTTPException(
|
||
status_code=404, detail="unsupported simulated payment page"
|
||
)
|
||
return HTMLResponse(body)
|
||
|
||
|
||
def _complete_simulated_payment(
|
||
payment_id: str,
|
||
token: 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")
|
||
try:
|
||
portal_payload = verify_portal_token(token, settings.portal_token_secret)
|
||
except PortalTokenError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||
if (
|
||
str(portal_payload["order_id"]) != payment.order_id
|
||
or payment.checkout_token != token
|
||
):
|
||
raise HTTPException(status_code=403, detail="payment token mismatch")
|
||
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)
|
||
return RedirectResponse(url=f"/portal/{token}/status", status_code=303)
|
||
|
||
|
||
def _render_info_page(
|
||
order: Order, token: str, payload: dict[str, Any], stage: str
|
||
) -> str:
|
||
consent_version = str(payload.get("consent_version") or "t12-web-mvp-v1")
|
||
consent_scope = str(payload.get("consent_scope") or "web-self-service-order-intake")
|
||
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 []
|
||
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; padding: 14px 0 0; margin-top: 20px; background: linear-gradient(180deg, rgba(243,247,251,0), var(--bg) 32%); }}
|
||
.wizard-actions .actions {{ background:#fff; border:1px solid var(--border); border-radius:18px; box-shadow:var(--shadow); padding:12px; }}
|
||
.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); }}
|
||
.footer-link {{ margin-top:18px; }}
|
||
@media (max-width: 980px) {{
|
||
.hero, .field-grid, .wizard-steps {{ grid-template-columns:1fr; }}
|
||
.wizard-actions .actions {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; }}
|
||
.wizard-actions button {{ width:100%; min-height:52px; }}
|
||
#submit-step {{ grid-column: 1 / -1; }}
|
||
}}
|
||
@media (max-width: 640px) {{
|
||
.wrap {{ padding:20px 14px 88px; }}
|
||
.panel {{ padding:18px; border-radius:20px; }}
|
||
.wizard-actions {{ left: 0; right: 0; }}
|
||
.wizard-actions .actions {{ grid-template-columns:1fr; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class=\"wrap\">
|
||
<section class=\"hero\">
|
||
<section class=\"panel main-panel\">
|
||
<span class=\"eyebrow\">资料填写向导</span>
|
||
<h1>填写并提交资料</h1>
|
||
<p class=\"lead\">支付完成后,请按向导逐步补充分数、位次、偏好与已有方案信息。我们会把这份资料作为后续方案分析与交付的基础。</p>
|
||
|
||
<section class=\"wizard-head\">
|
||
<h2>四步资料向导</h2>
|
||
<p>你不需要一次性完成所有内容。可以先保存草稿,再回来继续补充;最后一步同时完成协议确认与提交。</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>
|
||
</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><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\"><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_cities\" value=\"{escape(",".join(payload.get("target_cities") or []))}\" /></div>
|
||
<div class=\"field\"><label>目标专业(逗号分隔)</label><input name=\"target_majors\" value=\"{escape(",".join(payload.get("target_majors") 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>如果你已经拿到其他平台、老师或 AI 生成的方案,可以在这里上传并说明担心点。</p>
|
||
<div class=\"field\"><label>已有方案说明</label><textarea name=\"existing_plan_summary\">{escape(str(payload.get("existing_plan_summary") or ""))}</textarea></div>
|
||
<div class=\"field\"><label>备注</label><textarea name=\"guardian_notes\">{escape(str(payload.get("guardian_notes") 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>
|
||
|
||
<section class=\"step-panel\" data-step=\"4\" style=\"display:none;\">
|
||
<h3>确认并提交</h3>
|
||
<p>请确认监护人已知情并同意将资料用于志愿填报服务,并在下方快速复核关键信息后提交。</p>
|
||
<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>
|
||
|
||
<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>
|
||
</form>
|
||
<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 = 4;
|
||
|
||
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);
|
||
return {{
|
||
mode,
|
||
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,
|
||
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_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').innerHTML = list.map(([label, value]) => `<div style="padding:8px 0;border-bottom:1px solid #e5edf7;"><strong>${{label}}:</strong><span>${{String(value)}}</span></div>`).join('');
|
||
}}
|
||
|
||
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();
|
||
}}
|
||
|
||
async function submitIntake(mode) {{
|
||
const payload = collectPayload(mode);
|
||
const resultNode = document.getElementById('result');
|
||
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) {{
|
||
resultNode.textContent = body.detail || body.message || '提交失败,请检查资料后重试。';
|
||
return;
|
||
}}
|
||
resultNode.textContent = mode === 'draft' ? '草稿已保存,可稍后继续补充。' : '资料提交成功,正在进入订单状态页…';
|
||
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>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_status_page(token: str, context: dict[str, Any]) -> str:
|
||
order = context["order"]
|
||
report_links = ""
|
||
can_access_delivery = context["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:
|
||
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; }}
|
||
.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>
|
||
<main class=\"wrap\">
|
||
<section class=\"hero\">
|
||
<section class=\"panel\">
|
||
<span class=\"eyebrow\">订单进度总览</span>
|
||
<h1>{escape(context['stage_title'])}</h1>
|
||
<p class=\"lead\">{escape(context['stage_subtitle'])}</p>
|
||
<span class=\"stage-pill\">当前阶段:{escape(context['stage'])}</span>
|
||
<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>
|
||
</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}
|
||
{delivery_html}
|
||
{station_notice_html}
|
||
<section class=\"panel\">
|
||
<h2>下一步操作</h2>
|
||
<ul class=\"action-list\">
|
||
<li><a href=\"/portal/{escape(token)}/info\">填写 / 更新资料</a></li>
|
||
<li><a href=\"/portal/{escape(token)}/notifications\">查看通知审计</a></li>
|
||
{report_links}
|
||
</ul>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
</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>
|
||
<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>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_report_page(order: Order) -> str:
|
||
if order.audit_report 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 content
|
||
if path.suffix.lower() == ".json":
|
||
pretty = json.dumps(json.loads(content), ensure_ascii=False, indent=2)
|
||
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(pretty)}</pre></body></html>"
|
||
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
|
||
if order.plan_file and Path(order.plan_file).is_file():
|
||
content = Path(order.plan_file).read_text(encoding="utf-8")
|
||
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
|
||
raise HTTPException(status_code=404, detail="report not found")
|
||
|
||
|
||
def _render_deletion_request_page(token: str, order: Order) -> 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>删除申请</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><main class='wrap'>
|
||
<section class='panel'><span class='eyebrow'>删除申请</span><h1>提交删除申请</h1><p class='lead'>可用于申请删除资料、附件或交付物。提交后,系统会保留必要审计记录,并由人工核验订单与监护人信息后处理。</p></section>
|
||
<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></body></html>"""
|