mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 16:07:22 +08:00
Sync remote sparkflow changes and add send console
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.friends import fetch_account_friends
|
||||
from core.tasks import run_browser_tasks
|
||||
from utils.config import (
|
||||
get_app_settings,
|
||||
get_config,
|
||||
@@ -96,6 +97,31 @@ def coerce_int(value, default, minimum=0):
|
||||
return max(minimum, int(default))
|
||||
|
||||
|
||||
def _schedule_timezone():
|
||||
return timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=_schedule_timezone())
|
||||
return parsed.astimezone(_schedule_timezone())
|
||||
|
||||
|
||||
def _target_sent_today(account, target_name):
|
||||
entry = dict(account.get("message_history") or {}).get(target_name) or {}
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"))
|
||||
return bool(sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date())
|
||||
|
||||
|
||||
def login_desktop_api_url():
|
||||
settings = get_app_settings(force_reload=True)
|
||||
return str(settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
|
||||
@@ -278,6 +304,21 @@ def create_app():
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/ops/send-console", response_class=HTMLResponse)
|
||||
async def send_console_page(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"send_console.html",
|
||||
{
|
||||
"flash": pop_flash(request),
|
||||
"ops": get_ops_snapshot(),
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/accounts/{unique_id}/update")
|
||||
async def update_account(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
@@ -378,6 +419,47 @@ def create_app():
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/retry-target")
|
||||
async def retry_account_target(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
target_name = str(form.get("target", "")).strip()
|
||||
if not target_name:
|
||||
flash(request, "Target is required for retry.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
account_copy = dict(account)
|
||||
account_copy["targets"] = [target_name]
|
||||
config = get_config(force_reload=True)
|
||||
config["taskCount"] = 1
|
||||
|
||||
try:
|
||||
await run_browser_tasks(config, [account_copy])
|
||||
except Exception as exc:
|
||||
flash(request, f"Retry failed for {account.get('username', 'Account')} / {target_name}: {exc}", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
|
||||
if _target_sent_today(updated_account, target_name):
|
||||
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
|
||||
else:
|
||||
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
|
||||
reason = str(failure_entry.get("reason") or "Retry did not confirm a successful send.")
|
||||
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/config")
|
||||
async def save_runtime_config(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
@@ -493,9 +575,9 @@ def create_app():
|
||||
|
||||
pid = run_task_now()
|
||||
if pid == -1:
|
||||
flash(request, "Task launch failed. Check console logs for Missing Docker or protected log_file path.", "error")
|
||||
flash(request, "Failed to start failed-target retry run. Check server logs for details.", "error")
|
||||
else:
|
||||
flash(request, f"Triggered a background task run (pid {pid}).", "success")
|
||||
flash(request, f"Triggered a failed-target retry run in the background (pid {pid}).", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/proxy/refresh")
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from utils.config import get_app_settings, get_config, repo_root, save_config
|
||||
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -193,6 +196,7 @@ def run_task_now():
|
||||
cwd=cwd,
|
||||
env={
|
||||
"SPARKFLOW_MANUAL_RUN": "1",
|
||||
"SPARKFLOW_MANUAL_FAILED_ONLY": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
},
|
||||
)
|
||||
@@ -375,6 +379,172 @@ def current_daily_schedule():
|
||||
return ""
|
||||
|
||||
|
||||
def _schedule_timezone():
|
||||
timezone_name = (
|
||||
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
|
||||
or str(os.getenv("TZ") or "").strip()
|
||||
or "Asia/Shanghai"
|
||||
)
|
||||
try:
|
||||
return ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
if timezone_name == "Asia/Shanghai":
|
||||
return timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
return datetime.now().astimezone().tzinfo
|
||||
|
||||
|
||||
def _normalize_send_window():
|
||||
raw = dict(get_config(force_reload=True).get("dailySendWindow") or {})
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled", False)),
|
||||
"startHour": int(raw.get("startHour", 10)),
|
||||
"endHour": int(raw.get("endHour", 18)),
|
||||
"scheduleIntervalMinutes": max(1, int(raw.get("scheduleIntervalMinutes", 10))),
|
||||
}
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
|
||||
|
||||
def _account_identity(user):
|
||||
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
|
||||
|
||||
|
||||
def _scheduled_send_time(user, target_name, send_window, now):
|
||||
window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60)
|
||||
start_of_window = now.replace(
|
||||
hour=send_window["startHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
seed = f"{now.date().isoformat()}|{_account_identity(user)}|{target_name}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8")).digest()
|
||||
offset_minutes = int.from_bytes(digest[:8], "big") % window_minutes
|
||||
return start_of_window + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
def _build_target_status(account, target_name, now, send_window):
|
||||
history = dict(account.get("message_history") or {})
|
||||
failure_queue = dict(account.get("failure_queue") or {})
|
||||
|
||||
history_entry = history.get(target_name) or {}
|
||||
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
|
||||
if sent_at and sent_at.date() == now.date():
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "sent",
|
||||
"message": str(history_entry.get("message") or ""),
|
||||
"sentAt": sent_at.isoformat(timespec="seconds"),
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": "",
|
||||
}
|
||||
|
||||
failure_entry = failure_queue.get(target_name) or {}
|
||||
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
|
||||
if last_attempt_at and last_attempt_at.date() == now.date():
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "failed",
|
||||
"message": str(failure_entry.get("message") or ""),
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
|
||||
"category": str(failure_entry.get("category") or ""),
|
||||
"reason": str(failure_entry.get("reason") or ""),
|
||||
"attemptCount": int(failure_entry.get("attemptCount") or 0),
|
||||
"scheduledAt": "",
|
||||
}
|
||||
|
||||
scheduled_at = None
|
||||
if send_window.get("enabled"):
|
||||
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
|
||||
if scheduled_at > now:
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "pending",
|
||||
"message": "",
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "unprocessed",
|
||||
"message": "",
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
|
||||
}
|
||||
|
||||
|
||||
def get_send_console_snapshot():
|
||||
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)]
|
||||
send_window = _normalize_send_window()
|
||||
now = datetime.now(_schedule_timezone())
|
||||
|
||||
summary = {
|
||||
"enabled_accounts": len(accounts),
|
||||
"today_sent_targets": 0,
|
||||
"today_failed_targets": 0,
|
||||
"today_pending_targets": 0,
|
||||
"today_unprocessed_targets": 0,
|
||||
}
|
||||
account_rows = []
|
||||
|
||||
for account in accounts:
|
||||
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in account.get("targets") or []]
|
||||
sent_targets = [item for item in statuses if item["status"] == "sent"]
|
||||
failed_targets = [item for item in statuses if item["status"] == "failed"]
|
||||
pending_targets = [item for item in statuses if item["status"] == "pending"]
|
||||
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
|
||||
|
||||
summary["today_sent_targets"] += len(sent_targets)
|
||||
summary["today_failed_targets"] += len(failed_targets)
|
||||
summary["today_pending_targets"] += len(pending_targets)
|
||||
summary["today_unprocessed_targets"] += len(unprocessed_targets)
|
||||
|
||||
account_rows.append(
|
||||
{
|
||||
"unique_id": str(account.get("unique_id") or ""),
|
||||
"username": account.get("username") or "",
|
||||
"sent_targets": sent_targets,
|
||||
"failed_targets": failed_targets,
|
||||
"pending_targets": pending_targets,
|
||||
"unprocessed_targets": unprocessed_targets,
|
||||
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
|
||||
"failure_queue": dict(account.get("failure_queue") or {}),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"now": now.isoformat(timespec="seconds"),
|
||||
"summary": summary,
|
||||
"accounts": account_rows,
|
||||
}
|
||||
|
||||
|
||||
def _check_image_present():
|
||||
"""Return True if the douyin-sparkflow:local image exists."""
|
||||
try:
|
||||
@@ -400,6 +570,7 @@ def get_ops_snapshot():
|
||||
"compose_file": str(compose_file_path() or ""),
|
||||
"containers": get_container_status(),
|
||||
"task_containers": get_task_container_rows(),
|
||||
"send_console": get_send_console_snapshot(),
|
||||
"daily_schedule": current_daily_schedule(),
|
||||
"crontab": read_crontab(),
|
||||
"log_tail": read_log_tail(120),
|
||||
|
||||
@@ -141,6 +141,74 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="send-console-summary">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>发送控制台摘要</h2>
|
||||
<p class="muted compact">展示今天的成功、失败、待发送与待补发状态。失败目标可在详情页单独重试。</p>
|
||||
</div>
|
||||
<a class="link-button" href="/ops/send-console">打开发送控制台</a>
|
||||
</div>
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(4, minmax(0, 1fr));">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value">{{ ops.send_console.summary.enabled_accounts }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功目标</span>
|
||||
<strong class="stat-value">{{ ops.send_console.summary.today_sent_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon green">OK</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value">{{ ops.send_console.summary.today_failed_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon soft">!</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送 / 未处理</span>
|
||||
<strong class="stat-value">{{ ops.send_console.summary.today_pending_targets + ops.send_console.summary.today_unprocessed_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon soft">...</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="table-shell" style="margin-top: 16px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>今日成功</th>
|
||||
<th>失败待补发</th>
|
||||
<th>待发送</th>
|
||||
<th>未处理</th>
|
||||
<th>最后失败原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ops.send_console.accounts %}
|
||||
<tr>
|
||||
<td><strong>{{ row.username }}</strong><br><span class="muted">{{ row.unique_id }}</span></td>
|
||||
<td>{{ row.sent_targets|length }}</td>
|
||||
<td>{{ row.failed_targets|length }}</td>
|
||||
<td>{{ row.pending_targets|length }}</td>
|
||||
<td>{{ row.unprocessed_targets|length }}</td>
|
||||
<td>{{ row.last_failure_reason or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">暂无发送状态摘要。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="account-management">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
@@ -437,6 +505,21 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const runNowForm = document.querySelector('form[action="/ops/run-now"]');
|
||||
if (!runNowForm) return;
|
||||
const button = runNowForm.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.textContent = "补发全部失败项";
|
||||
}
|
||||
if (!runNowForm.nextElementSibling || !runNowForm.nextElementSibling.classList.contains("failed-only-note")) {
|
||||
const note = document.createElement("p");
|
||||
note.className = "muted compact failed-only-note";
|
||||
note.textContent = "“补发全部失败项”只处理今天 failure_queue 中的目标,不会全量重发全部账号。";
|
||||
runNowForm.insertAdjacentElement("afterend", note);
|
||||
}
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}发送控制台{% endblock %}
|
||||
{% block page_title %}发送控制台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set send_console = ops.send_console %}
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>今日发送总览</h2>
|
||||
<p class="muted compact">当前时间:{{ send_console.now }}。本页按账号展示今天已成功、失败待补发、待发送和未处理目标。</p>
|
||||
</div>
|
||||
<div class="button-row two" style="width: 320px;">
|
||||
<a class="ghost-button" href="/">返回首页</a>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">补发全部失败项</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value">{{ send_console.summary.enabled_accounts }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon green">OK</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon soft">!</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送 / 未处理</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon soft">...</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for account in send_console.accounts %}
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>{{ account.username }}</h2>
|
||||
<p class="muted compact">unique_id: {{ account.unique_id }}</p>
|
||||
</div>
|
||||
<div class="status-line">
|
||||
<span class="pill soft">成功 {{ account.sent_targets|length }}</span>
|
||||
<span class="pill {% if account.failed_targets %}danger{% else %}soft{% endif %}">失败 {{ account.failed_targets|length }}</span>
|
||||
<span class="pill">待发送 {{ account.pending_targets|length }}</span>
|
||||
<span class="pill warning">未处理 {{ account.unprocessed_targets|length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-grid" style="grid-template-columns: repeat(2, minmax(0, 1fr));">
|
||||
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
|
||||
<h3>今日已成功</h3>
|
||||
<div class="table-shell" style="margin-top: 12px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>消息</th>
|
||||
<th>sentAt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.sent_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.message or "-" }}</td>
|
||||
<td>{{ item.sentAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">暂无今日成功目标。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
|
||||
<h3>失败待补发</h3>
|
||||
<div class="table-shell" style="margin-top: 12px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>失败分类</th>
|
||||
<th>失败原因</th>
|
||||
<th>次数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.failed_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.category or "-" }}</td>
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="ghost-button">重试此目标</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">暂无失败待补发目标。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
|
||||
<h3>今日待发送</h3>
|
||||
<div class="table-shell" style="margin-top: 12px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.pending_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">暂无今日待发送目标。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
|
||||
<h3>今日未处理</h3>
|
||||
<div class="table-shell" style="margin-top: 12px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.unprocessed_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">暂无未处理目标。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user