优化了部分支付宝界面

This commit is contained in:
yml2213
2026-08-12 19:12:22 +08:00
parent 1a20c367db
commit 5074faf9f6
13 changed files with 980 additions and 117 deletions
+4
View File
@@ -138,6 +138,10 @@ mkdir -p "$YYB_WORKER_DATA_DIR"
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
start_yyb_worker() { start_yyb_worker() {
if curl -fsS "${DEV_YYB_WORKER_URL}/health" >/dev/null 2>&1; then
echo "本地 YYB Worker 已运行: ${DEV_YYB_WORKER_URL}(复用现有进程,如需重启请先停掉该进程)"
return
fi
if [ ! -d "$YYB_WORKER_NODE_MODULES/jsdom" ]; then if [ ! -d "$YYB_WORKER_NODE_MODULES/jsdom" ]; then
echo "正在安装本地 YYB Worker Node 依赖..." echo "正在安装本地 YYB Worker Node 依赖..."
(cd "$ROOT_DIR/services/yyb-worker" && npm ci) (cd "$ROOT_DIR/services/yyb-worker" && npm ci)
@@ -212,6 +212,80 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], document: dict) -> None:
"""Persist the order identifiers and pre-payment baseline needed by a later check.
``check`` only re-reads this file and never touches web_save, so re-running a
check is side-effect free.
"""
from pyvm.order_status import order_ids
record = {
"order_identifiers": {
"token_id": order.get("token_id", ""),
"transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
},
"baseline_order_states": baseline,
"baseline_order_ids": sorted(order_ids(document)),
"qr_created_at": int(time.time()),
}
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None:
"""Match a finished order by the identifiers recorded at QR creation time.
The official order list may not carry token_id/out_trade_no, so no fallback
to "any newly completed order" is allowed: an unrelated order finishing on
the same account must not be reported as this payment.
"""
from pyvm.order_status import is_finished
for item in listed:
if is_finished(item) and any(
identifiers.get(key) and str(item.get(key, "")) == str(identifiers[key])
for key in identifiers
):
return item
return None
def cmd_check_only(session_path: Path, out_dir: Path) -> int:
"""Read-only payment completion check bound to a previously created QR.
Only an order identifier recorded at QR creation time counts as a match;
a fallback to "any newly completed order" is intentionally NOT used because
the order list cannot be proven to carry our token/out_trade_no fields,
and another order completing on the same account could be misjudged.
Exit codes: 0 = confirmed, 1 = not confirmed yet, 2 = check failed.
"""
from pyvm.order_status import (
completion_summary,
get_official_orders,
is_finished,
)
session = load_json(session_path)
cookies = dict(session.get("cookies", {}))
if not cookies:
raise ValueError("会话 cookies 为空,无法检测到账")
meta = load_json(out_dir / "payment-meta.json")
baseline = meta.get("baseline_order_states", {})
print("[jsdom-pay:check] 拉取官方订单列表...")
document = get_official_orders(cookies)
listed = [item for item in document.get("list", []) if isinstance(item, dict)]
matched = _match_finished_by_identifiers(listed, meta.get("order_identifiers", {}))
status_path = out_dir / "payment-status.json"
save_payment_status(status_path, document, baseline, matched)
if matched:
print(f"[jsdom-pay:check] 已按订单标识确认到账: {completion_summary(matched)}")
return 0
print("[jsdom-pay:check] 未匹配到本次订单标识,保持未确认状态,请人工核对")
return 1
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码") parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json")) parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
@@ -228,12 +302,20 @@ def main() -> int:
parser.add_argument("--payment-interval", type=float, default=3, parser.add_argument("--payment-interval", type=float, default=3,
help="订单完成状态检查间隔秒数") help="订单完成状态检查间隔秒数")
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成") parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成")
parser.add_argument("--check-only", action="store_true",
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单")
args = parser.parse_args() args = parser.parse_args()
if args.payment_timeout <= 0 or args.payment_interval <= 0: if args.payment_timeout <= 0 or args.payment_interval <= 0:
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数") raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
session_path = Path(args.session).resolve() session_path = Path(args.session).resolve()
response_path = Path(args.mall_response).resolve() response_path = Path(args.mall_response).resolve()
stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
if args.check_only:
out_dir.mkdir(parents=True, exist_ok=True)
return cmd_check_only(session_path, out_dir)
session = load_json(session_path) session = load_json(session_path)
cookies = dict(session.get("cookies", {})) cookies = dict(session.get("cookies", {}))
if not cookies: if not cookies:
@@ -312,6 +394,17 @@ def main() -> int:
order, cookies, web_token, anti_token, encrypt_msg, order, cookies, web_token, anti_token, encrypt_msg,
args.zone_id, payment_pf, args.amount_fen, args.zone_id, payment_pf, args.amount_fen,
) )
from pyvm.order_status import order_completion_states, get_official_orders
# 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。
baseline_document = None
baseline_states = None
try:
baseline_document = get_official_orders(cookies)
baseline_states = order_completion_states(baseline_document)
print("[jsdom-pay] 支付前订单基线已建立")
except Exception as exc: # noqa: BLE001
print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试")
print("[jsdom-pay] 提交 web_save...") print("[jsdom-pay] 提交 web_save...")
raw = request_bytes(SAVE_URL, cookies, body.encode()) raw = request_bytes(SAVE_URL, cookies, body.encode())
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8") (out_dir / "web-save-response.json").write_text(raw, encoding="utf-8")
@@ -331,23 +424,36 @@ def main() -> int:
make_qr(sign, qr_path) make_qr(sign, qr_path)
print("[jsdom-pay] 微信付款码已生成") print("[jsdom-pay] 微信付款码已生成")
print(f"[jsdom-pay] PNG: {qr_path}") print(f"[jsdom-pay] PNG: {qr_path}")
print(f"[jsdom-pay] 付款链接: {sign}")
if baseline_document is None:
try:
baseline_document = get_official_orders(cookies)
baseline_states = order_completion_states(baseline_document)
except Exception as exc: # noqa: BLE001
# 付款码已生成,基线缺失只影响确认精度(仅按订单标识确认),不能阻断付款。
print(f"[jsdom-pay] 基线获取失败: {exc};将仅按订单标识确认到账")
baseline_document = {"list": []}
baseline_states = {}
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document)
if args.skip_payment_check: if args.skip_payment_check:
print("[jsdom-pay] 已跳过付款结果检查。") print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
return 0 return 0
from pyvm.order_status import find_completed_order, get_official_orders, order_completion_states
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...") print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
baseline_document = get_official_orders(cookies)
baseline_states = order_completion_states(baseline_document)
status_path = out_dir / "payment-status.json" status_path = out_dir / "payment-status.json"
save_payment_status(status_path, baseline_document, baseline_states) save_payment_status(status_path, baseline_document, baseline_states)
identifiers = {key: value for key, value in {
"token_id": order.get("token_id", ""),
"transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
}.items() if value}
deadline = time.monotonic() + args.payment_timeout deadline = time.monotonic() + args.payment_timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
time.sleep(args.payment_interval) time.sleep(args.payment_interval)
document = get_official_orders(cookies) document = get_official_orders(cookies)
completed = find_completed_order(document, baseline_states) completed = _match_finished_by_identifiers(
[item for item in document.get("list", []) if isinstance(item, dict)], identifiers)
save_payment_status(status_path, document, baseline_states, completed) save_payment_status(status_path, document, baseline_states, completed)
if completed: if completed:
print("[jsdom-pay] 微信付款成功,商城订单已完成。") print("[jsdom-pay] 微信付款成功,商城订单已完成。")
@@ -175,13 +175,16 @@ def main() -> int:
poll_query["last"] = last poll_query["last"] = last
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url) poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
errcode, code = parse_poll(poll.text) errcode, code = parse_poll(poll.text)
if errcode == 405: if errcode == 405 and code:
break break
if errcode == 402: if errcode == 405:
# 已扫码、手机待确认:继续轮询,不能立即退出(否则 code 为空导致登录失败)。
last = "405"
elif errcode == 402:
raise RuntimeError("二维码已过期,请重新执行登录") raise RuntimeError("二维码已过期,请重新执行登录")
if errcode == 403: elif errcode == 403:
raise RuntimeError("用户取消了扫码登录") raise RuntimeError("用户取消了扫码登录")
if errcode == 404: else:
last = "404" last = "404"
time.sleep(args.interval) time.sleep(args.interval)
else: else:
+255 -33
View File
@@ -55,10 +55,17 @@ def _safe_log(job: dict, line: str) -> None:
job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:] job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:]
def _run_process(job_id: str, command: list[str], phase: str) -> None: def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int:
"""Run one stage process and return its exit code.
When ``mark_success`` is False the caller owns the post-success state
transition (used by the staged payment flow).
"""
job = _jobs[job_id] job = _jobs[job_id]
environment = os.environ.copy() environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None) environment.pop("NODE_OPTIONS", None)
# 子进程输出经管道实时转存日志;关闭块缓冲避免日志滞后。
environment["PYTHONUNBUFFERED"] = "1"
with _lock: with _lock:
job["phase"] = phase job["phase"] = phase
job["status"] = "running" job["status"] = "running"
@@ -75,21 +82,27 @@ def _run_process(job_id: str, command: list[str], phase: str) -> None:
with _lock: with _lock:
job["process_pid"] = None job["process_pid"] = None
if code != 0: if code != 0:
job["status"] = "failed" if job.get("status") == "stopped":
job["phase"] = phase # 停止请求已 kill 本进程:保留停止语义,不被阶段失败覆盖。
job["message"] = f"{phase}失败(退出码 {code}" job["message"] = "任务已停止"
else:
job["status"] = "failed"
job["phase"] = phase
job["message"] = f"{phase}失败(退出码 {code}"
elif phase == "login": elif phase == "login":
job["status"] = "ready" job["status"] = "ready"
job["phase"] = "selection" job["phase"] = "selection"
job["message"] = "登录成功,请选择平台、点券、区服和角色" job["message"] = "登录成功,请选择平台、点券、区服和角色"
else: elif mark_success:
job["status"] = "success" job["status"] = "success"
job["phase"] = "completed" job["phase"] = "completed"
job["message"] = "付款流程已完成" job["message"] = "付款流程已完成"
return code
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
with _lock: with _lock:
job["status"] = "failed" job["status"] = "failed"
job["message"] = str(exc) job["message"] = str(exc)
return 1
def _start_login(job_id: str, provider: str, timeout: int) -> None: def _start_login(job_id: str, provider: str, timeout: int) -> None:
@@ -131,47 +144,158 @@ def _selection_options(job_id: str, platform: str, points: int | None, zone_id:
"default_product": product, "default_zone": selected_zone} "default_product": product, "default_zone": selected_zone}
def _start_payment(job_id: str, selection: dict) -> None: def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str,
failed_message: str) -> bool:
"""Run one payment stage; return True on success without touching final state."""
job = _jobs[job_id]
with _lock:
if job.get("status") == "stopped":
return False
job["status"] = "running"
job["phase"] = phase
job["message"] = running_message
code = _run_process(job_id, command, phase, mark_success=False)
if code != 0:
with _lock:
if job.get("status") == "stopped":
return False
job["status"] = "failed"
job["phase"] = phase
job["message"] = f"{failed_message}(退出码 {code}"
return False
return True
PAYMENT_CHECK_INTERVAL = 3
PAYMENT_CHECK_TIMEOUT = 300
def _check_payment_once(job_id: str) -> int:
"""Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed."""
job = _jobs[job_id]
directory = _job_dir(job_id)
command = [sys.executable, "scripts/jsdom-pay.py", "--check-only",
"--session", str(directory / "mall-session.json"),
"--out-dir", str(directory / "jsdom-order")]
environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None)
try:
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
env=environment, timeout=90)
except subprocess.TimeoutExpired:
with _lock:
job["payment_last_checked_at"] = int(time.time())
return 2
for line in (result.stdout or "").splitlines():
_safe_log(job, line)
for line in (result.stderr or "").splitlines():
_safe_log(job, line)
with _lock:
job["payment_last_checked_at"] = int(time.time())
return result.returncode
def _monitor_payment(job_id: str) -> None:
"""Background loop that re-checks completion until timeout or confirmation."""
job = _jobs[job_id]
deadline = time.monotonic() + PAYMENT_CHECK_TIMEOUT
while time.monotonic() < deadline:
time.sleep(PAYMENT_CHECK_INTERVAL)
with _lock:
if job.get("status") in {"stopped", "success"}:
return
code = _check_payment_once(job_id)
if code == 0:
with _lock:
job["status"] = "success"
job["phase"] = "completed"
job["message"] = "已确认到账,充值完成"
return
if code == 2:
with _lock:
job["message"] = "到账检测暂时失败,将继续重试"
with _lock:
job["status"] = "payment_timeout"
job["phase"] = "payment"
job["message"] = "付款码已生成,但未在时限内确认到账,请人工核对"
def _payment_flow(job_id: str, selection: dict) -> None:
"""Staged payment: create mall order -> create payment QR -> monitor arrival.
The QR is generated before any waiting, so the UI switches to
waiting_payment immediately; only a timeout moves to payment_timeout.
"""
job = _jobs[job_id]
directory = _job_dir(job_id) directory = _job_dir(job_id)
session = directory / "mall-session.json" session = directory / "mall-session.json"
response = directory / "mall-order-response.json" response = directory / "mall-order-response.json"
output = directory / "jsdom-order" output = directory / "jsdom-order"
command = [sys.executable, "main.py", "mall", "auto", "--session", str(session), with _lock:
"--order-template", str(ROOT / "config" / "mall-order-template.json"), job["status"] = "ordering"
"--quantity", "1", "--product-id", str(selection["product_id"]), job["phase"] = "payment"
"--offer-id", str(selection["offer_id"]), job["message"] = "正在创建商城订单"
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]), order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session),
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]), "--order-template", str(ROOT / "config" / "mall-order-template.json"),
"--output", str(response)] "--quantity", "1", "--product-id", str(selection["product_id"]),
"--offer-id", str(selection["offer_id"]),
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]),
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]),
"--output", str(response)]
if selection.get("order_pf"): if selection.get("order_pf"):
command.extend(["--pf", str(selection["order_pf"])]) order_cmd.extend(["--pf", str(selection["order_pf"])])
def run() -> None: if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"):
_run_process(job_id, command, "order") return
job = _jobs[job_id] pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
if job.get("status") != "success" or not response.exists(): "--mall-response", str(response), "--out-dir", str(output),
"--zone-id", str(selection["zone_id"]),
"--pf", str(selection.get("order_pf", "")),
"--amount-fen", str(selection["price_fen"]),
"--skip-payment-check"]
if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"):
return
meta_path = output / "payment-meta.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
meta = {}
with _lock:
job["status"] = "waiting_payment"
job["phase"] = "payment"
job["message"] = "付款码已生成,请扫码付款"
job["payment_qr_created_at"] = meta.get("qr_created_at", int(time.time()))
_monitor_payment(job_id)
def _start_payment(job_id: str, selection: dict) -> None:
with _lock:
if _jobs[job_id].get("status") == "stopped":
return return
pay_command = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session), _jobs[job_id]["status"] = "ordering"
"--mall-response", str(response), "--out-dir", str(output), _jobs[job_id]["phase"] = "payment"
"--zone-id", str(selection["zone_id"]), _jobs[job_id]["message"] = "正在创建商城订单"
"--pf", str(selection.get("order_pf", "")), threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start()
"--amount-fen", str(selection["price_fen"])]
_run_process(job_id, pay_command, "payment")
threading.Thread(target=run, daemon=True).start()
def _stop_job(job_id: str) -> None: def _stop_job(job_id: str) -> None:
if job_id not in _jobs: if job_id not in _jobs:
raise ValueError("任务不存在") raise ValueError("任务不存在")
pid = _jobs[job_id].get("process_pid") job = _jobs[job_id]
pid = job.get("process_pid")
if pid: if pid:
try: try:
os.kill(int(pid), 15) os.kill(int(pid), 15)
except ProcessLookupError: except ProcessLookupError:
pass pass
with _lock: with _lock:
_jobs[job_id]["status"] = "failed" if job.get("status") == "waiting_payment":
_jobs[job_id]["phase"] = "stopped" job["status"] = "stopped"
_jobs[job_id]["message"] = "任务已停止" job["phase"] = "payment"
job["message"] = "已放弃到账追踪;商城订单仍可能完成,请人工核对"
else:
job["status"] = "stopped"
job["phase"] = "stopped"
job["message"] = "任务已停止"
def _public_job(job_id: str) -> dict: def _public_job(job_id: str) -> dict:
@@ -206,6 +330,9 @@ def _public_job(job_id: str) -> dict:
} }
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
for key in ("payment_qr_created_at", "payment_last_checked_at"):
if job.get(key) is not None:
result[key] = job[key]
return result return result
@@ -289,13 +416,44 @@ class Handler(BaseHTTPRequestHandler):
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"], "order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
} }
_jobs[job_id]["phase"] = "payment" _jobs[job_id]["phase"] = "payment"
return self._json(200, _public_job(job_id)) result = _public_job(job_id)
result["selection"] = _jobs[job_id]["selection"]
return self._json(200, result)
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment": if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment":
job_id = path[2] job_id = path[2]
if job_id not in _jobs or not _jobs[job_id].get("selection"): if job_id not in _jobs:
return self._json(400, {"detail": "任务不存在"})
job = _jobs[job_id]
with _lock:
if job.get("status") == "stopped":
return self._json(400, {"detail": "任务已停止,不能生成付款码"})
if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \
or job.get("process_pid"):
return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"})
if not job.get("selection"):
return self._json(400, {"detail": "请先完成角色选择"}) return self._json(400, {"detail": "请先完成角色选择"})
_start_payment(job_id, _jobs[job_id]["selection"]) _start_payment(job_id, job["selection"])
return self._json(202, _public_job(job_id)) return self._json(202, _public_job(job_id))
if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]:
job_id = path[2]
if job_id not in _jobs:
return self._json(400, {"detail": "任务不存在"})
job = _jobs[job_id]
with _lock:
if job.get("status") not in {"waiting_payment", "payment_timeout"}:
return self._json(400, {"detail": "当前任务状态不支持检测到账"})
code = _check_payment_once(job_id)
with _lock:
if code == 0:
job["status"] = "success"
job["phase"] = "completed"
job["message"] = "已确认到账,充值完成"
elif code == 1:
# 保持 payment_timeout:检测由人工反复触发,避免无人监控的 waiting_payment。
job["message"] = "仍未确认到账,请继续人工核对"
else:
job["message"] = "到账检测暂时失败,请稍后重试"
return self._json(200, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop": if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop":
_stop_job(path[2]) _stop_job(path[2])
return self._json(200, _public_job(path[2])) return self._json(200, _public_job(path[2]))
@@ -317,6 +475,66 @@ class Handler(BaseHTTPRequestHandler):
return return
def _restore_jobs(data_dir: Path) -> int:
"""Rebuild in-memory jobs from task directories after a restart.
Only payment-phase jobs with a persisted QR baseline are restored so that
the payment QR stays visible and the read-only check can be re-run.
"""
restored = 0
for directory in data_dir.iterdir():
if not directory.is_dir():
continue
job_id = directory.name
if job_id in _jobs:
continue
session = directory / "mall-session.json"
output = directory / "jsdom-order"
meta_path = output / "payment-meta.json"
if not session.exists():
continue
if not meta_path.exists():
_jobs[job_id] = {
"job_id": job_id, "status": "ready", "phase": "selection",
"logs": ["服务重启,已从任务目录恢复登录会话"],
"directory": str(directory),
"created_at": int(time.time()),
"message": "已恢复:登录会话有效,请选择充值信息",
}
restored += 1
continue
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
status = "waiting_payment"
phase = "payment"
message = "已恢复:付款码已生成,可重新检测到账"
last_checked = None
status_path = output / "payment-status.json"
if status_path.exists():
try:
record = json.loads(status_path.read_text(encoding="utf-8"))
last_checked = record.get("checked_at")
if record.get("matched_completion"):
status = "success"
phase = "completed"
message = "已恢复:已确认到账"
except (OSError, json.JSONDecodeError):
pass
_jobs[job_id] = {
"job_id": job_id, "status": status, "phase": phase,
"logs": ["服务重启,已从任务目录恢复本任务"],
"directory": str(directory),
"created_at": int(time.time()),
"message": message,
"payment_qr_created_at": meta.get("qr_created_at"),
"payment_last_checked_at": last_checked,
}
restored += 1
return restored
def main() -> int: def main() -> int:
global DEFAULT_DATA, WORKER_KEY global DEFAULT_DATA, WORKER_KEY
parser = argparse.ArgumentParser(description="YYB admin worker") parser = argparse.ArgumentParser(description="YYB admin worker")
@@ -325,12 +543,16 @@ def main() -> int:
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA) parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY") parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
args = parser.parse_args() args = parser.parse_args()
DEFAULT_DATA = args.data_dir # 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
DEFAULT_DATA = Path(args.data_dir).resolve()
if args.key is not None: if args.key is not None:
WORKER_KEY = args.key WORKER_KEY = args.key
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY: if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY") parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
DEFAULT_DATA.mkdir(parents=True, exist_ok=True) DEFAULT_DATA.mkdir(parents=True, exist_ok=True)
restored = _restore_jobs(DEFAULT_DATA)
if restored:
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
server = ThreadingHTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"YYB worker listening on {args.host}:{args.port}", flush=True) print(f"YYB worker listening on {args.host}:{args.port}", flush=True)
server.serve_forever() server.serve_forever()
@@ -0,0 +1,26 @@
"""应用宝支付元数据字段:金额与支付时间线"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260812_0022"
down_revision: Union[str, None] = "20260812_0021"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True))
op.add_column("yyb_recharge_tasks", sa.Column("payment_started_at", sa.DateTime(), nullable=True))
op.add_column("yyb_recharge_tasks", sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True))
op.add_column("yyb_recharge_tasks", sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("yyb_recharge_tasks", "payment_last_checked_at")
op.drop_column("yyb_recharge_tasks", "payment_qr_created_at")
op.drop_column("yyb_recharge_tasks", "payment_started_at")
op.drop_column("yyb_recharge_tasks", "price_fen")
+4
View File
@@ -144,12 +144,16 @@ class YybRechargeTask(Base):
zone_name = Column(String(128), default="") zone_name = Column(String(128), default="")
role_id = Column(String(64), default="") role_id = Column(String(64), default="")
role_name = Column(String(128), default="") role_name = Column(String(128), default="")
price_fen = Column(Integer, nullable=True)
status = Column(String(32), default="created", nullable=False, index=True) status = Column(String(32), default="created", nullable=False, index=True)
phase = Column(String(32), default="login", nullable=False) phase = Column(String(32), default="login", nullable=False)
message = Column(String(512), default="") message = Column(String(512), default="")
result = Column(JSON, nullable=True) result = Column(JSON, nullable=True)
login_qr_data = Column(EncryptedText(), default="") login_qr_data = Column(EncryptedText(), default="")
payment_qr_data = Column(EncryptedText(), default="") payment_qr_data = Column(EncryptedText(), default="")
payment_started_at = Column(DateTime, nullable=True)
payment_qr_created_at = Column(DateTime, nullable=True)
payment_last_checked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True) finished_at = Column(DateTime, nullable=True)
+2
View File
@@ -28,6 +28,7 @@ PERMISSIONS = {
"douyu:config": "斗鱼配置管理", "douyu:config": "斗鱼配置管理",
"yyb:session": "应用宝扫码登录与角色查询", "yyb:session": "应用宝扫码登录与角色查询",
"yyb:recharge": "应用宝充值与付款", "yyb:recharge": "应用宝充值与付款",
"yyb:manage": "接管并操作他人的应用宝任务",
"yyb:history": "查看应用宝充值历史", "yyb:history": "查看应用宝充值历史",
# 虎牙 # 虎牙
"huya:account": "虎牙账号管理(兼容旧权限)", "huya:account": "虎牙账号管理(兼容旧权限)",
@@ -67,6 +68,7 @@ ROLE_PERMISSIONS = {
"douyu:config", "douyu:config",
"yyb:session", "yyb:session",
"yyb:recharge", "yyb:recharge",
"yyb:manage",
"yyb:history", "yyb:history",
"huya:account", "huya:account",
"huya:view_all", "huya:view_all",
+94 -25
View File
@@ -12,18 +12,29 @@ from ..deps import get_current_user, require_permission
from ..models import User, YybRechargeTask from ..models import User, YybRechargeTask
from ..permissions import user_has_permission from ..permissions import user_has_permission
from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest
from ..services.yyb_service import public_task, sync_task from ..services.yyb_service import _utcnow, public_task, sync_task
from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"]) router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
def _get_task(db: Session, task_id: int, current: User) -> YybRechargeTask: def _get_task(db: Session, task_id: int, current: User, write: bool = False) -> YybRechargeTask:
"""读取任务。
查看:本人或 yyb:history;写操作:本人或 yyb:manage。
历史权限只读,不能通过本函数修改他人任务。
"""
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first() task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first()
if not task: if not task:
raise HTTPException(404, "充值任务不存在") raise HTTPException(404, "充值任务不存在")
if task.created_by != current.id and not user_has_permission(current, "yyb:history"): if task.created_by == current.id:
return task
if write:
if not user_has_permission(current, "yyb:manage"):
raise HTTPException(403, "无权操作他人的充值任务")
return task
if not user_has_permission(current, "yyb:history"):
raise HTTPException(403, "无权查看该充值任务") raise HTTPException(403, "无权查看该充值任务")
return task return task
@@ -35,6 +46,11 @@ def _worker_call(call):
raise HTTPException(502, str(exc)) from exc raise HTTPException(502, str(exc)) from exc
def _creator_username(db: Session, task: YybRechargeTask) -> str:
user = db.query(User).filter(User.id == task.created_by).first()
return user.username if user else ""
@router.post("/tasks") @router.post("/tasks")
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
data = _worker_call(YybWorkerClient().create_job) data = _worker_call(YybWorkerClient().create_job)
@@ -44,12 +60,12 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu
db.add(task) db.add(task)
db.commit() db.commit()
db.refresh(task) db.refresh(task)
return public_task(task, include_qr=False) return public_task(task, include_qr=False, creator_username=current.username)
@router.post("/tasks/{task_id}/login") @router.post("/tasks/{task_id}/login")
def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout)) data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout))
task.provider = payload.provider task.provider = payload.provider
task.status = str(data.get("status", "waiting_login")) task.status = str(data.get("status", "waiting_login"))
@@ -58,7 +74,7 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db),
if data.get("qr_data"): if data.get("qr_data"):
task.login_qr_data = data["qr_data"] task.login_qr_data = data["qr_data"]
db.commit() db.commit()
return public_task(task) return public_task(task, creator_username=_creator_username(db, task))
@router.get("/tasks/{task_id}") @router.get("/tasks/{task_id}")
@@ -70,18 +86,34 @@ def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depend
# Worker 暂时重启时仍返回最近一次持久化状态。 # Worker 暂时重启时仍返回最近一次持久化状态。
pass pass
return public_task(task, include_qr=user_has_permission(current, "yyb:session"), return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
include_payment_qr=user_has_permission(current, "yyb:recharge")) include_payment_qr=user_has_permission(current, "yyb:recharge"),
creator_username=_creator_username(db, task))
@router.get("/tasks") @router.get("/tasks")
def list_tasks(limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:history"))): def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
tasks = db.query(YybRechargeTask).order_by(YybRechargeTask.id.desc()).limit(limit).all() status: str | None = Query(None, max_length=32),
return [public_task(task, include_qr=False) for task in tasks] limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
if scope == "all" and not user_has_permission(current, "yyb:history"):
raise HTTPException(403, "无权查看全部充值任务")
query = db.query(YybRechargeTask)
if scope == "mine":
query = query.filter(YybRechargeTask.created_by == current.id)
if status:
query = query.filter(YybRechargeTask.status == status)
rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all()
usernames = {
user.id: user.username
for user in db.query(User).filter(User.id.in_({row.created_by for row in rows})).all()
}
return [public_task(task, include_qr=False, creator_username=usernames.get(task.created_by, ""))
for task in rows]
@router.get("/tasks/{task_id}/selection-options") @router.get("/tasks/{task_id}/selection-options")
def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id)) data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id))
task.platform = platform task.platform = platform
db.commit() db.commit()
@@ -90,35 +122,72 @@ def selection_options(task_id: int, platform: str = Query("android"), points: in
@router.post("/tasks/{task_id}/selection") @router.post("/tasks/{task_id}/selection")
def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())) data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()))
selected = data.get("selection", payload.model_dump()) selected = data.get("selection", payload.model_dump())
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"): for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"):
setattr(task, field, selected[field]) setattr(task, field, selected[field])
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以创建付款码" task.price_fen = int(selected.get("price_fen") or 0)
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码"
db.commit() db.commit()
return public_task(task) return public_task(task, creator_username=_creator_username(db, task))
@router.post("/tasks/{task_id}/payment") @router.post("/tasks/{task_id}/payment")
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
task = _get_task(db, task_id, current) # 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。
_get_task(db, task_id, current, write=True)
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).with_for_update().first()
if not task:
raise HTTPException(404, "充值任务不存在")
if task.status != "ready" or task.phase != "payment":
raise HTTPException(409, "当前状态不能生成付款码(仅待生成付款码状态可操作)")
if not task.product_id or not task.role_id: if not task.product_id or not task.role_id:
raise HTTPException(400, "请先完成商品、区服和角色选择") raise HTTPException(400, "请先完成商品、区服和角色选择")
data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id)) task.status = "ordering"
task.status = str(data.get("status", "running")) task.message = "正在创建商城订单"
task.phase = "payment" task.payment_started_at = _utcnow()
task.message = "正在创建订单和付款码"
db.commit() db.commit()
return public_task(task) try:
data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id))
except HTTPException:
# Worker 不可用时回滚原子迁移,避免任务卡死在“下单中”。
task.status = "ready"
task.phase = "payment"
task.message = "生成付款码失败,请稍后重试"
task.payment_started_at = None
db.commit()
raise
task.status = str(data.get("status", "ordering"))
task.phase = str(data.get("phase", "payment"))
task.message = str(data.get("message", task.message))
db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
@router.post("/tasks/{task_id}/payment/check")
def payment_check(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
task = _get_task(db, task_id, current, write=True)
if task.status not in {"waiting_payment", "payment_timeout"}:
raise HTTPException(409, "当前任务状态不支持检测到账")
data = _worker_call(lambda: YybWorkerClient().payment_check(task.worker_job_id))
task.status = str(data.get("status", task.status))
task.phase = str(data.get("phase", task.phase))
task.message = str(data.get("message", task.message))
task.payment_last_checked_at = _utcnow()
if task.status == "success":
task.phase = "completed"
db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
@router.post("/tasks/{task_id}/stop") @router.post("/tasks/{task_id}/stop")
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id)) data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
task.status = "failed" task.status = "stopped"
task.phase = "stopped" task.phase = str(data.get("phase", "stopped"))
task.message = "任务已停止" task.message = str(data.get("message", "任务已停止"))
task.finished_at = _utcnow()
db.commit() db.commit()
return public_task(task) return public_task(task, creator_username=_creator_username(db, task))
+35 -7
View File
@@ -15,12 +15,27 @@ def _utcnow():
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _from_ts(value) -> datetime | None:
try:
return datetime.fromtimestamp(int(value), tz=timezone.utc) if value else None
except (TypeError, ValueError, OSError):
return None
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
rows = db.query(YybRechargeTask).filter(YybRechargeTask.status.in_(["created", "waiting_login", "running", "ready"])).all() rows = db.query(YybRechargeTask).filter(
YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"])
).all()
for task in rows: for task in rows:
task.status = "failed" if task.status == "ordering":
task.message = message task.status = "waiting_payment"
task.finished_at = _utcnow() task.message = "服务重启,支付流程中断,可重新检测到账"
elif task.status == "waiting_payment":
task.message = "服务重启,到账检测中断,可重新检测到账"
else:
task.status = "failed"
task.message = message
task.finished_at = _utcnow()
if rows: if rows:
db.commit() db.commit()
return len(rows) return len(rows)
@@ -44,23 +59,36 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
"logs": data.get("logs", []), "logs": data.get("logs", []),
"payment_status": data.get("payment_status"), "payment_status": data.get("payment_status"),
} }
qr_created_at = _from_ts(data.get("payment_qr_created_at"))
if qr_created_at:
task.payment_qr_created_at = qr_created_at
last_checked_at = _from_ts(data.get("payment_last_checked_at"))
if last_checked_at:
task.payment_last_checked_at = last_checked_at
if task.status in {"success", "failed"} and task.finished_at is None: if task.status in {"success", "failed"} and task.finished_at is None:
task.finished_at = _utcnow() task.finished_at = _utcnow()
if task.status not in {"success", "failed", "stopped"} and task.finished_at is not None:
task.finished_at = None
db.commit() db.commit()
db.refresh(task) db.refresh(task)
return task return task
def public_task(task: YybRechargeTask, include_qr: bool = True, def public_task(task: YybRechargeTask, include_qr: bool = True,
include_payment_qr: bool | None = None) -> dict[str, Any]: include_payment_qr: bool | None = None,
creator_username: str = "") -> dict[str, Any]:
result: dict[str, Any] = { result: dict[str, Any] = {
"id": task.id, "task_id": task.task_id, "id": task.id, "task_id": task.task_id,
"provider": task.provider, "platform": task.platform, "points": task.points, "provider": task.provider, "platform": task.platform, "points": task.points,
"price_fen": task.price_fen,
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name, "product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name,
"role_id": task.role_id, "role_name": task.role_name, "status": task.status, "role_id": task.role_id, "role_name": task.role_name, "status": task.status,
"phase": task.phase, "message": task.message, "result": task.result, "phase": task.phase, "message": task.message, "result": task.result,
"created_by": task.created_by, "created_at": task.created_at, "created_by": task.created_by, "created_by_username": creator_username,
"finished_at": task.finished_at, "created_at": task.created_at, "finished_at": task.finished_at,
"payment_started_at": task.payment_started_at,
"payment_qr_created_at": task.payment_qr_created_at,
"payment_last_checked_at": task.payment_last_checked_at,
} }
if task.result: if task.result:
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg") result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
@@ -53,5 +53,8 @@ class YybWorkerClient:
def payment(self, worker_job_id: str) -> dict[str, Any]: def payment(self, worker_job_id: str) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/payment") return self._request("POST", f"/v1/jobs/{worker_job_id}/payment")
def payment_check(self, worker_job_id: str) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/payment/check")
def stop(self, worker_job_id: str) -> dict[str, Any]: def stop(self, worker_job_id: str) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/stop") return self._request("POST", f"/v1/jobs/{worker_job_id}/stop")
+5
View File
@@ -152,9 +152,11 @@ export interface YybTask {
task_id: string; task_id: string;
worker_job_id?: string; worker_job_id?: string;
created_by: number; created_by: number;
created_by_username?: string;
provider: string; provider: string;
platform: 'android' | 'ios'; platform: 'android' | 'ios';
points: number | null; points: number | null;
price_fen?: number | null;
product_id: string; product_id: string;
zone_id: string; zone_id: string;
zone_name: string; zone_name: string;
@@ -168,6 +170,9 @@ export interface YybTask {
login_qr_mime_type?: string; login_qr_mime_type?: string;
payment_qr_data?: string; payment_qr_data?: string;
payment_qr_mime_type?: string; payment_qr_mime_type?: string;
payment_started_at?: string | null;
payment_qr_created_at?: string | null;
payment_last_checked_at?: string | null;
created_at: string | null; created_at: string | null;
finished_at: string | null; finished_at: string | null;
} }
+4 -1
View File
@@ -6,7 +6,8 @@ export const yybApi = {
login: (id: number, provider: 'qq' | 'wechat') => login: (id: number, provider: 'qq' | 'wechat') =>
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }), api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`), getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
listTasks: () => api.get<YybTask[], YybTask[]>('/yyb/tasks'), listTasks: (params?: { scope?: 'mine' | 'all'; status?: string }) =>
api.get<YybTask[], YybTask[]>('/yyb/tasks', { params }),
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) => options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, { api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) }, params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) },
@@ -14,4 +15,6 @@ export const yybApi = {
select: (id: number, data: { platform: 'android' | 'ios'; points: number; product_id: string; zone_id: string; zone_name: string; role_id: string; role_name: string }) => select: (id: number, data: { platform: 'android' | 'ios'; points: number; product_id: string; zone_id: string; zone_name: string; role_id: string; role_name: string }) =>
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data), api.post<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data),
payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`), payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`),
paymentCheck: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment/check`),
stop: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/stop`),
}; };
+428 -40
View File
@@ -1,6 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Card, Col, Descriptions, Image, Radio, Row, Select, Space, Steps, Tag, Typography, message } from 'antd'; import {
import { CheckCircleOutlined, QrcodeOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined } from '@ant-design/icons'; Alert, Button, Card, Col, Collapse, Descriptions, Empty, Image, List, Popconfirm,
Radio, Row, Select, Space, Spin, Tag, Typography, message,
} from 'antd';
import {
CheckCircleOutlined, QqOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { yybApi } from '../api/modules'; import { yybApi } from '../api/modules';
import type { YybSelectionOptions, YybTask } from '../api/types'; import type { YybSelectionOptions, YybTask } from '../api/types';
import { getUser } from '../store/auth'; import { getUser } from '../store/auth';
@@ -8,89 +14,471 @@ import { usePermissions } from '../hooks/usePermissions';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const phaseIndex: Record<string, number> = { login: 0, selection: 1, payment: 2, completed: 3 }; const STATUS_META: Record<string, { label: string; color: string }> = {
created: { label: '待登录', color: 'default' },
waiting_login: { label: '等待扫码', color: 'processing' },
ready: { label: '待操作', color: 'gold' },
running: { label: '处理中', color: 'processing' },
ordering: { label: '下单中', color: 'processing' },
waiting_payment: { label: '待微信付款', color: 'blue' },
payment_timeout: { label: '确认超时', color: 'warning' },
success: { label: '已确认到账', color: 'success' },
failed: { label: '失败', color: 'error' },
stopped: { label: '已停止', color: 'default' },
};
const TERMINAL_STATUSES = ['success', 'failed', 'stopped'];
const fmtTime = (value?: string | null) => (value ? dayjs(value).format('MM-DD HH:mm') : '-');
const yuan = (fen?: number | null) => `¥${((fen ?? 0) / 100).toFixed(2)}`;
function OrderSummary({ task }: { task: YybTask }) {
return (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS 区' : 'Android 区'}</Descriptions.Item>
<Descriptions.Item label="商品">
{task.points ? `${task.points} 点券` : '-'}
{task.price_fen ? <Text type="danger">{yuan(task.price_fen)}</Text> : null}
</Descriptions.Item>
<Descriptions.Item label="区服">{task.zone_name || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">{task.role_name ? `${task.role_name}${task.role_id}` : '-'}</Descriptions.Item>
<Descriptions.Item label="金额">{task.price_fen ? yuan(task.price_fen) : '-'}</Descriptions.Item>
</Descriptions>
);
}
export default function YybRechargePage() { export default function YybRechargePage() {
const [task, setTask] = useState<YybTask | null>(null); const [task, setTask] = useState<YybTask | null>(null);
const [recentTasks, setRecentTasks] = useState<YybTask[]>([]);
const [options, setOptions] = useState<YybSelectionOptions | null>(null); const [options, setOptions] = useState<YybSelectionOptions | null>(null);
const [platform, setPlatform] = useState<'android' | 'ios'>('android'); const [platform, setPlatform] = useState<'android' | 'ios'>('android');
const [points, setPoints] = useState<number>(); const [points, setPoints] = useState<number>();
const [zoneId, setZoneId] = useState<string>(); const [zoneId, setZoneId] = useState<string>();
const [roleId, setRoleId] = useState<string>(); const [roleId, setRoleId] = useState<string>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optionsLoading, setOptionsLoading] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const { can } = usePermissions(getUser()); const { can } = usePermissions(getUser());
const refresh = async () => { const optionsSeq = useRef(0);
if (!task) return; const taskIdRef = useRef<number | null>(null);
try { setTask(await yybApi.getTask(task.id)); } catch { /* 保留当前状态 */ } useEffect(() => { taskIdRef.current = task?.id ?? null; }, [task?.id]);
};
const loadRecent = useCallback(async (scope: 'mine' | 'all') => {
try { setRecentTasks(await yybApi.listTasks({ scope })); }
catch (error) { message.error(error instanceof Error ? error.message : '加载任务列表失败'); }
}, []);
const refresh = useCallback(async () => {
const id = taskIdRef.current;
if (id == null) return;
try { setTask(await yybApi.getTask(id)); } catch { /* 保留当前状态 */ }
}, []);
const currentStatus = task?.status;
const currentPhase = task?.phase;
useEffect(() => { useEffect(() => {
if (!task || ['success', 'failed'].includes(task.status)) return; if (currentStatus === undefined || TERMINAL_STATUSES.includes(currentStatus)) return;
const timer = window.setInterval(refresh, 2500); const timer = window.setInterval(() => void refresh(), 2500);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [task?.id, task?.status]); }, [task?.id, currentStatus, refresh]);
useEffect(() => { useEffect(() => {
if (task?.status === 'ready' && task.phase === 'selection' && !options) { let cancelled = false;
void loadOptions(platform); (async () => {
} try {
}, [task?.status, task?.phase]); const tasks = await yybApi.listTasks({ scope: 'mine' });
if (cancelled) return;
setRecentTasks(tasks);
const active = tasks.find(item => !TERMINAL_STATUSES.includes(item.status));
if (active) {
const synced = await yybApi.getTask(active.id);
if (!cancelled) setTask(synced);
}
} catch { /* 忽略列表加载失败 */ }
})();
return () => { cancelled = true; };
}, []);
const loadOptions = async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => { const loadOptions = useCallback(async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => {
if (!task) return; const id = taskIdRef.current;
setLoading(true); if (id == null) return;
const seq = ++optionsSeq.current;
setOptionsLoading(true);
try { try {
const data = await yybApi.options(task.id, nextPlatform, nextPoints, nextZone); const data = await yybApi.options(id, nextPlatform, nextPoints, nextZone);
if (seq !== optionsSeq.current) return;
setOptions(data); setOptions(data);
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points); if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id); if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id);
setRoleId(undefined); const firstRole = data.roles.filter(item => item.ban_status !== '1')[0];
setRoleId(firstRole?.role_id);
} catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); } } catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); }
finally { setLoading(false); } finally { if (seq === optionsSeq.current) setOptionsLoading(false); }
}; }, []);
useEffect(() => {
if (currentStatus === 'ready' && currentPhase === 'selection' && !options) {
void loadOptions(platform);
}
}, [currentStatus, currentPhase, options, platform, loadOptions]);
const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]); const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]);
const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]); const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]);
const selectedRole = useMemo(() => options?.roles.find(item => item.role_id === roleId), [options, roleId]); const selectedRole = useMemo(() => options?.roles.filter(item => item.ban_status !== '1').find(item => item.role_id === roleId), [options, roleId]);
const openTask = async (id: number) => {
setLoading(true);
try {
const synced = await yybApi.getTask(id);
setTask(synced);
setOptions(null);
setRoleId(undefined);
setLogsOpen(false);
if (synced.phase === 'selection') setPlatform(synced.platform || 'android');
} catch (error) { message.error(error instanceof Error ? error.message : '加载任务失败'); }
finally { setLoading(false); }
};
const createTask = async () => { const createTask = async () => {
setLoading(true); setLoading(true);
try { setTask(await yybApi.createTask()); setOptions(null); } catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); } try { setTask(await yybApi.createTask()); setOptions(null); setRoleId(undefined); }
catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
const startLogin = async (provider: 'qq' | 'wechat') => { const startLogin = async (provider: 'qq' | 'wechat') => {
if (!task) return;
setLoading(true); setLoading(true);
try { setTask(await yybApi.login(task.id, provider)); } catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); } try {
let current = task;
if (!current) {
current = await yybApi.createTask();
setTask(current);
setOptions(null);
setRoleId(undefined);
}
setTask(await yybApi.login(current.id, provider));
} catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
const submitSelection = async () => { const submitSelection = async () => {
if (!task || !selectedProduct || !selectedZone || !selectedRole) return; if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
setLoading(true); setLoading(true);
try { try {
setTask(await yybApi.select(task.id, { platform, points: selectedProduct.points, product_id: selectedProduct.product_id, zone_id: selectedZone.zone_id, zone_name: selectedZone.name, role_id: selectedRole.role_id, role_name: selectedRole.name })); setTask(await yybApi.select(task.id, {
platform, points: selectedProduct.points, product_id: selectedProduct.product_id,
zone_id: selectedZone.zone_id, zone_name: selectedZone.name,
role_id: selectedRole.role_id, role_name: selectedRole.name,
}));
message.success('选择已保存'); message.success('选择已保存');
} catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); } } catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
const createPayment = async () => { const createPayment = async () => {
if (!task) return; if (!task) return;
setLoading(true); setLoading(true);
try { setTask(await yybApi.payment(task.id)); } catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); } try { setTask(await yybApi.payment(task.id)); }
catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
return <Space direction="vertical" size={16} style={{ width: '100%' }}> const reCheck = async () => {
<Row justify="space-between" align="middle"><Title level={3} style={{ margin: 0 }}></Title><Button icon={<ReloadOutlined />} onClick={task ? refresh : createTask}>{task ? '刷新任务' : '新建充值任务'}</Button></Row> if (!task) return;
<Steps current={phaseIndex[task?.phase || 'login']} items={[{ title: '扫码登录' }, { title: '选择充值信息' }, { title: '微信付款' }, { title: '完成' }]} /> setLoading(true);
{!task && <Card><Space direction="vertical"><Text>使 YYB </Text><Button type="primary" icon={<ShoppingCartOutlined />} onClick={createTask} loading={loading}></Button></Space></Card>} try { setTask(await yybApi.paymentCheck(task.id)); message.success('已重新检测到账'); }
{task && <> catch (error) { message.error(error instanceof Error ? error.message : '检测到账失败'); }
{task.status === 'failed' && <Alert type="error" showIcon message={task.message || '任务失败'} />} finally { setLoading(false); }
{task.phase === 'login' && <Card title="扫码登录"><Space direction="vertical" size={12}><Radio.Group value={task.provider || undefined} onChange={event => void startLogin(event.target.value)} disabled={loading}><Radio.Button value="qq">QQ </Radio.Button><Radio.Button value="wechat"><WechatOutlined /> </Radio.Button></Radio.Group>{task.login_qr_data && <Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />}<Text type="secondary">{task.message}</Text></Space></Card>} };
{task.phase === 'selection' && <Card title="选择充值信息" loading={loading}><Space direction="vertical" style={{ width: '100%' }}><Radio.Group value={platform} onChange={event => { const value = event.target.value; setPlatform(value); void loadOptions(value); }}><Radio.Button value="android">Android </Radio.Button><Radio.Button value="ios">iOS </Radio.Button></Radio.Group>{options && <Row gutter={[12, 12]}><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="点券档位" value={points} options={options.products.map(item => ({ value: item.points, label: `${item.points} 点券(${(item.price_fen / 100).toFixed(2)} 元)` }))} onChange={value => { setPoints(value); void loadOptions(platform, value, zoneId); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="区服" value={zoneId} options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}${item.zone_id}` }))} onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="角色" value={roleId} options={options.roles.filter(item => item.ban_status !== '1').map(item => ({ value: item.role_id, label: `${item.name}${item.role_id}` }))} onChange={setRoleId} /></Col></Row>}<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={submitSelection}></Button></Space></Card>}
{task.phase === 'payment' && <Card title="微信付款"><Descriptions column={1} size="small"><Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS' : 'Android'}</Descriptions.Item><Descriptions.Item label="商品">{task.points} </Descriptions.Item><Descriptions.Item label="区服">{task.zone_name}</Descriptions.Item><Descriptions.Item label="角色">{task.role_name}{task.role_id}</Descriptions.Item></Descriptions>{task.payment_qr_data ? <Space direction="vertical"><Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /><Tag icon={<QrcodeOutlined />} color="blue">使</Tag></Space> : can('yyb:recharge') ? <Button type="primary" icon={<WechatOutlined />} onClick={createPayment} loading={loading || task.status === 'running'} disabled={task.status === 'running'}>{task.status === 'running' ? '正在生成付款码' : '生成微信付款码'}</Button> : <Text type="secondary"></Text>}<div><Text type="secondary">{task.message}</Text></div></Card>} const stopTask = async () => {
{task.phase === 'completed' && <Alert type="success" showIcon icon={<CheckCircleOutlined />} message="充值订单已确认完成" description={task.message} />} if (!task) return;
</>} setLoading(true);
</Space>; try { setTask(await yybApi.stop(task.id)); }
catch (error) { message.error(error instanceof Error ? error.message : '停止任务失败'); }
finally { setLoading(false); }
};
const status = task?.status ?? '';
const statusMeta = STATUS_META[status] ?? { label: status, color: 'default' };
const logs = task?.result?.logs ?? [];
const showLogs = logs.length > 0;
const canStop = task && !['waiting_payment', 'payment_timeout', 'success'].includes(status);
const renderLoginCard = () => {
if (!task) return null;
const started = status !== 'created';
return (
<Card title="扫码登录">
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Radio.Group value={task.provider || undefined} disabled={started} onChange={event => void startLogin(event.target.value)}>
<Radio.Button value="qq">QQ </Radio.Button>
<Radio.Button value="wechat"><WechatOutlined /> </Radio.Button>
</Radio.Group>
{task.login_qr_data && (
<Space direction="vertical" size={4}>
<Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />
<Text type="secondary">使{task.provider === 'qq' ? '手机 QQ' : '微信'}</Text>
</Space>
)}
<Text type="secondary">{task.message}</Text>
{started && task.status !== 'failed' && (
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void startLogin(task.provider as 'qq' | 'wechat')} loading={loading}></Button>
{canStop && <Popconfirm title="确定停止该任务?" onConfirm={stopTask}><Button danger icon={<StopOutlined />} loading={loading}></Button></Popconfirm>}
</Space>
)}
</Space>
</Card>
);
};
const renderSelectionCard = () => {
if (!task) return null;
return (
<Card title="选择充值信息" loading={optionsLoading}>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Radio.Group value={platform} onChange={event => { const value = event.target.value as 'android' | 'ios'; setPlatform(value); void loadOptions(value); }}>
<Radio.Button value="android">Android </Radio.Button>
<Radio.Button value="ios">iOS </Radio.Button>
</Radio.Group>
{options ? (
<>
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
<Radio.Group
value={points}
onChange={event => { const value = event.target.value as number; setPoints(value); void loadOptions(platform, value, zoneId); }}
>
<Space direction="vertical" size={6}>
{options.products.map(item => (
<Radio key={item.product_id} value={item.points}>
<span style={{ display: 'inline-block', minWidth: 110 }}>{item.points} </span>
<Text type="danger">{yuan(item.price_fen)}</Text>
</Radio>
))}
</Space>
</Radio.Group>
</div>
</div>
<div style={{ maxWidth: 360 }}>
<Text strong></Text>
<Select
style={{ width: '100%', marginTop: 8 }}
placeholder="选择区服"
value={zoneId}
options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}${item.zone_id}` }))}
onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }}
/>
</div>
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{selectedRole ? (
<Text>{selectedRole.name}{selectedRole.role_id}</Text>
) : <Text type="secondary"></Text>}
</div>
</div>
<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={() => void submitSelection()} loading={loading}>
</Button>
</>
) : <Spin tip="正在查询商品信息..." />}
</Space>
</Card>
);
};
const renderPaymentCard = () => {
if (!task) return null;
const terminal = task.status === 'failed' || task.status === 'stopped';
return (
<Card title="微信付款">
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<OrderSummary task={task} />
{status === 'ready' && (
<>
<Alert type="info" showIcon message={`确认本次充值 ${task.points} 点券(${yuan(task.price_fen)}),角色与区服见上方摘要`} />
<Button type="primary" icon={<WechatOutlined />} onClick={() => void createPayment()} disabled={!task.price_fen} loading={loading}>
</Button>
</>
)}
{status === 'ordering' && (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Spin tip={task.message || '正在生成付款码'}><div style={{ height: 40 }} /></Spin>
{showLogs && (
<Collapse
activeKey={logsOpen ? ['logs'] : undefined}
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
items={[{
key: 'logs',
label: <Text strong></Text>,
children: (
<div style={{ maxHeight: 200, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
</div>
),
}]}
/>
)}
</Space>
)}
{status === 'waiting_payment' && (
<Space direction="vertical" size={10}>
{task.payment_qr_data ? (
<Space direction="vertical" size={4}>
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} />
<Tag color="blue">使</Tag>
<Text type="secondary"> {fmtTime(task.payment_qr_created_at)} · {fmtTime(task.payment_last_checked_at)}</Text>
</Space>
) : <Spin tip="正在生成付款码..." />}
<Text type="secondary">{task.message}</Text>
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
<Button danger icon={<StopOutlined />} loading={loading}></Button>
</Popconfirm>
</Space>
)}
{status === 'payment_timeout' && (
<Space direction="vertical" size={10}>
<Alert type="warning" showIcon
message="付款码已生成,但未在时限内确认到账"
description="订单可能已支付或尚未支付。可重新检测到账(不会重复下单),或在执行记录中核对订单状态。" />
{task.payment_qr_data && (
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} />
)}
<Space>
<Button type="primary" onClick={() => void reCheck()} loading={loading}></Button>
<Button onClick={() => setLogsOpen(true)}></Button>
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
<Button danger icon={<StopOutlined />} loading={loading}></Button>
</Popconfirm>
</Space>
</Space>
)}
{terminal && <Alert type={status === 'failed' ? 'error' : 'info'} showIcon message={task.message || '任务已结束'} />}
</Space>
</Card>
);
};
const renderCompletedCard = () => (
<Alert type="success" showIcon icon={<CheckCircleOutlined />}
message="充值订单已确认到账"
description={task?.message} />
);
const renderMainCard = () => {
if (!task) return null;
if (task.phase === 'login') return renderLoginCard();
if (task.phase === 'selection') return renderSelectionCard();
if (task.phase === 'payment') return renderPaymentCard();
if (task.phase === 'completed') return renderCompletedCard();
return <Card><Text type="secondary">{task.message}</Text></Card>;
};
return (
<Row gutter={[16, 16]}>
<Col xs={24} lg={16}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small">
<Row justify="space-between" align="middle" gutter={[12, 8]}>
<Col>
<Space wrap>
<Title level={4} style={{ margin: 0 }}></Title>
{task && <Tag color={statusMeta.color}>{statusMeta.label}</Tag>}
</Space>
</Col>
<Col>
{task && (
<Button icon={<ShoppingCartOutlined />} onClick={() => void createTask()} loading={loading}>
</Button>
)}
</Col>
</Row>
{task && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">
{task.created_by_username || `#${task.created_by}`}
{task.created_at ? ` · 创建于 ${fmtTime(task.created_at)}` : ''}
{task.finished_at ? ` · 结束于 ${fmtTime(task.finished_at)}` : ''}
</Text>
</div>
)}
</Card>
{!task && (
<Card title="扫码登录">
<Space direction="vertical" size={12}>
<Text></Text>
<Space size={12}>
<Button size="large" icon={<QqOutlined />} onClick={() => void startLogin('qq')} loading={loading}>QQ </Button>
<Button size="large" icon={<WechatOutlined />} onClick={() => void startLogin('wechat')} loading={loading}></Button>
</Space>
</Space>
</Card>
)}
{task && renderMainCard()}
</Space>
</Col>
<Col xs={24} lg={8}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card title="最近任务" size="small" extra={
can('yyb:history') ? (
<Button type="link" size="small" onClick={() => void loadRecent('all')}></Button>
) : undefined
}>
{recentTasks.length === 0 ? <Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} /> : (
<List
size="small"
dataSource={recentTasks.slice(0, 12)}
renderItem={item => (
<List.Item
style={{ cursor: 'pointer', paddingLeft: 4 }}
onClick={() => void openTask(item.id)}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space wrap size={6}>
<Tag color={STATUS_META[item.status]?.color ?? 'default'} style={{ marginInlineEnd: 0 }}>{STATUS_META[item.status]?.label ?? item.status}</Tag>
{item.points ? <Text>{item.points} </Text> : null}
{item.price_fen ? <Text type="danger">{yuan(item.price_fen)}</Text> : null}
{item.role_name ? <Text type="secondary">{item.role_name}</Text> : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.created_by_username || `#${item.created_by}`} · {fmtTime(item.created_at)}
</Text>
</Space>
</List.Item>
)}
/>
)}
</Card>
{task && (
<Card title="订单摘要" size="small">
<OrderSummary task={task} />
</Card>
)}
{showLogs && (
<Collapse
ghost
activeKey={logsOpen ? ['logs'] : undefined}
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
items={[{
key: 'logs',
label: <Text strong>{task?.status === 'failed' ? '(失败原因)' : ''}</Text>,
children: (
<div style={{ maxHeight: 280, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
</div>
),
}]}
/>
)}
</Space>
</Col>
</Row>
);
} }